diff --git a/.claude/commands/regression-lens.md b/.claude/commands/regression-lens.md new file mode 100644 index 00000000..954f9b30 --- /dev/null +++ b/.claude/commands/regression-lens.md @@ -0,0 +1,80 @@ +--- +description: Audit recent merges for defects the fixes themselves introduced +argument-hint: "[commit-ish or PR range, e.g. 4bf0230 or 'last 20 hours']" +--- + +Run a **regression lens** over this repository. + +This is not a bug hunt. It audits **the fixes themselves**, asking only what they broke and which +siblings they missed. It exists because roughly one in five defects found in this codebase's audit +sweeps was caused by an earlier fix in the same campaign, and nothing else looks for those. + +## Scope + +Audit: **$ARGUMENTS** + +If that is empty, audit everything merged to `develop` in the last 24 hours (`git log --oneline +--since="24 hours ago"`). Read each diff in full with `git show `, then read the **current** state +of every file touched — a later commit may already have changed it. + +Keep the scope tight. One pass over one batch of fixes finds more than one pass over everything. + +## The only two questions + +1. **What did this fix break?** Did it tighten or loosen a rule — validation, guard, allowlist, filter, + cache, default, lifetime, error path, order of operations — without accounting for a legitimate case + that depended on the previous behaviour? +2. **Which siblings did it miss?** This codebase has sets of ten (alarm types, list components, edit + dialogs, services, `*Create`/`*Update` DTO pairs) and eleven (locale files). A fix applied to one + member is suspect until the others are checked. + +## Calibration — the shapes this keeps finding + +Give the auditor these, so it knows what it is looking for: + +- **A constraint added, the bad case verified refused, the legitimate cases never enumerated.** A live + resolution locked out users configured elsewhere (#601 → #626). Save-time validation broke seeding, + because two presets carry empty filters on purpose (#604 → #637). An allowlist would have refused + `blanche` and `npc 0`, both live in production. +- **A claim wider than its evidence.** `isAdmin` made live without distinguishing "not an admin" from + "could not ask", so an outage de-admined live sessions (#624 → #656). A comment asserting one key was + the only type-agnostic one, when the repo's own whitelist listed four (#671 → #674). +- **Validation in the wrong place.** Checks added *after* the thing they guard is created, so a refusal + answers 400 and leaves an orphan behind (#647 → #665). +- **One member of a set of ten.** `bulkDelete` hardened, `bulkUpdateDistance` left (#603 → #641). Create + DTO bounded, Update DTO not (#612 → #660). + +## Ground rules for the auditor + +- Verify against the code. `git show` the diff, then read the current file. Never reason from a commit + message. +- Check any PoracleNG claim against `E:/PGAN/pogogit/PoracleNG`, pinned to the commit production runs — + see the "Keep the PoracleNG Checkout Pinned To What Prod Runs" section of `CLAUDE.md`. +- Read "Fixing Defects Without Causing Them" in `CLAUDE.md` first. +- Before claiming a value should be refused, query production for what currently satisfies the loose + rule. Connection details are in `.env`. +- Do **not** report defects in code the audited commits did not touch. That is a different lens's job. +- Do **not** re-report findings from earlier passes. +- **A clean result is the expected and desired outcome.** Say so plainly and stop. Manufacturing a + marginal finding to appear thorough costs real work, because every finding gets acted on. + +Report each finding with the commit that introduced it, the legitimate case now broken (or the sibling +now missed), a demonstrable failure case, and what should happen instead. Then list what was checked +and cleared. + +## Run it until it comes back empty + +One pass is not enough — its own fixes can introduce the next round. Re-run, scoping each pass to the +previous pass's fixes, until a pass reports nothing. + +Observed convergence when this was first run: **8 → 5 → 2 → 1 → 0**. Pass one held a severe defect (an +outage de-admining a live session); pass two another (an orphan profile left behind a 400); by pass four +the only finding was an overclaiming comment. Expect roughly that shape. Stopping at pass one would have +left five defects live. + +## Fixing what it finds + +Follow `CLAUDE.md`. In particular: give every guard a **legitimate-case-still-passes** test beside the +refusal test, and **revert the fix and confirm the new test goes red** before trusting it. A test written +alongside a fix encodes that fix's own assumptions and passes either way — one spec in this repo was +asserting a broken request shape, so the suite was defending the bug. diff --git a/.editorconfig b/.editorconfig index 24f0393b..5e9ea124 100644 --- a/.editorconfig +++ b/.editorconfig @@ -311,7 +311,7 @@ csharp_space_between_empty_square_brackets = false csharp_space_between_square_brackets = false # Wrap options # https://docs.microsoft.com/visualstudio/ide/editorconfig-formatting-conventions#wrap-options -csharp_preserve_single_line_statements = false +csharp_preserve_single_line_statements = true csharp_preserve_single_line_blocks = false ########################################## diff --git a/.env.example b/.env.example index ec5db6b1..9b0f79bf 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,11 @@ DISCORD_BOT_TOKEN=your_discord_bot_token # Forum channel for geofence submission threads (optional) # DISCORD_GEOFENCE_FORUM_CHANNEL_ID= +# Public URL where users reach this site, no trailing slash (optional). +# Used to link geofence review threads straight to the admin review page. +# The link is omitted when this is unset. +# PUBLIC_URL=https://alerts.example.com + # ═══════════════════════════════════════════════════════════════════════════════ # TELEGRAM (optional) # ═══════════════════════════════════════════════════════════════════════════════ @@ -75,6 +80,50 @@ TELEGRAM_ENABLED=false # TELEGRAM_BOT_TOKEN= # TELEGRAM_BOT_USERNAME= +# ═══════════════════════════════════════════════════════════════════════════════ +# EXTERNAL SSO / OIDC (optional — delegate login to your own OAuth2/OIDC provider) +# ═══════════════════════════════════════════════════════════════════════════════ +# Point PoracleWeb at any OAuth2/OIDC provider (Keycloak, Authentik, Auth0, Okta, …) for SSO. +# The provider's userinfo endpoint must return a claim holding the user's Poracle id +# (a Discord/Telegram id) — set OIDC_IDENTITY_CLAIM to that claim name. +# Enabled is auto-inferred when ClientId + the three URLs are all set; set explicitly to override. +# Replace the example URLs below with your provider's actual endpoints. +# OIDC_ENABLED=true +# OIDC_PROVIDER_NAME=My SSO +# OIDC_AUTHORIZATION_URL=https://sso.example.com/authorize +# OIDC_TOKEN_URL=https://sso.example.com/oauth/token +# OIDC_USERINFO_URL=https://sso.example.com/oauth/userinfo +# OIDC_CLIENT_ID=your_oidc_client_id +# OIDC_CLIENT_SECRET=your_oidc_client_secret +# OIDC_SCOPES=openid profile email +# OIDC_IDENTITY_CLAIM=discord_id +# OIDC_USERNAME_CLAIM=preferred_username +# OIDC_AVATAR_CLAIM=picture +# OIDC_IDENTITY_TYPE=discord:user +# OIDC_USE_PKCE=true +# +# --- Refresh tokens (optional, opt-in) — silent session renewal + revocation propagation --- +# When OFF (default) the provider's tokens are discarded after login and the internal session +# JWT lives its full Jwt:ExpirationMinutes (24h); users re-auth at expiry. When ON, PoracleWeb +# brokers the provider's refresh token SERVER-SIDE (encrypted at rest, never sent to the browser), +# silently renews the session, and propagates provider-side disable/logout. Requires the provider +# to actually issue a refresh token. Fully provider-agnostic — see docs/configuration/oidc-refresh-tokens.md. +# OIDC_USE_REFRESH_TOKENS=true +# OIDC_ACCESS_TOKEN_MINUTES=30 # internal JWT lifetime for refresh-backed OIDC sessions only +# OIDC_REFRESH_TOKEN_LIFETIME_DAYS=30 # PoracleWeb-side absolute session cap before a real re-login +# OIDC_SESSION_REVOKED_RETENTION_DAYS=2 # how long revoked/rotated session rows are kept (replay detection) before cleanup deletes them +# OIDC_OFFLINE_ACCESS_SCOPE=offline_access # appended to the authorize scope so the provider issues an RT; empty to disable +# OIDC_TOKEN_AUTH_METHOD=client_secret_post # client_secret_post (body) | client_secret_basic (HTTP Basic) +# +# Per-provider notes (token auth method / offline scope / identity claim): +# PogoAlerts : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=discord_id +# Keycloak : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_basic OIDC_IDENTITY_CLAIM=sub +# Authentik : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub +# Auth0 : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub +# Okta : OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_basic OIDC_IDENTITY_CLAIM=sub +# Azure/Entra: OIDC_OFFLINE_ACCESS_SCOPE=offline_access OIDC_TOKEN_AUTH_METHOD=client_secret_post OIDC_IDENTITY_CLAIM=sub +# Google : OIDC_OFFLINE_ACCESS_SCOPE= (empty) and append ?access_type=offline to OIDC_AUTHORIZATION_URL + # ═══════════════════════════════════════════════════════════════════════════════ # PORACLE API — your running PoracleNG instance # ═══════════════════════════════════════════════════════════════════════════════ @@ -101,6 +150,37 @@ KOJI_PROJECT_NAME=YourProjectName # Set to the URL you access PoracleWeb.NET from. Not required in development mode. # CORS_ORIGIN=http://192.168.1.50:8082 +# ═══════════════════════════════════════════════════════════════════════════════ +# PUBLIC URL — the address users reach this instance on +# ═══════════════════════════════════════════════════════════════════════════════ +# Sets the OAuth callback URLs (Discord and OIDC) outright instead of guessing them +# from each incoming request. Set this if sign-in fails with an invalid redirect_uri, +# or just set it anyway — it is the one value your identity provider must also have +# registered, so stating it here keeps the two in step. +# +# Origin only: no trailing path, no query. A bad value stops the app at startup. +# Leave it blank to keep the old behaviour of following the incoming request, which +# is correct for a direct-exposed instance or one whose proxy is declared below. +# PUBLIC_URL=https://poracle.example.com + +# ═══════════════════════════════════════════════════════════════════════════════ +# REVERSE PROXY — required if anything sits in front of PoracleWeb.NET +# ═══════════════════════════════════════════════════════════════════════════════ +# Nginx, Caddy, Traefik, Cloudflare Tunnel and friends terminate TLS themselves and +# announce the original request with X-Forwarded-For and X-Forwarded-Proto. Those +# headers are only believed from addresses named here — a forgeable header would let +# any caller hand itself a fresh rate-limit allowance on the sign-in endpoints. +# +# Leave both blank only if the app is exposed directly. Behind an undeclared proxy the +# app sees every request as plain HTTP from the proxy's address, which means all users +# share one rate-limit bucket AND OAuth callback URLs are built as http:// — Discord +# and OIDC providers then reject the sign-in with "Invalid redirect_uri". (PUBLIC_URL +# above fixes the sign-in on its own; only these settings fix the rate-limit bucket.) +# +# Comma-separated. Use the address the proxy connects FROM, as the container sees it. +# PROXY_KNOWN_PROXIES=10.0.3.20 +# PROXY_KNOWN_NETWORKS=172.18.0.0/16,10.0.0.0/8 + # ═══════════════════════════════════════════════════════════════════════════════ # PORACLE CONFIG (optional — for DTS template previews) # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5f4917c8..c6ce9e01 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,6 +3,9 @@ updates: # .NET (backend) - package-ecosystem: nuget directory: / + # main only moves when a release is merged (see CLAUDE.md), so bumps land on + # develop first and reach released code with the release that carries them. + target-branch: develop schedule: interval: weekly day: monday @@ -13,19 +16,46 @@ updates: - dependencies commit-message: prefix: deps + ignore: + # Microsoft.OpenApi 3.x cannot be used while Microsoft.AspNetCore.OpenApi targets the 2.x + # object model. Its source generator assigns IOpenApiMediaType.Example, which became + # read-only in 3.0, so the build fails in generated code no edit here can reach: + # OpenApiXmlCommentSupport.generated.cs: error CS0200: Property or indexer + # IOpenApiMediaType.Example cannot be assigned to -- it is read only + # + # The direct reference exists only to clear GHSA-v5pm-xwqc-g5wc, which + # Microsoft.AspNetCore.OpenApi 10.0.10 reintroduces by pinning 2.0.0 transitively (see the + # comment in Pgan.PoracleWebNet.Api.csproj). Minor and patch updates inside 2.x still come + # through, so a later advisory is not masked. + # + # Drop this once Microsoft.AspNetCore.OpenApi ships a release built against 3.x -- at which + # point the direct reference should go too. See #702. + - dependency-name: Microsoft.OpenApi + update-types: + - version-update:semver-major groups: - microsoft: + # One group for the whole .NET platform. These packages ship as a single versioned set: + # Microsoft.EntityFrameworkCore 10.0.x transitively requires Microsoft.Extensions.* at + # >= the same 10.0.x, so any grouping that splits them produces a PR that cannot restore. + # + # The previous `microsoft` / `aspnetcore` / `ef-core` split did exactly that, and the + # patterns overlapped besides -- `Microsoft.*` is a superset of both `Microsoft.AspNetCore.*` + # and `Microsoft.EntityFrameworkCore*`. It yielded three PRs carving up one package set, + # of which only the widest could build. See #366: EF Core went to 10.0.10 while + # Microsoft.Extensions.* stayed at 10.0.8, giving `NU1605: Detected package downgrade` + # (a hard error under the .NET 10 SDK, not a warning). + # + # MySql.EntityFrameworkCore belongs here too: it version-locks to + # Microsoft.EntityFrameworkCore.Relational and drags the same Extensions floor with it. + dotnet: patterns: - 'Microsoft.*' - 'System.*' - aspnetcore: - patterns: - - 'Microsoft.AspNetCore.*' - - 'Microsoft.Extensions.*' - ef-core: - patterns: - - 'Microsoft.EntityFrameworkCore*' - 'MySql.EntityFrameworkCore' + exclude-patterns: + # Test-only and versioned independently of the platform (18.x, not 10.0.x). + # Kept in the `test` group so a runtime bump and a test-tooling bump stay separable. + - 'Microsoft.NET.Test.Sdk' test: patterns: - 'xunit*' @@ -36,6 +66,9 @@ updates: # Angular frontend - package-ecosystem: npm directory: /Applications/Pgan.PoracleWebNet.App/ClientApp + # main only moves when a release is merged (see CLAUDE.md), so bumps land on + # develop first and reach released code with the release that carries them. + target-branch: develop schedule: interval: weekly day: monday @@ -91,6 +124,9 @@ updates: # GitHub Actions - package-ecosystem: github-actions directory: / + # main only moves when a release is merged (see CLAUDE.md), so bumps land on + # develop first and reach released code with the release that carries them. + target-branch: develop schedule: interval: weekly day: monday @@ -106,6 +142,9 @@ updates: # Dockerfile base images - package-ecosystem: docker directory: / + # main only moves when a release is merged (see CLAUDE.md), so bumps land on + # develop first and reach released code with the release that carries them. + target-branch: develop schedule: interval: weekly day: monday diff --git a/.github/workflows/auto-merge-deps.yml b/.github/workflows/auto-merge-deps.yml index eabad42e..d7524829 100644 --- a/.github/workflows/auto-merge-deps.yml +++ b/.github/workflows/auto-merge-deps.yml @@ -1,11 +1,13 @@ name: Dependabot auto-merge +# Only triggers on pull_request_target. Listing `push:` here previously caused +# the workflow to fire on push events instead of pull_request_target ones, +# so Dependabot PRs never got auto-approved and every push recorded a failure +# run. pr-labeler.yml uses pull_request_target alone and triggers correctly, +# which was the side-by-side that confirmed the issue. on: pull_request_target: types: [opened, synchronize, reopened, ready_for_review] - # Claim push events so GitHub doesn't create phantom 0-job failed runs. - # The job early-exits for non-pull_request_target events. - push: permissions: contents: write @@ -13,17 +15,19 @@ permissions: jobs: auto-merge: - # Skip the job entirely on push events so GitHub records the run as "skipped" - # (neutral, green in status UI) instead of "failure" with 0 jobs. The prior - # approach of claiming push with in-step gates still produced failed runs - # because the job itself never spawned for non-dependabot pushes. - if: github.event_name == 'pull_request_target' && github.actor == 'dependabot[bot]' runs-on: ubuntu-latest steps: + # Sentinel step so the run records as "success" for non-Dependabot PRs. + # Without it, every step below is gated by `github.actor == 'dependabot[bot]'` + # and a non-Dependabot PR would produce a job with zero successful steps, + # which GitHub records as failure. + - name: Workflow ran + run: echo "Auto-merge workflow evaluated for actor=${{ github.actor }}" + - name: Fetch Dependabot metadata id: meta if: github.actor == 'dependabot[bot]' - uses: dependabot/fetch-metadata@v2 + uses: dependabot/fetch-metadata@v3 - name: Enable auto-merge for low-risk bumps # Auto-merge criteria: @@ -31,8 +35,20 @@ jobs: # - any grouped bundle (groups are curated; CI gates) # - GitHub Actions minor bumps # Majors and runtime-dep minors wait for human review. + # + # The major exclusion is hoisted out in front of the group clause deliberately. Without + # it, `dependency-group != ''` waved through a major inside any curated group, which + # contradicts the line above. That is not hypothetical: #344 opened as + # jest-preset-angular 16.1.5 -> 16.2.0 and Dependabot recreated it in place as + # 16.1.5 -> 17.0.0, still auto-merge eligible because it belongs to the `jest` group. + # + # For a grouped PR, fetch-metadata reports `update-type` as the highest semver change in + # the bundle, so one major anywhere in a group holds the whole bundle for review. If the + # output is ever absent the comparison is simply true and grouped bundles behave exactly + # as they did before, so this cannot fail closed on us. if: | - github.actor == 'dependabot[bot]' && ( + github.actor == 'dependabot[bot]' && + steps.meta.outputs.update-type != 'version-update:semver-major' && ( steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.dependency-group != '' || (steps.meta.outputs.package-ecosystem == 'github_actions' && @@ -43,13 +59,16 @@ jobs: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Same major exclusion as the auto-merge gate above — a bundle held for review must not + # arrive pre-approved, or the review requirement is satisfied without anyone looking. - name: Approve on behalf of repo if: | - github.actor == 'dependabot[bot]' && ( + github.actor == 'dependabot[bot]' && + steps.meta.outputs.update-type != 'version-update:semver-major' && ( steps.meta.outputs.update-type == 'version-update:semver-patch' || steps.meta.outputs.dependency-group != '' ) - run: gh pr review --approve "$PR_URL" --body "Auto-approved: low-risk bump, gated on CI." + run: gh pr review --approve "$PR_URL" --body 'Auto-approved low-risk bump, gated on CI.' env: PR_URL: ${{ github.event.pull_request.html_url }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index dc1bfde1..cc22ffa8 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -1,150 +1,98 @@ -name: Update Changelog +name: Changelog Check +# Verify-only: confirms a PR adds an entry under "## [Unreleased]" in CHANGELOG.md. +# It never writes to the repo, so it cannot trip branch protection on `main`. +# Replaces the old post-merge auto-writer, which always failed pushing to protected main +# and risked duplicate entries. Release cuts are still handled by release-changelog.yml. + +# `merge_group` is listed so this required check reports against the merge queue's temporary +# branch; without it, every queued PR stalls waiting for a check that never runs. +# +# The check itself is a pull-request-level policy and is deliberately a no-op in the queue. +# A `merge_group` payload has no `pull_request` object at all, so TITLE, LABELS, BASE_SHA and +# HEAD_SHA would all be empty: the exempt-prefix test would miss, `git show ""` would fail, +# and the job would report a bogus failure on a PR that already passed this gate. on: pull_request: - types: [closed] - branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] + branches: [main, develop] + merge_group: + +permissions: + contents: read jobs: - update-changelog: - if: github.event.pull_request.merged == true + changelog: + name: Changelog entry present runs-on: ubuntu-latest - permissions: - contents: write - steps: + # Unconditional, so the job always has at least one successful step. A job whose every + # step is skipped by an `if:` is recorded as a failure, which would block the queue -- + # the same trap documented in auto-merge-deps.yml. + - name: Context + run: echo "event=${{ github.event_name }}" + - name: Checkout - uses: actions/checkout@v6 + if: github.event_name == 'pull_request' + uses: actions/checkout@v7 with: - ref: main fetch-depth: 0 - - name: Categorize PR - id: categorize + - name: Already validated on the pull request + if: github.event_name == 'merge_group' + run: echo "Merge queue run — the CHANGELOG gate was enforced when this PR was reviewed." + + - name: Require a CHANGELOG entry under [Unreleased] + if: github.event_name == 'pull_request' + env: + # Passed via env (not inlined) to avoid shell injection from PR titles/labels. + TITLE: ${{ github.event.pull_request.title }} + LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - TITLE="${{ github.event.pull_request.title }}" - PR_NUM="${{ github.event.pull_request.number }}" - PR_URL="${{ github.event.pull_request.html_url }}" + set -euo pipefail - # Extract category from conventional commit prefix - if echo "$TITLE" | grep -qiE '^feat(\(.*\))?[!]?:'; then - CATEGORY="Added" - elif echo "$TITLE" | grep -qiE '^fix(\(.*\))?[!]?:'; then - CATEGORY="Fixed" - elif echo "$TITLE" | grep -qiE '^refactor(\(.*\))?[!]?:'; then - CATEGORY="Changed" - elif echo "$TITLE" | grep -qiE '^perf(\(.*\))?[!]?:'; then - CATEGORY="Changed" - elif echo "$TITLE" | grep -qiE '^breaking(\(.*\))?[!]?:'; then - CATEGORY="Changed" - elif echo "$TITLE" | grep -qiE '^deprecate(\(.*\))?[!]?:'; then - CATEGORY="Deprecated" - elif echo "$TITLE" | grep -qiE '^remove(\(.*\))?[!]?:'; then - CATEGORY="Removed" - elif echo "$TITLE" | grep -qiE '^security(\(.*\))?[!]?:'; then - CATEGORY="Security" - elif echo "$TITLE" | grep -qiE '^docs(\(.*\))?[!]?:'; then - echo "skip=true" >> "$GITHUB_OUTPUT" + # 1) Exempt non-user-facing PR types (mirrors the previous skip set). + # + # `deps:` is exempt here but is NOT undocumented: release-changelog.yml batches every + # merged `deps:` PR since the previous tag into a "### Dependencies" section when it + # cuts the release. Requiring a per-PR entry instead would mean a bot committing to + # each Dependabot branch, which makes concurrent Dependabot PRs conflict on CHANGELOG.md + # and makes Dependabot stop rebasing them (it abandons PRs modified by others). + if printf '%s' "$TITLE" | grep -qiE '^(deps|docs|style|chore|ci|test|build)(\(.*\))?[!]?:'; then + echo "Exempt PR type — skipping changelog check." + echo " title: $TITLE" exit 0 - elif echo "$TITLE" | grep -qiE '^(style|chore|ci|test)(\(.*\))?[!]?:'; then - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - else - CATEGORY="Changed" fi - # Strip prefix from title for the entry text - ENTRY=$(echo "$TITLE" | sed -E 's/^[a-zA-Z]+(\(.*\))?[!]?:\s*//') - - echo "category=$CATEGORY" >> "$GITHUB_OUTPUT" - echo "entry=$ENTRY" >> "$GITHUB_OUTPUT" - echo "pr_num=$PR_NUM" >> "$GITHUB_OUTPUT" - echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" - echo "skip=false" >> "$GITHUB_OUTPUT" - - - name: Update CHANGELOG.md - if: steps.categorize.outputs.skip != 'true' - run: | - CATEGORY="${{ steps.categorize.outputs.category }}" - ENTRY="${{ steps.categorize.outputs.entry }}" - PR_NUM="${{ steps.categorize.outputs.pr_num }}" - PR_URL="${{ steps.categorize.outputs.pr_url }}" - - # Skip if this PR is already referenced in the [Unreleased] section - # (e.g., changelog was updated manually in the PR branch) - UNRELEASED_BLOCK=$(awk '/^## \[Unreleased\]/,/^## \[[0-9]/' CHANGELOG.md 2>/dev/null) - if echo "$UNRELEASED_BLOCK" | grep -qF "#$PR_NUM"; then - echo "PR #$PR_NUM already referenced in [Unreleased] — skipping auto-insert" + # 2) Manual escape hatch for legitimate exceptions. + if printf ',%s,' "$LABELS" | grep -q ',skip-changelog,'; then + echo "skip-changelog label present — skipping changelog check." exit 0 fi - # Check if CHANGELOG.md exists - if [ ! -f CHANGELOG.md ]; then - cat > CHANGELOG.md << 'INIT' - # Changelog - - All notable changes to this project are documented in this file. + # 3) Extract the [Unreleased] block from both sides of the PR. + unreleased() { + git show "$1:CHANGELOG.md" 2>/dev/null \ + | awk '/^## \[Unreleased\]/{f=1; next} f && /^## \[/{f=0} f' + } + base_block="$(unreleased "$BASE_SHA" || true)" + head_block="$(unreleased "$HEAD_SHA" || true)" - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + # 4) Top-level entries this PR newly adds under [Unreleased]. + new_entries="$(comm -13 \ + <(printf '%s\n' "$base_block" | grep -E '^- ' | sort -u) \ + <(printf '%s\n' "$head_block" | grep -E '^- ' | sort -u) || true)" - ## [Unreleased] - INIT - fi - - # Use awk to insert entry under [Unreleased] only (not older release sections) - # Pass values via environment to avoid awk -v escaping issues with special characters - export AWK_CATEGORY="### $CATEGORY" - export AWK_ENTRY="- $ENTRY ([PR #$PR_NUM]($PR_URL))" - awk ' - BEGIN { category=ENVIRON["AWK_CATEGORY"]; entry=ENVIRON["AWK_ENTRY"]; found_unreleased=0; inserted=0 } - /^## \[Unreleased\]/ { found_unreleased=1; print; next } - # If we hit the next version section, unreleased block is over - found_unreleased && /^## \[/ { - if (!inserted) { - print "" - print category - print entry - inserted=1 - } - found_unreleased=0 - print; next - } - # Found existing category header under [Unreleased] - found_unreleased && !inserted && $0 == category { - print - print entry - inserted=1 - next - } - # Hit a different category or blank line before any matching category — insert new section before it - found_unreleased && !inserted && /^### / { - print category - print entry - print "" - inserted=1 - print; next - } - { print } - END { - if (!inserted) { - print "" - print category - print entry - } - } - ' CHANGELOG.md > CHANGELOG.tmp - if [ -s CHANGELOG.tmp ]; then - mv CHANGELOG.tmp CHANGELOG.md - else - echo "::error::awk produced empty output — CHANGELOG.md not modified" - rm -f CHANGELOG.tmp - exit 1 + if [ -n "$new_entries" ]; then + echo "Found new [Unreleased] entry/entries:" + printf '%s\n' "$new_entries" + exit 0 fi - - name: Commit and push - if: steps.categorize.outputs.skip != 'true' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add CHANGELOG.md - git diff --cached --quiet || (git commit -m "docs: update changelog for PR #${{ github.event.pull_request.number }}" && git push) + echo "::error::This PR has no new entry under '## [Unreleased]' in CHANGELOG.md." + echo "Add a Keep a Changelog entry (e.g. under '### Fixed'), or:" + echo " - use a 'deps|docs|style|chore|ci|test|build:' PR title for non-user-facing changes, or" + echo " - apply the 'skip-changelog' label for a legitimate exception." + exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8a54e48..5160b390 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,15 @@ name: CI +# `merge_group` is required for the merge queue on `main`. Both jobs below are required +# status checks, and a queued PR waits on them being reported against the queue's temporary +# merge branch -- not against the PR head. Without this trigger they never run there and +# every queued PR stalls until it times out. on: - push: - branches: [main] - pull_request: - branches: [main] + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + merge_group: jobs: backend: @@ -13,15 +18,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET 10 - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x' - name: Cache NuGet packages - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.slnx') }} @@ -39,7 +44,7 @@ jobs: - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: backend-test-results path: '**/TestResults/*.trx' @@ -54,15 +59,22 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Node.js 22 - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '22' cache: 'npm' cache-dependency-path: Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json + # Node 22 ships npm 10.9.7, whose `npm ci` rejects lockfiles that omit nested + # optional-peer entries (chokidar@4 / readdirp@4 under @angular-devkit/*) that + # newer npm versions prune. Pinning npm 11 here matches what Dependabot uses + # to regenerate lockfiles, so `npm ci` stays in sync with that resolution. + - name: Pin npm 11 + run: npm install -g npm@11 + - name: Install dependencies run: npm ci diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 9422e5a1..c3c65470 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Checkout PR head - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.sha }} @@ -46,7 +46,7 @@ jobs: - name: Build and push id: build - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . push: true @@ -56,7 +56,7 @@ jobs: cache-to: type=gha,mode=max - name: Comment preview instructions on PR - uses: marocchino/sticky-pull-request-comment@v2 + uses: marocchino/sticky-pull-request-comment@v3 with: header: preview-image message: | diff --git a/.github/workflows/docker-prune.yml b/.github/workflows/docker-prune.yml index c706a3c2..66b499c1 100644 --- a/.github/workflows/docker-prune.yml +++ b/.github/workflows/docker-prune.yml @@ -18,7 +18,7 @@ jobs: pull-requests: read steps: - name: Delete pr-* tags for closed PRs - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: ORG: ${{ env.ORG }} PACKAGE: ${{ env.PACKAGE }} @@ -56,16 +56,63 @@ jobs: } } - prune-main-shas: + prune-develop-shas: runs-on: ubuntu-latest permissions: packages: write steps: - - name: Keep only last 10 main- images - uses: actions/delete-package-versions@v5 + # Deliberately github-script rather than actions/delete-package-versions. + # + # That action's `ignore-versions` matches a package version's NAME, and for container packages the + # name is the digest -- never the tag. So the ignore list protected nothing, and + # `min-versions-to-keep: 10` deleted every version older than the ten most recent pushes whatever + # it was tagged. Twenty merges to develop after a release was enough to take `latest` and the + # version tags with it: on 2026-08-09 the registry held `beta` and 28 `develop-` tags and + # nothing else, so every documented `docker compose up` failed to pull for anyone not already + # running an image. The v2.13.0 tags had published successfully and were then pruned away. + # + # A version is deleted only when EVERY tag on it looks like develop-. A develop push tags one + # digest both `beta` and `develop-`, and a release tags one digest `latest`, `X.Y.Z`, `X.Y` + # and `` -- checking every tag is what keeps those safe. + - name: Keep only the last 10 develop- images + uses: actions/github-script@v9 + env: + ORG: ${{ env.ORG }} + PACKAGE: ${{ env.PACKAGE }} + KEEP: '10' with: - package-name: poracleweb.net - package-type: container - owner: pgan-dev - min-versions-to-keep: 10 - ignore-versions: '^(latest|beta|v\\d+\\.\\d+.*|pr-.*)$' + script: | + const { ORG, PACKAGE } = process.env; + const keep = Number(process.env.KEEP); + + const versions = await github.paginate( + github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg, + { package_type: 'container', package_name: PACKAGE, org: ORG, per_page: 100 }, + ); + + const isDevelopSha = t => /^develop-[0-9a-f]+$/i.test(t); + const prunable = versions.filter(v => { + const tags = v.metadata?.container?.tags ?? []; + return tags.length > 0 && tags.every(isDevelopSha); + }); + + // Sorted explicitly rather than trusting the API's order. Which images get deleted depends + // entirely on this, and an undocumented ordering is not something to bet the registry on. + prunable.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + const surplus = prunable.slice(keep); + console.log(`${versions.length} versions, ${prunable.length} develop-only, deleting ${surplus.length}`); + + for (const v of surplus) { + const tags = (v.metadata?.container?.tags ?? []).join(','); + try { + await github.rest.packages.deletePackageVersionForOrg({ + package_type: 'container', + package_name: PACKAGE, + org: ORG, + package_version_id: v.id, + }); + console.log(`Deleted ${tags}`); + } catch (e) { + console.log(`Skipping ${tags}: ${e.message}`); + } + } diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 15f2ab82..6452ca9b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,7 +4,7 @@ on: release: types: [published] push: - branches: [main] + branches: [develop] workflow_dispatch: env: @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Log in to GitHub Container Registry uses: docker/login-action@v4 @@ -41,17 +41,29 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=beta,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - type=sha,prefix=main-,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + type=raw,value=beta,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} + type=sha,prefix=develop-,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} type=sha,prefix=,enable=${{ github.event_name == 'release' }} + # Computed here rather than pulled out of the metadata-action JSON, so the value passed + # to the build is plainly visible in the log next to the one baked into the labels. + - name: Build timestamp + id: build + run: echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" + - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Mirrors the OCI labels above into the running container, where GET /api/version can + # read them. Labels alone are not visible from inside the container at runtime. + build-args: | + BUILD_VERSION=${{ steps.meta.outputs.version }} + BUILD_REVISION=${{ github.sha }} + BUILD_DATE=${{ steps.build.outputs.date }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c178649..697db5d2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,6 +6,12 @@ on: paths: - 'docs/**' - 'mkdocs.yml' + # Rebuild when a release is published so the mkdocs-material version badge + # (fetched at build time from the GitHub Releases API) stays current even + # when a release touches only code/CHANGELOG and not docs/. + release: + types: [published] + workflow_dispatch: permissions: contents: write @@ -14,9 +20,9 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: '3.12' diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 2479019f..fa27ebc7 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Apply label from branch prefix or title - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const pr = context.payload.pull_request; diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index 5a2b9019..823173ef 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: main fetch-depth: 0 @@ -27,15 +27,111 @@ jobs: # Replace [Unreleased] with the version and add new [Unreleased] sed -i "s/^## \[Unreleased\]/## [Unreleased]\n\n## [$VERSION] - $DATE/" CHANGELOG.md - # Update comparison links - PREV_VERSION=$(grep -oP '^\[[\d.]+\]' CHANGELOG.md | head -2 | tail -1 | tr -d '[]') + # Update comparison links. The new version's [x.y.z]: link is not added until the sed + # below, so the first existing version link-def is the immediately-preceding release. + PREV_VERSION=$(grep -oP '^\[[\d.]+\]' CHANGELOG.md | head -1 | tr -d '[]') if [ -n "$PREV_VERSION" ]; then sed -i "s|\[Unreleased\]: .*|[Unreleased]: https://github.com/${{ github.repository }}/compare/v$VERSION...HEAD\n[$VERSION]: https://github.com/${{ github.repository }}/compare/v$PREV_VERSION...v$VERSION|" CHANGELOG.md fi + # Collect dependency bumps into the freshly-cut version section. + # + # Dependabot PRs are exempt from the per-PR changelog check (see changelog.yml) so they can + # be batched here instead. The alternative — a bot committing an entry onto each Dependabot + # branch — makes concurrent Dependabot PRs conflict on the same lines of CHANGELOG.md, and + # Dependabot stops rebasing any PR another actor has modified. + - name: Add Dependencies section for merged deps PRs + env: + TAG: ${{ github.event.release.tag_name }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + VERSION="${TAG#v}" + + # Range: previous tag (exclusive) through this release's tag. `git describe` walks back + # from the tag's parent to the preceding tag; with no preceding tag (first release) the + # range degrades to the whole history, which is what we want. + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then END="$TAG"; else END="HEAD"; fi + PREV_TAG="$(git describe --tags --abbrev=0 "$END^" 2>/dev/null || true)" + if [ -n "$PREV_TAG" ]; then RANGE="$PREV_TAG..$END"; else RANGE="$END"; fi + echo "Collecting dependency commits over $RANGE" + + # Squash merges land as `deps: bump the angular group ... (#338)`. Only `deps:` is + # collected: that prefix is configured for the nuget and npm ecosystems in + # dependabot.yml. `ci:` (Actions) and `build:` (Docker base images) are deliberately + # excluded — they are not runtime dependencies of the shipped product. + entries="$(git log --no-merges --pretty=%s "$RANGE" \ + | grep -E '^deps(\(.*\))?!?: ' \ + | sed -E 's/^deps(\(.*\))?!?: //' \ + | sed -E "s|\(#([0-9]+)\)\$|([#\1](https://github.com/$REPO/pull/\1))|" \ + | sed -E 's/^(.)/\U\1/' \ + | sort -u \ + | sed 's/^/- /' || true)" + + if [ -z "$entries" ]; then + echo "No dependency commits in range — no Dependencies section to add." + exit 0 + fi + + echo "Adding:" + printf '%s\n' "$entries" + + # Append the section at the end of the new version's block, i.e. immediately before the + # next `## [version]` heading or the link-definition footer, whichever comes first. + block="### Dependencies"$'\n'"$entries"$'\n' + # index() rather than a regex: the version is interpolated, and `[2.12.0]` in a regex + # is a character class, not a literal — that silently never matches the heading. + awk -v ver="$VERSION" -v block="$block" ' + !done && index($0, "## [" ver "]") == 1 { inver = 1; prev = $0; print; next } + inver && (/^## \[/ || /^\[[^]]+\]: /) { + if (prev != "") print "" + print block + inver = 0; done = 1 + } + { prev = $0; print } + END { + if (inver) { + if (prev != "") print "" + print block + } + } + ' CHANGELOG.md > CHANGELOG.tmp && mv CHANGELOG.tmp CHANGELOG.md + + # Flags whether the GitHub App is configured. Secrets can't be read in `if:` conditions + # directly, so surface it as a step output the later steps can gate on. + - name: Detect app config + id: cfg + env: + APP_ID: ${{ secrets.CHANGELOG_APP_ID }} + run: | + if [ -n "$APP_ID" ]; then + echo "has_app=true" >> "$GITHUB_OUTPUT" + else + echo "has_app=false" >> "$GITHUB_OUTPUT" + echo "::warning::CHANGELOG_APP_ID/CHANGELOG_APP_PRIVATE_KEY not set — opening the changelog PR with GITHUB_TOKEN, which does not trigger the required CI checks. The PR will need a manual (admin) merge. See the workflow header for setup." + fi + + # An App installation token makes the PR author the app bot, so push/PR events trigger the + # required status checks (Backend/Frontend/Changelog). GITHUB_TOKEN-authored PRs do NOT + # trigger workflows (GitHub's recursion guard), which leaves required checks permanently + # pending and the PR blocked. App tokens are short-lived and auto-minted per run — no PAT + # rotation. Requires a GitHub App (Contents: write, Pull requests: write) installed on this + # repo, with its App ID and a private key stored as the secrets below. + - name: Generate GitHub App token + id: app-token + if: steps.cfg.outputs.has_app == 'true' + uses: actions/create-github-app-token@v3 + with: + app-id: ${{ secrets.CHANGELOG_APP_ID }} + private-key: ${{ secrets.CHANGELOG_APP_PRIVATE_KEY }} + - name: Open PR with changelog update - uses: peter-evans/create-pull-request@v7 + id: cpr + uses: peter-evans/create-pull-request@v8 with: + # App token when configured; otherwise GITHUB_TOKEN (PR opens but needs a manual merge). + token: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} commit-message: "docs: cut changelog for ${{ github.event.release.tag_name }}" title: "docs: cut changelog for ${{ github.event.release.tag_name }}" body: | @@ -43,8 +139,38 @@ jobs: Promotes the `[Unreleased]` section to `[${{ github.event.release.tag_name }}]` and opens a fresh `[Unreleased]` section. + Also appends a `### Dependencies` section listing every `deps:` PR merged since the previous tag. + Triggered by release [${{ github.event.release.tag_name }}](${{ github.event.release.html_url }}). branch: changelog/${{ github.event.release.tag_name }} base: main labels: docs,automated delete-branch: true + + # Approve from the github-actions[bot] identity. The PR was authored by the app bot, so this + # is a distinct identity and satisfies the "1 approval" branch-protection rule. + - name: Approve the changelog PR + if: steps.cfg.outputs.has_app == 'true' && steps.cpr.outputs.pull-request-number + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ steps.cpr.outputs.pull-request-number }} + run: | + gh pr review "$PR" --approve \ + --body "Automated changelog cut — approving the mechanical [Unreleased] → version promotion. Source entries were already reviewed on their own PRs." + + # Squash-merge once the required checks pass (they run because the PR is app-authored). + # + # No --delete-branch: `main` uses a merge queue, and gh rejects the flag outright with + # "Cannot use `-d` or `--delete-branch` when merge queue enabled", failing the whole step. + # It was redundant anyway -- create-pull-request above already sets `delete-branch: true`, + # which removes the branch once the PR closes. + # + # --squash is likewise decorative now (gh warns "the merge strategy for main is set by the + # merge queue") but is kept so the intent still reads correctly if the queue is ever removed. + - name: Enable auto-merge + if: steps.cfg.outputs.has_app == 'true' && steps.cpr.outputs.pull-request-number + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR: ${{ steps.cpr.outputs.pull-request-number }} + run: | + gh pr merge "$PR" --squash --auto diff --git a/.gitignore b/.gitignore index 9ce5b7db..b009dfff 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,10 @@ docker-compose.override.yml appsettings.*.local.json ## Claude Code -.claude/ +# Contents ignored rather than the directory itself: git cannot re-include anything beneath an +# excluded directory, and the shared slash commands below need re-including. +.claude/* +!.claude/commands/ ## IDE .idea/ @@ -33,8 +36,23 @@ avatar-cache.json *.http *.mjs screenshot-*.png +# Playwright MCP server output + loose screenshots at the repo root +# (tracked PNGs all live under ClientApp/public/assets, so this is root-only) +.playwright-mcp/ +/*.png Applications/Pgan.PoracleWebNet.Api/cookies.txt beta-discord-messages.txt # ASP.NET DataProtection runtime keys — never commit Data/dataprotection-keys/ +# DATA_DIR fallback for standalone `dotnet run` (Program.cs uses ./data when DATA_DIR is unset) +Applications/Pgan.PoracleWebNet.Api/data/ + +# Built Angular bundle copied into the API host on publish — regenerated, never committed +Applications/Pgan.PoracleWebNet.Api/wwwroot/ + +# Local-only Docker test build (npm 11 pin); not part of the app build +Dockerfile.local + +# mkdocs build output. `mkdocs build` writes it locally; CI publishes from docs/ instead. +site/ diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/DiscordSettings.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/DiscordSettings.cs index d1200ead..5d0f5ddd 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/DiscordSettings.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/DiscordSettings.cs @@ -4,7 +4,6 @@ public class DiscordSettings { public string ClientId { get; set; } = string.Empty; public string ClientSecret { get; set; } = string.Empty; - public string RedirectUri { get; set; } = string.Empty; public string FrontendUrl { get; set; } = "http://localhost:4200"; public string BotToken { get; set; } = string.Empty; public string GuildId { get; set; } = string.Empty; diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs index 575a3a60..a35ba4f8 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs @@ -15,6 +15,13 @@ public interface IJwtService /// string GenerateToken(UserInfo user); + /// + /// Generates a fresh JWT with an explicit lifetime (minutes), overriding the configured + /// default. Used for refresh-backed OIDC sessions, which are deliberately short-lived so + /// provider-side revocation propagates quickly via silent refresh. + /// + string GenerateToken(UserInfo user, int lifetimeMinutes); + /// /// Generates a JWT for an impersonated user. Includes an impersonatedBy claim /// identifying the admin who initiated the impersonation. @@ -25,6 +32,11 @@ public interface IJwtService /// Generates a JWT by copying claims from an existing /// and replacing profileNo. Framework-injected claims (exp, nbf, /// iat, iss, aud) are filtered out to avoid duplication. + /// + /// The re-issued token keeps the original exp rather than starting a fresh lifetime -- a + /// re-issue must never extend a session. Pass to replace the copied + /// claim with a freshly resolved value. See #624. + /// /// - string GenerateTokenWithReplacedProfile(ClaimsPrincipal existingPrincipal, int profileNo); + string GenerateTokenWithReplacedProfile(ClaimsPrincipal existingPrincipal, int profileNo, bool? isAdmin = null); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs index 87f9a55c..6386cae0 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs @@ -33,6 +33,12 @@ public string GenerateToken(UserInfo user) return this.WriteToken(claims); } + public string GenerateToken(UserInfo user, int lifetimeMinutes) + { + var claims = BuildClaims(user); + return this.WriteToken(claims, lifetimeMinutes); + } + public string GenerateImpersonationToken(UserInfo user, string impersonatedBy) { var claims = BuildClaims(user); @@ -40,7 +46,7 @@ public string GenerateImpersonationToken(UserInfo user, string impersonatedBy) return this.WriteToken(claims); } - public string GenerateTokenWithReplacedProfile(ClaimsPrincipal existingPrincipal, int profileNo) + public string GenerateTokenWithReplacedProfile(ClaimsPrincipal existingPrincipal, int profileNo, bool? isAdmin = null) { var claims = new List(); foreach (var claim in existingPrincipal.Claims) @@ -60,7 +66,42 @@ public string GenerateTokenWithReplacedProfile(ClaimsPrincipal existingPrincipal } claims.Add(new Claim("profileNo", profileNo.ToString(CultureInfo.InvariantCulture))); - return this.WriteToken(claims); + + // A re-issue must not extend the session. This used to end in WriteToken(claims), which applies + // the configured default of 24 hours -- so an OIDC login's deliberately short 30-minute access + // token became a day-long one on the first profile switch, and a user who switched profile once + // a day never expired at all. Revocation is supposed to propagate within roughly one access + // token's lifetime; renewing on re-issue quietly removed that bound. See #624. + var remaining = RemainingMinutes(existingPrincipal); + if (isAdmin is { } resolved) + { + // Copied verbatim, isAdmin outlived the rights it described: nothing revalidates the claim, + // so de-admining someone had no effect while they kept switching profile. + claims.RemoveAll(c => string.Equals(c.Type, "isAdmin", StringComparison.Ordinal)); + claims.Add(new Claim("isAdmin", resolved.ToString().ToLowerInvariant())); + } + + return remaining is { } minutes + ? this.WriteToken(claims, minutes) + : this.WriteToken(claims); + } + + /// + /// Whole minutes left on the principal's own exp, or null when it carries none. + /// + private static int? RemainingMinutes(ClaimsPrincipal principal) + { + var exp = principal.FindFirst("exp")?.Value ?? principal.FindFirst(JwtRegisteredClaimNames.Exp)?.Value; + if (!long.TryParse(exp, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seconds)) + { + return null; + } + + var remaining = DateTimeOffset.FromUnixTimeSeconds(seconds) - DateTimeOffset.UtcNow; + + // The request authenticated, so the token was live when it arrived; a floor of one minute keeps + // a token that expires mid-request from being re-issued already dead. + return Math.Max(1, (int)Math.Ceiling(remaining.TotalMinutes)); } private static List BuildClaims(UserInfo user) @@ -88,7 +129,9 @@ private static List BuildClaims(UserInfo user) return claims; } - private string WriteToken(List claims) + private string WriteToken(List claims) => this.WriteToken(claims, this._settings.ExpirationMinutes); + + private string WriteToken(List claims, int lifetimeMinutes) { var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(this._settings.Secret)); var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); @@ -97,7 +140,7 @@ private string WriteToken(List claims) issuer: this._settings.Issuer, audience: this._settings.Audience, claims: claims, - expires: DateTime.UtcNow.AddMinutes(this._settings.ExpirationMinutes), + expires: DateTime.UtcNow.AddMinutes(lifetimeMinutes), signingCredentials: credentials); return new JwtSecurityTokenHandler().WriteToken(token); diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/OidcSettings.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/OidcSettings.cs new file mode 100644 index 00000000..192965f6 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/OidcSettings.cs @@ -0,0 +1,115 @@ +namespace Pgan.PoracleWebNet.Api.Configuration; + +/// +/// Configuration for a generic external OIDC / OAuth2 login provider. This lets any +/// self-hoster delegate PoracleWeb login to their own identity provider (PGAN's +/// PogoAlerts being one instance). It mirrors the Discord flow, parameterized by config. +/// All values come from env/appsettings (the provider secret is never stored in the DB); +/// the admin runtime on/off toggle is the separate enable_oidc site setting. +/// +public class OidcSettings +{ + /// Master switch from server config. When false the provider is hidden regardless of other values. + public bool Enabled { get; set; } + + /// Display name shown on the login button, e.g. "PogoAlerts". + public string ProviderName { get; set; } = string.Empty; + + /// Browser-facing authorization endpoint. For PogoAlerts this is e.g. https://pogoalerts.net/login. + public string AuthorizationUrl { get; set; } = string.Empty; + + /// Token endpoint that exchanges the authorization code for an access token. + public string TokenUrl { get; set; } = string.Empty; + + /// + /// Optional OIDC RP-initiated logout (end-session) endpoint. When set, signing out of + /// PoracleWeb redirects the browser here with a post_logout_redirect_uri so the + /// provider can also end its own session (true single logout). When empty, logout is + /// local-only (the provider session survives). For PogoAlerts this is e.g. + /// https://pogoalerts.net/logout. + /// + public string EndSessionUrl { get; set; } = string.Empty; + + /// UserInfo endpoint (OpenID Connect compatible) returning the user's claims. + public string UserInfoUrl { get; set; } = string.Empty; + + public string ClientId { get; set; } = string.Empty; + public string ClientSecret { get; set; } = string.Empty; + + /// Space-delimited OAuth scopes requested at authorization time. + public string Scopes { get; set; } = "openid profile email"; + + /// + /// UserInfo claim whose value is the Poracle human id (a Discord or Telegram id). + /// Defaults to discord_id (PogoAlerts passes through the linked Discord id); + /// falls back to sub when the configured claim is absent. + /// + public string IdentityClaim { get; set; } = "discord_id"; + + /// UserInfo claim used as the display username. + public string UsernameClaim { get; set; } = "preferred_username"; + + /// UserInfo claim used as the avatar URL. + public string AvatarClaim { get; set; } = "picture"; + + /// + /// Value written to the JWT type claim for users who log in via this provider. + /// Defaults to discord:user so downstream admin/role resolution treats the + /// passed-through Discord id consistently with a direct Discord login. + /// + public string IdentityType { get; set; } = "discord:user"; + + /// Whether to use PKCE (Proof Key for Code Exchange) — recommended and supported by PogoAlerts. + public bool UsePkce { get; set; } = true; + + /// + /// Master opt-in for consuming the provider's refresh token (silent session renewal + + /// revocation propagation). Default false — when off, behavior is identical to a + /// plain login: the provider's tokens are discarded and the internal JWT lives its full + /// . Requires the provider to actually issue a + /// refresh token (standard providers gate that behind the offline_access scope — + /// see ). + /// + public bool UseRefreshTokens { get; set; } + + /// + /// Internal JWT lifetime (minutes) for refresh-backed OIDC sessions only. Kept short so a + /// disable/revocation at the provider propagates within roughly one access-token lifetime. + /// Other logins (Discord, Telegram, local, OIDC without refresh) are unaffected and keep + /// . + /// + public int AccessTokenMinutes { get; set; } = 30; + + /// + /// PoracleWeb-side absolute cap (days) on a refresh session/family before a real re-login is + /// forced. Independent of the provider's own refresh-token lifetime; if the provider's token + /// expires first, the refresh call fails and the session is revoked — correct either way. + /// + public int RefreshTokenLifetimeDays { get; set; } = 30; + + /// + /// How long (days) a revoked/rotated oidc_sessions row is retained before the cleanup + /// service deletes it. Revoked rows are kept briefly so a replayed old opaque token is still + /// detected (and family-revoked) rather than silently 401ing; replay happens fast, so a short + /// window suffices. Kept separate from so frequent + /// rotation doesn't pile up 30 days of dead rows. Expired rows are deleted regardless of this. + /// + public int RevokedRetentionDays { get; set; } = 2; + + /// + /// Scope appended to the authorization request (only when is on + /// and it isn't already present) so a standards-compliant provider issues a refresh token. + /// Defaults to offline_access. Set empty for providers that issue refresh tokens + /// unconditionally, or that use a non-standard mechanism (e.g. Google's + /// access_type=offline appended directly to ). + /// + public string OfflineAccessScope { get; set; } = "offline_access"; + + /// + /// How client credentials are presented at the token endpoint: client_secret_post + /// (default — credentials in the form body, what PogoAlerts uses) or client_secret_basic + /// (HTTP Basic auth header — the default for Keycloak/Okta). Applies to both the + /// authorization-code exchange and the refresh-token grant. + /// + public string TokenEndpointAuthMethod { get; set; } = "client_secret_post"; +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/PublicOrigin.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/PublicOrigin.cs new file mode 100644 index 00000000..12200b7b --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/PublicOrigin.cs @@ -0,0 +1,62 @@ +namespace Pgan.PoracleWebNet.Api.Configuration; + +/// +/// Normalises the optional PUBLIC_URL setting into a bare origin (scheme://host[:port]). +/// +/// OAuth providers require the callback URI to be registered in advance and to match byte-for-byte +/// between the authorize request and the token exchange, so it cannot be safely derived from the +/// incoming request when the deployment sits behind a proxy that has not been declared -- the scheme +/// comes back as http and the provider rejects the sign-in. Setting PUBLIC_URL states +/// the answer outright instead. +/// +/// Left unset, callers fall back to the request scheme and host, which is the historical behaviour +/// and remains correct for a directly-exposed instance or one whose proxy is declared via +/// PROXY_KNOWN_PROXIES / PROXY_KNOWN_NETWORKS. +/// +internal static class PublicOrigin +{ + /// + /// Validates a configured public URL. Returns false with a non-null + /// when the value is present but unusable, and false with a null error when simply unset. + /// + public static bool TryNormalize(string? configured, out string normalized, out string? error) + { + normalized = string.Empty; + error = null; + + if (string.IsNullOrWhiteSpace(configured)) + { + return false; + } + + var trimmed = configured.Trim(); + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + error = $"'{trimmed}' is not an absolute URL. Expected something like https://poracle.example.com."; + return false; + } + + if (uri.Scheme is not ("http" or "https")) + { + error = $"'{trimmed}' uses the '{uri.Scheme}' scheme. Only http and https are supported."; + return false; + } + + // A path would silently produce callback URIs like https://host/poracle/api/auth/... which is + // never what the app serves, so refuse it rather than emit a URI the provider will reject. + if (uri.AbsolutePath.Trim('/').Length > 0 || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) + { + error = $"'{trimmed}' must be an origin only, with no path, query or fragment " + + $"(e.g. {uri.Scheme}://{uri.Authority})."; + return false; + } + + normalized = $"{uri.Scheme}://{uri.Authority}"; + return true; + } + + /// Returns the normalised origin, or null when unset or unusable. + public static string? NormalizeOrNull(string? configured) => + TryNormalize(configured, out var normalized, out _) ? normalized : null; +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/SecurityHeaders.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/SecurityHeaders.cs new file mode 100644 index 00000000..490ddabf --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/SecurityHeaders.cs @@ -0,0 +1,47 @@ +namespace Pgan.PoracleWebNet.Api.Configuration; + +/// +/// Response security headers applied to every request by the middleware in Program.cs. +/// Extracted from an inline lambda so the policy values are assertable in unit tests +/// without booting the whole app (issue #383). +/// +public static class SecurityHeaders +{ + /// + /// Sends the full referrer on same-origin requests and nothing at all cross-origin. + /// + /// + /// Cross-origin suppression is the point: without it, every remote image host the SPA + /// touches (uicons on raw.githubusercontent.com, Discord avatars on cdn.discordapp.com, + /// Google Fonts) learns the origin of the PoracleWeb instance the user is browsing, + /// which for a private instance is the thing worth not disclosing. Issue #383. + /// + /// Do NOT tighten this to no-referrer. AuthController reads the Referer + /// header on the login and logout entry points (DiscordLogin, the OIDC login path, and + /// OIDC RP-initiated logout) to recover which frontend origin the user came from, + /// validate it against the configured CORS origins, and redirect back there after the + /// provider callback. Those reads are same-origin when the SPA is served by this host, + /// so same-origin keeps them working; no-referrer would blank them and + /// silently bounce users to this host's own origin after login instead. + /// + public const string ReferrerPolicy = "same-origin"; + + public const string ContentSecurityPolicy = + "default-src 'self'; script-src 'self' 'unsafe-hashes' 'sha256-MhtPZXr7+LpJUY5qtMutB+qWfQtMaPccfe7QXtCcEYc=' https://telegram.org; " + + "style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; " + + "connect-src 'self' https://raw.githubusercontent.com; frame-src https://oauth.telegram.org"; + + /// + /// Stamps the security headers onto an outgoing response. + /// + public static void Apply(IHeaderDictionary headers) + { + ArgumentNullException.ThrowIfNull(headers); + + headers.XContentTypeOptions = "nosniff"; + headers.XFrameOptions = "DENY"; + headers.XXSSProtection = "0"; + headers["Referrer-Policy"] = ReferrerPolicy; + headers.ContentSecurityPolicy = ContentSecurityPolicy; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index be23019d..363848d8 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -59,9 +59,13 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register Services services.AddScoped(); + // Keeps quick-pick tracked uids pointing at live rows when an edit rotates them (#403). + // Registered before the alarm services, which all depend on it. + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -72,15 +76,19 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -98,7 +106,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv var scannerConnectionString = configuration.GetConnectionString("ScannerDb"); if (!string.IsNullOrEmpty(scannerConnectionString)) { - services.AddDbContext(options => + services.AddDbContext(options => options.UseMySQL(scannerConnectionString)); services.AddScoped(); } @@ -115,12 +123,45 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv // Register HttpClient for Poracle API (config, geofences, templates — read-only proxy) services.AddHttpClient(); + // Which PoracleNG this is, and what it can store. /health is unauthenticated, so this needs no + // secret and still answers when the API key is wrong -- a state that otherwise looks exactly + // like the server being down. + services.AddScoped(); + + // The one outbound call PoracleWeb makes. Anonymous, cached for six hours, and switchable off + // with disable_update_check for deployments that do not want egress at all. + services.AddHttpClient(client => + { + client.Timeout = TimeSpan.FromSeconds(5); + // GitHub refuses anonymous API calls that do not identify themselves. + client.DefaultRequestHeaders.UserAgent.ParseAdd("PoracleWeb.NET"); + }); + + services.AddHttpClient(client => + { + // A diagnostic must not hold a request open: an unreachable server should answer + // "unknown" quickly rather than stall the admin page behind a default 100s timeout. + client.Timeout = TimeSpan.FromSeconds(5); + }); + // Register HttpClient for PoracleNG tracking proxy (alarm CRUD — replaces direct DB writes) - services.AddHttpClient(); + // Registered as the concrete type, then decorated: UserOwnedOverrideAreaProxy is what the rest + // of the app resolves as IPoracleTrackingProxy. It lets an alarm confine itself to a geofence the + // user drew, which PoracleNG's tracking write refuses outright because those fences are served + // userSelectable=false. HACK: trusted-set-areas. + services.AddHttpClient(); + services.AddScoped(sp => new UserOwnedOverrideAreaProxy( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); // Register HttpClient for PoracleNG human/profile proxy (replaces direct DB writes) services.AddHttpClient(); + // Register HttpClient for PoracleNG summary schedule proxy (quest summary delivery) + services.AddHttpClient(); + // Register HttpClient for Discord notification service services.AddHttpClient(client => { @@ -133,6 +174,13 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv } }); + // Unauthenticated client for pulling the static map off the tileserver before uploading it to + // Discord. Kept separate so the bot token never leaves discordapp.com. + services.AddHttpClient(DiscordNotificationService.MapImageHttpClientName, client => + { + client.Timeout = TimeSpan.FromSeconds(15); + }); + // Register HttpClient for Koji API var kojiToken = configuration["Koji:BearerToken"] ?? string.Empty; services.AddHttpClient(client => @@ -144,13 +192,23 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv } }); + // Register the generic OIDC HTTP client (code exchange / refresh / userinfo) and the + // server-side refresh-session service (opaque-token rotation + encrypted RT storage). + services.AddHttpClient(); + services.AddScoped(); + // Register JWT service (shared token generation across controllers) services.AddSingleton(); + // Admin status and delegated webhooks, resolved live rather than trusted from a claim minted + // at login. See #624 and #626. + services.AddScoped(); + // Register settings services.Configure(configuration.GetSection("Jwt")); services.Configure(configuration.GetSection("Discord")); services.Configure(configuration.GetSection("Telegram")); + services.Configure(configuration.GetSection("Oidc")); services.Configure(configuration.GetSection("Poracle")); services.Configure(configuration.GetSection("Koji")); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs index aaebf311..db9215b6 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs @@ -1,6 +1,8 @@ +using Microsoft.Extensions.Caching.Memory; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -9,21 +11,36 @@ namespace Pgan.PoracleWebNet.Api.Controllers; [Route("api/admin")] public partial class AdminController( IHumanService humanService, + IMemoryCache cache, + IUserPurgeService userPurgeService, IWebhookDelegateService webhookDelegateService, - IPoracleApiProxy poracleApiProxy, + IPoracleApiProxy poracleApiProxy, + IPoracleServerProfileService serverProfileService, + IUpdateCheckService updateCheckService, + IConfiguration configuration, IPoracleHumanProxy humanProxy, IOptions poracleSettings, IJwtService jwtService, + IUserRoleResolver roleResolver, ILogger logger) : BaseApiController { private readonly IHumanService _humanService = humanService; - private readonly IWebhookDelegateService _webhookDelegateService = webhookDelegateService; + private readonly IMemoryCache _cache = cache; + private readonly IUserPurgeService _userPurgeService = userPurgeService; + private readonly IWebhookDelegateService _webhookDelegateService = webhookDelegateService; + private readonly IPoracleServerProfileService _serverProfileService = serverProfileService; + private readonly IUpdateCheckService _updateCheckService = updateCheckService; + private readonly IConfiguration _configuration = configuration; private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; private readonly IPoracleHumanProxy _humanProxy = humanProxy; private readonly PoracleSettings _poracleSettings = poracleSettings.Value; private readonly IJwtService _jwtService = jwtService; + private readonly IUserRoleResolver _roleResolver = roleResolver; private readonly ILogger _logger = logger; + /// Caps one avatar batch. The admin user list is the only caller and batches per viewport. + private const int MaxAvatarBatchSize = 200; + [HttpGet("users")] public async Task GetAllUsers() { @@ -46,12 +63,88 @@ public async Task GetAllUsers() h.DisabledDate, h.CurrentProfileNo, h.Language, + h.Notes, AvatarUrl = Services.AvatarCacheService.GetAvatarOrDefault(h.Id, h.Type) }); return this.Ok(userList); } + /// + /// The webhooks a delegate manages. + /// + /// + /// /my-webhooks renders only when the session carries managedWebhooks, which happens only for + /// NON-admins -- and it loaded its rows from the admin user list, which rejects exactly those people. + /// The only users who could see the page were the only users the endpoint refused: a 403, an empty + /// table and a failure toast. Scoped to the caller's own grants instead. See #564. + /// + [HttpGet("my-webhooks")] + public async Task GetManagedWebhooks() + { + // Resolved live rather than read from the JWT claim. The claim is minted at login and lives 24 + // hours, so revoking a delegate left them managing the webhook until they happened to sign in + // again -- and impersonation authorises off the same claim. See #601. + // The union the JWT claim is built from, not the local table alone. Resolving from + // poracle_web.webhook_delegates only meant a delegate configured in PoracleJS -- the + // delegateAdministration mechanism -- saw the nav item, got an empty page here, and a 403 from + // impersonate. See #626. + var managed = (await this._roleResolver.ResolveAsync(this.UserId)).ManagedWebhooks ?? []; + if (managed.Length == 0) + { + return this.Ok(Array.Empty()); + } + + var humans = await this._humanService.GetAllAsync(); + + var webhooks = humans + .Where(h => managed.Contains(h.Id, StringComparer.Ordinal)) + .Select(h => new + { + h.Id, + h.Name, + h.Type, + h.Enabled, + h.AdminDisable, + h.LastChecked, + h.DisabledDate, + h.CurrentProfileNo, + h.Language, + h.Notes, + AvatarUrl = Services.AvatarCacheService.GetAvatarOrDefault(h.Id, h.Type), + }); + + return this.Ok(webhooks); + } + + /// + /// Resolves avatar URLs for a batch of user IDs. already holds + /// them (the background cache populates it and GET users reads the same source), so this is a + /// lookup rather than a fetch -- it never calls Discord. + /// + [HttpPost("users/avatars")] + public IActionResult GetUserAvatars([FromBody] string[] userIds) + { + if (!this.IsAdmin) + { + return this.Forbid(); + } + + if (userIds is null || userIds.Length == 0) + { + return this.Ok(new Dictionary()); + } + + // Bounded so a caller cannot ask for an unlimited batch in one request. + var avatars = userIds + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .Take(MaxAvatarBatchSize) + .ToDictionary(id => id, id => Services.AvatarCacheService.GetAvatarOrDefault(id), StringComparer.Ordinal); + + return this.Ok(avatars); + } + [HttpGet("users/by-id")] public async Task GetUser([FromQuery] string id) { @@ -79,6 +172,7 @@ public async Task GetUser([FromQuery] string id) human.Area, human.Latitude, human.Longitude, + human.Notes, AvatarUrl = avatarUrl }); } @@ -99,6 +193,11 @@ public async Task EnableUser([FromQuery] string id) await this._humanProxy.AdminDisabledAsync(id, false); + // Evict the block cache so this takes effect on the next request rather than up to a + // minute later. Without it the filter kept serving the value it had already cached, which + // is how the first live check of this fix appeared to fail. See #609. + this._cache.Remove($"blocked:{id}"); + // Re-fetch to return the updated state var updated = await this._humanService.GetByIdAsync(id) ?? human; return this.Ok(updated); @@ -112,6 +211,17 @@ public async Task DisableUser([FromQuery] string id) return this.Forbid(); } + // Now that a block is actually enforced (#609), an admin blocking their own account loses the + // API immediately -- including the endpoint that would unblock it. The list shows every account, + // their own included, one row among many. See #613. + if (string.Equals(id, this.UserId, StringComparison.Ordinal)) + { + return this.BadRequest(new + { + error = "You cannot block your own account.", + }); + } + var human = await this._humanService.GetByIdAsync(id); if (human is null) { @@ -120,6 +230,11 @@ public async Task DisableUser([FromQuery] string id) await this._humanProxy.AdminDisabledAsync(id, true); + // Evict the block cache so this takes effect on the next request rather than up to a + // minute later. Without it the filter kept serving the value it had already cached, which + // is how the first live check of this fix appeared to fail. See #609. + this._cache.Remove($"blocked:{id}"); + // Re-fetch to return the updated state var updated = await this._humanService.GetByIdAsync(id) ?? human; return this.Ok(updated); @@ -222,13 +337,82 @@ public async Task CreateWebhook([FromBody] CreateWebhookRequest r AdminDisable = 0, }; - var created = await this._humanService.CreateAsync(human); - LogWebhookCreated(this._logger, this.UserId, request.Url); - return this.Ok(created); + try + { + var created = await this._humanService.CreateAsync(human); + LogWebhookCreated(this._logger, this.UserId, request.Url); + return this.Ok(created); + } + catch (HttpRequestException) + { + // PoracleNG commits the human and can still fail on the rest of its create, leaving a row the + // admin was told was never written -- and a retry that answers 409 for a webhook the UI does + // not show. Undo the half-write so the reported failure is the truth. See #482. + if (await this._humanService.ExistsAsync(request.Url)) + { + await this._humanService.DeleteUserAsync(request.Url); + } + + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "Poracle rejected the webhook. Nothing was created.", + }); + } } public record CreateWebhookRequest(string Name, string Url); + /// + /// Which PoracleNG this instance is talking to, what it can store, and whether that is new enough. + /// + /// + /// Admin-only because it describes the deployment rather than the account. Refreshes on request: + /// the point of looking is usually that something just changed. + /// + [HttpGet("server-profile")] + public async Task GetServerProfile([FromQuery] bool refresh = false) + { + if (!this.IsAdmin) + { + return this.Forbid(); + } + + if (refresh) + { + this._serverProfileService.Invalidate(); + this._updateCheckService.Invalidate(); + } + + var profile = await this._serverProfileService.GetAsync(); + + // The build args are absent on a locally built image, which is not the same as being behind. + var runningWeb = this._configuration["BUILD_VERSION"]; + var webRevision = this._configuration["BUILD_REVISION"]; + var (webUpdate, ngUpdate) = await this._updateCheckService.CheckAsync(runningWeb, profile.Version); + + return this.Ok(new + { + version = profile.Version, + schemaVersion = profile.SchemaVersion, + capabilities = profile.Capabilities, + reachable = profile.Reachable, + checkedAt = profile.CheckedAt, + minimumSupported = PoracleServerProfile.MinimumSupported.ToString(), + belowMinimum = profile.IsBelowMinimum, + poracleUpdate = new { running = ngUpdate.Running, latest = ngUpdate.Latest, state = ngUpdate.State.ToString() }, + webUpdate = new { running = webUpdate.Running, latest = webUpdate.Latest, state = webUpdate.State.ToString() }, + + // This site's own build, so the card can name both halves of the deployment rather than + // only the one it talks to. + web = new + { + version = string.IsNullOrWhiteSpace(runningWeb) ? null : runningWeb, + revision = string.IsNullOrWhiteSpace(webRevision) ? null : webRevision, + buildDate = this._configuration["BUILD_DATE"], + }, + }); + } + [HttpGet("poracle-admins")] public async Task GetPoracleAdmins() { @@ -395,6 +579,33 @@ public async Task AddWebhookDelegate([FromBody] WebhookDelegateRe return this.Forbid(); } + if (string.IsNullOrWhiteSpace(request.WebhookId) || request.WebhookId.Length > 500) + { + return this.BadRequest(new { error = "webhookId is required and must be 500 characters or fewer." }); + } + + // userId had neither check, though its column is half the width: over 100 characters surfaced as an + // unhandled DbUpdateException, and an empty string persisted a delegate granting nothing to nobody + // that then appeared in the admin view. Same shape as the guard above. See #483. + if (string.IsNullOrWhiteSpace(request.UserId) || request.UserId.Length > 100) + { + return this.BadRequest(new { error = "userId is required and must be 100 characters or fewer." }); + } + + // Neither id was checked against anything, so a typo created a grant over a webhook that does not + // exist, for a user who does not exist, and the admin delegates view then listed it as real. A + // grant is only meaningful between two accounts that exist. See #514. + var webhook = await this._humanService.GetByIdAsync(request.WebhookId); + if (webhook is null || !string.Equals(webhook.Type, "webhook", StringComparison.OrdinalIgnoreCase)) + { + return this.BadRequest(new { error = "webhookId does not name an existing webhook." }); + } + + if (!await this._humanService.ExistsAsync(request.UserId)) + { + return this.BadRequest(new { error = "userId does not name an existing user." }); + } + var delegates = await this._webhookDelegateService.AddDelegateAsync(request.WebhookId, request.UserId); return this.Ok(delegates); } @@ -416,8 +627,14 @@ public record WebhookDelegateRequest(string WebhookId, string UserId); [HttpPost("impersonate")] public async Task ImpersonateById([FromBody] ImpersonateRequest request) { - // Allow admins or delegates who manage this specific webhook - var isDelegate = this.ManagedWebhooks.Contains(request.UserId); + // Allow admins or delegates who manage this specific webhook. Resolved live rather than read from + // the JWT claim: the claim is minted at login and lives 24 hours, so a revoked delegate could keep + // impersonating the webhook until they next signed in. See #601. + // Same union as the claim and as my-webhooks, so a PoracleJS-configured delegate is not refused + // by an endpoint the nav item just offered them. See #626. + var isDelegate = !this.IsAdmin + && ((await this._roleResolver.ResolveAsync(this.UserId)).ManagedWebhooks ?? []) + .Contains(request.UserId, StringComparer.Ordinal); if (!this.IsAdmin && !isDelegate) { return this.Forbid(); @@ -460,7 +677,11 @@ public async Task DeleteUser([FromQuery] string id) return this.Forbid(); } - var deleted = await this._humanService.DeleteUserAsync(id); + // Everything the account owns goes with it: alarms, geofences, delegate grants, quick picks and + // their applied state. Removing the humans row alone left all of it behind, unreachable but + // intact, and re-creating the same id adopted the lot -- including impersonation rights over a + // recreated webhook URL. See #510, #511, #512. + var deleted = await this._userPurgeService.PurgeAsync(id); if (!deleted) { return this.NotFound(); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs index f9878df1..e95b22ae 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Mvc; using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Api.Controllers; @@ -32,6 +33,17 @@ public async Task GetAll() return this.Ok(geofences); } + /// Adds the cached Discord avatars the admin list renders. See #618. + private static void AddAvatars(UserGeofence geofence) + { + geofence.OwnerAvatarUrl = Services.AvatarCacheService.GetAvatarOrDefault(geofence.HumanId); + + if (!string.IsNullOrEmpty(geofence.ReviewedBy)) + { + geofence.ReviewedByAvatarUrl = Services.AvatarCacheService.GetAvatarOrDefault(geofence.ReviewedBy); + } + } + [HttpGet("submissions")] public async Task GetSubmissions() { @@ -57,7 +69,17 @@ public async Task AdminDelete(int id) await this._userGeofenceService.AdminDeleteAsync(this.UserId, id); return this.NoContent(); } - catch (InvalidOperationException ex) + catch (KojiOperationException ex) + { + // Koji down, or a region deleted between the region list loading and the approve click. This + // used to reach the admin as 500 "An unexpected error occurred." See #422. + LogKojiFailure(this._logger, ex, id); + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "The geofence server rejected the request. It may be unavailable, or the region may no longer exist." + }); + } + catch (GeofenceNotFoundException ex) { LogAdminDeleteFailed(this._logger, ex, id); return this.NotFound(new @@ -65,6 +87,17 @@ public async Task AdminDelete(int id) error = ex.Message }); } + catch (InvalidOperationException ex) + { + // Validation and state-machine failures are the caller's input, not a missing record. These + // all used to come back as 404 carrying a validation message, so the SPA toasted + // "Not found" for a submission sitting visible in the admin list. See #421. + LogAdminDeleteFailed(this._logger, ex, id); + return this.BadRequest(new + { + error = ex.Message + }); + } } [HttpPost("submissions/{id:int}/approve")] @@ -77,10 +110,24 @@ public async Task ApproveSubmission(int id, [FromBody] ApproveReq try { - var result = await this._userGeofenceService.ApproveSubmissionAsync(this.UserId, id, request?.PromotedName); + var result = await this._userGeofenceService.ApproveSubmissionAsync( + this.UserId, id, request?.PromotedName, request?.ParentId, request?.GroupName); + // The SPA swaps the list row for this response, so it needs the same avatars the list + // projection adds -- without them the card fell back to raw snowflakes. See #618. + AddAvatars(result); return this.Ok(result); } - catch (InvalidOperationException ex) + catch (KojiOperationException ex) + { + // Koji down, or a region deleted between the region list loading and the approve click. This + // used to reach the admin as 500 "An unexpected error occurred." See #422. + LogKojiFailure(this._logger, ex, id); + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "The geofence server rejected the request. It may be unavailable, or the region may no longer exist." + }); + } + catch (GeofenceNotFoundException ex) { LogApproveSubmissionFailed(this._logger, ex, id); return this.NotFound(new @@ -88,6 +135,17 @@ public async Task ApproveSubmission(int id, [FromBody] ApproveReq error = ex.Message }); } + catch (InvalidOperationException ex) + { + // Validation and state-machine failures are the caller's input, not a missing record. These + // all used to come back as 404 carrying a validation message, so the SPA toasted + // "Not found" for a submission sitting visible in the admin list. See #421. + LogApproveSubmissionFailed(this._logger, ex, id); + return this.BadRequest(new + { + error = ex.Message + }); + } } [HttpPost("submissions/{id:int}/reject")] @@ -101,9 +159,22 @@ public async Task RejectSubmission(int id, [FromBody] RejectReque try { var result = await this._userGeofenceService.RejectSubmissionAsync(this.UserId, id, request.ReviewNotes); + // The SPA swaps the list row for this response, so it needs the same avatars the list + // projection adds -- without them the card fell back to raw snowflakes. See #618. + AddAvatars(result); return this.Ok(result); } - catch (InvalidOperationException ex) + catch (KojiOperationException ex) + { + // Koji down, or a region deleted between the region list loading and the approve click. This + // used to reach the admin as 500 "An unexpected error occurred." See #422. + LogKojiFailure(this._logger, ex, id); + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "The geofence server rejected the request. It may be unavailable, or the region may no longer exist." + }); + } + catch (GeofenceNotFoundException ex) { LogRejectSubmissionFailed(this._logger, ex, id); return this.NotFound(new @@ -111,6 +182,17 @@ public async Task RejectSubmission(int id, [FromBody] RejectReque error = ex.Message }); } + catch (InvalidOperationException ex) + { + // Validation and state-machine failures are the caller's input, not a missing record. These + // all used to come back as 404 carrying a validation message, so the SPA toasted + // "Not found" for a submission sitting visible in the admin list. See #421. + LogRejectSubmissionFailed(this._logger, ex, id); + return this.BadRequest(new + { + error = ex.Message + }); + } } public class ApproveRequest @@ -119,6 +201,18 @@ public string? PromotedName { get; set; } + + /// Optional Koji parent id to assign on promotion. Null keeps the submission's existing region. + public int? ParentId + { + get; set; + } + + /// Optional Koji group/region display name to assign on promotion. Null keeps the existing value. + public string? GroupName + { + get; set; + } } public class RejectRequest @@ -134,4 +228,7 @@ public class RejectRequest [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to reject geofence submission {Id}")] private static partial void LogRejectSubmissionFailed(ILogger logger, Exception ex, int id); + + [LoggerMessage(Level = LogLevel.Error, Message = "Koji rejected an operation while handling geofence {GeofenceId}")] + private static partial void LogKojiFailure(ILogger logger, Exception ex, int geofenceId); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AreaController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AreaController.cs index 78b64368..405f7615 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AreaController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AreaController.cs @@ -1,9 +1,19 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Api.Filters; using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + namespace Pgan.PoracleWebNet.Api.Controllers; +// Gated per-action, not per-controller: the reads stay open, the same way UserGeofenceController +// keeps its reads open. The class-level gate 403d every area lookup, including the ones nobody asked +// for -- the delivery preview embedded in every add-alarm dialog, the geofence page's map overlay, the +// dashboard's map card -- and the error interceptor read those as "this page is dead" and bounced the +// user to the dashboard. Turning off area editing took the separately-toggled geofence page with it. +// disable_areas means area subscriptions cannot be CHANGED; it never meant they cannot be seen. +// See #506, #515, #516. [Route("api/areas")] public partial class AreaController( IPoracleHumanProxy humanProxy, @@ -43,7 +53,13 @@ public async Task GetAvailableAreas() var areasJson = await this._poracleApiProxy.GetAreasWithGroupsAsync(this.UserId); if (areasJson != null) { - return this.Content(areasJson, "application/json"); + // PoracleNG returns its whole fence set regardless of userSelectable, and PoracleWeb feeds + // every user-drawn geofence into that set (userSelectable:false, to keep them off the bot's + // area picker). Streaming the response through therefore handed any signed-in user the names + // of every private geofence anyone had drawn -- "home", "work area", and worse. The Areas + // page filtered them out client-side, so nothing looked wrong. Filter server-side, where it + // counts, and keep the user's OWN fences: those are theirs to see. See #544. + return this.Content(await this.RemoveOtherUsersPrivateAreasAsync(areasJson), "application/json"); } } catch (Exception ex) @@ -54,10 +70,68 @@ public async Task GetAvailableAreas() return this.Ok(Array.Empty()); } + + /// + /// Drops areas marked userSelectable:false unless the caller owns them. + /// + /// + /// A malformed payload is passed through untouched rather than emptied: the page is more useful with + /// an unfiltered list than with none, and the names are not a secret worth failing closed over -- but + /// anything we can parse, we filter. See #544. + /// + private async Task RemoveOtherUsersPrivateAreasAsync(string areasJson) + { + JsonElement parsed; + try + { + parsed = JsonDocument.Parse(areasJson).RootElement; + } + catch (JsonException) + { + return areasJson; + } + + if (parsed.ValueKind != JsonValueKind.Array) + { + return areasJson; + } + + var ownNames = (await this._userGeofenceService.GetByUserAsync(this.UserId) ?? []) + .SelectMany(g => new[] { g.KojiName, g.PromotedName }) + .Where(n => !string.IsNullOrEmpty(n)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var kept = parsed.EnumerateArray() + .Where(area => IsSelectable(area) || OwnedByCaller(area, ownNames)) + .ToList(); + + return JsonSerializer.Serialize(kept); + } + + private static bool IsSelectable(JsonElement area) => + area.ValueKind != JsonValueKind.Object + || !area.TryGetProperty("userSelectable", out var selectable) + || selectable.ValueKind != JsonValueKind.False; + + private static bool OwnedByCaller(JsonElement area, HashSet ownNames) => + area.ValueKind == JsonValueKind.Object + && area.TryGetProperty("name", out var name) + && name.ValueKind == JsonValueKind.String + && ownNames.Contains(name.GetString() ?? string.Empty); [HttpPut] + [RequireFeatureEnabled(DisableFeatureKeys.Areas)] public async Task UpdateAreas([FromBody] UpdateAreasRequest request) { - // Lowercase area names to match Poracle's expected format (PHP PoracleWeb does strtolower) + // Lowercase area names because Poracle matches areas case-sensitively. + // A null entry used to throw an NRE here and surface as a 500. + if (request.Areas is not null && Array.Exists(request.Areas, a => a is null)) + { + return this.BadRequest(new + { + error = "Area names cannot be null." + }); + } + var normalizedAreas = request.Areas != null && request.Areas.Length > 0 ? request.Areas.Select(a => a.ToLowerInvariant()).ToArray() : []; @@ -80,6 +154,15 @@ public async Task UpdateAreas([FromBody] UpdateAreasRequest reque // needed. The discard is intentional. _ = await this._userGeofenceService.PreserveOwnedAreasInHumanAsync(this.UserId, normalizedAreas); + // Read back rather than echo. PoracleNG silently drops any name whose fence is not + // userSelectable, so returning the submitted list asserted subscriptions the user does not have + // - a typo or a stale area name looked accepted until the next page load. See #476. + var stored = await this._humanProxy.GetAreasAsync(this.UserId); + if (stored is not null && stored.Value.TryGetProperty("area", out var storedAreas)) + { + return this.Ok(ParseAreaJson(storedAreas.GetString())); + } + return this.Ok(normalizedAreas); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs index 2e6353a6..d865758c 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services; +using Pgan.PoracleWebNet.Api.Services.Oidc; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -16,32 +18,81 @@ namespace Pgan.PoracleWebNet.Api.Controllers; [EnableRateLimiting("auth")] public partial class AuthController( IHumanService humanService, + IProfileService profileService, IPoracleApiProxy poracleApiProxy, IPoracleHumanProxy humanProxy, ISiteSettingService siteSettingService, IWebhookDelegateService webhookDelegateService, IJwtService jwtService, + IUserRoleResolver roleResolver, + IOidcClient oidcClient, + IOidcSessionService oidcSessionService, IOptions discordSettings, IOptions telegramSettings, + IOptions oidcSettings, IOptions poracleSettings, IConfiguration configuration, ILogger logger) : BaseApiController { private const string EnableDiscordKey = "enable_discord"; private const string EnableTelegramKey = "enable_telegram"; + private const string TelegramBotUsernameKey = "telegram_bot"; + private const string EnableOidcKey = "enable_oidc"; + private const string EnableOidcSloKey = "enable_oidc_slo"; + + // Quote characters used in the allowed_role_ids example across the translated tooltips -- + // users paste them in along with the IDs. + private static readonly char[] RoleIdTrimChars = + ['"', '\'', '«', '»', '“', '”', '„', '‘', '’', ' ', '\t']; private readonly IHumanService _humanService = humanService; + private readonly IProfileService _profileService = profileService; private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; private readonly IPoracleHumanProxy _humanProxy = humanProxy; private readonly ISiteSettingService _siteSettingService = siteSettingService; private readonly IWebhookDelegateService _webhookDelegateService = webhookDelegateService; private readonly IJwtService _jwtService = jwtService; + private readonly IUserRoleResolver _roleResolver = roleResolver; + private readonly IOidcClient _oidcClient = oidcClient; + private readonly IOidcSessionService _oidcSessionService = oidcSessionService; private readonly DiscordSettings _discordSettings = discordSettings.Value; private readonly TelegramSettings _telegramSettings = telegramSettings.Value; + private readonly OidcSettings _oidcSettings = oidcSettings.Value; private readonly PoracleSettings _poracleSettings = poracleSettings.Value; private readonly string[] _allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + private readonly string? _publicOrigin = PublicOrigin.NormalizeOrNull(configuration["PublicUrl"]); private readonly ILogger _logger = logger; + /// + /// The origin this instance is reached on. PUBLIC_URL when configured, otherwise the + /// request's own scheme and host -- which is only correct when the app is exposed directly or + /// its proxy is declared via PROXY_KNOWN_PROXIES / PROXY_KNOWN_NETWORKS. + /// + /// Every OAuth callback URI is built from this single method so the authorize request and the + /// token exchange cannot drift apart -- providers compare the two byte-for-byte. + /// + private string SelfOrigin() => this._publicOrigin ?? $"{this.Request.Scheme}://{this.Request.Host}"; + + /// + /// Warns when a callback URI is about to go out as http:// while the request carries an + /// X-Forwarded-Proto: https that was not trusted. The forwarded-headers middleware strips + /// the header once it applies it, so seeing it here means the proxy was not declared -- the + /// provider is about to reject the sign-in and this is the only place that can say why. + /// + private void WarnIfProxySchemeIgnored(string callbackUri) + { + if (!callbackUri.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var forwardedProto = this.Request.Headers["X-Forwarded-Proto"].FirstOrDefault(); + if (string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase)) + { + LogProxySchemeIgnored(this._logger, callbackUri); + } + } + [AllowAnonymous] [HttpGet("discord/login")] public async Task DiscordLogin() @@ -65,7 +116,7 @@ public async Task DiscordLogin() // Save the frontend origin so we know where to redirect after the callback. // Validate against configured CORS origins to prevent open redirect token theft. - var selfOrigin = $"{this.Request.Scheme}://{this.Request.Host}"; + var selfOrigin = this.SelfOrigin(); var origin = selfOrigin; var referer = this.Request.Headers.Referer.FirstOrDefault(); @@ -83,12 +134,14 @@ public async Task DiscordLogin() this.Response.Cookies.Append("oauth_origin", origin, cookieOptions); // Redirect URI points to the API itself, not the Angular app - var callbackUri = $"{this.Request.Scheme}://{this.Request.Host}/api/auth/discord/callback"; + var callbackUri = $"{selfOrigin}/api/auth/discord/callback"; + this.WarnIfProxySchemeIgnored(callbackUri); var redirectUrl = "https://discordapp.com/api/oauth2/authorize" + $"?client_id={this._discordSettings.ClientId}" + $"&redirect_uri={Uri.EscapeDataString(callbackUri)}" + "&response_type=code" + "&scope=identify" + + "&prompt=none" + $"&state={Uri.EscapeDataString(state)}"; return this.Redirect(redirectUrl); @@ -127,7 +180,8 @@ public async Task DiscordCallback([FromQuery] string code, [FromQ ["client_secret"] = this._discordSettings.ClientSecret, ["grant_type"] = "authorization_code", ["code"] = code, - ["redirect_uri"] = $"{this.Request.Scheme}://{this.Request.Host}/api/auth/discord/callback" + // Must match the authorize request byte-for-byte -- both go through SelfOrigin(). + ["redirect_uri"] = $"{this.SelfOrigin()}/api/auth/discord/callback" }); var tokenResponse = await httpClient.PostAsync("https://discordapp.com/api/oauth2/token", tokenRequest); @@ -212,6 +266,283 @@ public async Task DiscordCallback([FromQuery] string code, [FromQ return this.Redirect($"{frontendUrl}/auth/discord/callback#token={jwt}"); } + [AllowAnonymous] + [HttpGet("oidc/login")] + public IActionResult OidcLogin() + { + // Generic external OIDC/OAuth2 provider — a configurable twin of the Discord flow. + // No early enable_oidc gate here: admins must be able to log in even when the + // provider is disabled for regular users. The check runs in OidcCallback() once + // we know whether the user is an admin (mirrors Discord). + if (!this.OidcConfigured()) + { + return this.NotFound(new + { + error = "External login provider is not configured." + }); + } + + var state = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + var isHttps = string.Equals(this.Request.Scheme, "https", StringComparison.OrdinalIgnoreCase); + var cookieOptions = new CookieOptions + { + HttpOnly = true, + Secure = isHttps, + SameSite = SameSiteMode.Lax, + MaxAge = TimeSpan.FromMinutes(10) + }; + + this.Response.Cookies.Append("oauth_state", state, cookieOptions); + + // Save the frontend origin (validated against CORS origins) so the callback knows + // where to redirect — identical handling to DiscordLogin. + var selfOrigin = this.SelfOrigin(); + var origin = selfOrigin; + + var referer = this.Request.Headers.Referer.FirstOrDefault(); + if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refererUri)) + { + var refererOrigin = $"{refererUri.Scheme}://{refererUri.Authority}"; + if (this._allowedOrigins.Length > 0 + ? this._allowedOrigins.Any(o => string.Equals(o, refererOrigin, StringComparison.OrdinalIgnoreCase)) + : string.Equals(refererOrigin, selfOrigin, StringComparison.OrdinalIgnoreCase)) + { + origin = refererOrigin; + } + } + + this.Response.Cookies.Append("oauth_origin", origin, cookieOptions); + + var callbackUri = $"{selfOrigin}/api/auth/oidc/callback"; + this.WarnIfProxySchemeIgnored(callbackUri); + + var query = new Dictionary + { + ["client_id"] = this._oidcSettings.ClientId, + ["redirect_uri"] = callbackUri, + ["response_type"] = "code", + ["scope"] = this.BuildOidcScope(), + ["state"] = state, + }; + + if (this._oidcSettings.UsePkce) + { + // PKCE: store the verifier in an HttpOnly cookie and send only the S256 challenge. + var codeVerifier = Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + var challenge = Base64UrlEncode(SHA256.HashData(Encoding.ASCII.GetBytes(codeVerifier))); + this.Response.Cookies.Append("oauth_pkce_verifier", codeVerifier, cookieOptions); + query["code_challenge"] = challenge; + query["code_challenge_method"] = "S256"; + } + + return this.Redirect(BuildUrlWithQuery(this._oidcSettings.AuthorizationUrl, query)); + } + + [AllowAnonymous] + [HttpGet("oidc/logout")] + public async Task OidcLogout() + { + // OIDC RP-initiated (single) logout: bounce the browser to the provider's end-session + // endpoint so it can clear its OWN session too, then return to the signed-out landing. + // The frontend has already discarded the local JWT before calling this. + var selfOrigin = this.SelfOrigin(); + var origin = selfOrigin; + + // Validate the return origin the same way the login flow validates oauth_origin. + var referer = this.Request.Headers.Referer.FirstOrDefault(); + if (!string.IsNullOrEmpty(referer) && Uri.TryCreate(referer, UriKind.Absolute, out var refererUri)) + { + var refererOrigin = $"{refererUri.Scheme}://{refererUri.Authority}"; + if (this._allowedOrigins.Length > 0 + ? this._allowedOrigins.Any(o => string.Equals(o, refererOrigin, StringComparison.OrdinalIgnoreCase)) + : string.Equals(refererOrigin, selfOrigin, StringComparison.OrdinalIgnoreCase)) + { + origin = refererOrigin; + } + } + + // ?loggedout=1 tells the login page to show the signed-out panel instead of auto-redirecting. + var postLogout = $"{origin}/login?loggedout=1"; + + // Single logout requires both a configured end-session endpoint and the admin + // runtime toggle (enable_oidc_slo; absent = on). Otherwise fall back to local logout. + var sloSetting = await this._siteSettingService.GetValueAsync(EnableOidcSloKey); + var sloDisabledByAdmin = string.Equals(sloSetting, "false", StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(this._oidcSettings.EndSessionUrl) || sloDisabledByAdmin) + { + return this.Redirect(postLogout); + } + + var query = new Dictionary + { + ["post_logout_redirect_uri"] = postLogout, + ["client_id"] = this._oidcSettings.ClientId, + }; + + return this.Redirect(BuildUrlWithQuery(this._oidcSettings.EndSessionUrl, query)); + } + + [AllowAnonymous] + [HttpGet("oidc/callback")] + public async Task OidcCallback([FromQuery] string code, [FromQuery] string? state) + { + var frontendUrl = this.GetFrontendUrl(); + + // Validate OAuth state parameter for CSRF protection + var savedState = this.Request.Cookies["oauth_state"]; + this.Response.Cookies.Delete("oauth_state"); + + var pkceVerifier = this.Request.Cookies["oauth_pkce_verifier"]; + this.Response.Cookies.Delete("oauth_pkce_verifier"); + + if (string.IsNullOrEmpty(state) || string.IsNullOrEmpty(savedState) || state != savedState) + { + return this.BadRequest(new + { + error = "Invalid OAuth state. Possible CSRF attack." + }); + } + + if (!this.OidcConfigured()) + { + return this.Redirect($"{frontendUrl}/login#error=oidc_disabled"); + } + + if (string.IsNullOrEmpty(code)) + { + return this.Redirect($"{frontendUrl}/login#error=missing_code"); + } + + // Exchange the authorization code for tokens via the generic, provider-agnostic OIDC client. + // Must match the authorize request byte-for-byte -- both go through SelfOrigin(). + var redirectUri = $"{this.SelfOrigin()}/api/auth/oidc/callback"; + var tokenResult = await this._oidcClient.ExchangeCodeAsync(code, redirectUri, pkceVerifier); + if (tokenResult is null) + { + return this.Redirect($"{frontendUrl}/login#error=oidc_token_exchange_failed"); + } + + var userInfoJson = await this._oidcClient.GetUserInfoAsync(tokenResult.AccessToken); + if (userInfoJson is null) + { + return this.Redirect($"{frontendUrl}/login#error=oidc_userinfo_failed"); + } + + var (userInfo, error) = await this.BuildOidcUserInfoAsync(userInfoJson.Value); + if (error is not null || userInfo is null) + { + return this.Redirect($"{frontendUrl}/login#error={error ?? "oidc_no_identity"}"); + } + + if (!string.IsNullOrEmpty(userInfo.AvatarUrl)) + { + Services.AvatarCacheService.SetAvatar(userInfo.Id, userInfo.AvatarUrl); + Services.AvatarCacheService.Save(); + } + + // When refresh-token consumption is enabled AND the provider actually issued a refresh + // token, persist an encrypted server-side session and hand the browser a short-lived JWT + // plus an opaque refresh token. Otherwise fall back to a normal full-lifetime JWT — this is + // the graceful path for providers that don't issue refresh tokens (or when the feature is off). + if (this._oidcSettings.UseRefreshTokens && !string.IsNullOrEmpty(tokenResult.RefreshToken)) + { + var (ip, ua) = this.GetClientMetadata(); + var opaque = await this._oidcSessionService.IssueAsync(userInfo.Id, tokenResult.RefreshToken, ip, ua); + var shortJwt = this._jwtService.GenerateToken(userInfo, this._oidcSettings.AccessTokenMinutes); + return this.Redirect($"{frontendUrl}/auth/oidc/callback#token={shortJwt}&refresh_token={opaque}"); + } + + if (this._oidcSettings.UseRefreshTokens) + { + LogOidcRefreshUnavailable(this._logger); + } + + var jwt = this._jwtService.GenerateToken(userInfo); + return this.Redirect($"{frontendUrl}/auth/oidc/callback#token={jwt}"); + } + + [AllowAnonymous] + [EnableRateLimiting("auth")] + [HttpPost("oidc/refresh")] + public async Task OidcRefresh([FromBody] OidcRefreshRequest request) + { + if (!this._oidcSettings.UseRefreshTokens || string.IsNullOrEmpty(request?.RefreshToken)) + { + return this.Unauthorized(new { error = "invalid_grant" }); + } + + var (ip, ua) = this.GetClientMetadata(); + + OidcRotationTicket ticket; + try + { + ticket = await this._oidcSessionService.StartRotationAsync(request.RefreshToken, ip, ua); + } + catch (UnauthorizedAccessException) + { + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Redeem the provider refresh token. A failure here means the provider revoked it (or the + // user was disabled at the provider) — propagate by revoking our family and logging out. + var tokenResult = await this._oidcClient.RefreshAsync(ticket.DecryptedRefreshToken); + if (tokenResult is null) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "provider_revoked"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Re-validate the user live (existence, enabled, roles) on every refresh. + var userInfoJson = await this._oidcClient.GetUserInfoAsync(tokenResult.AccessToken); + if (userInfoJson is null) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "provider_revoked"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + var (userInfo, error) = await this.BuildOidcUserInfoAsync(userInfoJson.Value); + if (error is not null || userInfo is null) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "account_inactive"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Propagate an admin disable: kill the session rather than keep renewing it. (A user who + // merely toggled their own alerts off keeps AdminDisable == false and is unaffected.) + if (userInfo.AdminDisable) + { + await this._oidcSessionService.AbortRotationAsync(ticket, "admin_disable"); + return this.Unauthorized(new { error = "invalid_grant" }); + } + + // Non-rotating providers return no new refresh token — carry the existing one forward. + var newIdpRefreshToken = tokenResult.RefreshToken ?? ticket.DecryptedRefreshToken; + await this._oidcSessionService.CompleteRotationAsync(ticket, newIdpRefreshToken); + + var jwt = this._jwtService.GenerateToken(userInfo, this._oidcSettings.AccessTokenMinutes); + + return this.Ok(new + { + token = jwt, + refreshToken = ticket.NewOpaqueToken, + expiresIn = this._oidcSettings.AccessTokenMinutes * 60, + }); + } + + [AllowAnonymous] + [EnableRateLimiting("auth")] + [HttpPost("oidc/refresh/revoke")] + public async Task OidcRefreshRevoke([FromBody] OidcRefreshRequest request) + { + if (!string.IsNullOrEmpty(request?.RefreshToken)) + { + await this._oidcSessionService.RevokeAsync(request.RefreshToken, "logout"); + } + + return this.NoContent(); + } + [AllowAnonymous] [HttpPost("telegram/verify")] public async Task TelegramVerify([FromBody] Dictionary telegramData) @@ -344,10 +675,31 @@ public async Task TelegramConfig() return this.Ok(new { enabled = this._telegramSettings.Enabled && !disabledBySetting, - botUsername = this._telegramSettings.BotUsername + botUsername = await this.ResolveTelegramBotUsernameAsync(), }); } + /// + /// The Telegram bot username the login widget is built with. + /// + /// + /// Configuration wins; the telegram_bot site setting is the fallback. The admin page has + /// always offered that field and nothing has ever read it, so an admin filling it in to fix a dead + /// Telegram button saw it save and change nothing. Precedence is this way round on purpose: a + /// deployment whose env var is set and working must not have its widget repointed at whatever was + /// typed into the UI back when the field was inert. See #620. + /// + private async Task ResolveTelegramBotUsernameAsync() + { + if (!string.IsNullOrWhiteSpace(this._telegramSettings.BotUsername)) + { + return this._telegramSettings.BotUsername; + } + + var configured = await this._siteSettingService.GetValueAsync(TelegramBotUsernameKey); + return configured?.Trim().TrimStart('@') ?? string.Empty; + } + /// /// Returns auth provider availability for the login page. Combines server-side /// .env/appsettings configuration ("configured") with admin-togglable site @@ -376,6 +728,30 @@ public async Task Providers() var telegramSetting = await this._siteSettingService.GetValueAsync(EnableTelegramKey); var telegramDisabledByAdmin = string.Equals(telegramSetting, "false", StringComparison.OrdinalIgnoreCase); + // Generic external OIDC provider — "configured" requires the full server-side config. + // Unlike Discord/Telegram (absent setting = enabled), OIDC is OPT-IN: it is only + // active when enable_oidc is explicitly "true", so the default sign-in mode is local. + // The AUTH_FORCE_LOCAL break-glass env flag forces it off regardless — recovery when + // an admin switches to OIDC against a broken provider and locks everyone out. + var oidcConfigured = this.OidcConfigured(); + var oidcSetting = await this._siteSettingService.GetValueAsync(EnableOidcKey); + var forceLocal = configuration.GetValue("Auth:ForceLocal"); + var oidcEnabledByAdmin = string.Equals(oidcSetting, "true", StringComparison.OrdinalIgnoreCase) && !forceLocal; + + // Single logout: available when a provider end-session endpoint is configured AND the + // admin runtime toggle is on (enable_oidc_slo; absent = on once the URL is wired). + var endSessionConfigured = !string.IsNullOrWhiteSpace(this._oidcSettings.EndSessionUrl); + var sloSetting = await this._siteSettingService.GetValueAsync(EnableOidcSloKey); + var sloEnabledByAdmin = !string.Equals(sloSetting, "false", StringComparison.OrdinalIgnoreCase); + + // Silent refresh is active when the provider is configured and the server-side master + // switch (OIDC_USE_REFRESH_TOKENS) is on. Read-only status — there is intentionally no + // runtime admin override: enabling/disabling refresh is a deploy-time decision because it + // is coupled to the per-login JWT lifetime (turning it off mid-session would strand the + // short-lived JWTs of already-logged-in users). Single logout (above) is different — it + // only affects the next logout, so it stays a runtime toggle. + var refreshConfigured = oidcConfigured && this._oidcSettings.UseRefreshTokens; + return this.Ok(new { discord = new @@ -387,20 +763,82 @@ public async Task Providers() { configured = telegramConfigured, enabledByAdmin = !telegramDisabledByAdmin, - botUsername = telegramConfigured ? this._telegramSettings.BotUsername : string.Empty, + botUsername = telegramConfigured ? await this.ResolveTelegramBotUsernameAsync() : string.Empty, + }, + oidc = new + { + configured = oidcConfigured, + enabledByAdmin = oidcEnabledByAdmin, + providerName = oidcConfigured ? this._oidcSettings.ProviderName : string.Empty, + // Whether single logout ("Sign out everywhere") is available: end-session + // endpoint configured AND enabled by the admin (enable_oidc_slo). + endSession = oidcConfigured && endSessionConfigured && sloEnabledByAdmin, + // Whether the frontend should run silent refresh (server brokers the provider RT). + refresh = refreshConfigured, }, }); } + /// + /// The active profile's name, or null when it cannot be read. + /// + /// + /// Never fails the call: the menu label is cosmetic, and /api/auth/me also carries the enabled flag and + /// the profile resync, which matter a great deal more than a name. See #520. + /// + private async Task ActiveProfileNameAsync(int profileNo) + { + try + { + var profile = await this._profileService.GetByUserAndProfileNoAsync(this.UserId, profileNo); + return profile?.Name; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + LogProfileNameLookupFailed(this._logger, ex, profileNo); + return null; + } + } + [EnableRateLimiting("auth-read")] [HttpGet("me")] public async Task Me() { // Read enabled status from DB (not JWT) so it reflects real-time changes var human = await this._humanService.GetByIdAsync(this.UserId); - var adminDisable = human != null && human.AdminDisable == 1; - var enabled = human == null || (human.Enabled == 1 && human.AdminDisable == 0); - var dbProfileNo = human?.CurrentProfileNo ?? this.ProfileNo; + + // A missing row used to read as healthy, so a deleted account kept answering 200 with + // enabled:true while every other endpoint threw. The SPA only signs out on 401, so the user sat + // in a fully rendered app where nothing worked, for up to the token lifetime, with no way out but + // clearing storage by hand. The account is gone; say so, and let the interceptor sign them out. + // See #545. + if (human is null) + { + return this.Unauthorized(new { error = "This account no longer exists." }); + } + + // Blocking a user stopped Poracle delivering to them and did nothing else: no filter, controller or + // service looked at admin_disable, so an existing token kept full read/write access for the rest of + // its 24-hour life, and a fresh login minted another one. The SPA signs out on 401, so answering + // that here ends the session on the next poll, the same way a deleted account does. See #597. + // + // Not while impersonating. This 401 ends the CALLER's session, and under impersonation the caller is + // the admin: inspecting a blocked account signed them out of their own, and the SPA's 401 handler + // discards the stashed admin token with everything else, so there was no way back. Blocked is exactly + // the state an admin inspects an account to confirm -- lapsed subscriptions are why alerts stop -- + // so it is returned as data instead. The SPA already renders a banner from adminDisable. See #706. + // + // The deleted-account 401 above deliberately still fires: there is no account left to show. The SPA + // drops an impersonating admin back to their own token on any 401 rather than ending the session. + if (human.AdminDisable == 1 && !this.IsImpersonating) + { + return this.Unauthorized(new { error = "This account has been blocked by an administrator." }); + } + + var adminDisable = human.AdminDisable == 1; + var enabled = human.Enabled == 1 && human.AdminDisable == 0; + var dbProfileNo = human.CurrentProfileNo; + var managedWebhooks = await this.ResolveManagedWebhooksAsync(); var userInfo = new UserInfo { @@ -412,17 +850,35 @@ public async Task Me() Enabled = enabled, ProfileNo = dbProfileNo, AvatarUrl = this.User.FindFirstValue("avatarUrl"), - ManagedWebhooks = this.ManagedWebhooks.Length > 0 ? this.ManagedWebhooks : null + ManagedWebhooks = managedWebhooks, + ProfileName = await this.ActiveProfileNameAsync(dbProfileNo), }; // Detect JWT/DB profile desync — PoracleNG can change current_profile_no // out-of-band via the active_hours scheduler or bot !profile commands. // When mismatched, issue a refreshed JWT so subsequent API calls use the // correct profile and alarms don't land on the wrong profile. - if (human != null && dbProfileNo != this.ProfileNo) + if (dbProfileNo != this.ProfileNo) { LogProfileResync(this._logger, this.UserId, this.ProfileNo, dbProfileNo); - userInfo.Token = this._jwtService.GenerateToken(userInfo); + // GenerateToken builds from UserInfo, which has no impersonatedBy field, so an admin + // impersonation session lost the only server-side record of what it was -- on precisely the + // out-of-band profile changes this branch exists to absorb. Replace the profile on the + // current principal instead, which is what every other profile-replacement site does and + // which copies every non-registered claim. See #484. + // isAdmin resolved fresh rather than copied: this is the one place a long-lived session + // routinely re-mints its token, so copying the claim let revoked admin rights live on. See #624. + var roles = await this._roleResolver.ResolveAsync(this.UserId); + + // Null means "leave the claim alone". Two cases need it: the resolver could not reach PoracleNG, + // where treating unknown as false stripped admin for the rest of the session (#656); and an + // impersonation session, which AdminController deliberately mints with IsAdmin = false and which + // would otherwise be re-elevated by resolving the impersonated user's own roles (#663). + bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating + ? roles.IsAdmin + : null; + userInfo.IsAdmin = resolvedAdmin ?? this.IsAdmin; + userInfo.Token = this._jwtService.GenerateTokenWithReplacedProfile(this.User, dbProfileNo, resolvedAdmin); } return this.Ok(userInfo); @@ -470,105 +926,64 @@ public IActionResult Logout() => this.Ok(new }); /// - /// Calls PoracleJS getAdministrationRoles once and returns (isAdmin, managedWebhooks). - /// managedWebhooks merges: Poracle-resolved webhook delegation + our own webhook delegate service layer. + /// The webhooks this session may manage, resolved live rather than read from the JWT claim. /// - private async Task<(bool isAdmin, string[]? managedWebhooks)> GetRolesAsync(string userId) + /// + /// + /// The claim is minted at login and lives 24 hours, so a delegate granted access today could not + /// reach /my-webhooks until their token happened to refresh — while the page itself and + /// POST /api/admin/impersonate would already have let them in, both having moved to live + /// resolution in #601 and #626. This response drives whether the nav item renders at all, so it was + /// the last consumer of the claim and the one that decided whether the others were reachable. + /// + /// + /// Two cases keep the claim instead. An impersonation session, where resolving the impersonated + /// account's own delegations answers a different question entirely — the same trap #663 fixed for + /// admin status. And a degraded resolve, where an unreachable PoracleNG or a poracle_web blip + /// would otherwise strip a legitimate delegate's nav item mid-session (#656, #667); there the two + /// sets are unioned, since a partial answer may have found a new grant while missing an old one. + /// + /// + /// Nothing authorises off the claim any more — it is a cold fallback for the degraded path only. + /// Both real checks (the list and the impersonation grant) resolve live and fail closed. + /// + /// + private async Task ResolveManagedWebhooksAsync() { - // Fast path: configured admin IDs - if (!string.IsNullOrEmpty(this._poracleSettings.AdminIds)) - { - var adminIds = this._poracleSettings.AdminIds.Split(',', - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (adminIds.Contains(userId)) - { - return (true, null); - } - } + var claimed = this.ManagedWebhooks; - // Check Poracle config admins list - try - { - var config = await this._poracleApiProxy.GetConfigAsync(); - if (config?.Admins != null && - (config.Admins.Discord.Contains(userId) || config.Admins.Telegram.Contains(userId))) - { - return (true, null); - } - } - catch (Exception ex) + if (this.IsImpersonating) { - LogPoracleConfigFetchFailed(this._logger, ex, userId); + return claimed.Length > 0 ? claimed : null; } - // Call getAdministrationRoles once — resolves delegation including Discord guild roles - var managed = new HashSet(StringComparer.OrdinalIgnoreCase); - var isAdmin = false; - - try - { - var rolesJson = await this._poracleApiProxy.GetAdminRolesAsync(userId); - if (!string.IsNullOrEmpty(rolesJson)) - { - using var doc = JsonDocument.Parse(rolesJson); - var root = doc.RootElement; - - // Some versions return isAdmin at root; others wrap under admin.discord - if (root.TryGetProperty("isAdmin", out var isAdminProp) && isAdminProp.ValueKind == JsonValueKind.True) - { - isAdmin = true; - } - - // Parse admin.discord.webhooks — the authoritative delegate webhook list - if (root.TryGetProperty("admin", out var adminEl) && - adminEl.TryGetProperty("discord", out var discordEl)) - { - if (!isAdmin && - discordEl.TryGetProperty("isAdmin", out var discordAdmin) && - discordAdmin.ValueKind == JsonValueKind.True) - { - isAdmin = true; - } - - if (discordEl.TryGetProperty("webhooks", out var webhooks) && - webhooks.ValueKind == JsonValueKind.Array) - { - foreach (var wh in webhooks.EnumerateArray()) - { - if (wh.GetString() is { } id) - { - managed.Add(id); - } - } - } - } - } - } - catch (Exception ex) - { - LogAdminRolesFetchFailed(this._logger, ex, userId); - } + var roles = await this._roleResolver.ResolveAsync(this.UserId); + var resolved = roles.ManagedWebhooks ?? []; - if (isAdmin) + if (roles.Resolved) { - return (true, null); + return resolved.Length > 0 ? resolved : null; } - // Also merge our own webhook delegate service layer - try - { - var managedWebhookIds = await this._webhookDelegateService.GetManagedWebhookIdsAsync(userId); - foreach (var webhookId in managedWebhookIds) - { - managed.Add(webhookId); - } - } - catch (Exception ex) - { - LogPwebDelegatesFetchFailed(this._logger, ex, userId); - } + string[] merged = [.. resolved.Union(claimed, StringComparer.Ordinal)]; + return merged.Length > 0 ? merged : null; + } - return (false, managed.Count > 0 ? managed.ToArray() : null); + /// + /// Calls PoracleJS getAdministrationRoles once and returns (isAdmin, managedWebhooks). + /// managedWebhooks merges: Poracle-resolved webhook delegation + our own webhook delegate service layer. + /// + /// + /// The caller's current admin status and delegated webhooks. + /// + /// + /// The body of this moved to so the admin endpoints and the token + /// re-issue paths can ask the same question login asks. See #624 and #626. + /// + private async Task<(bool isAdmin, string[]? managedWebhooks)> GetRolesAsync(string userId) + { + var roles = await this._roleResolver.ResolveAsync(userId); + return (roles.IsAdmin, roles.ManagedWebhooks); } /// @@ -593,8 +1008,20 @@ public IActionResult Logout() => this.Ok(new return null; // No roles configured, allow everyone } - var allowedRoles = new HashSet( - roleIdsStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + var (allowedRoles, invalidEntries) = ParseAllowedRoleIds(roleIdsStr); + + if (invalidEntries.Count > 0) + { + LogRoleIdsIgnored(this._logger, string.Join(", ", invalidEntries)); + } + + if (allowedRoles.Count == 0) + { + // The setting is non-empty but nothing in it can ever match a Discord role ID. + // Fail closed: treating this as "allow everyone" would silently disable the gate. + LogRoleIdsUnusable(this._logger); + return "role_check_failed"; + } // Need bot token and guild ID to check roles if (string.IsNullOrEmpty(this._discordSettings.BotToken) || string.IsNullOrEmpty(this._discordSettings.GuildId)) @@ -642,8 +1069,7 @@ public IActionResult Logout() => this.Ok(new #pragma warning restore CA1873 } - // User must have ALL of the allowed roles - if (allowedRoles.IsSubsetOf(userRoles)) + if (HasAllowedRole(allowedRoles, userRoles)) { return null; } @@ -658,6 +1084,52 @@ public IActionResult Logout() => this.Ok(new } } + /// + /// Parses the allowed_role_ids site setting into a set of Discord role IDs. + /// Entries are comma-separated. Surrounding whitespace and quote characters are stripped so a + /// value pasted straight from the setting's example ("123,456") still parses, and entries + /// that are not snowflakes are reported separately instead of being kept as unmatchable garbage. + /// + internal static (HashSet RoleIds, List InvalidEntries) ParseAllowedRoleIds(string? value) + { + var roleIds = new HashSet(StringComparer.Ordinal); + var invalidEntries = new List(); + + if (string.IsNullOrWhiteSpace(value)) + { + return (roleIds, invalidEntries); + } + + foreach (var entry in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var trimmed = entry.Trim(RoleIdTrimChars); + if (trimmed.Length == 0) + { + continue; + } + + // Discord role IDs are snowflakes -- digits only. Anything else (a role name, stray + // punctuation) can never equal a value from the guild member API. + if (trimmed.All(char.IsAsciiDigit)) + { + roleIds.Add(trimmed); + } + else + { + invalidEntries.Add(trimmed); + } + } + + return (roleIds, invalidEntries); + } + + /// + /// Returns true when the user holds any one of the allowed roles. allowed_role_ids is an + /// allow-list: holding one listed role is enough, the user does not have to hold all of them. + /// + internal static bool HasAllowedRole(HashSet allowedRoleIds, HashSet userRoleIds) => + allowedRoleIds.Overlaps(userRoleIds); + private string GetFrontendUrl() { // Use the origin saved during the login step @@ -668,36 +1140,192 @@ private string GetFrontendUrl() return savedOrigin.TrimEnd('/'); } - // Fallback: same scheme/host as the request - return $"{this.Request.Scheme}://{this.Request.Host}"; + // Fallback: the configured public origin, or the request's own scheme and host + return this.SelfOrigin(); + } + + /// + /// The external OIDC provider is "configured" only when enabled and the full set of + /// endpoints plus a client id is present. Mirrors the Discord "configured" check. + /// + private bool OidcConfigured() => + this._oidcSettings.Enabled + && !string.IsNullOrWhiteSpace(this._oidcSettings.AuthorizationUrl) + && !string.IsNullOrWhiteSpace(this._oidcSettings.TokenUrl) + && !string.IsNullOrWhiteSpace(this._oidcSettings.UserInfoUrl) + && !string.IsNullOrWhiteSpace(this._oidcSettings.ClientId); + + /// + /// Builds the authorization-request scope, appending the configured offline-access scope + /// (default offline_access) when refresh consumption is enabled and it isn't already + /// present — the one and only scope mutation we perform. Standards-compliant providers gate + /// refresh-token issuance behind this scope; providers that issue unconditionally (or use a + /// non-standard mechanism) leave OIDC_OFFLINE_ACCESS_SCOPE empty. + /// + private string BuildOidcScope() + { + var scope = string.IsNullOrWhiteSpace(this._oidcSettings.Scopes) ? "openid" : this._oidcSettings.Scopes; + + if (this._oidcSettings.UseRefreshTokens && !string.IsNullOrWhiteSpace(this._oidcSettings.OfflineAccessScope)) + { + var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (!parts.Contains(this._oidcSettings.OfflineAccessScope, StringComparer.Ordinal)) + { + scope = $"{scope} {this._oidcSettings.OfflineAccessScope}"; + } + } + + return scope; + } + + /// + /// Maps OIDC userinfo claims to a , re-validating identity, registration, + /// the enable_oidc admin gate, and role access. Returns (user, null) on success or + /// (null, errorCode) on any failure. Shared by the login callback and the refresh path so + /// a user disabled/derole'd at the provider is re-evaluated on every silent refresh. + /// + private async Task<(UserInfo? user, string? error)> BuildOidcUserInfoAsync(JsonElement userInfoJson) + { + // The identity claim maps to the Poracle human id (a Discord/Telegram id). + // Fall back to the standard OIDC `sub` claim when the configured claim is absent. + var identity = GetClaimString(userInfoJson, this._oidcSettings.IdentityClaim) + ?? GetClaimString(userInfoJson, "sub"); + if (string.IsNullOrEmpty(identity)) + { + return (null, "oidc_no_identity"); + } + + var username = GetClaimString(userInfoJson, this._oidcSettings.UsernameClaim) ?? identity; + var avatarUrl = GetClaimString(userInfoJson, this._oidcSettings.AvatarClaim); + + var human = await this._humanService.GetByIdAsync(identity); + if (human == null) + { + return (null, "user_not_registered"); + } + + var (isAdmin, managedWebhooks) = await this.GetRolesAsync(identity); + if (!isAdmin) + { + // Enforce enable_oidc site setting for non-admin users. + // Admins can always log in so they can re-enable the setting. + var oidcSetting = await this._siteSettingService.GetValueAsync(EnableOidcKey); + if (string.Equals(oidcSetting, "false", StringComparison.OrdinalIgnoreCase)) + { + LogAuthMethodDisabled(this._logger, "OIDC"); + return (null, "oidc_disabled"); + } + + // Reuse Discord guild role gating when the identity is a Discord id. + var roleCheckResult = await this.CheckRoleAccessAsync(identity); + if (roleCheckResult != null) + { + return (null, roleCheckResult); + } + } + + var userInfo = new UserInfo + { + Id = identity, + Username = username, + Type = string.IsNullOrEmpty(this._oidcSettings.IdentityType) ? "discord:user" : this._oidcSettings.IdentityType, + IsAdmin = isAdmin, + AdminDisable = human.AdminDisable == 1, + Enabled = human.Enabled == 1 && human.AdminDisable == 0, + ProfileNo = human.CurrentProfileNo, + AvatarUrl = avatarUrl, + ManagedWebhooks = managedWebhooks + }; + + return (userInfo, null); + } + + /// Best-effort client IP + user-agent for session audit metadata (not security-bearing). + private (string? ip, string? ua) GetClientMetadata() + { + var ip = this.HttpContext.Connection.RemoteIpAddress?.ToString(); + var ua = this.Request.Headers.UserAgent.ToString(); + return (ip, string.IsNullOrEmpty(ua) ? null : ua); + } + + /// + /// Reads a UserInfo claim as a string, tolerating both string and numeric JSON values + /// (e.g. a numeric sub). Returns null when absent or empty. + /// + private static string? GetClaimString(JsonElement userInfo, string claim) + { + if (string.IsNullOrEmpty(claim) || !userInfo.TryGetProperty(claim, out var prop)) + { + return null; + } + + var value = prop.ValueKind switch + { + JsonValueKind.String => prop.GetString(), + JsonValueKind.Number => prop.GetRawText(), + _ => null + }; + + return string.IsNullOrEmpty(value) ? null : value; + } + + /// Base64url encoding without padding (RFC 7636), for PKCE verifier/challenge. + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + /// + /// Appends query parameters to a base URL, preserving any existing query string the + /// admin-configured authorization endpoint may already carry. Values are URL-encoded. + /// + private static string BuildUrlWithQuery(string baseUrl, IDictionary parameters) + { + var present = parameters + .Where(kvp => !string.IsNullOrEmpty(kvp.Value)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + return Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, present); } + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Could not read the name of active profile {ProfileNo}; the user menu falls back to the number.")] + private static partial void LogProfileNameLookupFailed(ILogger logger, Exception exception, int profileNo); + [LoggerMessage(Level = LogLevel.Warning, Message = "Role-based access enabled but Discord BotToken or GuildId not configured.")] private static partial void LogRoleMisconfigured(ILogger logger); [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch guild member {UserId}: {Status} {Body}")] private static partial void LogGuildMemberFetchFailed(ILogger logger, string userId, System.Net.HttpStatusCode status, string body); - [LoggerMessage(Level = LogLevel.Information, Message = "Role check for {UserId}: required=[{Required}], user=[{UserRoles}]")] - private static partial void LogRoleCheck(ILogger logger, string userId, string required, string userRoles); + [LoggerMessage(Level = LogLevel.Information, Message = "Role check for {UserId}: allowed=[{Allowed}], user=[{UserRoles}]")] + private static partial void LogRoleCheck(ILogger logger, string userId, string allowed, string userRoles); - [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} denied: missing required roles.")] + [LoggerMessage(Level = LogLevel.Information, Message = "User {UserId} denied: has none of the allowed roles.")] private static partial void LogRoleDenied(ILogger logger, string userId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Ignoring allowed_role_ids entries that are not Discord role IDs: {Entries}")] + private static partial void LogRoleIdsIgnored(ILogger logger, string entries); + + [LoggerMessage(Level = LogLevel.Error, Message = "allowed_role_ids is set but contains no usable Discord role IDs; denying non-admin logins.")] + private static partial void LogRoleIdsUnusable(ILogger logger); + [LoggerMessage(Level = LogLevel.Error, Message = "Role check failed for {UserId}, denying access.")] private static partial void LogRoleCheckFailed(ILogger logger, Exception ex, string userId); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "OAuth callback URI was built as '{CallbackUri}', but this request arrived with " + + "X-Forwarded-Proto: https from a proxy that is not declared, so the header was ignored. " + + "The provider will reject this sign-in as an invalid redirect_uri. Fix it by setting " + + "PROXY_KNOWN_PROXIES / PROXY_KNOWN_NETWORKS to your proxy's address, or PUBLIC_URL to " + + "the URL users reach this instance on.")] + private static partial void LogProxySchemeIgnored(ILogger logger, string callbackUri); + [LoggerMessage(Level = LogLevel.Warning, Message = "Discord token exchange failed: {Status} {Body}")] private static partial void LogDiscordTokenExchangeFailed(ILogger logger, System.Net.HttpStatusCode status, string body); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch Poracle config for admin check for {UserId}.")] - private static partial void LogPoracleConfigFetchFailed(ILogger logger, Exception ex, string userId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch administration roles for {UserId}.")] - private static partial void LogAdminRolesFetchFailed(ILogger logger, Exception ex, string userId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch webhook delegates for {UserId}.")] - private static partial void LogPwebDelegatesFetchFailed(ILogger logger, Exception ex, string userId); + [LoggerMessage(Level = LogLevel.Information, Message = "OIDC refresh tokens are enabled but the provider returned no refresh token (offline_access not granted?); falling back to a standard session.")] + private static partial void LogOidcRefreshUnavailable(ILogger logger); [LoggerMessage(Level = LogLevel.Information, Message = "Auth attempt blocked: {Method} login is disabled by site setting.")] private static partial void LogAuthMethodDisabled(ILogger logger, string method); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/BaseApiController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/BaseApiController.cs index 1ae0cde7..72aa13e0 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/BaseApiController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/BaseApiController.cs @@ -1,5 +1,6 @@ using System.Globalization; using System.Security.Claims; +using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,10 +14,52 @@ public abstract class BaseApiController : ControllerBase protected string UserId => this.User.FindFirstValue("userId") ?? throw new UnauthorizedAccessException(); protected int ProfileNo => int.Parse(this.User.FindFirstValue("profileNo") ?? "1", CultureInfo.InvariantCulture); protected bool IsAdmin => this.User.FindFirstValue("isAdmin") == "true"; + + /// + /// True when the caller is an admin (or webhook delegate) inspecting somebody else's account, so + /// names the account being looked at rather than the person looking. + /// + /// + /// Only GenerateImpersonationToken sets the claim, and the JWT is signed, so an inspected + /// user cannot mint one for themselves. Every decision about the CALLER -- their admin rights, + /// whether their session survives -- must consult this before reading the effective id, or it + /// answers a question about the wrong person. See #663, #706. + /// + protected bool IsImpersonating => this.User.FindFirst("impersonatedBy") is not null; protected string Username => this.User.FindFirstValue("username") ?? string.Empty; protected string[] ManagedWebhooks => this.User.FindFirstValue("managedWebhooks") ?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) ?? []; + /// + /// Rejects a distance the create path would have rejected. The "update all" endpoints bind a bare + /// [FromBody] int, which model validation cannot annotate, so the check has to be explicit. + /// Returns null when the value is acceptable. See #417. + /// + protected IActionResult? RejectInvalidDistance(int distance) => + distance < 0 + ? this.BadRequest(new + { + error = "Distance must be zero or greater." + }) + : null; + + /// + /// True when applying an update leaves the stored alarm exactly as it was. + /// + /// + /// Every edit dialog resubmits the whole form, so pressing Save with nothing changed sends the values + /// already stored. PoracleNG answers that with {alreadyPresent:1, insert:0, updates:0} -- the same + /// shape it uses when the edit collides with a DIFFERENT alarm -- which #463 turned into a 409 telling + /// the user another alarm was in the way when the only candidate was itself. Detected here, where both + /// sides are in hand, rather than guessed at from the response. Nothing to write means nothing to send. + /// See #498, #499, #501. + /// + protected static bool LeavesAlarmUnchanged(TAlarm before, Func applyUpdate) + { + var original = JsonSerializer.Serialize(before); + return JsonSerializer.Serialize(applyUpdate()) == original; + } + /// Checks that matches the authenticated user. Returns true when NOT owned (i.e. should return 404). protected bool NotOwnedByCurrentUser(string? ownerId) => !string.Equals(ownerId, this.UserId, StringComparison.Ordinal); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/CleaningController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/CleaningController.cs index 963fdbc3..cb42cde5 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/CleaningController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/CleaningController.cs @@ -1,61 +1,119 @@ -using Microsoft.AspNetCore.Mvc; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Api.Controllers; - -[Route("api/cleaning")] -public class CleaningController(ICleaningService cleaningService) : BaseApiController -{ - private readonly ICleaningService _cleaningService = cleaningService; - - [HttpGet("status")] - public async Task GetStatus() - { - var status = await this._cleaningService.GetCleanStatusAsync(this.UserId, this.ProfileNo); - return this.Ok(status); - } - - [HttpPut("all/{enabled:int}")] - public async Task ToggleAll(int enabled) - { - var total = 0; - total += await this._cleaningService.ToggleCleanMonstersAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanRaidsAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanEggsAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanQuestsAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanInvasionsAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanLuresAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanNestsAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanGymsAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanFortChangesAsync(this.UserId, this.ProfileNo, enabled); - total += await this._cleaningService.ToggleCleanMaxBattlesAsync(this.UserId, this.ProfileNo, enabled); - return this.Ok(new - { - updated = total - }); - } - - [HttpPut("{alarmType}/{enabled:int}")] - public async Task ToggleClean(string alarmType, int enabled) - { - var count = alarmType.ToLowerInvariant() switch - { - "monsters" => await this._cleaningService.ToggleCleanMonstersAsync(this.UserId, this.ProfileNo, enabled), - "raids" => await this._cleaningService.ToggleCleanRaidsAsync(this.UserId, this.ProfileNo, enabled), - "eggs" => await this._cleaningService.ToggleCleanEggsAsync(this.UserId, this.ProfileNo, enabled), - "quests" => await this._cleaningService.ToggleCleanQuestsAsync(this.UserId, this.ProfileNo, enabled), - "invasions" => await this._cleaningService.ToggleCleanInvasionsAsync(this.UserId, this.ProfileNo, enabled), - "lures" => await this._cleaningService.ToggleCleanLuresAsync(this.UserId, this.ProfileNo, enabled), - "nests" => await this._cleaningService.ToggleCleanNestsAsync(this.UserId, this.ProfileNo, enabled), - "gyms" => await this._cleaningService.ToggleCleanGymsAsync(this.UserId, this.ProfileNo, enabled), - "fortchanges" => await this._cleaningService.ToggleCleanFortChangesAsync(this.UserId, this.ProfileNo, enabled), - "maxbattles" => await this._cleaningService.ToggleCleanMaxBattlesAsync(this.UserId, this.ProfileNo, enabled), - _ => throw new ArgumentException($"Unknown alarm type: {alarmType}") - }; - - return this.Ok(new - { - updated = count - }); - } -} +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +[Route("api/cleaning")] +public class CleaningController(ICleaningService cleaningService, IFeatureGate featureGate) : BaseApiController +{ + private readonly ICleaningService _cleaningService = cleaningService; + private readonly IFeatureGate _featureGate = featureGate; + + [HttpGet("status")] + public async Task GetStatus() + { + var status = await this._cleaningService.GetCleanStatusAsync(this.UserId, this.ProfileNo); + return this.Ok(status); + } + + [HttpPut("all/{enabled:int}")] + public async Task ToggleAll(int enabled) + { + // enabled is a flag, not a bitmask. CleanFlags.Preserve masks with bit 1, so an even value + // such as 2 silently DISABLED cleaning while reporting the rows updated -- and the write is not + // free: it rotates every uid, and for max battles it deletes and reinserts every row. See #472. + if (enabled is not (0 or 1)) + { + return this.BadRequest(new + { + error = "enabled must be 0 or 1." + }); + } + // Each toggle re-checks its own feature gate, so awaiting all of them unconditionally meant one + // disabled alarm type threw partway through -- 403 to the caller, with the types processed before + // it already written. Skip disabled types instead, and tell the caller which were skipped. + var total = 0; + var skipped = new List(); + + foreach (var (disableKey, toggle) in this.BulkToggles()) + { + if (!await this._featureGate.IsEnabledAsync(disableKey)) + { + skipped.Add(disableKey); + continue; + } + + total += await toggle(enabled); + } + + return this.Ok(new + { + updated = total, + skipped, + }); + } + + /// Every alarm type the bulk toggle covers, paired with the gate that can switch it off. + private IEnumerable<(string DisableKey, Func> Toggle)> BulkToggles() + { + yield return (DisableFeatureKeys.Pokemon, e => this._cleaningService.ToggleCleanMonstersAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.Raids, e => this._cleaningService.ToggleCleanRaidsAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.Raids, e => this._cleaningService.ToggleCleanEggsAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.Quests, e => this._cleaningService.ToggleCleanQuestsAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.Invasions, e => this._cleaningService.ToggleCleanInvasionsAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.Lures, e => this._cleaningService.ToggleCleanLuresAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.Nests, e => this._cleaningService.ToggleCleanNestsAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.Gyms, e => this._cleaningService.ToggleCleanGymsAsync(this.UserId, this.ProfileNo, e)); + yield return (DisableFeatureKeys.MaxBattles, e => this._cleaningService.ToggleCleanMaxBattlesAsync(this.UserId, this.ProfileNo, e)); + } + + [HttpPut("{alarmType}/{enabled:int}")] + public async Task ToggleClean(string alarmType, int enabled) + { + // enabled is a flag, not a bitmask. CleanFlags.Preserve masks with bit 1, so an even value + // such as 2 silently DISABLED cleaning while reporting the rows updated -- and the write is not + // free: it rotates every uid, and for max battles it deletes and reinserts every row. See #472. + if (enabled is not (0 or 1)) + { + return this.BadRequest(new + { + error = "enabled must be 0 or 1." + }); + } + var count = alarmType.ToLowerInvariant() switch + { + "monsters" => await this._cleaningService.ToggleCleanMonstersAsync(this.UserId, this.ProfileNo, enabled), + "raids" => await this._cleaningService.ToggleCleanRaidsAsync(this.UserId, this.ProfileNo, enabled), + "eggs" => await this._cleaningService.ToggleCleanEggsAsync(this.UserId, this.ProfileNo, enabled), + "quests" => await this._cleaningService.ToggleCleanQuestsAsync(this.UserId, this.ProfileNo, enabled), + "invasions" => await this._cleaningService.ToggleCleanInvasionsAsync(this.UserId, this.ProfileNo, enabled), + "lures" => await this._cleaningService.ToggleCleanLuresAsync(this.UserId, this.ProfileNo, enabled), + "nests" => await this._cleaningService.ToggleCleanNestsAsync(this.UserId, this.ProfileNo, enabled), + "gyms" => await this._cleaningService.ToggleCleanGymsAsync(this.UserId, this.ProfileNo, enabled), + + "maxbattles" => await this._cleaningService.ToggleCleanMaxBattlesAsync(this.UserId, this.ProfileNo, enabled), + // Unknown types are client input, not a fault. "pokemon" is a natural guess for "monsters". + _ => (int?)null + }; + + if (count is null) + { + return this.BadRequest(new + { + error = $"Unknown alarm type '{alarmType}'. Expected one of: {string.Join(", ", ValidAlarmTypes)}." + }); + } + + return this.Ok(new + { + updated = count.Value + }); + } + + private static readonly string[] ValidAlarmTypes = + [ + "monsters", "raids", "eggs", "quests", "invasions", + "lures", "nests", "gyms", "maxbattles", + ]; +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/ConfigController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/ConfigController.cs index 62e1a630..e2aa8434 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/ConfigController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/ConfigController.cs @@ -53,7 +53,12 @@ public IActionResult GetDts() return this.Ok(Array.Empty()); } - [AllowAnonymous] + /// + /// Poracle's client-facing configuration. Authenticated: the only consumers are the Pokemon + /// add/edit dialogs, which sit behind the auth guard, and the payload is projected through + /// so the admin id lists, the delegation map, the provider URL + /// and the static key never reach a browser at all. + /// [HttpGet] public async Task GetConfig() { @@ -62,7 +67,7 @@ public async Task GetConfig() var config = await this._poracleApiProxy.GetConfigAsync(); if (config != null) { - return this.Ok(config); + return this.Ok(PublicPoracleConfig.From(config)); } } catch (Exception ex) @@ -70,11 +75,9 @@ public async Task GetConfig() LogFetchConfigFailed(this._logger, ex); } - return this.Ok(new PoracleConfig + return this.Ok(new PublicPoracleConfig { Locale = "en", - ProviderUrl = "", - StaticKey = "", PoracleVersion = "unknown", PvpFilterMaxRank = 100, PvpFilterLittleMinCp = 0, diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/EggController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/EggController.cs index a6f2694f..ccb40ec5 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/EggController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/EggController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] EggCreate model) { var egg = model.ToEgg(); - egg.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._eggService.CreateAsync(this.UserId, egg); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] EggUpdate model) return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._eggService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._eggService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/FortChangeController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/FortChangeController.cs index b3d94988..97600f02 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/FortChangeController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/FortChangeController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] FortChangeCreate model) { var fortChange = model.ToFortChange(); - fortChange.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._fortChangeService.CreateAsync(this.UserId, fortChange); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] FortChangeUpdate mod return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._fortChangeService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._fortChangeService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/GeofenceFeedController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/GeofenceFeedController.cs index 557046c1..440bb592 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/GeofenceFeedController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/GeofenceFeedController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Pgan.PoracleWebNet.Core.Abstractions.Repositories; using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models.Helpers; namespace Pgan.PoracleWebNet.Api.Controllers; @@ -64,8 +65,11 @@ public async Task GetPoracleFeed() LogDeserializePolygonFailed(this._logger, ex, g.KojiName, g.Id); } - if (polygon == null || polygon.Length < 3) + // This feed is the single geofence source for PoracleJS, so a malformed polygon from one + // user is everyone's problem. Skip anything that is not a well-formed ring. See #410. + if (!PolygonValidation.IsWellFormed(polygon)) { + LogSkippedMalformedPolygon(this._logger, g.KojiName, g.Id); return null; } @@ -94,4 +98,7 @@ public async Task GetPoracleFeed() [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to deserialize polygon for geofence '{KojiName}' (ID {Id})")] private static partial void LogDeserializePolygonFailed(ILogger logger, Exception ex, string? kojiName, int id); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Skipped malformed polygon for geofence '{KojiName}' (id {Id}) when building the feed")] + private static partial void LogSkippedMalformedPolygon(ILogger logger, string? kojiName, int id); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/GymController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/GymController.cs index 91338367..d426c4bb 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/GymController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/GymController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] GymCreate model) { var gym = model.ToGym(); - gym.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._gymService.CreateAsync(this.UserId, gym); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] GymUpdate model) return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._gymService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._gymService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/InvasionController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/InvasionController.cs index 8f617685..710d8918 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/InvasionController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/InvasionController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] InvasionCreate model) { var invasion = model.ToInvasion(); - invasion.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._invasionService.CreateAsync(this.UserId, invasion); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] InvasionUpdate model return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._invasionService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._invasionService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs index 9e3ed4d0..b6913a9c 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs @@ -1,9 +1,14 @@ +using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Api.Filters; using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + namespace Pgan.PoracleWebNet.Api.Controllers; [Route("api/location")] +[RequireFeatureEnabled(DisableFeatureKeys.Location)] public class LocationController( IHumanService humanService, IProfileService profileService, @@ -56,34 +61,21 @@ public async Task UpdateLocation([FromBody] LocationUpdateRequest return this.NotFound(); } - // Single atomic call — PoracleNG handles writing to both humans and profiles tables - await this._humanProxy.SetLocationAsync(this.UserId, request.Latitude, request.Longitude); - - return this.Ok(new - { - latitude = request.Latitude, - longitude = request.Longitude - }); - } - - [HttpPut("language")] - public async Task UpdateLanguage([FromBody] LanguageUpdateRequest request) - { - var human = await this._humanService.GetByIdAndProfileAsync(this.UserId, this.ProfileNo); - if (human == null) - { - return this.NotFound(); - } + // [Required] on nullable doubles already rejects an absent coordinate, so by here both have values. + var latitude = request.Latitude!.Value; + var longitude = request.Longitude!.Value; - human.Language = request.Language; - await this._humanService.UpdateAsync(human); + // Single atomic call — PoracleNG handles writing to both humans and profiles tables + await this._humanProxy.SetLocationAsync(this.UserId, latitude, longitude); return this.Ok(new { - language = human.Language + latitude, + longitude }); } + [RequireFeatureEnabled(DisableFeatureKeys.Geocoding)] [HttpGet("geocode")] public async Task Geocode([FromQuery] string q) { @@ -112,6 +104,7 @@ public async Task Geocode([FromQuery] string q) } } + [RequireFeatureEnabled(DisableFeatureKeys.Geocoding)] [HttpGet("reverse")] public async Task ReverseGeocode([FromQuery] double lat, [FromQuery] double lon) { @@ -257,18 +250,77 @@ public double Lon public class LocationUpdateRequest { - public double Latitude + /// + /// Unbounded doubles were written straight to humans.latitude/longitude and the active profile, so + /// a location off the globe persisted and then failed silently downstream: weather returned 204 and + /// the static map 404 with no explanation, distance matching ran against a point that does not + /// exist, and the active-hours scheduler's timezone lookup was meaningless. 1e308 additionally + /// produced a 500 rather than a 400. See #423. + /// + /// + /// Nullable so that "absent" is distinguishable from "zero". As non-nullable doubles both members + /// bound to 0.0 when the request omitted them, [Range] passed, and 0,0 was written over the real + /// location -- exactly the outcome the remarks above describe as the harm this validation exists to + /// prevent, reached by the one path the validation could not see. See #480. + /// + [Required(ErrorMessage = "Latitude is required.")] + [Range(-90.0, 90.0, ErrorMessage = "Latitude must be between -90 and 90.")] + public double? Latitude { get; set; } - public double Longitude + + [Required(ErrorMessage = "Longitude is required.")] + [Range(-180.0, 180.0, ErrorMessage = "Longitude must be between -180 and 180.")] + public double? Longitude { get; set; } } - public class LanguageUpdateRequest + + + /// + /// The user's saved places, plus the profile pin every alarm falls back to. + /// + [HttpGet("places")] + public async Task GetPlaces() => + this.Ok(await this._humanProxy.GetPlacesAsync(this.UserId)); + + /// + /// Saves a place an alarm can be anchored to. + /// + /// + /// PoracleNG reports a rejected label inside a 200 because its endpoint answers per row, so the + /// refusal is unwrapped here and returned as a 400 the SPA can show against the field. + /// + [HttpPost("places")] + public async Task AddPlace([FromBody] SavedPlace place) + { + var refusal = await this._humanProxy.AddPlaceAsync(this.UserId, place); + + return refusal is null + ? this.Ok(await this._humanProxy.GetPlacesAsync(this.UserId)) + : this.BadRequest(new { error = refusal }); + } + + /// + /// Deletes a saved place, unless alarms still point at it. + /// + [HttpDelete("places/{label}")] + public async Task DeletePlace(string label) { - public string Language { get; set; } = string.Empty; + try + { + await this._humanProxy.DeletePlaceAsync(this.UserId, label); + } + catch (PlaceInUseException ex) + { + // Naming the alarms is the difference between "could not delete" and a person knowing what + // to repoint first. + return this.Conflict(new { error = ex.Message, referencingRules = ex.ReferencingRules }); + } + + return this.NoContent(); } } diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/LureController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/LureController.cs index a7edd837..5a282149 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/LureController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/LureController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] LureCreate model) { var lure = model.ToLure(); - lure.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._lureService.CreateAsync(this.UserId, lure); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] LureUpdate model) return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._lureService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._lureService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs index 641e5315..b8a0e609 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs @@ -1,72 +1,181 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Api.Controllers; - -[Route("api/masterdata")] -public class MasterDataController(IMasterDataService masterDataService, IPoracleApiProxy poracleApiProxy) : BaseApiController -{ - private readonly IMasterDataService _masterDataService = masterDataService; - private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; - - [AllowAnonymous] - [HttpGet("pokemon")] - public async Task GetPokemon() - { - var data = await this._masterDataService.GetPokemonDataAsync(); - if (data == null) - { - await this._masterDataService.RefreshCacheAsync(); - data = await this._masterDataService.GetPokemonDataAsync(); - } - - if (data == null) - { - return this.NotFound(new - { - message = "Pokemon data not available." - }); - } - - return this.Content(data, "application/json"); - } - - [AllowAnonymous] - [HttpGet("items")] - public async Task GetItems() - { - var data = await this._masterDataService.GetItemDataAsync(); - if (data == null) - { - await this._masterDataService.RefreshCacheAsync(); - data = await this._masterDataService.GetItemDataAsync(); - } - - if (data == null) - { - return this.NotFound(new - { - message = "Item data not available." - }); - } - - return this.Content(data, "application/json"); - } - - [AllowAnonymous] - [HttpGet("grunts")] - public async Task GetGrunts() - { - var grunts = await this._poracleApiProxy.GetGruntsAsync(); - if (grunts == null) - { - return this.NotFound(new - { - message = "Grunt data not available." - }); - } - - return this.Content(grunts, "application/json"); - } -} +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +[Route("api/masterdata")] +public partial class MasterDataController( + IMasterDataService masterDataService, + IPoracleApiProxy poracleApiProxy, + IRaidLevelService raidLevelService) : BaseApiController +{ + private readonly IMasterDataService _masterDataService = masterDataService; + private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly IRaidLevelService _raidLevelService = raidLevelService; + + [AllowAnonymous] + [HttpGet("pokemon")] + public async Task GetPokemon() + { + var data = await this._masterDataService.GetPokemonDataAsync(); + if (data == null) + { + await this._masterDataService.RefreshCacheAsync(); + data = await this._masterDataService.GetPokemonDataAsync(); + } + + if (data == null) + { + return this.NotFound(new + { + message = "Pokemon data not available." + }); + } + + return this.Content(data, "application/json"); + } + + [AllowAnonymous] + /// + /// Move ID to name map. Used to label the charged moves on Max Battle alarms, which otherwise + /// render as bare Move #123. + /// + [HttpGet("moves")] + public async Task GetMoves() + { + var data = await this._masterDataService.GetMoveDataAsync(); + if (data == null) + { + await this._masterDataService.RefreshCacheAsync(); + data = await this._masterDataService.GetMoveDataAsync(); + } + + if (data == null) + { + return this.NotFound(new + { + message = "Move data not available." + }); + } + + return this.Content(data, "application/json"); + } + + [HttpGet("items")] + public async Task GetItems() + { + var data = await this._masterDataService.GetItemDataAsync(); + if (data == null) + { + await this._masterDataService.RefreshCacheAsync(); + data = await this._masterDataService.GetItemDataAsync(); + } + + if (data == null) + { + return this.NotFound(new + { + message = "Item data not available." + }); + } + + return this.Content(data, "application/json"); + } + + /// + /// Canonical raid-level vocabulary (currently 19 levels from the WatWowMap masterfile). + /// Cached server-side; the frontend uses this to render the level selector and + /// fall back to bare integers for any level not in the list. + /// + [AllowAnonymous] + [HttpGet("raid-levels")] + public async Task GetRaidLevels() + { + var levels = await this._raidLevelService.GetAllAsync(); + return this.Ok(levels); + } + + /// + /// Monster master data (names, types, form names, stats, evolutions) keyed + /// "{pokemonId}_{formId}", translated into . + /// + /// + /// PoracleNG owns the translations, so this proxies its /api/masterdata/monsters and only + /// falls back to the WatWowMap masterfile - which is English-only - when that route is missing or + /// unreachable. Before this endpoint existed the SPA fetched the masterfile from GitHub directly, + /// so Pokemon names and types stayed English no matter what the display language was set to. + /// + [AllowAnonymous] + [HttpGet("monsters")] + public async Task GetMonsters([FromQuery] string? locale) + { + var requested = NormalizeLocale(locale); + + try + { + var localized = await this._poracleApiProxy.GetMonstersAsync(requested); + if (!string.IsNullOrWhiteSpace(localized)) + { + return this.Content(localized, "application/json"); + } + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + // Upstream unreachable or too slow - fall through to the English masterfile rather than + // leaving the selector with no names, types or forms at all. A misconfigured + // Poracle:ApiAddress throws InvalidOperationException instead and is left to surface. + } + + var fallback = await this._masterDataService.GetMonsterDataAsync(); + if (fallback == null) + { + await this._masterDataService.RefreshCacheAsync(); + fallback = await this._masterDataService.GetMonsterDataAsync(); + } + + if (fallback == null) + { + return this.NotFound(new + { + message = "Monster data not available." + }); + } + + return this.Content(fallback, "application/json"); + } + + /// + /// Constrains the locale to a BCP-47-ish shape before it reaches the upstream query string. + /// Anything else becomes en, which is also what PoracleNG defaults to. + /// + internal static string NormalizeLocale(string? locale) + { + if (string.IsNullOrWhiteSpace(locale)) + { + return "en"; + } + + var trimmed = locale.Trim(); + return LocalePattern().IsMatch(trimmed) ? trimmed : "en"; + } + + [System.Text.RegularExpressions.GeneratedRegex("^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$")] + private static partial System.Text.RegularExpressions.Regex LocalePattern(); + + [AllowAnonymous] + [HttpGet("grunts")] + public async Task GetGrunts() + { + var grunts = await this._poracleApiProxy.GetGruntsAsync(); + if (grunts == null) + { + return this.NotFound(new + { + message = "Grunt data not available." + }); + } + + return this.Content(grunts, "application/json"); + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/MaxBattleController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/MaxBattleController.cs index ad43a33b..cc7f1340 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/MaxBattleController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/MaxBattleController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] MaxBattleCreate model) { var maxBattle = model.ToMaxBattle(); - maxBattle.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._maxBattleService.CreateAsync(this.UserId, maxBattle); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] MaxBattleUpdate mode return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._maxBattleService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._maxBattleService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/MonsterController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/MonsterController.cs index 4293905e..dc1176c4 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/MonsterController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/MonsterController.cs @@ -35,8 +35,25 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] MonsterCreate model) { var monster = model.ToMonster(); - monster.ProfileNo = this.ProfileNo; + + var inverted = MonsterRangeValidator.Validate(monster); + if (inverted != null) + { + return this.BadRequest(new { error = inverted }); + } + + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._monsterService.CreateAsync(this.UserId, monster); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +69,24 @@ public async Task Update(int uid, [FromBody] MonsterUpdate model) return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + + // Checked after the merge, not on the request: a PUT carrying only minIv inverts the window + // against the value already stored, which validating the DTO alone cannot see. See #461. + var inverted = MonsterRangeValidator.Validate(existing); + if (inverted != null) + { + return this.BadRequest(new { error = inverted }); + } + var result = await this._monsterService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +127,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._monsterService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/NestController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/NestController.cs index b70163ed..3419b71d 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/NestController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/NestController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] NestCreate model) { var nest = model.ToNest(); - nest.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._nestService.CreateAsync(this.UserId, nest); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] NestUpdate model) return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._nestService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._nestService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/NotificationLanguageController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/NotificationLanguageController.cs new file mode 100644 index 00000000..8786b205 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/NotificationLanguageController.cs @@ -0,0 +1,65 @@ +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +/// +/// The user's notification language. +/// +/// +/// Deliberately its own controller rather than an action on LocationController. That controller +/// carries a class-level disable_location gate, which also blocked these two endpoints even +/// though they only touch humans.language and have nothing to do with a location. The Areas page +/// hosts the language selector and calls this on init, so with locations disabled the 403 carried a +/// disableKey, the error interceptor read it as a dead page and redirected to the dashboard - making an +/// enabled feature unreachable. Authentication is unchanged: BaseApiController is [Authorize]. See #479. +/// +[Route("api/location/language")] +public class NotificationLanguageController(IHumanService humanService) : BaseApiController +{ + private readonly IHumanService _humanService = humanService; + + [HttpGet] + public async Task GetLanguage() + { + var human = await this._humanService.GetByIdAsync(this.UserId); + if (human == null) + { + return this.NotFound(); + } + + return this.Ok(new + { + language = human.Language + }); + } + + [HttpPut] + public async Task UpdateLanguage([FromBody] LanguageUpdateRequest request) + { + var human = await this._humanService.GetByIdAsync(this.UserId); + if (human == null) + { + return this.NotFound(); + } + + // humans.language is varchar(255); a longer value used to overflow on write. + if (request.Language is { Length: > 255 }) + { + return this.BadRequest(new { error = "Language must be 255 characters or fewer." }); + } + + human.Language = request.Language; + await this._humanService.UpdateAsync(human); + + return this.Ok(new + { + language = human.Language + }); + } + + public class LanguageUpdateRequest + { + public string Language { get; set; } = string.Empty; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs index ca231f56..de0c727c 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs @@ -1,22 +1,36 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Api.Filters; using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Models.Helpers; namespace Pgan.PoracleWebNet.Api.Controllers; [Route("api/profiles")] +[RequireFeatureEnabled(DisableFeatureKeys.Profiles)] public class ProfileController( IProfileService profileService, IHumanService humanService, IPoracleHumanProxy humanProxy, - IJwtService jwtService) : BaseApiController + IProfileRepository profileRepository, + IJwtService jwtService, + IUserRoleResolver roleResolver, + IUserGeofenceRepository userGeofenceRepository) : BaseApiController { private readonly IProfileService _profileService = profileService; private readonly IHumanService _humanService = humanService; private readonly IPoracleHumanProxy _humanProxy = humanProxy; + private readonly IProfileRepository _profileRepository = profileRepository; private readonly IJwtService _jwtService = jwtService; + private readonly IUserRoleResolver _roleResolver = roleResolver; + private readonly IUserGeofenceRepository _userGeofenceRepository = userGeofenceRepository; + + /// Matches the profiles.name column, so an over-long name is refused rather than 500ing. + private const int MaxProfileNameLength = 255; [HttpGet] public async Task GetAll() @@ -33,35 +47,158 @@ public async Task GetAll() return this.Ok(profiles); } + /// + /// Drops any private geofence name from a submitted area list that the caller does not own. + /// + /// + /// Public and admin areas are anyone's to select, so they pass through. What does not is another + /// user's private fence: PoracleNG's setAreas intersects against userSelectable fences and would + /// have stripped it, but this path writes the profile row directly. See #647. + /// + private async Task RemoveAreasTheCallerMayNotSelectAsync(string? area) + { + if (string.IsNullOrWhiteSpace(area)) + { + return "[]"; + } + + List? requested; + try + { + requested = JsonSerializer.Deserialize>(area); + } + catch (JsonException ex) + { + // Silently emptying a list we could not read is the worst of both: the caller asked for + // something and gets a 201 saying it worked. See #658. + throw new ArgumentException("area must be a JSON array of area names.", nameof(area), ex); + } + + if (requested is null || requested.Count == 0) + { + return "[]"; + } + + // Approved fences are excluded: approval promotes them to public Koji areas that anyone may + // select, and when no promotedName was supplied the public area's name IS the KojiName -- so + // denying it silently dropped a legitimate public area from the new profile. Only another + // user's still-private fence is refused. See #658. + var privateNames = (await this._userGeofenceRepository.GetAllAsync()) + .Where(g => !string.Equals(g.HumanId, this.UserId, StringComparison.OrdinalIgnoreCase)) + .Where(g => !string.Equals(g.Status, "approved", StringComparison.OrdinalIgnoreCase)) + .Select(g => g.KojiName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var allowed = requested + .Where(name => !string.IsNullOrWhiteSpace(name) && !privateNames.Contains(name)) + .ToList(); + + return JsonSerializer.Serialize(allowed); + } + [HttpPost] public async Task Create([FromBody] Profile profile) { - profile.Id = this.UserId; + if (string.IsNullOrWhiteSpace(profile.Name)) + { + return this.BadRequest(new + { + error = "Profile name is required." + }); + } + + // profiles.name is varchar(255); anything longer reached the database and came back as an + // opaque 500. See #467. + if (profile.Name.Trim().Length > MaxProfileNameLength) + { + return this.BadRequest(new + { + error = $"Profile name must be {MaxProfileNameLength} characters or fewer." + }); + } - // Assign next available profile number - var existing = await this._profileService.GetByUserAsync(this.UserId); - var maxNo = existing.Any() ? existing.Max(p => p.ProfileNo) : 0; - profile.ProfileNo = maxNo + 1; + profile.Id = this.UserId; - var (isValid, validationError) = ValidateActiveHours(profile.ActiveHours); + var (isValid, validationError) = ActiveHoursValidator.Validate(profile.ActiveHours); if (!isValid) { return this.BadRequest(validationError); } + // The request supplies its own geography, and nothing bounded it. Coordinates out of range reach + // the active-hours scheduler, which reads them as a timezone; an area list could name another + // user's private geofence, which PoracleNG's own setAreas filter would have stripped but a direct + // write does not. See #647. + // + // Both checks run BEFORE the profile is created. Behind it, a refusal answered 400 while leaving + // a profile PoracleNG had already made -- and since addProfile ignores `area`, that orphan came + // up carrying the ACTIVE profile's entire area list and location, the inheritance #563 exists to + // prevent. A retry then made a second one. See #665. + if (profile.Latitude is < -90 or > 90 || profile.Longitude is < -180 or > 180) + { + return this.BadRequest(new + { + error = "Latitude must be between -90 and 90, and longitude between -180 and 180.", + }); + } + + string requestedAreas; + try + { + requestedAreas = await this.RemoveAreasTheCallerMayNotSelectAsync(profile.Area); + } + catch (ArgumentException ex) + { + return this.BadRequest(new { error = ex.Message }); + } + + // PoracleNG assigns the lowest free number, not max+1, so the number cannot be predicted for a + // user who has ever deleted a non-last profile. Ask what it chose. See #407. + var before = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); + var body = JsonSerializer.SerializeToElement(new { name = profile.Name, - profileNo = profile.ProfileNo, - area = profile.Area ?? "[]", + area = requestedAreas, latitude = profile.Latitude, longitude = profile.Longitude, active_hours = profile.ActiveHours }); await this._humanProxy.AddProfileAsync(this.UserId, body); - // Re-read the created profile from the DB so we return the full model - var result = await this._profileService.GetByUserAndProfileNoAsync(this.UserId, profile.ProfileNo); + var after = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); + var createdNo = ProfileNumbering.ResolveCreated(before, after, profile.Name); + if (createdNo is null) + { + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "The profile was not created." + }); + } + + // addProfile ignores area, latitude and longitude, so a new profile came up carrying whatever the + // ACTIVE profile had -- every area subscription the user held, and a location that also drives the + // active-hours timezone. Duplicate and import already write the geography directly after creating; + // create needs the same. Empty unless the request asked for something. See #563. + try + { + await this._profileRepository.UpdateAsync(new Profile + { + Id = this.UserId, + ProfileNo = createdNo.Value, + Name = profile.Name.Trim(), + Area = requestedAreas, + Latitude = profile.Latitude, + Longitude = profile.Longitude, + }); + } + catch (InvalidOperationException) + { + // The row is not visible yet in some PoracleNG timings. The profile exists either way, and a + // wrong area list is fixable from the Areas page; a failed create is not. + } + + var result = await this._profileService.GetByUserAndProfileNoAsync(this.UserId, createdNo.Value); return this.CreatedAtAction(nameof(GetAll), result); } @@ -74,7 +211,15 @@ public async Task Update(int profileNo, [FromBody] Profile profil return this.NotFound(); } - var (isValid, validationError) = ValidateActiveHours(profile.ActiveHours); + if (profile.Name is not null && profile.Name.Trim().Length > MaxProfileNameLength) + { + return this.BadRequest(new + { + error = $"Profile name must be {MaxProfileNameLength} characters or fewer." + }); + } + + var (isValid, validationError) = ActiveHoursValidator.Validate(profile.ActiveHours); if (!isValid) { return this.BadRequest(validationError); @@ -88,7 +233,20 @@ public async Task Update(int profileNo, [FromBody] Profile profil }); await this._humanProxy.UpdateProfileAsync(this.UserId, body); - // Re-read from proxy to return the updated model + // PoracleNG's update handler answers ok and silently drops the name, while honouring active_hours + // on the very same request -- so rename has to be written directly. The response used to be + // re-read and returned as a 200 carrying the OLD name, and the SPA built a success toast from it. + // See #406. + var newName = profile.Name?.Trim(); + if (!string.IsNullOrEmpty(newName) && !string.Equals(newName, existing.Name, StringComparison.Ordinal)) + { + var renamed = await this._profileRepository.RenameAsync(this.UserId, profileNo, newName); + if (!renamed) + { + return this.NotFound(); + } + } + var result = await this._profileService.GetByUserAndProfileNoAsync(this.UserId, profileNo); return this.Ok(result); } @@ -108,7 +266,18 @@ public async Task SwitchProfile(int profileNo) await this._humanProxy.SwitchProfileAsync(this.UserId, profileNo); // Issue a new JWT with the updated profileNo so all subsequent API calls use it - var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, profileNo); + // Resolved fresh, not copied from the old token: a profile switch was the way a de-admined + // user kept their isAdmin claim alive indefinitely. See #624. + var roles = await this._roleResolver.ResolveAsync(this.UserId); + + // Null means "leave the claim alone". Two cases need it: the resolver could not reach PoracleNG, + // where treating unknown as false stripped admin for the rest of the session (#656); and an + // impersonation session, which AdminController deliberately mints with IsAdmin = false and which + // would otherwise be re-elevated by resolving the impersonated user's own roles (#663). + bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating + ? roles.IsAdmin + : null; + var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, profileNo, resolvedAdmin); return this.Ok(new { @@ -120,9 +289,12 @@ public async Task SwitchProfile(int profileNo) [HttpPost("duplicate")] public async Task Duplicate([FromBody] DuplicateProfileRequest request) { - if (string.IsNullOrWhiteSpace(request.Name)) + // Duplicate skipped the length check create has had since #467, so a long name reached the + // varchar(255) column and came back as an opaque 500. See #519. + var nameError = ProfileNameRules.Validate(request.Name); + if (nameError is not null) { - return this.BadRequest("Profile name is required."); + return this.BadRequest(new { error = nameError }); } var sourceProfile = await this._profileService.GetByUserAndProfileNoAsync(this.UserId, request.FromProfileNo); @@ -131,15 +303,14 @@ public async Task Duplicate([FromBody] DuplicateProfileRequest re return this.NotFound(); } - // Assign next available profile number - var existing = await this._profileService.GetByUserAsync(this.UserId); - var newProfileNo = existing.Any() ? existing.Max(p => p.ProfileNo) + 1 : 1; + // See #407: PoracleNG picks the number, so create first and then ask which one it used. Copying + // to a predicted max+1 wrote the alarms to a profile_no with no profile row, and those orphans + // later attached themselves to whatever profile was eventually created at that number. + var before = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); - // Create the new profile var body = JsonSerializer.SerializeToElement(new { name = request.Name.Trim(), - profileNo = newProfileNo, area = sourceProfile.Area ?? "[]", latitude = sourceProfile.Latitude, longitude = sourceProfile.Longitude, @@ -147,6 +318,41 @@ public async Task Duplicate([FromBody] DuplicateProfileRequest re }); await this._humanProxy.AddProfileAsync(this.UserId, body); + var after = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); + var resolved = ProfileNumbering.ResolveCreated(before, after, request.Name.Trim()); + if (resolved is null) + { + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "The profile was not created." + }); + } + + var newProfileNo = resolved.Value; + + // PoracleNG's addProfile ignores area, latitude and longitude while honouring active_hours + // from the same payload, so a duplicate silently inherited the ACTIVE profile's geography + // instead of the source's: the right alarms over the wrong map, and a location that also + // feeds the active-hours timezone. Write them directly, the same way rename has to (#406). + // See #466. + try + { + await this._profileRepository.UpdateAsync(new Profile + { + Id = this.UserId, + ProfileNo = newProfileNo, + Name = request.Name.Trim(), + Area = sourceProfile.Area ?? "[]", + Latitude = sourceProfile.Latitude, + Longitude = sourceProfile.Longitude, + }); + } + catch (InvalidOperationException) + { + // The row is not there yet in some PoracleNG timings; the profile still exists and the + // alarms still copy, so this must not fail the duplicate. + } + // Copy all alarms from source to new profile; clean up on failure try { @@ -178,88 +384,6 @@ public async Task Delete(int profileNo) return this.NoContent(); } - - internal static (bool IsValid, string? Error) ValidateActiveHours(string? activeHours) - { - if (string.IsNullOrWhiteSpace(activeHours)) - { - return (true, null); - } - - activeHours = activeHours.Trim(); - - JsonElement arr; - try - { - arr = JsonSerializer.Deserialize(activeHours); - } - catch (JsonException) - { - return (false, "active_hours must be a valid JSON array."); - } - - if (arr.ValueKind != JsonValueKind.Array) - { - return (false, "active_hours must be a JSON array."); - } - - if (arr.GetArrayLength() > 28) - { - return (false, "active_hours may contain at most 28 entries."); - } - - foreach (var entry in arr.EnumerateArray()) - { - if (entry.ValueKind != JsonValueKind.Object) - { - return (false, "Each active_hours entry must be an object."); - } - - if (!entry.TryGetProperty("day", out var dayProp) || !TryGetIntValue(dayProp, out var day) || day < 1 || day > 7) - { - return (false, "Each active_hours entry must have a 'day' between 1 and 7."); - } - - if (!entry.TryGetProperty("hours", out var hoursProp)) - { - return (false, "Each active_hours entry must have an 'hours' property."); - } - - if (!TryGetIntValue(hoursProp, out var hours) || hours < 0 || hours > 23) - { - return (false, "Each active_hours entry must have 'hours' between 0 and 23."); - } - - if (!entry.TryGetProperty("mins", out var minsProp)) - { - return (false, "Each active_hours entry must have a 'mins' property."); - } - - if (!TryGetIntValue(minsProp, out var mins) || mins < 0 || mins > 59) - { - return (false, "Each active_hours entry must have 'mins' between 0 and 59."); - } - } - - return (true, null); - } - - private static bool TryGetIntValue(JsonElement element, out int value) - { - if (element.ValueKind == JsonValueKind.Number) - { - return element.TryGetInt32(out value); - } - - if (element.ValueKind == JsonValueKind.String && - int.TryParse(element.GetString(), out value)) - { - return true; - } - - value = 0; - return false; - } } public class DuplicateProfileRequest diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileOverviewController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileOverviewController.cs index 8ec17d82..a2300c19 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileOverviewController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileOverviewController.cs @@ -1,21 +1,34 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Api.Filters; using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Models.Helpers; + namespace Pgan.PoracleWebNet.Api.Controllers; [Route("api/profile-overview")] -public class ProfileOverviewController( +[RequireFeatureEnabled(DisableFeatureKeys.Profiles)] +public partial class ProfileOverviewController( IProfileOverviewService profileOverviewService, IProfileService profileService, + IProfileRepository profileRepository, IPoracleHumanProxy humanProxy, - IJwtService jwtService) : BaseApiController + IJwtService jwtService, + IUserRoleResolver roleResolver, + ILogger logger) : BaseApiController { private readonly IProfileOverviewService _profileOverviewService = profileOverviewService; private readonly IPoracleHumanProxy _humanProxy = humanProxy; private readonly IJwtService _jwtService = jwtService; + private readonly IUserRoleResolver _roleResolver = roleResolver; private readonly IProfileService _profileService = profileService; + private readonly IProfileRepository _profileRepository = profileRepository; + private readonly ILogger _logger = logger; [HttpGet] public async Task GetAllProfilesOverview() @@ -27,6 +40,15 @@ public async Task GetAllProfilesOverview() [HttpPost("duplicate/{profileNo:int}")] public async Task DuplicateProfile(int profileNo, [FromBody] ProfileOverviewDuplicateRequest request) { + // This endpoint had no name check at all, so an empty or over-long name reached the varchar(255) + // column and came back as an opaque 500 -- and this page prompts for the name with a free-text + // input that has no maxlength, prefilled " (Copy)". See #504, #519. + var nameError = ProfileNameRules.Validate(request.Name); + if (nameError is not null) + { + return this.BadRequest(new { error = nameError }); + } + // Verify source profile exists var source = await this._profileService.GetByUserAndProfileNoAsync(this.UserId, profileNo); if (source == null) @@ -34,15 +56,13 @@ public async Task DuplicateProfile(int profileNo, [FromBody] Prof return this.NotFound(); } - // Create the new profile with next available number - var existing = await this._profileService.GetByUserAsync(this.UserId); - var maxNo = existing.Any() ? existing.Max(p => p.ProfileNo) : 0; - var newProfileNo = maxNo + 1; + // PoracleNG assigns the lowest free number, not max+1, so it cannot be predicted. Create, then + // ask which number it used. See #407. + var before = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); var body = JsonSerializer.SerializeToElement(new { name = request.Name, - profileNo = newProfileNo, area = source.Area ?? "[]", latitude = source.Latitude, longitude = source.Longitude, @@ -50,6 +70,18 @@ public async Task DuplicateProfile(int profileNo, [FromBody] Prof }); await this._humanProxy.AddProfileAsync(this.UserId, body); + var after = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); + var resolved = ProfileNumbering.ResolveCreated(before, after, request.Name); + if (resolved is null) + { + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "The profile was not created." + }); + } + + var newProfileNo = resolved.Value; + // Copy all alarms from source to new profile; roll back on failure int alarmsCopied; try @@ -70,8 +102,27 @@ public async Task DuplicateProfile(int profileNo, [FromBody] Prof throw; } + // PoracleNG's addProfile ignores area, latitude and longitude, so the copy silently inherited + // whichever profile happened to be active: the right alarms over the wrong map, and a location + // that also drives the active-hours timezone. Written AFTER the alarm copy, because copying + // switches profiles and back and the switch rewrites the geography again. This is #466, fixed on + // /api/profiles/duplicate and left open here. See #503. + await this.WriteGeographyAsync( + newProfileNo, request.Name.Trim(), source.Area ?? "[]", source.Latitude, source.Longitude); + // Issue a new JWT so the current profile stays correct - var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, this.ProfileNo); + // Resolved fresh, not copied from the old token: a profile switch was the way a de-admined + // user kept their isAdmin claim alive indefinitely. See #624. + var roles = await this._roleResolver.ResolveAsync(this.UserId); + + // Null means "leave the claim alone". Two cases need it: the resolver could not reach PoracleNG, + // where treating unknown as false stripped admin for the rest of the session (#656); and an + // impersonation session, which AdminController deliberately mints with IsAdmin = false and which + // would otherwise be re-elevated by resolving the impersonated user's own roles (#663). + bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating + ? roles.IsAdmin + : null; + var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, this.ProfileNo, resolvedAdmin); return this.Ok(new { @@ -84,11 +135,14 @@ public async Task DuplicateProfile(int profileNo, [FromBody] Prof [HttpPost("import")] public async Task ImportProfile([FromBody] ProfileOverviewImportRequest request) { - // Create a new profile with next available number and unique name - var existing = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); - var maxNo = existing.Count > 0 ? existing.Max(p => p.ProfileNo) : 0; - var newProfileNo = maxNo + 1; + // Import used to 500 on a blank or over-long name, where create answers a clean 400. See #467. + var nameError = ProfileNameRules.Validate(request.ProfileName); + if (nameError is not null) + { + return this.BadRequest(new { error = nameError }); + } + var existing = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); var existingNames = existing.Select(p => p.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); var profileName = request.ProfileName; if (existingNames.Contains(profileName)) @@ -105,18 +159,76 @@ public async Task ImportProfile([FromBody] ProfileOverviewImportR var body = JsonSerializer.SerializeToElement(new { name = profileName, - profileNo = newProfileNo, area = "[]", latitude = 0.0, longitude = 0.0 }); await this._humanProxy.AddProfileAsync(this.UserId, body); - // Import all alarms into the new profile - var alarmsCopied = await this._profileOverviewService.ImportAlarmsAsync( - this.UserId, newProfileNo, request.Alarms); + var after = (await this._profileService.GetByUserAsync(this.UserId)).ToList(); + var resolved = ProfileNumbering.ResolveCreated(existing, after, profileName); + if (resolved is null) + { + return this.StatusCode(StatusCodes.Status502BadGateway, new + { + error = "The profile was not created." + }); + } - var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, this.ProfileNo); + var newProfileNo = resolved.Value; + + // A malformed payload -- including "alarms": null, which the SPA's typeof-object guard lets + // through from the file picker -- used to throw here with the profile row already created, + // leaving a junk profile behind per failed attempt. See #407. + int alarmsCopied; + try + { + alarmsCopied = await this._profileOverviewService.ImportAlarmsAsync( + this.UserId, newProfileNo, request.Alarms); + } + catch (FeatureDisabledException) + { + // Roll back the shell profile, but let this reach the global filter so it still maps to a + // 403 with a disableKey rather than being flattened into a generic 400. See #236. + await this._humanProxy.DeleteProfileAsync(this.UserId, newProfileNo); + throw; + } + catch (AlarmValidationException) + { + // Roll back the shell profile, then let it reach the global filter: the message names the + // alarm and the field, which is a great deal more use than "check that the file is valid". + // See #548. + await this._humanProxy.DeleteProfileAsync(this.UserId, newProfileNo); + throw; + } + catch (Exception ex) + { + await this._humanProxy.DeleteProfileAsync(this.UserId, newProfileNo); + LogImportFailed(this._logger, ex, newProfileNo); + return this.BadRequest(new + { + error = "The profile could not be imported. Check that the file contains a valid alarms list." + }); + } + + // The shell profile is created empty on purpose, but copying the alarms switches PoracleNG onto + // it and back, and that switch carries the active profile's areas and location across -- so an + // import ended up subscribed to every area the user currently had, delivering notifications they + // never chose for it. Restore what the file actually declared. See #522. + await this.WriteGeographyAsync(newProfileNo, profileName, "[]", 0.0, 0.0); + + // Resolved fresh, not copied from the old token: a profile switch was the way a de-admined + // user kept their isAdmin claim alive indefinitely. See #624. + var roles = await this._roleResolver.ResolveAsync(this.UserId); + + // Null means "leave the claim alone". Two cases need it: the resolver could not reach PoracleNG, + // where treating unknown as false stripped admin for the rest of the session (#656); and an + // impersonation session, which AdminController deliberately mints with IsAdmin = false and which + // would otherwise be re-elevated by resolving the impersonated user's own roles (#663). + bool? resolvedAdmin = roles.Resolved && !this.IsImpersonating + ? roles.IsAdmin + : null; + var newToken = this._jwtService.GenerateTokenWithReplacedProfile(this.User, this.ProfileNo, resolvedAdmin); return this.Ok(new { @@ -126,6 +238,40 @@ public async Task ImportProfile([FromBody] ProfileOverviewImportR }); } + /// + /// Writes a profile's name, areas and location directly. + /// + /// + /// PoracleNG has no endpoint that sets these on an existing profile -- its update ignores name (#406) + /// and addProfile ignores the geography -- so this is a direct write, the same workaround rename uses. + /// Never fails the request: the alarms are already copied, and a wrong area list is recoverable from + /// the Areas page while a failed duplicate is not. + /// + private async Task WriteGeographyAsync(int profileNo, string name, string area, double latitude, double longitude) + { + try + { + await this._profileRepository.UpdateAsync(new Profile + { + Id = this.UserId, + ProfileNo = profileNo, + Name = name, + Area = area, + Latitude = latitude, + Longitude = longitude, + }); + } + catch (InvalidOperationException ex) + { + LogGeographyWriteFailed(this._logger, ex, profileNo); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not write areas and location onto profile {ProfileNo}; it may have inherited them from the active profile")] + private static partial void LogGeographyWriteFailed(ILogger logger, Exception ex, int profileNo); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Profile import failed for profile {ProfileNo}; the partially created profile was rolled back")] + private static partial void LogImportFailed(ILogger logger, Exception ex, int profileNo); } public record ProfileOverviewDuplicateRequest(string Name); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs index e4113da2..ab668801 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/QuestController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] QuestCreate model) { var quest = model.ToQuest(); - quest.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._questService.CreateAsync(this.UserId, quest); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] QuestUpdate model) return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._questService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._questService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/QuickPickController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/QuickPickController.cs index 30b790a7..3807ca63 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/QuickPickController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/QuickPickController.cs @@ -1,116 +1,198 @@ -using Microsoft.AspNetCore.Mvc; -using Pgan.PoracleWebNet.Core.Abstractions.Services; -using Pgan.PoracleWebNet.Core.Models; - -namespace Pgan.PoracleWebNet.Api.Controllers; - -[Route("api/quick-picks")] -public class QuickPickController(IQuickPickService quickPickService) : BaseApiController -{ - private readonly IQuickPickService _quickPickService = quickPickService; - - [HttpGet] - public async Task GetAll() - { - var picks = await this._quickPickService.GetAllAsync(this.UserId, this.ProfileNo); - return this.Ok(picks); - } - - [HttpGet("{id}")] - public async Task GetById(string id) - { - var pick = await this._quickPickService.GetByIdAsync(id); - if (pick is null) - { - return this.NotFound(); - } - - return this.Ok(pick); - } - - [HttpPost] - public async Task SaveAdmin([FromBody] QuickPickDefinition definition) - { - if (!this.IsAdmin) - { - return this.Forbid(); - } - - var saved = await this._quickPickService.SaveAdminPickAsync(definition); - return this.Ok(saved); - } - - [HttpPost("user")] - public async Task SaveUser([FromBody] QuickPickDefinition definition) - { - var saved = await this._quickPickService.SaveUserPickAsync(this.UserId, definition); - return this.Ok(saved); - } - - [HttpDelete("{id}")] - public async Task DeleteAdmin(string id) - { - if (!this.IsAdmin) - { - return this.Forbid(); - } - - var deleted = await this._quickPickService.DeleteAdminPickAsync(id); - if (!deleted) - { - return this.NotFound(); - } - - return this.NoContent(); - } - - [HttpDelete("user/{id}")] - public async Task DeleteUser(string id) - { - var deleted = await this._quickPickService.DeleteUserPickAsync(this.UserId, id); - if (!deleted) - { - return this.NotFound(); - } - - return this.NoContent(); - } - - [HttpPost("{id}/apply")] - public async Task Apply(string id, [FromBody] QuickPickApplyRequest request) - { - var state = await this._quickPickService.ApplyAsync(this.UserId, this.ProfileNo, id, request); - return this.Ok(state); - } - - [HttpPost("{id}/reapply")] - public async Task Reapply(string id, [FromBody] QuickPickApplyRequest request) - { - var state = await this._quickPickService.ReapplyAsync(this.UserId, this.ProfileNo, id, request); - return this.Ok(state); - } - - [HttpDelete("{id}/remove")] - public async Task Remove(string id) - { - var removed = await this._quickPickService.RemoveAsync(this.UserId, this.ProfileNo, id); - if (!removed) - { - return this.NotFound(); - } - - return this.NoContent(); - } - - [HttpPost("seed")] - public async Task Seed() - { - if (!this.IsAdmin) - { - return this.Forbid(); - } - - await this._quickPickService.SeedDefaultsAsync(); - return this.NoContent(); - } -} +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +[Route("api/quick-picks")] +public class QuickPickController( + IQuickPickService quickPickService, + ISiteSettingService siteSettingService) : BaseApiController +{ + private readonly IQuickPickService _quickPickService = quickPickService; + private readonly ISiteSettingService _siteSettingService = siteSettingService; + + [HttpGet] + public async Task GetAll() + { + var picks = await this._quickPickService.GetAllAsync(this.UserId, this.ProfileNo); + return this.Ok(picks); + } + + [HttpGet("{id}")] + public async Task GetById(string id) + { + // Scoped: a global pick is public, a user pick is only its owner's. Returning any row by id leaked + // another user's private pick along with their Discord ID in ownerUserId. + var pick = this.IsAdmin + ? await this._quickPickService.GetByIdAsync(id) + : await this._quickPickService.GetVisibleByIdAsync(this.UserId, id); + + if (pick is null) + { + return this.NotFound(); + } + + return this.Ok(pick); + } + + [HttpPost] + public async Task SaveAdmin([FromBody] QuickPickDefinition definition) + { + if (!this.IsAdmin) + { + return this.Forbid(); + } + + var saved = await this._quickPickService.SaveAdminPickAsync(definition); + return this.Ok(saved); + } + + [HttpPost("user")] + public async Task SaveUser([FromBody] QuickPickDefinition definition) + { + try + { + var saved = await this._quickPickService.SaveUserPickAsync(this.UserId, definition); + return this.Ok(saved); + } + catch (UnauthorizedAccessException) + { + // The id belongs to a global pick or another user's pick. + return this.Forbid(); + } + } + + [HttpDelete("{id}")] + public async Task DeleteAdmin(string id) + { + if (!this.IsAdmin) + { + return this.Forbid(); + } + + var deleted = await this._quickPickService.DeleteAdminPickAsync(id); + if (!deleted) + { + return this.NotFound(); + } + + return this.NoContent(); + } + + [HttpDelete("user/{id}")] + public async Task DeleteUser(string id) + { + var deleted = await this._quickPickService.DeleteUserPickAsync(this.UserId, id); + if (!deleted) + { + return this.NotFound(); + } + + return this.NoContent(); + } + + [HttpPost("{id}/apply")] + public async Task Apply(string id, [FromBody] QuickPickApplyRequest request) + { + var invalid = this.ValidateApplyRequest(request); + if (invalid is not null) + { + return invalid; + } + + try + { + var state = await this._quickPickService.ApplyAsync(this.UserId, this.ProfileNo, id, request); + return this.Ok(state); + } + catch (InvalidOperationException ex) + { + // Unknown id, or a definition carrying an alarm type the applier cannot handle. The sibling + // GET and DELETE already 404 on an unknown id; this used to be an unhandled 500. + return this.NotFound(new { error = ex.Message }); + } + } + + [HttpPost("{id}/reapply")] + public async Task Reapply(string id, [FromBody] QuickPickApplyRequest request) + { + var invalid = this.ValidateApplyRequest(request); + if (invalid is not null) + { + return invalid; + } + + try + { + var state = await this._quickPickService.ReapplyAsync(this.UserId, this.ProfileNo, id, request); + return this.Ok(state); + } + catch (InvalidOperationException ex) + { + return this.NotFound(new { error = ex.Message }); + } + } + + /// + /// Rejects override values the alarm endpoints would refuse anyway. Previously the throw happened + /// after the alarms were created but before the applied-state write, leaving the pick un-applied + /// with no cleanup path. + /// + private BadRequestObjectResult? ValidateApplyRequest(QuickPickApplyRequest? request) + { + if (request?.Clean is { } clean && clean is < 0 or > 7) + { + return this.BadRequest(new + { + error = "clean must be between 0 and 7 (a 3-bit mask: 1 auto-delete, 2 edit, 4 summary)." + }); + } + + if (request?.Distance is { } distance && distance < 0) + { + return this.BadRequest(new { error = "distance cannot be negative." }); + } + + return null; + } + + [HttpDelete("{id}/remove")] + public async Task Remove(string id) + { + var removed = await this._quickPickService.RemoveAsync(this.UserId, this.ProfileNo, id); + if (!removed) + { + return this.NotFound(); + } + + return this.NoContent(); + } + + /// Marks that the built-in presets have been created once. See #634, #662. + private const string QuickPicksSeededKey = "quick_picks_seeded"; + + [HttpPost("seed")] + public async Task Seed() + { + if (!this.IsAdmin) + { + return this.Forbid(); + } + + await this._quickPickService.SeedDefaultsAsync(); + + // Whether an installation has been seeded is a property of the installation. The SPA used to + // record it in localStorage, so a second admin or a different browser reseeded anyway, and a + // failed seed latched the flag and never retried. Written here, after the seed actually + // succeeded. See #662. + await this._siteSettingService.CreateOrUpdateAsync(new SiteSetting + { + Key = QuickPicksSeededKey, + Value = "true", + Category = "admin", + ValueType = "boolean", + }); + return this.NoContent(); + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/RaidController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/RaidController.cs index f374158e..da2c4100 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/RaidController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/RaidController.cs @@ -35,8 +35,18 @@ public async Task GetByUid(int uid) public async Task Create([FromBody] RaidCreate model) { var raid = model.ToRaid(); - raid.ProfileNo = this.ProfileNo; + // Deliberately not stamped from the JWT claim: writes no longer carry profile_no, so + // PoracleNG files the alarm under the live current_profile_no. Echoing a possibly-stale + // claim back would assert a profile the row was never written to. See #411. var result = await this._raidService.CreateAsync(this.UserId, raid); + + // PoracleNG assigns no uid when the submission duplicates an alarm the user already has, so + // nothing was created. Answering 201 with a Location of /0 advertised a resource that 404s. + // 200 keeps multi-select creates working while no longer claiming a creation. See #459. + if (result.Uid <= 0) + { + return this.Ok(result); + } return this.CreatedAtAction(nameof(GetByUid), new { uid = result.Uid @@ -52,7 +62,16 @@ public async Task Update(int uid, [FromBody] RaidUpdate model) return this.NotFound(); } - model.ApplyUpdate(existing); + // Nothing to write means nothing to send: see LeavesAlarmUnchanged. + if (LeavesAlarmUnchanged(existing, () => + { + model.ApplyUpdate(existing); + return existing; + })) + { + return this.Ok(existing); + } + var result = await this._raidService.UpdateAsync(this.UserId, existing); return this.Ok(result); } @@ -93,6 +112,12 @@ public async Task UpdateBulkDistance([FromBody] BulkDistanceReque [HttpPut("distance")] public async Task UpdateAllDistance([FromBody] int distance) { + var invalid = this.RejectInvalidDistance(distance); + if (invalid != null) + { + return invalid; + } + var count = await this._raidService.UpdateDistanceByUserAsync(this.UserId, this.ProfileNo, distance); return this.Ok(new { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs index 1ee078fb..cb656b0a 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs @@ -1,6 +1,8 @@ +using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Pgan.PoracleWebNet.Api.Configuration; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -9,18 +11,45 @@ namespace Pgan.PoracleWebNet.Api.Controllers; [Route("api/settings")] -public class SettingsController( +public partial class SettingsController( ISiteSettingService siteSettingService, IOptions discordSettings, IOptions poracleSettings, - IOptions telegramSettings) : BaseApiController + IOptions telegramSettings, + IOptions oidcSettings, + IUpstreamFeatureFlagService upstreamFlags, + IConfiguration configuration, + IPoracleApiProxy poracleApiProxy, + IMemoryCache cache, + ILogger logger) : BaseApiController { - private static readonly HashSet SensitiveKeys = new(StringComparer.OrdinalIgnoreCase) + /// + /// Exact setting keys a non-admin may read. This is an allowlist, deliberately: the previous + /// denylist listed scan_db, which matches no real key (the rows are scan_dbhost, + /// scan_dbuser, scan_dbpass, ...) and omitted cf_id/cf_secret entirely, so a + /// scanner-database password and a Cloudflare Access token were served to every authenticated session. + /// With an allowlist a newly added credential-bearing key is hidden by default instead of exposed. + /// Admins still receive everything. + /// + private static readonly HashSet UserVisibleKeys = new(StringComparer.OrdinalIgnoreCase) { - "api_secret", "telegram_bot_token", "scan_db", - "discord_client_secret", "discord_bot_token", + "allowed_languages", "custom_title", "favicon_url", "header_logo_url", + "hide_header_logo", "signup_url", "site_name", + // The custom nav link is public branding, not a credential. Left off this list it reached admins + // only -- the one group that least needs it -- so an admin configuring it saw it work and had no + // way to tell it was invisible to everyone else. See #513. + "custom_page_name", "custom_page_url", "custom_page_icon", + // Poracle's own locale, synthesized rather than stored -- see GetPoracleLocaleAsync. + PoracleLocaleKey, }; + /// + /// Key families the SPA reads dynamically rather than by literal name: feature gates via + /// isDisabled(key) / disabledFeatureGuard, and the uicons URL set. All are + /// booleans or public asset URLs. + /// + private static readonly string[] UserVisibleKeyPrefixes = ["disable_", "enable_", "uicons_"]; + private static readonly HashSet InternalKeys = new(StringComparer.OrdinalIgnoreCase) { "migration_completed", @@ -29,10 +58,28 @@ public class SettingsController( private const string EnableDiscordKey = "enable_discord"; private const string EnableTelegramKey = "enable_telegram"; + /// + /// Pseudo-setting carrying Poracle's configured locale. It is not an admin-editable row: it is + /// read from Poracle's config and appended to the settings response so the SPA can use it as the last + /// language fallback ahead of the hardcoded en. + /// + internal const string PoracleLocaleKey = "poracle_locale"; + + private const string PoracleLocaleCacheKey = "settings:poracle_locale"; + + /// Matches the shape of a locale tag (de, pt-BR, zh-cn) and nothing else. + [GeneratedRegex("^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$")] + private static partial Regex LocalePattern(); + private readonly DiscordSettings _discordSettings = discordSettings.Value; private readonly PoracleSettings _poracleSettings = poracleSettings.Value; private readonly TelegramSettings _telegramSettings = telegramSettings.Value; + private readonly OidcSettings _oidcSettings = oidcSettings.Value; private readonly ISiteSettingService _siteSettingService = siteSettingService; + private readonly IUpstreamFeatureFlagService _upstreamFlags = upstreamFlags; + private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly IMemoryCache _cache = cache; + private readonly ILogger _logger = logger; [HttpGet] public async Task GetAll() @@ -42,13 +89,37 @@ public async Task GetAll() // Always hide internal system settings (e.g. migration sentinel) settings = settings.Where(s => !InternalKeys.Contains(s.Key)); - // Non-admin users only see non-sensitive settings + // Non-admins see only the allowlisted keys the SPA actually needs. if (!this.IsAdmin) { - settings = settings.Where(s => !SensitiveKeys.Contains(s.Key)); + settings = settings.Where(s => IsUserVisible(s.Key)); } - return this.Ok(settings.ToList()); + return this.Ok(await this.WithPoracleLocaleAsync(settings)); + } + + /// True when a non-admin may read . + internal static bool IsUserVisible(string? key) => + !string.IsNullOrWhiteSpace(key) + && (UserVisibleKeys.Contains(key) + || Array.Exists(UserVisibleKeyPrefixes, p => key.StartsWith(p, StringComparison.OrdinalIgnoreCase))); + + /// + /// The disable_* keys the upstream Poracle deployment forces off in its own config, on top + /// of whatever the site settings say. Lets the SPA hide those sections and the admin page mark the + /// matching toggle as not-ours-to-change, instead of showing a switch that reads "enabled" while + /// every write 403s. + /// + /// + /// Open to any signed-in user, not just admins: the same information is already obtainable by + /// POSTing an alarm and reading the disableKey off the 403, and every non-admin consumer + /// (nav, route guards) needs it. Empty when Poracle is unreachable or too old to report the flags. + /// + [HttpGet("upstream-disabled")] + public async Task GetUpstreamDisabled() + { + var keys = await this._upstreamFlags.GetDisabledKeysAsync(); + return this.Ok(keys.OrderBy(k => k, StringComparer.Ordinal).ToList()); } [AllowAnonymous] @@ -57,7 +128,7 @@ public async Task GetAll() public async Task GetPublic() { var publicSettings = await this._siteSettingService.GetPublicAsync(); - return this.Ok(publicSettings); + return this.Ok(await this.WithPoracleLocaleAsync(publicSettings)); } [HttpGet("discord-config")] @@ -95,6 +166,52 @@ public IActionResult GetTelegramConfig() }); } + /// + /// Returns the server-side OIDC provider configuration (env / appsettings) for the admin + /// settings UI to display read-only. Secrets are masked; the client secret is never returned + /// in full. configured reflects whether the full provider config is present, and + /// forceLocal surfaces the AUTH_FORCE_LOCAL break-glass so the UI can explain why + /// OIDC may be inactive even when enabled. + /// + [HttpGet("oidc-config")] + public IActionResult GetOidcConfig() + { + if (!this.IsAdmin) + { + return this.Forbid(); + } + + var configured = !string.IsNullOrEmpty(this._oidcSettings.ClientId) + && !string.IsNullOrEmpty(this._oidcSettings.AuthorizationUrl) + && !string.IsNullOrEmpty(this._oidcSettings.TokenUrl) + && !string.IsNullOrEmpty(this._oidcSettings.UserInfoUrl); + + return this.Ok(new + { + configured, + enabled = this._oidcSettings.Enabled, + forceLocal = configuration.GetValue("Auth:ForceLocal"), + providerName = this._oidcSettings.ProviderName, + authorizationUrl = this._oidcSettings.AuthorizationUrl, + tokenUrl = this._oidcSettings.TokenUrl, + userInfoUrl = this._oidcSettings.UserInfoUrl, + endSessionUrl = this._oidcSettings.EndSessionUrl, + clientId = MaskValue(this._oidcSettings.ClientId), + clientSecret = MaskSecret(this._oidcSettings.ClientSecret), + scopes = this._oidcSettings.Scopes, + identityClaim = this._oidcSettings.IdentityClaim, + usePkce = this._oidcSettings.UsePkce, + // Refresh-token consumption (server-side config only — controlled by OIDC_USE_REFRESH_TOKENS; + // there is no runtime admin toggle, as refresh is coupled to the per-login JWT lifetime). + useRefreshTokens = this._oidcSettings.UseRefreshTokens, + accessTokenMinutes = this._oidcSettings.AccessTokenMinutes, + refreshTokenLifetimeDays = this._oidcSettings.RefreshTokenLifetimeDays, + revokedRetentionDays = this._oidcSettings.RevokedRetentionDays, + offlineAccessScope = this._oidcSettings.OfflineAccessScope, + tokenEndpointAuthMethod = this._oidcSettings.TokenEndpointAuthMethod, + }); + } + [HttpPut("{key}")] public async Task Upsert(string key, [FromBody] SiteSettingRequest request) { @@ -111,6 +228,17 @@ public async Task Upsert(string key, [FromBody] SiteSettingReques }); } + // poracle_locale is a projection of Poracle's config, not a row this page owns. Nothing stopped + // it being written, and because a real row wins over the synthesized value, one accidental save + // would have pinned the language default forever and silently stopped tracking Poracle. See #780. + if (string.Equals(key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase)) + { + return this.BadRequest(new + { + error = "poracle_locale is read from Poracle's configuration and cannot be set here." + }); + } + // Prevent lockout: at least one login method must remain enabled. // Uses GetValueAsync so absent/null = enabled (safe default). Only blocks when // both are explicitly "False". @@ -151,6 +279,76 @@ public async Task Upsert(string key, [FromBody] SiteSettingReques return this.Ok(result); } + /// + /// Appends the Poracle locale pseudo-setting to , unless a real row of the + /// same key already exists -- an admin-set value wins over what Poracle reports. + /// + private async Task> WithPoracleLocaleAsync(IEnumerable settings) + { + var list = settings.ToList(); + if (list.Exists(s => string.Equals(s.Key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase))) + { + return list; + } + + var locale = await this.GetPoracleLocaleAsync(); + if (!string.IsNullOrEmpty(locale)) + { + list.Add(new SiteSetting + { + Key = PoracleLocaleKey, + Value = locale, + Category = "branding", + ValueType = "string", + }); + } + + return list; + } + + /// + /// Reads locale from Poracle's config, cached for five minutes. Both the settings endpoints that + /// serve it are hit on every page load, and one of them is anonymous, so an uncached read would put a + /// PoracleNG roundtrip in front of the login page. A Poracle outage caches a null and the SPA keeps its + /// existing stored/browser/en ordering -- the locale is a nicety, never a blocker. + /// + private async Task GetPoracleLocaleAsync() + { + if (this._cache.TryGetValue(PoracleLocaleCacheKey, out var cached)) + { + return cached; + } + + string? locale = null; + try + { + var config = await this._poracleApiProxy.GetConfigAsync(); + locale = NormalizeLocale(config?.Locale); + } + catch (Exception ex) + { + LogFetchLocaleFailed(this._logger, ex); + } + + this._cache.Set(PoracleLocaleCacheKey, locale, TimeSpan.FromMinutes(5)); + return locale; + } + + /// + /// Returns when it looks like a locale tag, otherwise null. Deliberately a + /// shape check rather than a list of the eleven languages this UI ships: the SPA does that matching + /// itself against its own language list and the allowed_languages filter, and a locale it cannot + /// place simply loses to en. An allowlist here would need updating every time a translation lands. + /// + internal static string? NormalizeLocale(string? locale) + { + var trimmed = locale?.Trim(); + return !string.IsNullOrEmpty(trimmed) && LocalePattern().IsMatch(trimmed) ? trimmed : null; + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to read Poracle's configured locale")] + private static partial void LogFetchLocaleFailed(ILogger logger, Exception ex); + public class SiteSettingRequest { public string? Value diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs new file mode 100644 index 00000000..834b8606 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs @@ -0,0 +1,204 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Pgan.PoracleWebNet.Api.Filters; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +/// +/// Per-user quest summary delivery schedules. The schedule is an active_hours array keyed by the +/// authenticated user + quest — there is NO id/userId route segment or body field anywhere +/// (the JWT's userId is the sole id source; summary_schedules is keyed per-user with no profile_no, +/// so any forwarded request-supplied id would be a full read/write/delete/trigger IDOR). +/// +/// +/// The whole controller is gated by disable_quests: a disabled type is gone rather than +/// read-only, so the schedule is unreachable until quests are switched back on. Admins are +/// intentionally NOT exempt (see #236). +/// +[Route("api/summary-schedules")] +[RequireFeatureEnabled(DisableFeatureKeys.Quests)] +public class SummaryScheduleController( + IPoracleSummaryProxy summaryProxy, + ISummaryCapabilityService capability) : BaseApiController +{ + // Case-insensitive on purpose (deliberate upgrade over TestAlertController's case-sensitive set). + private static readonly HashSet ValidAlertTypes = new(StringComparer.OrdinalIgnoreCase) + { + "quest" + }; + + private readonly IPoracleSummaryProxy _summaryProxy = summaryProxy; + private readonly ISummaryCapabilityService _capability = capability; + + /// + /// Returns whether quest summary delivery is enabled on this server, as a 200-body boolean sourced + /// from the config flag (tracking.quest_summary_enabled). Degrades to enabled:false on + /// any fault — never returns 5xx — so a transient outage is never mistaken for "feature off". + /// + [HttpGet("capability")] + public async Task GetCapability() => this.Ok(new + { + enabled = await this._capability.IsQuestSummaryEnabledAsync() + }); + + /// Lists every summary schedule for the authenticated user, across alert types. + [HttpGet] + public async Task GetSchedules() + { + var schedulesJson = await this._summaryProxy.GetSchedulesAsync(this.UserId); + if (schedulesJson is not { ValueKind: JsonValueKind.Array } array) + { + return this.Ok(Array.Empty()); + } + + var schedules = new List(); + foreach (var element in array.EnumerateArray()) + { + schedules.Add(MapSchedule(element)); + } + + return this.Ok(schedules); + } + + /// + /// Gets the schedule for one alert type. When no schedule exists yet this returns 200 with an + /// empty schedule (active_hours = []) rather than 404 — "no schedule yet" is a normal + /// empty state, not an error, and a 404 would trip the SPA's global not-found toast. Consistent + /// with returning an empty array. + /// + [HttpGet("{alertType}")] + public async Task GetSchedule(string alertType) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + // Accepted case-insensitively, but the backend matches case-sensitively. Forwarding "QUEST" + // made PUT/DELETE/trigger 500, and made GET report an empty schedule for a user who had one -- + // a read-modify-write client would then wipe it. + alertType = alertType.ToLowerInvariant(); + + var scheduleJson = await this._summaryProxy.GetScheduleAsync(this.UserId, alertType); + if (scheduleJson is not { ValueKind: JsonValueKind.Object } element) + { + return this.Ok(new SummarySchedule { AlertType = alertType, ActiveHours = "[]" }); + } + + return this.Ok(MapSchedule(element)); + } + + /// + /// Creates or replaces the schedule for one alert type (upsert). The DTO carries ONLY + /// ActiveHours — any id/userId/alertType body field is ignored; the route's validated + /// {alertType} is the sole alert-type source. Validates the active-hours payload via the + /// shared BEFORE proxying; null/whitespace clears the schedule. + /// + [HttpPut("{alertType}")] + [EnableRateLimiting("auth-read")] + public async Task SetSchedule(string alertType, [FromBody] SummaryScheduleRequest request) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + // Accepted case-insensitively, but the backend matches case-sensitively. Forwarding "QUEST" + // made PUT/DELETE/trigger 500, and made GET report an empty schedule for a user who had one -- + // a read-modify-write client would then wipe it. + alertType = alertType.ToLowerInvariant(); + + var (isValid, error) = ActiveHoursValidator.Validate(request.ActiveHours); + if (!isValid) + { + return this.BadRequest(new + { + error + }); + } + + var activeHours = string.IsNullOrWhiteSpace(request.ActiveHours) ? "[]" : request.ActiveHours; + await this._summaryProxy.SetScheduleAsync(this.UserId, alertType, activeHours); + return this.NoContent(); + } + + /// Removes the schedule for one alert type. Idempotent — succeeds even when absent. + [HttpDelete("{alertType}")] + [EnableRateLimiting("auth-read")] + public async Task DeleteSchedule(string alertType) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + // Accepted case-insensitively, but the backend matches case-sensitively. Forwarding "QUEST" + // made PUT/DELETE/trigger 500, and made GET report an empty schedule for a user who had one -- + // a read-modify-write client would then wipe it. + alertType = alertType.ToLowerInvariant(); + + await this._summaryProxy.DeleteScheduleAsync(this.UserId, alertType); + return this.NoContent(); + } + + /// + /// Flush-and-deliver-now: re-enriches the buffered quests, renders, and delivers the summary DM + /// synchronously, then clears the bucket. Rate-limited (5/60s) and client-cooldown-guarded so a + /// double-click cannot double-deliver. + /// + [HttpPost("{alertType}/trigger")] + [EnableRateLimiting("test-alert")] + public async Task Trigger(string alertType) + { + if (!ValidAlertTypes.Contains(alertType)) + { + return this.BadRequest(new + { + error = $"Invalid alarm type: {alertType}" + }); + } + + // Accepted case-insensitively, but the backend matches case-sensitively. Forwarding "QUEST" + // made PUT/DELETE/trigger 500, and made GET report an empty schedule for a user who had one -- + // a read-modify-write client would then wipe it. + alertType = alertType.ToLowerInvariant(); + + await this._summaryProxy.TriggerAsync(this.UserId, alertType); + return this.NoContent(); + } + + // Maps the upstream { id, alert_type, active_hours } element to SummarySchedule. + // MUST NOT read or echo the upstream "id" — it is the user id (IDOR leak). + private static SummarySchedule MapSchedule(JsonElement element) + { + var alertType = element.TryGetProperty("alert_type", out var alertTypeProp) && alertTypeProp.ValueKind == JsonValueKind.String + ? alertTypeProp.GetString() ?? "quest" + : "quest"; + + var activeHours = "[]"; + if (element.TryGetProperty("active_hours", out var activeHoursProp)) + { + activeHours = activeHoursProp.ValueKind == JsonValueKind.String + ? activeHoursProp.GetString() ?? "[]" + : activeHoursProp.GetRawText(); + } + + return new SummarySchedule + { + AlertType = alertType, + ActiveHours = activeHours + }; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs index a25ec969..d8ada6d8 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using Pgan.PoracleWebNet.Api.Filters; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -29,7 +30,45 @@ public async Task GetCustomGeofences() return this.Ok(geofences); } + /// Renames a geofence without disturbing which profiles are subscribed to it. + /// + /// The page used to edit by deleting and recreating, which re-subscribed only the active profile and + /// silently switched the geofence off everywhere else. See #543. + /// + [HttpPut("custom/{id:int}")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] + public async Task RenameGeofence(int id, [FromBody] UserGeofenceRenameRequest request) + { + try + { + var updated = await this._userGeofenceService.RenameAsync( + this.UserId, id, request.DisplayName, request.GroupName, request.ParentId); + return this.Ok(updated); + } + catch (GeofenceNotFoundException) + { + return this.NotFound(); + } + catch (UnauthorizedAccessException) + { + // Not Forbid: a geofence the caller does not own should not be distinguishable from one that + // does not exist. + return this.NotFound(); + } + catch (InvalidOperationException ex) + { + // The rename status guard (#646) threw straight past these arms as an unhandled 500. + // CreateGeofence already has this arm; rename was not given it. See #657. + return this.BadRequest(new { error = ex.Message }); + } + catch (ArgumentException ex) + { + return this.BadRequest(new { error = ex.Message }); + } + } + [HttpPost("custom")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] public async Task CreateGeofence([FromBody] UserGeofenceCreate model) { try @@ -47,6 +86,23 @@ public async Task CreateGeofence([FromBody] UserGeofenceCreate mo } } + /// Removes one of the caller's own geofences. + /// + /// + /// Deliberately not behind , unlike every other + /// mutation here. Switching the feature off hides the page and refuses new work, but the fences + /// that already exist keep being served in the geofence feed and keep matching — so gating this + /// too would leave someone receiving alerts from an area they can neither edit nor remove. + /// + /// + /// This is where geofences differ from the alarm types, whose controllers gate the whole class + /// and so refuse deletes as well. Those users still have the bot: !untrack removes an + /// alarm whatever the web says. Geofences are PoracleWeb-only and the bot has no equivalent + /// command, so this endpoint is the only route a user has to their own data. Production + /// carries 42 of them, so the stranding is not hypothetical. + /// + /// Pinned by a test, because this reads like an oversight and has been reported as one. + /// [HttpDelete("custom/{id:int}")] public async Task DeleteGeofence(int id) { @@ -70,6 +126,7 @@ public async Task DeleteGeofence(int id) } [HttpPost("custom/{kojiName}/submit")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] public async Task SubmitForReview(string kojiName) { try @@ -91,6 +148,10 @@ public async Task SubmitForReview(string kojiName) } } + // These write area subscriptions, so they answer to the same switch the Areas page does. + // Gated per-action because the reads on this controller stay open; disable_areas is + // enforced in the service, since the attribute does not allow two keys. See #478. + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] [HttpPost("custom/{id:int}/activate")] public async Task ActivateGeofence(int id) { @@ -113,6 +174,10 @@ public async Task ActivateGeofence(int id) } } + // These write area subscriptions, so they answer to the same switch the Areas page does. + // Gated per-action because the reads on this controller stay open; disable_areas is + // enforced in the service, since the attribute does not allow two keys. See #478. + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] [HttpPost("custom/{id:int}/deactivate")] public async Task DeactivateGeofence(int id) { @@ -171,6 +236,7 @@ public async Task ExportGeoJson() } [HttpPost("import/geojson")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] [EnableRateLimiting("geojson-import")] [RequestSizeLimit(5 * 1024 * 1024)] public async Task ImportGeoJson(IFormFile file) @@ -252,3 +318,5 @@ public async Task ImportGeoJson(IFormFile file) [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to import GeoJSON for user {UserId}")] private static partial void LogImportGeoJsonFailed(ILogger logger, Exception ex, string userId); } + +public record UserGeofenceRenameRequest(string DisplayName, string? GroupName, int? ParentId); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/VersionController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/VersionController.cs new file mode 100644 index 00000000..b65f644a --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/VersionController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +/// +/// Reports which build is actually running. The image already carries this in its OCI labels, +/// but those are only readable via `docker inspect` on the host -- which is no help when you +/// want to know what a deployed instance is serving from outside, or when the image was built +/// locally and carries no labels at all. +/// +[ApiController] +[Route("api/version")] +public class VersionController(IConfiguration configuration, IHostEnvironment environment) : ControllerBase +{ + /// Fallback when the build args were not supplied (local `docker build`, `dotnet run`). + internal const string Unknown = "unknown"; + + private readonly IConfiguration _configuration = configuration; + private readonly IHostEnvironment _environment = environment; + + /// + /// Returns the running build's version, git revision and build timestamp. + /// + /// + /// Anonymous on purpose: the main use is checking a deployment from outside without + /// credentials. Nothing here is sensitive -- the repository is public, so the commit SHA + /// is already visible on GitHub, and no configuration or secret is exposed. + /// + [HttpGet] + [AllowAnonymous] + public IActionResult Get() + { + var revision = Value("BUILD_REVISION"); + + return this.Ok(new + { + version = Value("BUILD_VERSION"), + revision, + // Short form purely for convenience -- it is what you actually paste into `git log`. + revisionShort = revision == Unknown ? Unknown : revision[..Math.Min(7, revision.Length)], + buildDate = Value("BUILD_DATE"), + environment = this._environment.EnvironmentName, + }); + } + + private string Value(string key) + { + var value = this._configuration[key]; + + return string.IsNullOrWhiteSpace(value) ? Unknown : value; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Filters/AccountGoneExceptionFilter.cs b/Applications/Pgan.PoracleWebNet.Api/Filters/AccountGoneExceptionFilter.cs new file mode 100644 index 00000000..56d1d577 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Filters/AccountGoneExceptionFilter.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Filters; + +/// +/// Answers 401 when the authenticated account no longer exists. +/// +/// +/// Registered globally, beside the conflict and validation filters. A deleted user's token stayed valid, so +/// every endpoint threw on PoracleNG's 404 and the global handler returned 500 -- and the SPA signs out only +/// on 401, so the session sat there failing on every page. /api/auth/me alone got this right (#545); this +/// makes the rest of the API agree with it. See #584. +/// +public sealed class AccountGoneExceptionFilter : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + } + + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.Exception is not AccountGoneException ex) + { + return; + } + + context.Result = new UnauthorizedObjectResult(new + { + error = ex.Message, + }); + context.ExceptionHandled = true; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Filters/AlarmValidationExceptionFilter.cs b/Applications/Pgan.PoracleWebNet.Api/Filters/AlarmValidationExceptionFilter.cs new file mode 100644 index 00000000..7ac7d422 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Filters/AlarmValidationExceptionFilter.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Filters; + +/// +/// Turns a service-layer rejection of a bad request into 400 Bad Request. +/// +/// +/// Registered globally, next to , so a guard that lives in the +/// service rather than in a DataAnnotation still reports the same way. Without it the update paths answered +/// 500 for exactly the request the create paths described in a 400. See #518. +/// +public sealed class AlarmValidationExceptionFilter : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + } + + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.Exception is not AlarmValidationException ex) + { + return; + } + + context.Result = new BadRequestObjectResult(new + { + error = ex.Message, + }); + context.ExceptionHandled = true; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Filters/BlockedAccountFilter.cs b/Applications/Pgan.PoracleWebNet.Api/Filters/BlockedAccountFilter.cs new file mode 100644 index 00000000..c269aed5 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Filters/BlockedAccountFilter.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Caching.Memory; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Api.Filters; + +/// +/// Rejects requests from an account an admin has blocked. +/// +/// +/// #597 made /api/auth/me answer 401 so the SPA signs a blocked user out. That is a client-side +/// courtesy, not enforcement: the API kept serving every other endpoint until the SPA happened to poll, and +/// anything not going through the SPA was unaffected entirely. Blocking has to mean the API refuses. +/// +/// The lookup is cached for a minute per user, so this costs one PoracleNG call per user per minute rather +/// than one per request. A minute of stale access after a block is the trade; without the cache this would +/// add a round trip to every single request. Auth endpoints are exempt so signing out and reading +/// /api/auth/me still work — that is how the SPA learns it has been blocked. +/// +/// See #609. +/// +public sealed class BlockedAccountFilter(IHumanService humanService, IMemoryCache cache) : IAsyncActionFilter +{ + private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(1); + + private readonly IHumanService _humanService = humanService; + private readonly IMemoryCache _cache = cache; + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(next); + + var userId = context.HttpContext.User.FindFirst("userId")?.Value; + var path = context.HttpContext.Request.Path.Value ?? string.Empty; + + if (string.IsNullOrEmpty(userId) + || path.StartsWith("/api/auth", StringComparison.OrdinalIgnoreCase)) + { + await next(); + return; + } + + if (await this.IsBlockedAsync(userId)) + { + context.Result = new ObjectResult(new + { + error = "This account has been blocked by an administrator.", + }) + { + StatusCode = StatusCodes.Status403Forbidden, + }; + + return; + } + + await next(); + } + + private async Task IsBlockedAsync(string userId) + { + var key = $"blocked:{userId}"; + if (this._cache.TryGetValue(key, out bool blocked)) + { + return blocked; + } + + try + { + var human = await this._humanService.GetByIdAsync(userId); + blocked = human?.AdminDisable == 1; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + // Never lock everyone out because PoracleNG blinked. A deleted account is handled elsewhere. + return false; + } + + this._cache.Set(key, blocked, CacheFor); + return blocked; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Filters/SummaryBackendUnavailableExceptionFilter.cs b/Applications/Pgan.PoracleWebNet.Api/Filters/SummaryBackendUnavailableExceptionFilter.cs new file mode 100644 index 00000000..38cb2eb3 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Filters/SummaryBackendUnavailableExceptionFilter.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Filters; + +/// +/// Maps thrown from PoracleSummaryProxy into a +/// generic HTTP 503. The response body carries no upstream URL, no X-Poracle-Secret, no +/// ex.Message, and no stack trace — the SPA treats this as a transient "try again later" banner, +/// NOT as "feature off" (the config-flag capability boolean is the only "feature off" source). +/// Registered globally in Program.cs next to FeatureDisabledExceptionFilter. +/// +public sealed class SummaryBackendUnavailableExceptionFilter : IExceptionFilter +{ + public void OnException(ExceptionContext context) + { + if (context.Exception is not SummaryBackendUnavailableException) + { + return; + } + + context.Result = new ObjectResult(new + { + error = "Quest summary service unavailable." + }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + context.ExceptionHandled = true; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Filters/TrackingConflictExceptionFilter.cs b/Applications/Pgan.PoracleWebNet.Api/Filters/TrackingConflictExceptionFilter.cs new file mode 100644 index 00000000..67e5a55c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Filters/TrackingConflictExceptionFilter.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Filters; + +/// +/// Turns a refused-because-it-collides write into 409 Conflict. +/// +/// +/// Registered globally so every alarm controller reports a collision the same way. Without it the +/// services' would surface as an opaque 500 - and before the +/// exception existed at all, the collision was reported as success. See #462 and #463. +/// +public sealed class TrackingConflictExceptionFilter : IActionFilter +{ + public void OnActionExecuting(ActionExecutingContext context) + { + } + + public void OnActionExecuted(ActionExecutedContext context) + { + if (context.Exception is not TrackingConflictException ex) + { + return; + } + + context.Result = new ConflictObjectResult(new + { + error = ex.Message, + trackingType = ex.TrackingType, + }); + context.ExceptionHandled = true; + } +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj b/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj index ebc4f244..1e672b9e 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj +++ b/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj @@ -11,9 +11,20 @@ - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Applications/Pgan.PoracleWebNet.Api/Program.cs b/Applications/Pgan.PoracleWebNet.Api/Program.cs index 4c180ace..59337915 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Program.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Program.cs @@ -47,6 +47,11 @@ // Bridge short env var names (from .env) to .NET's __ convention. // Docker Compose does this translation in docker-compose.yml; this makes the same .env work standalone. MapEnvVar("JWT_SECRET", "Jwt__Secret"); + +// Named in the #583 changelog entry as the way to declare trusted proxies, and never bridged -- so the +// documented escape hatch did nothing and an instance behind a real proxy had no way to opt in. See #596. +MapEnvVar("PROXY_KNOWN_PROXIES", "Proxy__KnownProxies"); +MapEnvVar("PROXY_KNOWN_NETWORKS", "Proxy__KnownNetworks"); MapEnvVar("JWT_ISSUER", "Jwt__Issuer", "PoracleWeb"); MapEnvVar("JWT_AUDIENCE", "Jwt__Audience", "PoracleWeb.App"); MapEnvVar("DISCORD_CLIENT_ID", "Discord__ClientId"); @@ -54,9 +59,35 @@ MapEnvVar("DISCORD_BOT_TOKEN", "Discord__BotToken"); MapEnvVar("DISCORD_GUILD_ID", "Discord__GuildId"); MapEnvVar("DISCORD_GEOFENCE_FORUM_CHANNEL_ID", "Discord__GeofenceForumChannelId"); +MapEnvVar("PUBLIC_URL", "Site__PublicUrl"); MapEnvVar("TELEGRAM_ENABLED", "Telegram__Enabled"); MapEnvVar("TELEGRAM_BOT_TOKEN", "Telegram__BotToken"); MapEnvVar("TELEGRAM_BOT_USERNAME", "Telegram__BotUsername"); +MapEnvVar("OIDC_ENABLED", "Oidc__Enabled"); +MapEnvVar("OIDC_PROVIDER_NAME", "Oidc__ProviderName"); +MapEnvVar("OIDC_AUTHORIZATION_URL", "Oidc__AuthorizationUrl"); +MapEnvVar("OIDC_TOKEN_URL", "Oidc__TokenUrl"); +MapEnvVar("OIDC_END_SESSION_URL", "Oidc__EndSessionUrl"); +MapEnvVar("OIDC_USERINFO_URL", "Oidc__UserInfoUrl"); +MapEnvVar("OIDC_CLIENT_ID", "Oidc__ClientId"); +MapEnvVar("OIDC_CLIENT_SECRET", "Oidc__ClientSecret"); +MapEnvVar("OIDC_SCOPES", "Oidc__Scopes"); +MapEnvVar("OIDC_IDENTITY_CLAIM", "Oidc__IdentityClaim"); +MapEnvVar("OIDC_USERNAME_CLAIM", "Oidc__UsernameClaim"); +MapEnvVar("OIDC_AVATAR_CLAIM", "Oidc__AvatarClaim"); +MapEnvVar("OIDC_IDENTITY_TYPE", "Oidc__IdentityType"); +MapEnvVar("OIDC_USE_PKCE", "Oidc__UsePkce"); +// Refresh-token consumption (opt-in, default off). When on, PoracleWeb brokers the provider's +// refresh token server-side for silent renewal + revocation propagation. Provider-agnostic. +MapEnvVar("OIDC_USE_REFRESH_TOKENS", "Oidc__UseRefreshTokens"); +MapEnvVar("OIDC_ACCESS_TOKEN_MINUTES", "Oidc__AccessTokenMinutes"); +MapEnvVar("OIDC_REFRESH_TOKEN_LIFETIME_DAYS", "Oidc__RefreshTokenLifetimeDays"); +MapEnvVar("OIDC_SESSION_REVOKED_RETENTION_DAYS", "Oidc__RevokedRetentionDays"); +MapEnvVar("OIDC_OFFLINE_ACCESS_SCOPE", "Oidc__OfflineAccessScope"); +MapEnvVar("OIDC_TOKEN_AUTH_METHOD", "Oidc__TokenEndpointAuthMethod"); +// Break-glass: forces the local login page regardless of the OIDC sign-in mode. Recovery +// path when an admin switches to OIDC against a broken/unreachable provider and gets locked out. +MapEnvVar("AUTH_FORCE_LOCAL", "Auth__ForceLocal"); MapEnvVar("PORACLE_API_ADDRESS", "Poracle__ApiAddress"); MapEnvVar("PORACLE_API_SECRET", "Poracle__ApiSecret"); MapEnvVar("PORACLE_ADMIN_IDS", "Poracle__AdminIds"); @@ -67,6 +98,7 @@ MapEnvVar("GOLBAT_API_ADDRESS", "Golbat__ApiAddress"); MapEnvVar("GOLBAT_API_SECRET", "Golbat__ApiSecret"); MapEnvVar("CORS_ORIGIN", "Cors__AllowedOrigins__0"); +MapEnvVar("PUBLIC_URL", "PublicUrl"); MapEnvVar("SCANNER_DB_CONNECTION", "ConnectionStrings__ScannerDb"); // Auto-compose MySQL connection strings from short env vars (DB_HOST, DB_PORT, etc.) @@ -87,6 +119,18 @@ Environment.SetEnvironmentVariable("Telegram__Enabled", "true"); } +// Auto-infer OIDC__Enabled=true when the full provider config is present but Enabled was +// not explicitly set — same first-time-setup safeguard as Telegram above. +var oidcEnabled = Environment.GetEnvironmentVariable("Oidc__Enabled"); +if (string.IsNullOrEmpty(oidcEnabled) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__ClientId")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__AuthorizationUrl")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__TokenUrl")) + && !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("Oidc__UserInfoUrl"))) +{ + Environment.SetEnvironmentVariable("Oidc__Enabled", "true"); +} + // Reload configuration after env var bridging builder.Configuration.AddEnvironmentVariables(); @@ -137,7 +181,15 @@ // Add controllers. The global FeatureDisabledExceptionFilter maps any FeatureDisabledException // thrown from a service into HTTP 403 — covers callers that bypass [RequireFeatureEnabled] // (e.g. QuickPickService → MonsterService.CreateAsync). See #236. -builder.Services.AddControllers(options => options.Filters.Add()); +builder.Services.AddControllers(options => +{ + options.Filters.Add(); + options.Filters.Add(); + options.Filters.Add(); + options.Filters.Add(); + options.Filters.Add(); + options.Filters.Add(); +}); // Add Poracle services (DbContext, repositories, services, settings) builder.Services.AddPoracleServices(builder.Configuration); @@ -146,6 +198,8 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // JWT Authentication var jwtSettings = builder.Configuration.GetSection("Jwt").Get()!; @@ -178,7 +232,11 @@ { PermitLimit = 30, Window = TimeSpan.FromSeconds(60), - QueueLimit = 2, + // Zero, like every other policy here. A queue of 2 did not reject requests 31 and 32 -- + // it parked them until the window rolled over, up to a minute later, so on a shared + // egress IP the 31st person to log in got a spinner instead of "too many requests", + // and an intermediate proxy could time the request out entirely. See #546. + QueueLimit = 0, AutoReplenishment = true, })); options.AddPolicy("auth-read", httpContext => @@ -242,6 +300,15 @@ await context.HttpContext.Response.WriteAsync( "Set it to the origin(s) of your frontend (e.g., [\"https://poracle.example.com\"])."); } +// PUBLIC_URL is optional, but a typo in it produces OAuth callback URIs the provider rejects with a +// message that says nothing about this setting. Fail at startup instead, where the cause is obvious. +var configuredPublicUrl = builder.Configuration["PublicUrl"]; +if (!Pgan.PoracleWebNet.Api.Configuration.PublicOrigin.TryNormalize(configuredPublicUrl, out _, out var publicUrlError) + && publicUrlError is not null) +{ + throw new InvalidOperationException($"Configuration 'PUBLIC_URL' is invalid: {publicUrlError}"); +} + builder.Services.AddCors(options => options.AddDefaultPolicy(policy => { if (allowedOrigins is { Length: > 0 }) @@ -322,23 +389,43 @@ await context.Response.WriteAsync( { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }; +// Clearing both lists tells ASP.NET to believe X-Forwarded-For from ANY peer, so a client could name its +// own address and hand itself a fresh rate-limit bucket per request -- including on the login endpoints +// the per-IP partitioning exists to protect. Trust only the proxies the deployment names. +// +// PROXY_KNOWN_PROXIES / PROXY_KNOWN_NETWORKS take comma-separated addresses and CIDR ranges. With +// neither set the header is ignored entirely and the connection address is used, which is correct for a +// direct-exposed instance and safe for one behind a proxy that has not been declared yet -- it means +// everyone behind that proxy shares a bucket, rather than everyone being able to forge one. See #583. +foreach (var proxy in SplitConfigList(builder.Configuration["Proxy:KnownProxies"])) +{ + if (System.Net.IPAddress.TryParse(proxy, out var address)) + { + forwardedHeadersOptions.KnownProxies.Add(address); + } +} + +foreach (var network in SplitConfigList(builder.Configuration["Proxy:KnownNetworks"])) +{ + var parts = network.Split('/', 2); + if (parts.Length == 2 + && System.Net.IPAddress.TryParse(parts[0], out var prefix) + && int.TryParse(parts[1], out var length)) + { #pragma warning disable ASPDEPR005 -forwardedHeadersOptions.KnownNetworks.Clear(); + forwardedHeadersOptions.KnownNetworks.Add(new Microsoft.AspNetCore.HttpOverrides.IPNetwork(prefix, length)); #pragma warning restore ASPDEPR005 -forwardedHeadersOptions.KnownProxies.Clear(); + } +} + app.UseForwardedHeaders(forwardedHeadersOptions); -// Security headers +// Security headers -- values live in SecurityHeaders so they can be unit-tested app.Use(async (context, next) => { context.Response.OnStarting(() => { - var headers = context.Response.Headers; - headers.XContentTypeOptions = "nosniff"; - headers.XFrameOptions = "DENY"; - headers.XXSSProtection = "0"; - headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; - headers.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-hashes' 'sha256-MhtPZXr7+LpJUY5qtMutB+qWfQtMaPccfe7QXtCcEYc=' https://telegram.org; style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://raw.githubusercontent.com; frame-src https://oauth.telegram.org"; + SecurityHeaders.Apply(context.Response.Headers); return Task.CompletedTask; }); await next(); @@ -352,11 +439,16 @@ await context.Response.WriteAsync( app.UseCors(); -app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); +// After authentication, deliberately. Registered before it, the partition key could not see +// User.Identity, so every "per-user" policy silently fell back to the IP -- one person behind a shared +// egress could exhaust the allowance for everyone behind it, and the per-user policies were per-user in +// name only. See #581. +app.UseRateLimiter(); + app.MapControllers(); // Serve Angular SPA @@ -385,6 +477,12 @@ static string UserOrIpPartitionKey(HttpContext ctx) } // Maps a short env var name to .NET's __ convention if the target is not already set. +/// Splits a comma-separated config value, ignoring blanks. +static string[] SplitConfigList(string? value) => + string.IsNullOrWhiteSpace(value) + ? [] + : value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + static void MapEnvVar(string shortName, string configName, string? defaultValue = null) { if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable(configName))) diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcClient.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcClient.cs new file mode 100644 index 00000000..819509fb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcClient.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// The token/userinfo result from the external OIDC provider. is +/// nullable: a provider may issue one only with offline_access, and on a refresh grant a +/// non-rotating provider returns none (the caller then keeps reusing the prior token). +/// is nullable for providers that omit it. +/// +public sealed record OidcTokenResult(string AccessToken, string? RefreshToken, int? ExpiresIn); + +/// +/// Provider-agnostic HTTP client for the external OIDC endpoints. Encapsulates the +/// authorization-code exchange, the refresh-token grant, and the userinfo fetch — including the +/// configurable token-endpoint client-authentication method (client_secret_post vs +/// client_secret_basic). Relies only on spec-standard OAuth2/OIDC; no discovery, JWKS, or +/// id_token required. +/// +public interface IOidcClient +{ + /// Exchanges an authorization code for tokens. Returns null on any provider error. + Task ExchangeCodeAsync(string code, string redirectUri, string? codeVerifier); + + /// Redeems a refresh token (grant_type=refresh_token). Returns null on any provider error. + Task RefreshAsync(string refreshToken); + + /// Fetches the userinfo claims with the given access token. Returns null on failure. + Task GetUserInfoAsync(string accessToken); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcSessionService.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcSessionService.cs new file mode 100644 index 00000000..6b9a4fcf --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcSessionService.cs @@ -0,0 +1,60 @@ +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// Carries the state of an in-progress refresh rotation between +/// and its completion. The presented row is +/// already revoked when this is returned; the caller must either +/// (success) or +/// (provider/userinfo failure). +/// +public sealed class OidcRotationTicket +{ + public required string UserId { get; init; } + public required string FamilyId { get; init; } + public required DateTime FamilyIssuedAt { get; init; } + + /// The provider refresh token decrypted from the presented session, for the IdP refresh call. + public required string DecryptedRefreshToken { get; init; } + + /// The new opaque token to hand back to the browser once the successor row is persisted. + public required string NewOpaqueToken { get; init; } + + /// SHA-256 of — the successor row's primary lookup key (internal). + public required string NewTokenHash { get; init; } +} + +/// +/// Server-side mechanics for OIDC refresh sessions: opaque-token issuance, encrypted storage of +/// the provider refresh token, atomic rotation, replay/family-revoke, and absolute-cap enforcement. +/// Provider-agnostic and orchestrated by AuthController (which owns userinfo re-validation +/// and role resolution). All "invalid" conditions throw +/// with message invalid_grant. +/// +public interface IOidcSessionService +{ + /// + /// Creates a new rotation family for a freshly authenticated user and returns the opaque token + /// to embed in the login callback. The provider refresh token is encrypted at rest. + /// + Task IssueAsync(string userId, string idpRefreshToken, string? ipAddress, string? userAgent); + + /// + /// Validates the presented opaque token (active, not replayed, within the absolute cap), then + /// atomically revokes it and reserves a successor. Throws + /// on replay (revoking the whole family), expiry, or cap. The caller then performs the IdP + /// refresh + userinfo re-validation using . + /// + Task StartRotationAsync(string opaqueToken, string? ipAddress, string? userAgent); + + /// Persists the successor session row (encrypting the carried-forward provider refresh token). + Task CompleteRotationAsync(OidcRotationTicket ticket, string newIdpRefreshToken); + + /// Revokes the whole family when the IdP refresh or userinfo re-validation fails mid-rotation. + Task AbortRotationAsync(OidcRotationTicket ticket, string reason); + + /// Revokes the family the presented opaque token belongs to (logout). Safe on unknown tokens. + Task RevokeAsync(string opaqueToken, string reason); + + /// Revokes every active session for a user (admin disable / logout-everywhere). + Task RevokeAllForUserAsync(string userId, string reason); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcClient.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcClient.cs new file mode 100644 index 00000000..7dd893a3 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcClient.cs @@ -0,0 +1,122 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// Provider-agnostic OIDC HTTP client. See . All provider divergences +/// (token-endpoint auth method, optional/non-rotating refresh tokens, missing expires_in) are +/// handled here so callers (login callback + refresh service) share one identical code path. +/// +public sealed partial class OidcClient( + HttpClient httpClient, + IOptions oidcSettings, + ILogger logger) : IOidcClient +{ + private readonly HttpClient _httpClient = httpClient; + private readonly OidcSettings _settings = oidcSettings.Value; + private readonly ILogger _logger = logger; + + public async Task ExchangeCodeAsync(string code, string redirectUri, string? codeVerifier) + { + var form = new Dictionary + { + ["grant_type"] = "authorization_code", + ["code"] = code, + ["redirect_uri"] = redirectUri, + }; + + if (this._settings.UsePkce && !string.IsNullOrEmpty(codeVerifier)) + { + form["code_verifier"] = codeVerifier; + } + + return await this.PostTokenAsync(form, "authorization_code"); + } + + public async Task RefreshAsync(string refreshToken) + { + var form = new Dictionary + { + ["grant_type"] = "refresh_token", + ["refresh_token"] = refreshToken, + }; + + return await this.PostTokenAsync(form, "refresh_token"); + } + + public async Task GetUserInfoAsync(string accessToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, this._settings.UserInfoUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await this._httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + LogUserInfoFailed(this._logger, response.StatusCode, body); + return null; + } + + return await response.Content.ReadFromJsonAsync(); + } + + /// + /// Posts a token request, applying the configured client-authentication method, and parses + /// the standard OAuth2 token response. Returns null on transport/HTTP error or a missing + /// access_token. + /// + private async Task PostTokenAsync(Dictionary form, string grant) + { + using var request = new HttpRequestMessage(HttpMethod.Post, this._settings.TokenUrl); + + // Identify the client. With client_secret_basic the secret rides in the Authorization + // header; with client_secret_post both id and secret go in the body. + form["client_id"] = this._settings.ClientId; + + if (string.Equals(this._settings.TokenEndpointAuthMethod, "client_secret_basic", StringComparison.OrdinalIgnoreCase)) + { + var credentials = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{this._settings.ClientId}:{this._settings.ClientSecret}")); + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); + } + else + { + form["client_secret"] = this._settings.ClientSecret; + } + + request.Content = new FormUrlEncodedContent(form); + + using var response = await this._httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(); + LogTokenFailed(this._logger, grant, response.StatusCode, body); + return null; + } + + var json = await response.Content.ReadFromJsonAsync(); + if (!json.TryGetProperty("access_token", out var accessTokenProp) || + accessTokenProp.GetString() is not { Length: > 0 } accessToken) + { + LogTokenFailed(this._logger, grant, response.StatusCode, "response had no access_token"); + return null; + } + + var refreshToken = json.TryGetProperty("refresh_token", out var rtProp) ? rtProp.GetString() : null; + int? expiresIn = json.TryGetProperty("expires_in", out var expProp) && expProp.ValueKind == JsonValueKind.Number + ? expProp.GetInt32() + : null; + + return new OidcTokenResult(accessToken, string.IsNullOrEmpty(refreshToken) ? null : refreshToken, expiresIn); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC {Grant} token request failed: {Status} {Body}")] + private static partial void LogTokenFailed(ILogger logger, string grant, System.Net.HttpStatusCode status, string body); + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC userinfo fetch failed: {Status} {Body}")] + private static partial void LogUserInfoFailed(ILogger logger, System.Net.HttpStatusCode status, string body); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionCleanupService.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionCleanupService.cs new file mode 100644 index 00000000..7ca93a95 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionCleanupService.cs @@ -0,0 +1,54 @@ +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// +/// Periodically deletes expired and long-revoked OIDC refresh sessions with a single set-based +/// delete. Only does work when refresh consumption is enabled; otherwise the table stays empty +/// and each pass is a cheap no-op. Runs every 6 hours; revoked rows are retained for the same +/// number of days as the session cap (for audit/replay-forensics) before deletion. +/// +public sealed partial class OidcSessionCleanupService( + IServiceScopeFactory scopeFactory, + IOptions oidcSettings, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan Interval = TimeSpan.FromHours(6); + + private readonly OidcSettings _settings = oidcSettings.Value; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Small initial stagger so startup isn't contended. + await Task.Delay(TimeSpan.FromSeconds(240), stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + using var scope = scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var retention = TimeSpan.FromDays(Math.Max(1, this._settings.RevokedRetentionDays)); + var deleted = await repo.DeleteExpiredAndStaleAsync(retention); + if (deleted > 0) + { + LogCleanup(logger, deleted); + } + } + catch (Exception ex) + { + LogCleanupFailed(logger, ex); + } + + await Task.Delay(Interval, stoppingToken); + } + } + + [LoggerMessage(Level = LogLevel.Information, Message = "OIDC session cleanup removed {Count} expired/stale rows.")] + private static partial void LogCleanup(ILogger logger, int count); + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC session cleanup failed; will retry next interval.")] + private static partial void LogCleanupFailed(ILogger logger, Exception ex); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionService.cs b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionService.cs new file mode 100644 index 00000000..ceacb863 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionService.cs @@ -0,0 +1,173 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Services.Oidc; + +/// See . The opaque token is 32 bytes of CSPRNG entropy +/// (base64url); only its SHA-256 hash is stored. The provider refresh token is encrypted with +/// DataProtection (purpose-scoped) and never leaves the server. +public sealed partial class OidcSessionService : IOidcSessionService +{ + private const string ProtectorPurpose = "Pgan.PoracleWebNet.OidcRefresh.v1"; + + private readonly IOidcSessionRepository _sessions; + private readonly IDataProtector _protector; + private readonly OidcSettings _settings; + private readonly ILogger _logger; + + public OidcSessionService( + IOidcSessionRepository sessions, + IDataProtectionProvider dataProtectionProvider, + IOptions oidcSettings, + ILogger logger) + { + this._sessions = sessions; + this._protector = dataProtectionProvider.CreateProtector(ProtectorPurpose); + this._settings = oidcSettings.Value; + this._logger = logger; + } + + public async Task IssueAsync(string userId, string idpRefreshToken, string? ipAddress, string? userAgent) + { + var opaque = GenerateOpaqueToken(); + var now = DateTime.UtcNow; + + await this._sessions.AddAsync(new OidcSession + { + SessionTokenHash = HashToken(opaque), + FamilyId = Guid.NewGuid().ToString(), + FamilyIssuedAt = now, + UserId = userId, + EncryptedRefreshToken = this._protector.Protect(idpRefreshToken), + ExpiresAt = now.AddDays(this._settings.RefreshTokenLifetimeDays), + CreatedUtc = now, + IpAddress = ipAddress, + UserAgent = userAgent, + }); + + return opaque; + } + + public async Task StartRotationAsync(string opaqueToken, string? ipAddress, string? userAgent) + { + var hash = HashToken(opaqueToken); + var session = await this._sessions.GetByHashAsync(hash); + if (session is null) + { + throw InvalidGrant(); + } + + // Replay: a presented-but-already-revoked token revokes the whole family. + if (session.RevokedAt is not null) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "replay_detected"); + LogReplayDetected(this._logger, HashPrefix(hash)); + throw InvalidGrant(); + } + + var now = DateTime.UtcNow; + + // Absolute cap: a family cannot be refreshed past FamilyIssuedAt + RefreshTokenLifetimeDays. + if (session.FamilyIssuedAt.AddDays(this._settings.RefreshTokenLifetimeDays) <= now) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "absolute_cap"); + throw InvalidGrant(); + } + + // Plain expiry (no family revoke). + if (session.ExpiresAt <= now) + { + throw InvalidGrant(); + } + + var newOpaque = GenerateOpaqueToken(); + var newHash = HashToken(newOpaque); + + // Atomic guard: revokes the presented row only if still active. 0 ⇒ a concurrent refresh + // already rotated it — treat as replay and revoke the family. + var affected = await this._sessions.TryRevokeForRotationAsync(hash, newHash); + if (affected == 0) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "replay_detected"); + LogReplayDetected(this._logger, HashPrefix(hash)); + throw InvalidGrant(); + } + + string decrypted; + try + { + decrypted = this._protector.Unprotect(session.EncryptedRefreshToken); + } + catch (CryptographicException) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, "decrypt_failed"); + throw InvalidGrant(); + } + + return new OidcRotationTicket + { + UserId = session.UserId, + FamilyId = session.FamilyId, + FamilyIssuedAt = session.FamilyIssuedAt, + DecryptedRefreshToken = decrypted, + NewOpaqueToken = newOpaque, + NewTokenHash = newHash, + }; + } + + public async Task CompleteRotationAsync(OidcRotationTicket ticket, string newIdpRefreshToken) + { + var now = DateTime.UtcNow; + await this._sessions.AddAsync(new OidcSession + { + SessionTokenHash = ticket.NewTokenHash, + FamilyId = ticket.FamilyId, + FamilyIssuedAt = ticket.FamilyIssuedAt, + UserId = ticket.UserId, + EncryptedRefreshToken = this._protector.Protect(newIdpRefreshToken), + // Fixed window: successor expires at the family's absolute cap. + ExpiresAt = ticket.FamilyIssuedAt.AddDays(this._settings.RefreshTokenLifetimeDays), + CreatedUtc = now, + }); + } + + public async Task AbortRotationAsync(OidcRotationTicket ticket, string reason) => + await this._sessions.RevokeFamilyAsync(ticket.FamilyId, reason); + + public async Task RevokeAsync(string opaqueToken, string reason) + { + if (string.IsNullOrEmpty(opaqueToken)) + { + return; + } + + var session = await this._sessions.GetByHashAsync(HashToken(opaqueToken)); + if (session is not null) + { + await this._sessions.RevokeFamilyAsync(session.FamilyId, reason); + } + } + + public async Task RevokeAllForUserAsync(string userId, string reason) => + await this._sessions.RevokeAllForUserAsync(userId, reason); + + private static string GenerateOpaqueToken() => + Base64UrlEncode(RandomNumberGenerator.GetBytes(32)); + + private static string HashToken(string token) => + Convert.ToHexStringLower(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(token))); + + private static string HashPrefix(string hash) => hash.Length <= 8 ? hash : hash[..8]; + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static UnauthorizedAccessException InvalidGrant() => new("invalid_grant"); + + [LoggerMessage(Level = LogLevel.Warning, Message = "OIDC refresh replay detected for session {HashPrefix}; family revoked.")] + private static partial void LogReplayDetected(ILogger logger, string hashPrefix); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/PoracleCompatibilityStartupService.cs b/Applications/Pgan.PoracleWebNet.Api/Services/PoracleCompatibilityStartupService.cs new file mode 100644 index 00000000..3650ef3b --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/PoracleCompatibilityStartupService.cs @@ -0,0 +1,78 @@ +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Api.Services; + +/// +/// Says once, at startup, which PoracleNG this is talking to — and complains if it is too old. +/// +/// +/// Every feature that needs 5.1.0 fails the same quiet way against an older server: the column does not +/// exist, PoracleNG's decoder drops the field, the write returns 200 and the filter does nothing. That +/// is indistinguishable from a bug in PoracleWeb unless somebody says the version out loud, so this +/// does, on every boot. +/// +public partial class PoracleCompatibilityStartupService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // After the migration service, whose delay this matches; nothing here blocks startup. + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + + try + { + using var scope = scopeFactory.CreateScope(); + var profiles = scope.ServiceProvider.GetRequiredService(); + var profile = await profiles.GetAsync(stoppingToken); + + if (!profile.Reachable) + { + LogUnreachable(logger); + return; + } + + if (profile.IsBelowMinimum) + { + LogTooOld(logger, profile.Version ?? "unknown", PoracleServerProfile.MinimumSupported.ToString()); + return; + } + + LogConnected( + logger, + profile.Version ?? "unknown", + profile.SchemaVersion?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "unknown", + profile.Capabilities.Count == 0 ? "none reported" : string.Join(", ", profile.Capabilities.Where(c => c.Value).Select(c => c.Key))); + } + catch (Exception ex) + { + // Never take the app down over a diagnostic. + LogCheckFailed(logger, ex); + } + } + + [LoggerMessage( + EventId = 6110, + Level = LogLevel.Information, + Message = "Connected to PoracleNG {Version} (schema {SchemaVersion}). Capabilities: {Capabilities}.")] + private static partial void LogConnected(ILogger logger, string version, string schemaVersion, string capabilities); + + [LoggerMessage( + EventId = 6111, + Level = LogLevel.Error, + Message = "PoracleNG is {Version}, and this build of PoracleWeb needs {Minimum} or newer. Per-alarm delivery " + + "scope, the PVP mega evolution filter and the minimum time filter write columns that do not exist on " + + "{Version}: those controls will appear to save and change nothing. Upgrade PoracleNG.")] + private static partial void LogTooOld(ILogger logger, string version, string minimum); + + [LoggerMessage( + EventId = 6112, + Level = LogLevel.Warning, + Message = "PoracleNG did not answer its health endpoint, so its version is unknown. Alarm, human and profile " + + "operations all proxy through it and will fail until it does.")] + private static partial void LogUnreachable(ILogger logger); + + [LoggerMessage(EventId = 6113, Level = LogLevel.Debug, Message = "The PoracleNG compatibility check did not run.")] + private static partial void LogCheckFailed(ILogger logger, Exception exception); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs b/Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs new file mode 100644 index 00000000..533a496e --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Api.Services; + +/// +/// A user's admin status and the webhooks they may administer, resolved from live sources. +/// +/// Whether the user is an admin. Meaningless when is false. +/// Webhooks the user may administer, or null. +/// +/// False when a source we needed was unreachable. Callers that stamp roles onto a token must treat +/// this as "do not change the claim" rather than as "not an admin" -- a PoracleNG blip during a +/// profile switch otherwise stripped admin for the rest of the session. See #656. +/// +public readonly record struct UserRoles(bool IsAdmin, string[]? ManagedWebhooks, bool Resolved = true); + +/// +/// Resolves admin status and delegated webhooks from the configured admin list, Poracle's config, +/// PoracleNG's getAdministrationRoles, and PoracleWeb's own delegate table. +/// +public interface IUserRoleResolver +{ + /// Resolves the user's current roles. + Task ResolveAsync(string userId); +} + +/// +/// The single place roles are worked out. +/// +/// +/// This used to live as a private method on AuthController, which meant login was the only +/// thing that could see it. Two defects came out of that: the isAdmin claim was minted once and +/// then copied verbatim through every token re-issue, so revoking someone's admin rights never took +/// effect while they kept switching profile (#624); and the admin endpoints that resolve delegated +/// webhooks live went to the local table alone, so a delegate configured in PoracleJS could see the +/// My Webhooks nav item and get an empty page and a 403 (#626). +/// +/// Results are cached for a minute. Both PoracleNG calls are network round-trips and the resolver now +/// sits on paths that are not login, so an uncached implementation would put two HTTP requests on +/// every profile switch. A minute is short enough that revoking rights still takes effect promptly. +/// +/// +public sealed partial class UserRoleResolver( + IPoracleApiProxy poracleApiProxy, + IWebhookDelegateService webhookDelegateService, + IOptions poracleSettings, + IMemoryCache cache, + ILogger logger) : IUserRoleResolver +{ + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(1); + + private readonly IMemoryCache _cache = cache; + private readonly ILogger _logger = logger; + private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly PoracleSettings _poracleSettings = poracleSettings.Value; + private readonly IWebhookDelegateService _webhookDelegateService = webhookDelegateService; + + public async Task ResolveAsync(string userId) + { + var cacheKey = $"roles:{userId}"; + if (this._cache.TryGetValue(cacheKey, out var cached)) + { + return cached; + } + + var resolved = await this.ResolveUncachedAsync(userId); + + // A degraded answer is never cached: doing so would hold a user at the wrong privilege level for + // the full minute after a momentary outage. See #656. + if (resolved.Resolved) + { + this._cache.Set(cacheKey, resolved, CacheTtl); + } + + return resolved; + } + + private async Task ResolveUncachedAsync(string userId) + { + // Fast path: configured admin IDs + if (!string.IsNullOrEmpty(this._poracleSettings.AdminIds)) + { + var adminIds = this._poracleSettings.AdminIds.Split(',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (adminIds.Contains(userId)) + { + return new UserRoles(true, null); + } + } + + // Tracked so a failure is reported as "unknown", not as "not an admin". See #656. + var configReadable = true; + var rolesReadable = true; + var delegatesReadable = true; + + // Check Poracle config admins list + try + { + var config = await this._poracleApiProxy.GetConfigAsync(); + if (config?.Admins != null && + (config.Admins.Discord.Contains(userId) || config.Admins.Telegram.Contains(userId))) + { + return new UserRoles(true, null); + } + } + catch (Exception ex) + { + LogPoracleConfigFetchFailed(this._logger, ex, userId); + configReadable = false; + } + + // Call getAdministrationRoles once — resolves delegation including Discord guild roles + var managed = new HashSet(StringComparer.OrdinalIgnoreCase); + var isAdmin = false; + + try + { + var rolesJson = await this._poracleApiProxy.GetAdminRolesAsync(userId); + if (!string.IsNullOrEmpty(rolesJson)) + { + using var doc = JsonDocument.Parse(rolesJson); + var root = doc.RootElement; + + // Some versions return isAdmin at root; others wrap under admin.discord + if (root.TryGetProperty("isAdmin", out var isAdminProp) && isAdminProp.ValueKind == JsonValueKind.True) + { + isAdmin = true; + } + + // Parse admin.discord.webhooks — the authoritative delegate webhook list + if (root.TryGetProperty("admin", out var adminEl) && + adminEl.TryGetProperty("discord", out var discordEl)) + { + if (!isAdmin && + discordEl.TryGetProperty("isAdmin", out var discordAdmin) && + discordAdmin.ValueKind == JsonValueKind.True) + { + isAdmin = true; + } + + if (discordEl.TryGetProperty("webhooks", out var webhooks) && + webhooks.ValueKind == JsonValueKind.Array) + { + foreach (var wh in webhooks.EnumerateArray()) + { + if (wh.GetString() is { } id) + { + managed.Add(id); + } + } + } + } + } + } + catch (Exception ex) + { + LogAdminRolesFetchFailed(this._logger, ex, userId); + rolesReadable = false; + } + + if (isAdmin) + { + return new UserRoles(true, null); + } + + // Also merge our own webhook delegate service layer + try + { + var managedWebhookIds = await this._webhookDelegateService.GetManagedWebhookIdsAsync(userId); + foreach (var webhookId in managedWebhookIds) + { + managed.Add(webhookId); + } + } + catch (Exception ex) + { + // The third source, and it was left out of the Resolved flag: a poracle_web blip returned a + // confident answer with an incomplete webhook list, which then got cached for the full + // minute and denied a legitimate delegate the whole time. See #667. + LogPwebDelegatesFetchFailed(this._logger, ex, userId); + delegatesReadable = false; + } + + return new UserRoles(false, managed.Count > 0 ? [.. managed] : null, configReadable && rolesReadable && delegatesReadable); + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch Poracle config for admin check for {UserId}.")] + private static partial void LogPoracleConfigFetchFailed(ILogger logger, Exception ex, string userId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch administration roles for {UserId}.")] + private static partial void LogAdminRolesFetchFailed(ILogger logger, Exception ex, string userId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch webhook delegates for {UserId}.")] + private static partial void LogPwebDelegatesFetchFailed(ILogger logger, Exception ex, string userId); +} diff --git a/Applications/Pgan.PoracleWebNet.Api/appsettings.json b/Applications/Pgan.PoracleWebNet.Api/appsettings.json index 2c23147b..1036d562 100644 --- a/Applications/Pgan.PoracleWebNet.Api/appsettings.json +++ b/Applications/Pgan.PoracleWebNet.Api/appsettings.json @@ -22,7 +22,6 @@ "Discord": { "ClientId": "", "ClientSecret": "", - "RedirectUri": "", "BotToken": "", "GuildId": "", "GeofenceForumChannelId": "" diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 6184b7f6..9e006960 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -8,18 +8,18 @@ "name": "client-app", "version": "0.0.0", "dependencies": { - "@angular/animations": "^21.2.8", - "@angular/cdk": "^21.2.6", - "@angular/common": "^21.2.8", - "@angular/compiler": "^21.2.8", - "@angular/core": "^21.2.8", - "@angular/forms": "^21.2.8", - "@angular/material": "^21.2.6", - "@angular/platform-browser": "^21.2.8", - "@angular/router": "^21.2.8", - "@ngx-translate/core": "^17.0.0", - "@ngx-translate/http-loader": "^17.0.0", - "@types/leaflet": "^1.9.21", + "@angular/animations": "^21.2.16", + "@angular/cdk": "^21.2.14", + "@angular/common": "^21.2.20", + "@angular/compiler": "^21.2.20", + "@angular/core": "^21.2.20", + "@angular/forms": "^21.2.20", + "@angular/material": "^21.2.14", + "@angular/platform-browser": "^21.2.20", + "@angular/router": "^21.2.20", + "@ngx-translate/core": "^18.0.0", + "@ngx-translate/http-loader": "^18.0.0", + "@types/leaflet": "^1.9.22", "@types/leaflet-draw": "^1.0.13", "chokidar": "^5.0.0", "leaflet": "^1.9.4", @@ -33,40 +33,31 @@ "@angular-eslint/eslint-plugin-template": "^19.0.0", "@angular-eslint/schematics": "^19.0.0", "@angular-eslint/template-parser": "^19.0.0", - "@angular/build": "^21.2.7", - "@angular/cli": "^21.2.7", - "@angular/compiler-cli": "^21.2.8", - "@angular/platform-browser-dynamic": "^21.2.8", - "@jest/globals": "^30.3.0", + "@angular/build": "^21.2.21", + "@angular/cli": "^21.2.21", + "@angular/compiler-cli": "^21.2.20", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/parser": "^8.56.0", "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-perfectionist": "^5.8.0", - "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-perfectionist": "^5.10.0", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", - "jest": "^30.3.0", - "jest-environment-jsdom": "^30.3.0", - "jest-preset-angular": "^16.1.1", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", - "prettier-eslint": "^16.4.0", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-preset-angular": "^17.0.0", + "jsdom": "^30.0.1", + "prettier": "^3.9.6", "ts-node": "^10.9.2", "typescript": "~5.9.2" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@algolia/abtesting": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", @@ -334,40 +325,6 @@ } } }, - "node_modules/@angular-devkit/architect/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/architect/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/architect/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -389,9 +346,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.7.tgz", - "integrity": "sha512-DONYY5u4IENO2qpd23mODaE4JI2EIohWV1kuJnsU9HIcm5wN714QB2z9WY/s4gLfUiAMIUu/8lpnW/0kOQZAnQ==", + "version": "21.2.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.21.tgz", + "integrity": "sha512-xOr6mZ00M6hgM/xltAVkPVc/Yd1a/Wa0CGecV64JTITaVaSH8p8+wXe62Xavdr3pcc9cLKKUUB4mAup4k7Kkyw==", "dev": true, "license": "MIT", "dependencies": { @@ -463,40 +420,6 @@ } } }, - "node_modules/@angular-devkit/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-devkit/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -560,40 +483,6 @@ } } }, - "node_modules/@angular-eslint/builder/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/builder/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/builder/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -701,40 +590,6 @@ } } }, - "node_modules/@angular-eslint/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@angular-eslint/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@angular-eslint/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -786,9 +641,10 @@ } }, "node_modules/@angular/animations": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.8.tgz", - "integrity": "sha512-RIqfVmfretQ0x/mXgMXe7Bw0Tpe8+zBV/Mm2OaNVyrmNG+9gYItEn5t/ZnQGcPD5nMNqckgp3+4/ZMc/qkS5ww==", + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.20.tgz", + "integrity": "sha512-H3Abe/dfPKNaGaoaqjrN9tZYNrAcoPnx5STLiAmpFSO4wz82/wwPO2mZCOBG4qjefredkNuuW/e+uuqZccXabw==", + "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -797,26 +653,26 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.8" + "@angular/core": "21.2.20" } }, "node_modules/@angular/build": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.7.tgz", - "integrity": "sha512-FpSkFqpsJtdN1cROekVYkmeV1QepdP+/d7fyYQEuNmlOlyqXSDh9qJmy4iL9VNbAU0rk+vFCtYM86rO7Pt9cSw==", + "version": "21.2.21", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.21.tgz", + "integrity": "sha512-Z88uTaPte8uEYW+Sn5BUz7YoGAbi1dcoz8X1eaJi30bT7qtZ5Bph0vELMBu2VCtp9JFgZhsLn+uy8F6iIPiOUQ==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.7", - "@babel/core": "7.29.0", + "@angular-devkit/architect": "0.2102.21", + "@babel/core": "7.29.7", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", "@inquirer/confirm": "5.1.21", "@vitejs/plugin-basic-ssl": "2.1.4", "beasties": "0.4.1", "browserslist": "^4.26.0", - "esbuild": "0.27.3", + "esbuild": "0.28.1", "https-proxy-agent": "7.0.6", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", @@ -825,14 +681,14 @@ "mrmime": "2.0.1", "parse5-html-rewriting-stream": "8.0.0", "picomatch": "4.0.4", - "piscina": "5.1.4", + "piscina": "5.2.0", "rolldown": "1.0.0-rc.4", "sass": "1.97.3", "semver": "7.7.4", "source-map-support": "0.5.21", "tinyglobby": "0.2.15", - "undici": "7.24.4", - "vite": "7.3.2", + "undici": "7.29.0", + "vite": "7.3.6", "watchpack": "2.5.1" }, "engines": { @@ -851,7 +707,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.7", + "@angular/ssr": "^21.2.21", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -901,13 +757,13 @@ } }, "node_modules/@angular/build/node_modules/@angular-devkit/architect": { - "version": "0.2102.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.7.tgz", - "integrity": "sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==", + "version": "0.2102.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.21.tgz", + "integrity": "sha512-WqITVviALevNpCQoI50e3YY9qNOQT+gqTow/4FhWsxlAJFMb5uyilFpVz2hGYWPgosF3OAKmT1h9r+Jm9S6SUQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.21", "rxjs": "7.8.2" }, "bin": { @@ -919,1117 +775,1588 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular/build/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/@angular/build/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@angular/build/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/@angular/build/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@angular/cdk": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.6.tgz", - "integrity": "sha512-1PBzFf+um/VZ1dFF6cT72Zsq+9C/ZWF9m5dP0uHJgo4psX3yMBoZlZu5YomBiAQ/ePSkqCuryv1vrelK+yd3Mw==", + "node_modules/@angular/build/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "parse5": "^8.0.0", - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/common": "^21.0.0 || ^22.0.0", - "@angular/core": "^21.0.0 || ^22.0.0", - "@angular/platform-browser": "^21.0.0 || ^22.0.0", - "rxjs": "^6.5.3 || ^7.4.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@angular/cli": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.7.tgz", - "integrity": "sha512-N/wj8fFRB718efIFYpwnYfy+MecZREZXsUNMTVndFLH6T0jCheb9PVetR6jsyZp6h46USNPOmJYJ/9255lME+Q==", + "node_modules/@angular/build/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@angular-devkit/architect": "0.2102.7", - "@angular-devkit/core": "21.2.7", - "@angular-devkit/schematics": "21.2.7", - "@inquirer/prompts": "7.10.1", - "@listr2/prompt-adapter-inquirer": "3.0.5", - "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.7", - "@yarnpkg/lockfile": "1.1.0", - "algoliasearch": "5.48.1", - "ini": "6.0.0", - "jsonc-parser": "3.3.1", - "listr2": "9.0.5", - "npm-package-arg": "13.0.2", - "pacote": "21.3.1", - "parse5-html-rewriting-stream": "8.0.0", - "semver": "7.7.4", - "yargs": "18.0.0", - "zod": "4.3.6" - }, - "bin": { - "ng": "bin/ng.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { - "version": "0.2102.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.7.tgz", - "integrity": "sha512-4K/5hln9iaPEt3F/NyYqncNLvYpzSjRslEkHl2xIgZwQsIFHEvhnDRBYj2/oatURQhBqO/Yu15z/icVOYLxuTg==", + "node_modules/@angular/build/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.7", - "rxjs": "7.8.2" - }, - "bin": { - "architect": "bin/cli.js" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.7.tgz", - "integrity": "sha512-LYAjjUI1qM7pR/sd0yYt8OLA6ljOOXjcfzV40I5XQNmhAxq90YYS5xwMcixOmWX+z5zvCYGvPXvJGWjzio6SUg==", + "node_modules/@angular/build/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.7", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.21", - "ora": "9.3.0", - "rxjs": "7.8.2" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@angular/build/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@angular/build/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", + "node_modules/@angular/build/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "node_modules/@angular/build/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "node_modules/@angular/build/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular/cli/node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", + "node_modules/@angular/build/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@angular/cli/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "node_modules/@angular/build/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/ora": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", - "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", + "node_modules/@angular/build/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.1", - "string-width": "^8.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/@angular/build/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "node_modules/@angular/build/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/@angular/cli/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/@angular/build/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/@angular/common": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.8.tgz", - "integrity": "sha512-ZvgcxsLPkSG0B1jc2ZXshAWIFBoQ0U9uwIX/zG/RGcfMpoKyEDNAebli6FTIpxIlz/35rtBNV7EGPhinjPTJFQ==", + "node_modules/@angular/build/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/core": "21.2.8", - "rxjs": "^6.5.3 || ^7.4.0" + "node": ">=18" } }, - "node_modules/@angular/compiler": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.8.tgz", - "integrity": "sha512-Il9KlT6qX8rWmun5jY6wMLx56bCQZpOVIFEyHM4ai2wmxvbqyxgRFKDs4iMRNn1h04Tgupl6cKSqP9lecIvH6w==", + "node_modules/@angular/build/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/@angular/compiler-cli": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.8.tgz", - "integrity": "sha512-S0W+6QazCsn/4xWZu0V5VmU9zmKIlqFR2FJSsAQUPReVmpA40SuQSP6A/cyMVIMYaHvO/cAXSHJVgpxBzBSL/Q==", + "node_modules/@angular/build/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "7.29.0", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^5.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^18.0.0" - }, - "bin": { - "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.8", - "typescript": ">=5.9 <6.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@angular/core": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.8.tgz", - "integrity": "sha512-hI7n4t8qgFJaVV55LIaNuzcdP+/IeuqQRu3huSLo47Gf6uZAD0Acj4Ye9SC8YNmhUu5/RiImngm9NOlcI2oCJA==", + "node_modules/@angular/build/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/compiler": "21.2.8", - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0 || ~0.16.0" - }, - "peerDependenciesMeta": { - "@angular/compiler": { - "optional": true - }, - "zone.js": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@angular/forms": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.8.tgz", - "integrity": "sha512-tyQAHjfMHcqETRkKQaZHjYqIK9W8uRenPpY2DF/Jl+S7CwcaX4T8t8TKgzvTynNzQW9QGiLg0pqVosVMKzBXJg==", + "node_modules/@angular/build/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.8", - "@angular/core": "21.2.8", - "@angular/platform-browser": "21.2.8", - "rxjs": "^6.5.3 || ^7.4.0" + "node": ">=18" } }, - "node_modules/@angular/material": { - "version": "21.2.6", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.6.tgz", - "integrity": "sha512-V4hblb5ekgXb5x+UXKRs2yiB0hZUkUJbYwGseMglkCeWQlLM4u6amlsUzP4uOwIWFOkM/ZYl9qz4YGZnvMAyjw==", + "node_modules/@angular/build/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "peerDependencies": { - "@angular/cdk": "21.2.6", - "@angular/common": "^21.0.0 || ^22.0.0", - "@angular/core": "^21.0.0 || ^22.0.0", - "@angular/forms": "^21.0.0 || ^22.0.0", - "@angular/platform-browser": "^21.0.0 || ^22.0.0", - "rxjs": "^6.5.3 || ^7.4.0" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@angular/platform-browser": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.8.tgz", - "integrity": "sha512-4fwmGf7GCuIsjFqx1gqqWC92YjlN9SmGJO17TPPsOm5zUOnDx+h3Bj9XjdXxlcBtugTb2xHk6Auqyv3lzWGlkw==", + "node_modules/@angular/build/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/animations": "21.2.8", - "@angular/common": "21.2.8", - "@angular/core": "21.2.8" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@angular/platform-browser-dynamic": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.8.tgz", - "integrity": "sha512-9XeplSHsKnLDm14dvwXG00Ox6WbDrhf7ub7MxxcJ6gCgRm/yqJ3Vrz4a+NBpYnelapqiCCGEdHeyx2xt8vG1qA==", + "node_modules/@angular/build/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.8", - "@angular/compiler": "21.2.8", - "@angular/core": "21.2.8", - "@angular/platform-browser": "21.2.8" + "node": ">=18" } }, - "node_modules/@angular/router": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.8.tgz", - "integrity": "sha512-KSlUbFHHKY84G6iKlB2FDMmh+lLmGjmpyT1p/kx8qZm1BuxJGOOU+oNgkCfaPJT1R2/muDXuxQ51uc/la6y28g==", + "node_modules/@angular/build/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@angular/common": "21.2.8", - "@angular/core": "21.2.8", - "@angular/platform-browser": "21.2.8", - "rxjs": "^6.5.3 || ^7.4.0" + "node": ">=18" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.10", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.10.tgz", - "integrity": "sha512-02OhhkKtgNRuicQ/nF3TRnGsxL9wp0r3Y7VlKWyOHHGmGyvXv03y+PnymU8FKFJMTjIr1Bk8U2g1HWSLrpAHww==", + "node_modules/@angular/build/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "node_modules/@angular/build/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "node_modules/@angular/build/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": "20 || >=22" + "node": ">=10" } }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, + "node_modules/@angular/cdk": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.14.tgz", + "integrity": "sha512-806REq/CLf37nEhmmd8Q+ILN8z/RVG2vk2n8YZ/4TdHpcBCi5ux4AxLbpMmduLwGPOzPagJ6ggRzE5fnX0rmcQ==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "parse5": "^8.0.0", + "tslib": "^2.3.0" }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "node_modules/@angular/cli": { + "version": "21.2.21", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.21.tgz", + "integrity": "sha512-9TqWGpguYGpAsycSYX/Ji6csuPrEntdd+D2iK0TWIVQ1APCma7UmBgI1ltcATQNezxWiLTiJIMIOEuPG+cIVnw==", "dev": true, "license": "MIT", + "dependencies": { + "@angular-devkit/architect": "0.2102.21", + "@angular-devkit/core": "21.2.21", + "@angular-devkit/schematics": "21.2.21", + "@inquirer/prompts": "7.10.1", + "@listr2/prompt-adapter-inquirer": "3.0.5", + "@modelcontextprotocol/sdk": "1.30.0", + "@schematics/angular": "21.2.21", + "@yarnpkg/lockfile": "1.1.0", + "algoliasearch": "5.48.1", + "ini": "6.0.0", + "jsonc-parser": "3.3.1", + "listr2": "9.0.5", + "npm-package-arg": "13.0.2", + "pacote": "21.5.1", + "parse5-html-rewriting-stream": "8.0.0", + "semver": "7.7.4", + "yargs": "18.0.0", + "zod": "4.3.6" + }, + "bin": { + "ng": "bin/ng.js" + }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.2102.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.21.tgz", + "integrity": "sha512-WqITVviALevNpCQoI50e3YY9qNOQT+gqTow/4FhWsxlAJFMb5uyilFpVz2hGYWPgosF3OAKmT1h9r+Jm9S6SUQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" + "@angular-devkit/core": "21.2.21", + "rxjs": "7.8.2" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", "bin": { - "semver": "bin/semver.js" + "architect": "bin/cli.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { + "version": "21.2.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.21.tgz", + "integrity": "sha512-LgTd/0CpyWSxMJLLk0X85F5RyDlJekssLlapU1C8fKqOUdWONYncTXYvZUQ+zbRAGXHcSZrst1Vm8qdXbo11Mg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" + "@angular-devkit/core": "21.2.21", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.21", + "ora": "9.3.0", + "rxjs": "7.8.2" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" } }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "node_modules/@angular/cli/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, "engines": { - "node": ">=6.9.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "node_modules/@angular/cli/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, "engines": { - "node": ">=6.9.0" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/@angular/cli/node_modules/cli-spinners": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "node_modules/@angular/cli/node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "node_modules/@angular/cli/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, "engines": { - "node": ">=6.9.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "node_modules/@angular/cli/node_modules/log-symbols": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", + "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "is-unicode-supported": "^2.0.0", + "yoctocolors": "^2.1.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "node_modules/@angular/cli/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "node_modules/@angular/cli/node_modules/ora": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", + "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7" + "chalk": "^5.6.2", + "cli-cursor": "^5.0.0", + "cli-spinners": "^3.2.0", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.1.0", + "log-symbols": "^7.0.1", + "stdin-discarder": "^0.3.1", + "string-width": "^8.1.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "node_modules/@angular/cli/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "license": "MIT", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=6.9.0" + "node": ">=10" } }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "node_modules/@angular/cli/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "node_modules/@angular/cli/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, "engines": { - "node": ">=6.9.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, + "node_modules/@angular/common": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.20.tgz", + "integrity": "sha512-TzUVfa4Asq/np3eStFX3Q6W+dPG7JDNtkj4XwTvRnSL0CN8pPe+wPHSo76awHFzYWcucxpZTmk2GUemeqxa4Yw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.20.tgz", + "integrity": "sha512-7UY4x+YOvDCUpiiVM1wEU9gIiME54vF9n+84FdaSL/6MxgHknkaB/R8iwNrfXwsHThzy4NKv+25UvlJrz9P7mg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "tslib": "^2.3.0" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "node_modules/@angular/compiler-cli": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.20.tgz", + "integrity": "sha512-CBG1/wvH8XtbxYiqz0CR62fEHPFn3w0Q6+iey5U1+b+tmeTVVRjHlkvntOz5Gqv+5xC/r/hJf5d5DWCOp4mIwg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/core": "7.29.7", + "@jridgewell/sourcemap-codec": "^1.4.14", + "chokidar": "^5.0.0", + "convert-source-map": "^1.5.1", + "reflect-metadata": "^0.2.0", + "semver": "^7.0.0", + "tslib": "^2.3.0", + "yargs": "^18.0.0" }, "bin": { - "parser": "bin/babel-parser.js" + "ng-xi18n": "bundles/src/bin/ng_xi18n.js", + "ngc": "bundles/src/bin/ngc.js" }, "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/compiler": "21.2.20", + "typescript": ">=5.9 <6.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, + "node_modules/@angular/core": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.20.tgz", + "integrity": "sha512-4yp6EKd1VJHUOiKmd3+s9UUXtBGsdO/IKewABuPXZ+UdrXPQSRMhgnMt49yqgz29XS7jSHyq6nZgt0CXOVEDZg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@angular/compiler": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, + "node_modules/@angular/forms": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.20.tgz", + "integrity": "sha512-OP1/UtMGBfJEVGs85ns1jSYVyyezQRQYPG1ReUDzGPGRt62+JQkLdtHYu6RKwkXzW+LF2mt8cpIb4YfhxnnYrA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@standard-schema/spec": "^1.0.0", + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@angular/common": "21.2.20", + "@angular/core": "21.2.20", + "@angular/platform-browser": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, + "node_modules/@angular/material": { + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.14.tgz", + "integrity": "sha512-fMQca8VRtei93JRRG9qQ+u08DCb0nga59Esoakq5yx3+A1NfdpFeUS1tBns56U04o8KAaIAwZK3NBqXz8ZKNqg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" + "tslib": "^2.3.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@angular/cdk": "21.2.14", + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/forms": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, + "node_modules/@angular/platform-browser": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.20.tgz", + "integrity": "sha512-KtIrkSol2Q5qx6Poj53fLN6x3FYTPXCJ3Qi4KzlPbK0EFoiltaYCh3C2hmBze9QNdA8TM719THGvIDpCznl8PQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "tslib": "^2.3.0" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@angular/animations": "21.2.20", + "@angular/common": "21.2.20", + "@angular/core": "21.2.20" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "dev": true, + "node_modules/@angular/router": { + "version": "21.2.20", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.20.tgz", + "integrity": "sha512-4QINfZGcIBwupu1ULCXFdXsDZeZbpRX5mm61DAqc7CnuCGV1udwbIVStOf4AmPEdfGzV7yLl77Z++EFoQQXgRA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "tslib": "^2.3.0" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@angular/common": "21.2.20", + "@angular/core": "21.2.20", + "@angular/platform-browser": "21.2.20", + "rxjs": "^6.5.3 || ^7.4.0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/types": "^7.27.3" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-syntax-typescript": { + "node_modules/@babel/helper-plugin-utils": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", + "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/types": "^7.24.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "css-tree": "^3.0.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, - "bin": { - "specificity": "bin/cli.js" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=12" + "node": ">=6.0.0" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", "dev": true, "funding": [ { @@ -2051,9 +2378,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", "dev": true, "funding": [ { @@ -2067,8 +2394,8 @@ ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" }, "engines": { "node": ">=20.19.0" @@ -2102,9 +2429,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", "dev": true, "funding": [ { @@ -2751,9 +3078,9 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", "dev": true, "license": "MIT", "engines": { @@ -2787,13 +3114,13 @@ "optional": true }, "node_modules/@hono/node-server": { - "version": "1.19.13", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.13.tgz", - "integrity": "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" @@ -3427,17 +3754,17 @@ } }, "node_modules/@jest/console": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", - "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", + "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3445,38 +3772,39 @@ } }, "node_modules/@jest/core": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", - "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", + "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/pattern": "30.4.0", + "@jest/reporters": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", + "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.3.0", - "jest-config": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-resolve-dependencies": "30.3.0", - "jest-runner": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "jest-watcher": "30.3.0", - "pretty-format": "30.3.0", + "jest-changed-files": "30.4.1", + "jest-config": "30.4.2", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-resolve-dependencies": "30.4.2", + "jest-runner": "30.4.2", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "jest-watcher": "30.4.1", + "pretty-format": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3492,9 +3820,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", - "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", + "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", "dev": true, "license": "MIT", "engines": { @@ -3502,35 +3830,35 @@ } }, "node_modules/@jest/environment": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", - "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", + "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-mock": "30.3.0" + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.3.0.tgz", - "integrity": "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", + "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3546,23 +3874,23 @@ } }, "node_modules/@jest/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.3.0", - "jest-snapshot": "30.3.0" + "expect": "30.4.1", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", - "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", + "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3573,18 +3901,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", - "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", + "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", - "@sinonjs/fake-timers": "^15.0.0", + "@jest/types": "30.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3601,47 +3929,47 @@ } }, "node_modules/@jest/globals": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", - "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/types": "30.3.0", - "jest-mock": "30.3.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", + "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-regex-util": "30.0.1" + "jest-regex-util": "30.4.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", - "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", + "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -3654,9 +3982,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -3674,9 +4002,9 @@ } }, "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", + "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3687,13 +4015,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", - "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", + "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -3718,14 +4046,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", - "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", + "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/types": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -3734,15 +4062,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", - "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", + "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", + "@jest/test-result": "30.4.1", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "slash": "^3.0.0" }, "engines": { @@ -3750,23 +4078,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", - "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", + "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -3783,14 +4111,14 @@ "license": "MIT" }, "node_modules/@jest/types": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", - "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", + "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", + "@jest/pattern": "30.4.0", + "@jest/schemas": "30.4.1", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", @@ -3967,13 +4295,13 @@ ] }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -4434,29 +4762,31 @@ } }, "node_modules/@ngx-translate/core": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz", - "integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-18.0.0.tgz", + "integrity": "sha512-Z9u9AXaeuyeHHimivvJQvhal/LJNB+5tlH+FimSU4QQ61FrtRDqgyn6nfFbc6Cb+59NDZdsh4QeTJyPCldFUwA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=16", - "@angular/core": ">=16" + "@angular/common": ">=18", + "@angular/core": ">=18", + "rxjs": ">=7" } }, "node_modules/@ngx-translate/http-loader": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-17.0.0.tgz", - "integrity": "sha512-hgS8sa0ARjH9ll3PhkLTufeVXNI2DNR2uFKDhBgq13siUXzzVr/a31M6zgecrtwbA34iaBV01hsTMbMS8V7iIw==", + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-18.0.0.tgz", + "integrity": "sha512-p6QpNcU3rkCYjHbKVIadcYtml/Njpr0aLp8neJ4uJWQ4Xm9f09bIePhOjdjAFHupTptKqKrG1J2gSi3RSwgYeA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": ">=16", - "@angular/core": ">=16" + "@angular/common": ">=18", + "@angular/core": ">=18", + "@ngx-translate/core": ">=18.0.0" } }, "node_modules/@nodelib/fs.scandir": { @@ -4498,9 +4828,9 @@ } }, "node_modules/@npmcli/agent": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", - "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", "dev": true, "license": "ISC", "dependencies": { @@ -4515,9 +4845,9 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -4568,9 +4898,9 @@ } }, "node_modules/@npmcli/git/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -4658,9 +4988,9 @@ } }, "node_modules/@npmcli/package-json/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -5090,13 +5420,13 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.3.6.tgz", + "integrity": "sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + "node": "^14.18.0 || >=16.0.0" }, "funding": { "url": "https://opencollective.com/pkgr" @@ -5688,14 +6018,14 @@ "license": "MIT" }, "node_modules/@schematics/angular": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.7.tgz", - "integrity": "sha512-aqEj3RyBtmH+41HZvrbfrpCo0e+0NzwyQyNSC/wLDShVqoidBtPbEdHU1FZ4+ni41da7rI3F12gUuAHws27kMA==", + "version": "21.2.21", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.21.tgz", + "integrity": "sha512-ZiR5CcDMBI+0TkP4WeazhJmu1SdIq81VvO9CbXEHBO9KQWTtuE0EQCnzQkpIo4Dyh6jDRpA6sA3gkh79vW4aNQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", - "@angular-devkit/schematics": "21.2.7", + "@angular-devkit/core": "21.2.21", + "@angular-devkit/schematics": "21.2.21", "jsonc-parser": "3.3.1" }, "engines": { @@ -5705,13 +6035,13 @@ } }, "node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": { - "version": "21.2.7", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.7.tgz", - "integrity": "sha512-LYAjjUI1qM7pR/sd0yYt8OLA6ljOOXjcfzV40I5XQNmhAxq90YYS5xwMcixOmWX+z5zvCYGvPXvJGWjzio6SUg==", + "version": "21.2.21", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.21.tgz", + "integrity": "sha512-LgTd/0CpyWSxMJLLk0X85F5RyDlJekssLlapU1C8fKqOUdWONYncTXYvZUQ+zbRAGXHcSZrst1Vm8qdXbo11Mg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.21", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -5724,9 +6054,9 @@ } }, "node_modules/@schematics/angular/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", "dev": true, "license": "MIT", "engines": { @@ -5839,9 +6169,9 @@ } }, "node_modules/@schematics/angular/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", "dev": true, "license": "MIT", "dependencies": { @@ -5885,9 +6215,9 @@ } }, "node_modules/@sigstore/core": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.0.tgz", - "integrity": "sha512-kxHrDQ9YgfrWUSXU0cjsQGv8JykOFZQ9ErNKbFPWzk3Hgpwu8x2hHrQ9IdA8yl+j9RTLTC3sAF3Tdq1IQCP4oA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -5937,14 +6267,14 @@ } }, "node_modules/@sigstore/verify": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.0.tgz", - "integrity": "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0" }, "engines": { @@ -5969,9 +6299,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "15.3.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", - "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -6189,9 +6519,9 @@ "license": "MIT" }, "node_modules/@types/leaflet": { - "version": "1.9.21", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", - "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -6248,17 +6578,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6271,22 +6601,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.67.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3" }, "engines": { @@ -6302,14 +6632,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", "debug": "^4.4.3" }, "engines": { @@ -6324,14 +6654,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6342,9 +6672,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", "dev": true, "license": "MIT", "engines": { @@ -6359,15 +6689,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6384,9 +6714,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", "dev": true, "license": "MIT", "engines": { @@ -6398,16 +6728,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6426,9 +6756,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -6439,16 +6769,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6463,13 +6793,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.67.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -7079,16 +7409,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-union": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", - "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/array.prototype.findlastindex": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", @@ -7208,16 +7528,16 @@ } }, "node_modules/babel-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", - "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", + "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.3.0", + "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.3.0", + "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -7250,9 +7570,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", - "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", + "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", "dev": true, "license": "MIT", "dependencies": { @@ -7290,13 +7610,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", - "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", + "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.3.0", + "babel-plugin-jest-hoist": "30.4.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -7394,21 +7714,21 @@ } }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -7418,6 +7738,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -7438,19 +7772,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -7591,9 +7912,9 @@ } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -8047,16 +8368,6 @@ "dev": true, "license": "MIT" }, - "node_modules/common-tags": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", - "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -8199,32 +8510,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -8440,26 +8725,6 @@ "node": ">=0.3.1" } }, - "node_modules/dir-glob": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", - "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -8634,13 +8899,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true, - "license": "MIT" - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -9006,9 +9264,9 @@ } }, "node_modules/eslint-import-resolver-typescript": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz", - "integrity": "sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", "dev": true, "license": "ISC", "dependencies": { @@ -9167,13 +9425,13 @@ } }, "node_modules/eslint-plugin-perfectionist": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.8.0.tgz", - "integrity": "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==", + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.10.1.tgz", + "integrity": "sha512-Kprsp9Us0GqAesYaAIzUViw57xYp5WBqzXrcE0Mtww++E5fexWXYBipMuuD7yvyH4vvpBH0+oJ+OMAmZ0oYXkw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.58.0", + "@typescript-eslint/utils": "^8.65.0", "natural-orderby": "^5.0.0" }, "engines": { @@ -9184,14 +9442,14 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.5.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", - "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", "dev": true, "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.12" + "synckit": "^0.11.13" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -9464,9 +9722,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, "license": "MIT", "engines": { @@ -9515,18 +9773,18 @@ } }, "node_modules/expect": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", - "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9584,13 +9842,14 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "debug": "^4.4.3", + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -9616,36 +9875,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -9728,19 +9957,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -10175,37 +10391,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/globby": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", - "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-union": "^2.1.0", - "dir-glob": "^3.0.1", - "fast-glob": "^3.2.9", - "ignore": "^5.2.0", - "merge2": "^1.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globby/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10265,29 +10450,6 @@ "node": ">=0.10.0" } }, - "node_modules/has-ansi": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", - "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-ansi/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -10383,9 +10545,9 @@ } }, "node_modules/hono": { - "version": "4.12.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", - "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", "dev": true, "license": "MIT", "engines": { @@ -10393,9 +10555,9 @@ } }, "node_modules/hosted-git-info": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", - "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", "dev": true, "license": "ISC", "dependencies": { @@ -10406,9 +10568,9 @@ } }, "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "11.3.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", - "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -10649,16 +10811,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -10683,56 +10835,328 @@ "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", "dev": true, - "license": "ISC", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=8" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -10741,25 +11165,41 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", + "call-bound": "^1.0.2", + "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -10768,15 +11208,12 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, "engines": { "node": ">= 0.4" }, @@ -10784,15 +11221,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -10801,22 +11237,29 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-bun-module": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", - "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, "license": "MIT", - "dependencies": { - "semver": "^7.7.1" + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, "engines": { "node": ">= 0.4" }, @@ -10824,14 +11267,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -10840,16 +11285,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" + "which-typed-array": "^1.1.16" }, "engines": { "node": ">= 0.4" @@ -10858,16 +11301,25 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" + "engines": { + "node": ">=10" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -10875,24 +11327,31 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -10901,1220 +11360,1388 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, "engines": { - "node": ">=6" + "node": ">=10" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "is-extglob": "^2.1.1" + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">=10" } }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, "engines": { "node": ">=8" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/jest": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", + "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", + "import-local": "^3.2.0", + "jest-cli": "30.4.2" + }, + "bin": { + "jest": "bin/jest.js" + }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/jest-changed-files": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", + "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", "dev": true, "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0" + }, "engines": { - "node": ">=0.12.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/jest-circus": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", + "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-runtime": "30.4.2", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", + "p-limit": "^3.1.0", + "pretty-format": "30.4.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/jest-cli": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", + "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/core": "30.4.2", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/jest-cli/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "node_modules/jest-cli/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "node_modules/jest-cli/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/jest-cli/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/jest-cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/jest-cli/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/jest-config": { + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", + "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", "dev": true, "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.4.0", + "@jest/test-sequencer": "30.4.1", + "@jest/types": "30.4.1", + "babel-jest": "30.4.1", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.4.2", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-runner": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", + "parse-json": "^5.2.0", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "node_modules/jest-diff": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/jest-docblock": { + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", + "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "detect-newline": "^3.1.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/jest-each": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", + "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "@jest/get-type": "30.1.0", + "@jest/types": "30.4.1", + "chalk": "^4.1.2", + "jest-util": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/jest-environment-jsdom": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", + "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", + "jsdom": "^26.1.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "node_modules/jest-environment-jsdom/node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "node_modules/jest-environment-jsdom/node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, - "license": "ISC" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "node_modules/jest-environment-jsdom/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, - "license": "BSD-3-Clause", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "node_modules/jest-environment-jsdom/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, - "license": "BSD-3-Clause", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { - "node": ">=10" + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/jest-environment-jsdom/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/jest-environment-jsdom/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "node_modules/jest-environment-jsdom/node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/jest-environment-jsdom/node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "node_modules/jest-environment-jsdom/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" }, "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", - "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.3.0", - "@jest/types": "30.3.0", - "import-local": "^3.2.0", - "jest-cli": "30.3.0" + "whatwg-encoding": "^3.1.1" }, - "bin": { - "jest": "bin/jest.js" + "engines": { + "node": ">=18" + } + }, + "node_modules/jest-environment-jsdom/node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" }, "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "canvas": "^3.0.0" }, "peerDependenciesMeta": { - "node-notifier": { + "canvas": { "optional": true } } }, - "node_modules/jest-changed-files": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", - "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "node_modules/jest-environment-jsdom/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-environment-jsdom/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.3.0", - "p-limit": "^3.1.0" + "entities": "^6.0.0" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/jest-circus": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", - "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "node_modules/jest-environment-jsdom/node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-runtime": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", - "p-limit": "^3.1.0", - "pretty-format": "30.3.0", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "tldts-core": "^6.1.86" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "bin": { + "tldts": "bin/cli.js" } }, - "node_modules/jest-cli": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", - "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "node_modules/jest-environment-jsdom/node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT", + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@jest/core": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" + "tldts": "^6.1.32" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">=16" } }, - "node_modules/jest-cli/node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/jest-environment-jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "punycode": "^2.3.1" }, "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/jest-cli/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-cli/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/jest-environment-jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/jest-cli/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8" - } - }, - "node_modules/jest-cli/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node": ">=18" + } + }, + "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "node": ">=18" } }, - "node_modules/jest-cli/node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "node_modules/jest-environment-node": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", + "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", "dev": true, "license": "MIT", "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { - "node": ">=12" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-config": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", - "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "node_modules/jest-haste-map": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", + "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.3.0", - "@jest/types": "30.3.0", - "babel-jest": "30.3.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.5.0", + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", - "jest-circus": "30.3.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-runner": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", - "parse-json": "^5.2.0", - "pretty-format": "30.3.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", + "picomatch": "^4.0.3", + "walker": "^1.0.8" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } + "optionalDependencies": { + "fsevents": "^2.3.3" } }, - "node_modules/jest-diff": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", - "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "node_modules/jest-leak-detector": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", + "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "node_modules/jest-matcher-utils": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", + "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.1.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.4.1", + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-each": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", - "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "node_modules/jest-message-util": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", + "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", - "jest-util": "30.3.0", - "pretty-format": "30.3.0" + "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", + "picomatch": "^4.0.3", + "pretty-format": "30.4.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-jsdom": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.3.0.tgz", - "integrity": "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg==", + "node_modules/jest-mock": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", + "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/environment-jsdom-abstract": "30.3.0", - "jsdom": "^26.1.0" + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" }, "peerDependencies": { - "canvas": "^3.0.0" + "jest-resolve": "*" }, "peerDependenciesMeta": { - "canvas": { + "jest-resolve": { "optional": true } } }, - "node_modules/jest-environment-jsdom/node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "node_modules/jest-preset-angular": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-17.0.0.tgz", + "integrity": "sha512-2yAHkA1c5rSICGJVtLYYqPC5RDsvo4+i4CwWFHVXwv41cHNX7gCWeh074IuZ5mFw7Vwsr+i25EowCnGtKkxNWw==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" + "@jest/environment-jsdom-abstract": "^30.4.1", + "bs-logger": "^0.2.6", + "esbuild-wasm": ">=0.28.0", + "jest-util": "^30.4.1", + "pretty-format": "^30.4.1", + "ts-jest": "^29.4.11" + }, + "engines": { + "node": "^20.11.1 || >=22.0.0" + }, + "optionalDependencies": { + "esbuild": ">=0.28.0" + }, + "peerDependencies": { + "@angular/compiler-cli": ">=20.0.0 <23.0.0", + "@angular/core": ">=20.0.0 <23.0.0", + "@angular/platform-browser": ">=20.0.0 <23.0.0", + "jest": "^30.0.0", + "jsdom": ">=26.0.0", + "typescript": ">=5.8" } }, - "node_modules/jest-environment-jsdom/node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "node_modules/jest-preset-angular/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "aix" ], - "license": "MIT-0", "engines": { "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/jest-preset-angular/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "node_modules/jest-preset-angular/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, + "optional": true, + "os": [ + "android" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "node_modules/jest-preset-angular/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/jest-preset-angular/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/jest-environment-jsdom/node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "node_modules/jest-preset-angular/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/jest-preset-angular/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "node_modules/jest-preset-angular/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/data-urls": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } } }, - "node_modules/jest-environment-jsdom/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/jest-environment-jsdom/node_modules/parse5": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", - "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], "dev": true, "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-environment-jsdom/node_modules/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=16" + "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "node_modules/jest-preset-angular/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "node_modules/jest-preset-angular/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } }, - "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "node_modules/jest-preset-angular/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { "node": ">=18" } }, - "node_modules/jest-environment-node": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", - "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "node_modules/jest-preset-angular/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0" - }, + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-haste-map": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", - "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.3.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.3.0", - "jest-worker": "30.3.0", - "picomatch": "^4.0.3", - "walker": "^1.0.8" - }, + "node_modules/jest-preset-angular/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" + "node": ">=18" } }, - "node_modules/jest-leak-detector": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", - "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "node_modules/jest-preset-angular/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.3.0" - }, + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-matcher-utils": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", - "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "node_modules/jest-preset-angular/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" - }, + "optional": true, + "os": [ + "sunos" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-message-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", - "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "node_modules/jest-preset-angular/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3", - "pretty-format": "30.3.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-mock": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", - "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "node_modules/jest-preset-angular/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "@jest/types": "30.3.0", - "@types/node": "*", - "jest-util": "30.3.0" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" } }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "node_modules/jest-preset-angular/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } + "node": ">=18" } }, - "node_modules/jest-preset-angular": { - "version": "16.1.4", - "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-16.1.4.tgz", - "integrity": "sha512-9RAEcxejwhumdGhOabraQ6ZSNAKJfOHHeQpq47fYOfBNNl4CIQf9um7a6vGK2iGSxvo0tNzw1mNVlYWKkPWx1g==", + "node_modules/jest-preset-angular/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "@jest/environment-jsdom-abstract": "^30.0.0", - "bs-logger": "^0.2.6", - "esbuild-wasm": ">=0.23.0", - "jest-util": "^30.0.0", - "pretty-format": "^30.0.0", - "ts-jest": "^29.4.0" + "optional": true, + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + "node": ">=18" }, "optionalDependencies": { - "esbuild": ">=0.23.0" - }, - "peerDependencies": { - "@angular/compiler-cli": ">=19.0.0 <22.0.0", - "@angular/core": ">=19.0.0 <22.0.0", - "@angular/platform-browser": ">=19.0.0 <22.0.0", - "@angular/platform-browser-dynamic": ">=19.0.0 <22.0.0", - "jest": "^30.0.0", - "jsdom": ">=26.0.0", - "typescript": ">=5.5" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "version": "30.4.0", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", + "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", "dev": true, "license": "MIT", "engines": { @@ -12122,18 +12749,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", - "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", + "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", + "jest-haste-map": "30.4.1", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -12142,46 +12769,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", - "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", + "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.3.0" + "jest-regex-util": "30.4.0", + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", - "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", + "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.3.0", - "@jest/environment": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/console": "30.4.1", + "@jest/environment": "30.4.1", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.3.0", - "jest-haste-map": "30.3.0", - "jest-leak-detector": "30.3.0", - "jest-message-util": "30.3.0", - "jest-resolve": "30.3.0", - "jest-runtime": "30.3.0", - "jest-util": "30.3.0", - "jest-watcher": "30.3.0", - "jest-worker": "30.3.0", + "jest-docblock": "30.4.0", + "jest-environment-node": "30.4.1", + "jest-haste-map": "30.4.1", + "jest-leak-detector": "30.4.1", + "jest-message-util": "30.4.1", + "jest-resolve": "30.4.1", + "jest-runtime": "30.4.2", + "jest-util": "30.4.1", + "jest-watcher": "30.4.1", + "jest-worker": "30.4.1", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -12211,32 +12838,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", - "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "version": "30.4.2", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", + "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/globals": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", + "@jest/globals": "30.4.1", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.3.0", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.3.0", - "jest-snapshot": "30.3.0", - "jest-util": "30.3.0", + "jest-haste-map": "30.4.1", + "jest-message-util": "30.4.1", + "jest-mock": "30.4.1", + "jest-regex-util": "30.4.0", + "jest-resolve": "30.4.1", + "jest-snapshot": "30.4.1", + "jest-util": "30.4.1", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -12245,9 +12872,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", - "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", + "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", "dev": true, "license": "MIT", "dependencies": { @@ -12256,20 +12883,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.3.0", + "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.3.0", - "@jest/transform": "30.3.0", - "@jest/types": "30.3.0", + "@jest/snapshot-utils": "30.4.1", + "@jest/transform": "30.4.1", + "@jest/types": "30.4.1", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.3.0", + "expect": "30.4.1", "graceful-fs": "^4.2.11", - "jest-diff": "30.3.0", - "jest-matcher-utils": "30.3.0", - "jest-message-util": "30.3.0", - "jest-util": "30.3.0", - "pretty-format": "30.3.0", + "jest-diff": "30.4.1", + "jest-matcher-utils": "30.4.1", + "jest-message-util": "30.4.1", + "jest-util": "30.4.1", + "pretty-format": "30.4.1", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -12278,13 +12905,13 @@ } }, "node_modules/jest-util": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", - "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", + "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -12296,18 +12923,18 @@ } }, "node_modules/jest-validate": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", - "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", + "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.3.0" + "pretty-format": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -12327,19 +12954,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", - "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", + "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@jest/test-result": "30.4.1", + "@jest/types": "30.4.1", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "string-length": "^4.0.2" }, "engines": { @@ -12347,15 +12974,15 @@ } }, "node_modules/jest-worker": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", - "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", + "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.3.0", + "jest-util": "30.4.1", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -12380,9 +13007,9 @@ } }, "node_modules/jose": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", - "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "version": "6.2.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz", + "integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==", "dev": true, "license": "MIT", "funding": { @@ -12410,39 +13037,39 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "canvas": "^3.2.3" }, "peerDependenciesMeta": { "canvas": { @@ -12450,6 +13077,41 @@ } } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -12732,13 +13394,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -12908,101 +13563,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/loglevel": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", - "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, - "node_modules/loglevel-colored-level-prefix": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz", - "integrity": "sha512-u45Wcxxc+SdAlh4yeF/uKlC1SPUPCy0gullSNKXod5I4bmifzk+Q4lSLExNEVn19tGaJipbZ4V4jbFn79/6mVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^1.1.3", - "loglevel": "^1.4.1" - } - }, - "node_modules/loglevel-colored-level-prefix/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/loglevel-colored-level-prefix/node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/loglevel-colored-level-prefix/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/loglevel-colored-level-prefix/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/loglevel-colored-level-prefix/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/loglevel-colored-level-prefix/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -13047,9 +13607,9 @@ "license": "ISC" }, "node_modules/make-fetch-happen": { - "version": "15.0.5", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.5.tgz", - "integrity": "sha512-uCbIa8jWWmQZt4dSnEStkVC6gdakiinAm4PiGsywIkguF0eWMdcjDz0ECYhUolFU3pFLOev9VNPCEygydXnddg==", + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", "dev": true, "license": "ISC", "dependencies": { @@ -13098,13 +13658,17 @@ "license": "CC0-1.0" }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -13127,43 +13691,6 @@ "dev": true, "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -13541,21 +14068,21 @@ } }, "node_modules/node-gyp": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", - "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", + "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { @@ -13591,6 +14118,16 @@ "node": ">=20" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", @@ -14079,9 +14616,9 @@ } }, "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", + "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", "dev": true, "license": "MIT", "engines": { @@ -14109,12 +14646,13 @@ "license": "BlueOak-1.0.0" }, "node_modules/pacote": { - "version": "21.3.1", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.3.1.tgz", - "integrity": "sha512-O0EDXi85LF4AzdjG74GUwEArhdvawi/YOHcsW6IijKNj7wm8IvEWNF5GnfuxNpQ/ZpO3L37+v8hqdVh8GgWYhg==", + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", "dev": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@npmcli/git": "^7.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/package-json": "^7.0.0", @@ -14128,7 +14666,6 @@ "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", "sigstore": "^4.0.0", "ssri": "^13.0.0", "tar": "^7.4.3" @@ -14180,12 +14717,12 @@ "license": "MIT" }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -14233,12 +14770,12 @@ } }, "node_modules/parse5/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -14321,19 +14858,9 @@ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/picocolors": { @@ -14367,9 +14894,9 @@ } }, "node_modules/piscina": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.1.4.tgz", - "integrity": "sha512-7uU4ZnKeQq22t9AsmHGD2w4OYQGonwFnTypDypaWi7Qr2EvQIFVtG8J5D/3bE7W123Wdc9+v4CZDu5hJXVCtBg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", + "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", "dev": true, "license": "MIT", "engines": { @@ -14542,9 +15069,9 @@ } }, "node_modules/prettier": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", - "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -14557,247 +15084,6 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/prettier-eslint": { - "version": "16.4.2", - "resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-16.4.2.tgz", - "integrity": "sha512-vtJAQEkaN8fW5QKl08t7A5KCjlZuDUNeIlr9hgolMS5s3+uzbfRHDwaRnzrdqnY2YpHDmeDS/8zY0MKQHXJtaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/parser": "^6.21.0", - "common-tags": "^1.8.2", - "dlv": "^1.1.3", - "eslint": "^8.57.1", - "indent-string": "^4.0.0", - "lodash.merge": "^4.6.2", - "loglevel-colored-level-prefix": "^1.0.0", - "prettier": "^3.5.3", - "pretty-format": "^29.7.0", - "require-relative": "^0.8.7", - "tslib": "^2.8.1", - "vue-eslint-parser": "^9.4.3" - }, - "engines": { - "node": ">=16.10.0" - }, - "funding": { - "url": "https://opencollective.com/prettier-eslint" - }, - "peerDependencies": { - "prettier-plugin-svelte": "^3.0.0", - "svelte-eslint-parser": "*" - }, - "peerDependenciesMeta": { - "prettier-plugin-svelte": { - "optional": true - }, - "svelte-eslint-parser": { - "optional": true - } - } - }, - "node_modules/prettier-eslint/node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/prettier-eslint/node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/prettier-eslint/node_modules/@typescript-eslint/parser": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", - "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/scope-manager": "6.21.0", - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/typescript-estree": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/prettier-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", - "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/prettier-eslint/node_modules/@typescript-eslint/types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", - "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/prettier-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", - "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "@typescript-eslint/visitor-keys": "6.21.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "minimatch": "9.0.3", - "semver": "^7.5.4", - "ts-api-utils": "^1.0.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/prettier-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", - "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "6.21.0", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^16.0.0 || >=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/prettier-eslint/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prettier-eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/prettier-eslint/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/prettier-eslint/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/prettier-eslint/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/prettier-eslint/node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "typescript": ">=4.2.0" - } - }, "node_modules/prettier-linter-helpers": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", @@ -14812,15 +15098,16 @@ } }, "node_modules/pretty-format": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", - "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", + "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "30.0.5", + "@jest/schemas": "30.4.1", "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" + "react-is-18": "npm:react-is@^18.3.1", + "react-is-19": "npm:react-is@^19.2.5" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -14849,20 +15136,6 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -14905,13 +15178,14 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -14942,13 +15216,17 @@ "license": "MIT" }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -14967,13 +15245,22 @@ "node": ">= 0.10" } }, - "node_modules/react-is": { + "node_modules/react-is-18": { + "name": "react-is", "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, "license": "MIT" }, + "node_modules/react-is-19": { + "name": "react-is", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "dev": true, + "license": "MIT" + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -15073,13 +15360,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-relative": { - "version": "0.8.7", - "resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz", - "integrity": "sha512-AKGr4qvHiryxRb19m3PsLRGuKVAbJLUD7E6eOaHkfKhwc+vSgVOCY5xNvm9EkolBKTOf0GrQAZKLimOCz81Khg==", - "dev": true, - "license": "MIT" - }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -15180,16 +15460,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -15699,15 +15969,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -15788,18 +16058,18 @@ } }, "node_modules/sigstore": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.0.tgz", - "integrity": "sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", "dev": true, "license": "Apache-2.0", "dependencies": { "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.1.0", + "@sigstore/core": "^3.2.1", "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.0", - "@sigstore/tuf": "^4.0.1", - "@sigstore/verify": "^3.1.0" + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -15857,13 +16127,13 @@ } }, "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", "dev": true, "license": "MIT", "dependencies": { - "ip-address": "^10.0.1", + "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" }, "engines": { @@ -16016,9 +16286,9 @@ } }, "node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", "dev": true, "license": "MIT", "engines": { @@ -16299,13 +16569,13 @@ "license": "MIT" }, "node_modules/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "version": "0.11.13", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.2.9" + "@pkgr/core": "^0.3.6" }, "engines": { "node": "^14.18.0 || >=16.0.0" @@ -16315,9 +16585,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -16434,22 +16704,22 @@ } }, "node_modules/tldts": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", - "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", "dev": true, "license": "MIT", "dependencies": { - "tldts-core": "^7.0.28" + "tldts-core": "^7.4.10" }, "bin": { "tldts": "bin/cli.js" } }, "node_modules/tldts-core": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", - "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", "dev": true, "license": "MIT" }, @@ -16460,19 +16730,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -16484,9 +16741,9 @@ } }, "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -16523,9 +16780,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.9", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", - "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -16535,7 +16792,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.4", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -16576,9 +16833,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -16739,18 +16996,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typed-array-buffer": { @@ -16879,9 +17154,9 @@ } }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -17038,13 +17313,13 @@ } }, "node_modules/vite": { - "version": "7.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", - "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", @@ -17112,48 +17387,6 @@ } } }, - "node_modules/vue-eslint-parser": { - "version": "9.4.3", - "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", - "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "eslint-scope": "^7.1.1", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.3.1", - "esquery": "^1.4.0", - "lodash": "^4.17.21", - "semver": "^7.3.6" - }, - "engines": { - "node": "^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=6.0.0" - } - }, - "node_modules/vue-eslint-parser/node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -17684,9 +17917,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "dev": true, "license": "MIT", "engines": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index fd65c83a..33a6d9e9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", - "start": "ng serve", + "start": "ng serve --proxy-config proxy.conf.json", "build": "ng build", "watch": "ng build --watch --configuration development", "test": "jest", @@ -15,18 +15,18 @@ "private": true, "packageManager": "npm@11.5.2", "dependencies": { - "@angular/animations": "^21.2.8", - "@angular/cdk": "^21.2.6", - "@angular/common": "^21.2.8", - "@angular/compiler": "^21.2.8", - "@angular/core": "^21.2.8", - "@angular/forms": "^21.2.8", - "@angular/material": "^21.2.6", - "@angular/platform-browser": "^21.2.8", - "@angular/router": "^21.2.8", - "@ngx-translate/core": "^17.0.0", - "@ngx-translate/http-loader": "^17.0.0", - "@types/leaflet": "^1.9.21", + "@angular/animations": "^21.2.16", + "@angular/cdk": "^21.2.14", + "@angular/common": "^21.2.20", + "@angular/compiler": "^21.2.20", + "@angular/core": "^21.2.20", + "@angular/forms": "^21.2.20", + "@angular/material": "^21.2.14", + "@angular/platform-browser": "^21.2.20", + "@angular/router": "^21.2.20", + "@ngx-translate/core": "^18.0.0", + "@ngx-translate/http-loader": "^18.0.0", + "@types/leaflet": "^1.9.22", "@types/leaflet-draw": "^1.0.13", "chokidar": "^5.0.0", "leaflet": "^1.9.4", @@ -40,29 +40,27 @@ "@angular-eslint/eslint-plugin-template": "^19.0.0", "@angular-eslint/schematics": "^19.0.0", "@angular-eslint/template-parser": "^19.0.0", - "@angular/build": "^21.2.7", - "@angular/cli": "^21.2.7", - "@angular/compiler-cli": "^21.2.8", - "@angular/platform-browser-dynamic": "^21.2.8", - "@jest/globals": "^30.3.0", + "@angular/build": "^21.2.21", + "@angular/cli": "^21.2.21", + "@angular/compiler-cli": "^21.2.20", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.67.0", "@typescript-eslint/parser": "^8.56.0", "@typescript-eslint/utils": "^8.56.0", "eslint": "^8.57.0", "eslint-config-prettier": "^10.1.8", - "eslint-import-resolver-typescript": "^4.4.4", + "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import": "^2.32.0", - "eslint-plugin-perfectionist": "^5.8.0", - "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-perfectionist": "^5.10.0", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", - "jest": "^30.3.0", - "jest-environment-jsdom": "^30.3.0", - "jest-preset-angular": "^16.1.1", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", - "prettier-eslint": "^16.4.0", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-preset-angular": "^17.0.0", + "jsdom": "^30.0.1", + "prettier": "^3.9.6", "ts-node": "^10.9.2", "typescript": "~5.9.2" } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json new file mode 100644 index 00000000..c2de9aaf --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json @@ -0,0 +1,14 @@ +{ + "/api": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + }, + "/auth": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.local.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.local.json new file mode 100644 index 00000000..e299aa76 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.local.json @@ -0,0 +1,8 @@ +{ + "/api": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/areas.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/areas.png index d7895967..596eabaa 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/areas.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/areas.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/cleaning.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/cleaning.png index 5af34e62..06cc776d 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/cleaning.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/cleaning.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dark-mode.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dark-mode.png index 28bd58c5..62e14f24 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dark-mode.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dark-mode.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dashboard-overview.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dashboard-overview.png index 6360b00d..1dfc901d 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dashboard-overview.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/dashboard-overview.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/geofences.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/geofences.png index eb8786cf..57b335fc 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/geofences.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/geofences.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/help-page.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/help-page.png index 36a28598..b810fcd3 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/help-page.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/help-page.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/invasions.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/invasions.png index 85faee81..8c00c44f 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/invasions.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/invasions.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/login.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/login.png index 89f71897..7f1696c9 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/login.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/login.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/onboarding.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/onboarding.png index c48a303d..f1c6cab4 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/onboarding.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/onboarding.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-add-dialog.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-add-dialog.png index 660e8e9f..5ba78908 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-add-dialog.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-add-dialog.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-list.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-list.png index f6cacab5..caab7a47 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-list.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/pokemon-list.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/profiles.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/profiles.png index 678e8bf7..ca5ada91 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/profiles.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/profiles.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quests.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quests.png index e732da94..06e8e876 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quests.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quests.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quick-picks.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quick-picks.png index 83d8adf2..3f12eb08 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quick-picks.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/quick-picks.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/raids.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/raids.png index c67c4407..42393c66 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/raids.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/raids.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/toolbar-theme.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/toolbar-theme.png index 8e9d5dde..7186a22d 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/toolbar-theme.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/toolbar-theme.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/user-menu.png b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/user-menu.png index 5402648d..398fc529 100644 Binary files a/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/user-menu.png and b/Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/help/user-menu.png differ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/setup-jest.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/setup-jest.ts index e4ea913a..05608684 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/setup-jest.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/setup-jest.ts @@ -1,11 +1,6 @@ import { TestBed } from '@angular/core/testing'; -import { - BrowserDynamicTestingModule, - platformBrowserDynamicTesting, -} from '@angular/platform-browser-dynamic/testing'; +import { BrowserTestingModule, platformBrowserTesting } from '@angular/platform-browser/testing'; -TestBed.initTestEnvironment( - BrowserDynamicTestingModule, - platformBrowserDynamicTesting(), - { teardown: { destroyAfterEach: true } }, -); +TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), { + teardown: { destroyAfterEach: true }, +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.spec.ts new file mode 100644 index 00000000..e8842650 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.spec.ts @@ -0,0 +1,46 @@ +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { TranslateService } from '@ngx-translate/core'; + +import { appConfig } from './app.config'; + +/** + * Guards the NG0200 regression introduced by the ngx-translate v18 upgrade. + * + * v18 loads `fallbackLang` eagerly from inside the TranslateService constructor. Setting it in + * `provideTranslateService()` therefore resolves TranslateLoader while the injector is still + * building TranslateService, which fails with NG0200 (circular dependency) and leaves the + * fallback language — English — rendering raw keys, while every other locale loaded fine. + * + * These tests exercise the real `appConfig` providers, so re-adding `fallbackLang` there fails + * here rather than silently in production. + */ +describe('appConfig translation wiring', () => { + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [...appConfig.providers, provideHttpClientTesting()], + }); + }); + + it('constructs TranslateService without a circular dependency', () => { + expect(() => TestBed.inject(TranslateService)).not.toThrow(); + }); + + it('does not fetch any translation before a language is requested', () => { + TestBed.inject(TranslateService); + // An eager fallback load would already have issued a request here. + TestBed.inject(HttpTestingController).verify(); + }); + + it('loads English through the configured HTTP loader once requested', () => { + const translate = TestBed.inject(TranslateService); + const http = TestBed.inject(HttpTestingController); + + translate.use('en'); + http.expectOne('./assets/i18n/en.json').flush({ NAV: { DASHBOARD: 'Dashboard' } }); + + expect(translate.instant('NAV.DASHBOARD')).toBe('Dashboard'); + http.verify(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts index 9b21a077..1238306f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts @@ -1,22 +1,39 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { MatPaginatorIntl } from '@angular/material/paginator'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideRouter } from '@angular/router'; import { provideTranslateService, TranslateLoader } from '@ngx-translate/core'; import { TranslateHttpLoader, provideTranslateHttpLoader } from '@ngx-translate/http-loader'; import { routes } from './app.routes'; +import { TranslatedPaginatorIntl } from './core/i18n/translated-paginator-intl'; import { authInterceptor } from './core/interceptors/auth.interceptor'; import { errorInterceptor } from './core/interceptors/error.interceptor'; +import { oidcRefreshInterceptor } from './core/interceptors/oidc-refresh.interceptor'; export const appConfig: ApplicationConfig = { providers: [ + // Material ships English paginator labels; without this the admin tables stay partly + // English in every other locale. See #425. + { provide: MatPaginatorIntl, useClass: TranslatedPaginatorIntl }, provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])), + // oidcRefreshInterceptor sits closest to the backend so it catches a 401 (and can silently + // refresh + retry) before errorInterceptor redirects to the login page. + provideHttpClient(withInterceptors([authInterceptor, errorInterceptor, oidcRefreshInterceptor])), provideAnimationsAsync(), + // No `fallbackLang` here on purpose. ngx-translate v18 loads the fallback language eagerly + // from inside the TranslateService constructor, which resolves TranslateLoader while the + // injector is still building TranslateService -- a circular dependency that fails with + // NG0200 ("error loading translation for en"). Only the fallback language is affected, so + // English silently rendered raw keys while every other locale, loaded later via use(), + // worked fine. v17's `defaultLanguage` did not load eagerly, which is why this only + // appeared after the v18 upgrade. + // + // I18nService.init() calls setFallbackLang('en') instead, after bootstrap, where the + // injector is complete and the loader resolves normally. provideTranslateService({ - defaultLanguage: 'en', loader: { provide: TranslateLoader, useClass: TranslateHttpLoader, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html index 7ec28460..6aa75662 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html @@ -6,11 +6,18 @@ > } -@if (auth.user()?.adminDisable) { +@if (auth.user()?.adminDisable && auth.isImpersonating()) { + +
+ block + +
+} @else if (auth.user()?.adminDisable) {
block @@ -103,13 +110,17 @@ + } + + + @if (ssoLogoutAvailable()) { + + } +

{{ 'MENU.DISPLAY_LANGUAGE_HINT' | translate }}

@for (lang of i18n.availableLanguages(); track lang.code) { }
+ + +

{{ 'MENU.ALERT_LANGUAGE_HINT' | translate }}

+ @for (lang of alertLanguage.languages; track lang.code) { + + } +
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.routes.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.routes.ts index 3c22839e..a6a89e11 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.routes.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.routes.ts @@ -18,13 +18,17 @@ export const routes: Routes = [ loadComponent: () => import('./modules/auth/callback.component').then(m => m.CallbackComponent), path: 'auth/discord/callback', }, + { + loadComponent: () => import('./modules/auth/callback.component').then(m => m.CallbackComponent), + path: 'auth/oidc/callback', + }, { canActivate: [authGuard], loadComponent: () => import('./modules/dashboard/dashboard.component').then(m => m.DashboardComponent), path: 'dashboard', }, { - canActivate: [authGuard], + canActivate: [authGuard, disabledFeatureGuard('disable_profiles')], loadComponent: () => import('./modules/profiles-overview/profile-overview.component').then(m => m.ProfileOverviewComponent), path: 'profiles', }, @@ -79,15 +83,22 @@ export const routes: Routes = [ path: 'max-battles', }, { - canActivate: [authGuard], + canActivate: [authGuard, disabledFeatureGuard('disable_areas')], loadComponent: () => import('./modules/areas/area-list.component').then(m => m.AreaListComponent), path: 'areas', }, { - canActivate: [authGuard], + canActivate: [authGuard, disabledFeatureGuard('disable_user_geofences')], loadComponent: () => import('./modules/geofences/geofence-list.component').then(m => m.GeofenceListComponent), path: 'geofences', }, + { + // Places moved into the Areas page, beside the pin it belongs with. Kept as a redirect because + // bookmarks and anything still pointing here should land somewhere useful rather than 404. + path: 'places', + pathMatch: 'full', + redirectTo: 'areas', + }, { canActivate: [authGuard], loadComponent: () => import('./modules/cleaning/cleaning.component').then(m => m.CleaningComponent), @@ -119,7 +130,7 @@ export const routes: Routes = [ path: 'admin/settings', }, { - canActivate: [authGuard, adminGuard], + canActivate: [authGuard, adminGuard, disabledFeatureGuard('disable_user_geofences')], loadComponent: () => import('./modules/admin/geofence-submissions/geofence-submissions.component').then(m => m.GeofenceSubmissionsComponent), path: 'admin/geofence-submissions', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss index 6a76de04..706d0279 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss @@ -430,11 +430,14 @@ kbd { font-size: 16px; } } +} - // Hide language selector on very small screens - @media (max-width: 480px) { - app-language-selector { - display: none; - } - } +// The alert-language menu mirrors the display-language one row for row; the two are only +// distinguishable at a glance if they look like siblings. Its hint says which is which. +.alert-language-hint { + color: var(--text-secondary, rgb(0 0 0 / 60%)); + font-size: 12px; + margin: 0; + max-width: 240px; + padding: 8px 16px 4px; } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.spec.ts index c5c66261..42ed3659 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.spec.ts @@ -8,6 +8,7 @@ import { provideTranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; import { App } from './app'; +import { AlertLanguageService } from './core/services/alert-language.service'; import { AuthService } from './core/services/auth.service'; import { DashboardService } from './core/services/dashboard.service'; import { I18nService } from './core/services/i18n.service'; @@ -20,9 +21,16 @@ interface NavItemShape { } /** - * Covers the nav-filter logic that #236 hardened: `disable_*` settings hide nav items - * for everyone (including admins). Without these tests the iteration-1 fix - * ("admins shouldn't bypass the disable filter in the nav") is silently regressable. + * Covers the nav-filter logic that #236 hardened: a `disable_*` setting takes effect for everyone, + * admins included. Without these tests the iteration-1 fix ("admins shouldn't bypass the disable + * filter in the nav") is silently regressable. + * + * Every disabled item is hidden, alarm types included. That is not a return to the pre-#784 + * behaviour: the page itself stays reachable and still lists and deletes the rules a user already + * has. What changed in #792 is where the way in lives. A padlocked nav item served the few people + * holding rules of a disabled type and was noise for everyone else, so the dashboard card carries it + * instead — shown for a disabled type only while it still has alarms, which the nav cannot know + * because it is drawn before counts load. */ describe('App nav filtering (#236)', () => { let settingsSignal: WritableSignal>; @@ -89,7 +97,7 @@ describe('App nav filtering (#236)', () => { ); }); - it.each([ + const ALARM_KEYS: [string, string][] = [ ['disable_mons', '/pokemon'], ['disable_raids', '/raids'], ['disable_quests', '/quests'], @@ -99,28 +107,33 @@ describe('App nav filtering (#236)', () => { ['disable_gyms', '/gyms'], ['disable_maxbattles', '/max-battles'], ['disable_fort_changes', '/fort-changes'], - ])('hides %s route from non-admin nav', (key, route) => { + ]; + + it.each(ALARM_KEYS)('hides the %s nav item when the type is disabled', (key, route) => { + // The page stays reachable — no route guard, and the dashboard links to it while alarms remain. + // What is gone is a padlocked item shown to every user of an instance that never enabled the type. const app = setup({ [key]: 'true' }, false); expect(alarmRoutes(app)).not.toContain(route); }); - it.each([ - ['disable_mons', '/pokemon'], - ['disable_raids', '/raids'], - ['disable_quests', '/quests'], - ['disable_invasions', '/invasions'], - ['disable_lures', '/lures'], - ['disable_nests', '/nests'], - ['disable_gyms', '/gyms'], - ['disable_maxbattles', '/max-battles'], - ['disable_fort_changes', '/fort-changes'], - ])('hides %s route from ADMIN nav too (no admin bypass)', (key, route) => { - // The original #236 bug was a UI/API mismatch — leaving the nav visible to admins - // while the API rejects them recreates the same defect class in miniature. + it.each(ALARM_KEYS)('hides the %s nav item from admins too (no admin bypass)', (key, route) => { const app = setup({ [key]: 'true' }, true); expect(alarmRoutes(app)).not.toContain(route); }); + it.each(ALARM_KEYS)('keeps the %s nav item while the type is enabled', (key, route) => { + const app = setup({}, false); + expect(alarmRoutes(app)).toContain(route); + }); + + it('still hides a disabled SETTINGS item from admins (no admin bypass)', () => { + // The original #236 bug was a UI/API mismatch — leaving the nav visible to admins while the API + // rejects them recreates the same defect class. Settings items are hidden outright, so this is + // where that guarantee still lives. + const app = setup({ disable_areas: 'true' }, true); + expect(settingsRoutes(app)).not.toContain('/areas'); + }); + it('hides /profiles when disable_profiles is true (settings group)', () => { const app = setup({ disable_profiles: 'true' }, false); expect(settingsRoutes(app)).not.toContain('/profiles'); @@ -132,13 +145,109 @@ describe('App nav filtering (#236)', () => { }); it('treats setting value "True" (capitalized) as disabled', () => { - // Matches SettingsService.isDisabled — case-insensitive check. - const app = setup({ disable_mons: 'True' }, false); - expect(alarmRoutes(app)).not.toContain('/pokemon'); + // Matches SettingsService.isDisabled — case-insensitive check. Asserted on a settings item, + // since those are the ones still hidden outright. + const app = setup({ disable_areas: 'True' }, false); + expect(settingsRoutes(app)).not.toContain('/areas'); }); it('treats setting value "false" as enabled', () => { - const app = setup({ disable_mons: 'false' }, false); - expect(alarmRoutes(app)).toContain('/pokemon'); + const app = setup({ disable_areas: 'false' }, false); + expect(settingsRoutes(app)).toContain('/areas'); + }); +}); + +/** + * The bootstrap path, which #426 already broke once: a signed-out visitor must reach only the + * anonymous settings endpoint. `/api/config` is `[Authorize]`, so sourcing Poracle's locale from + * there would 401 on every visit to the login page. The locale rides on the public settings call + * instead, and these tests pin that it does. + */ +describe('App bootstrap language defaults (#770)', () => { + const setup = (opts: { authenticated: boolean; settings: Record }) => { + const loadOnce = jest.fn(() => of([])); + const loadPublic = jest.fn(() => of([])); + const init = jest.fn(); + const alertLanguage = { languages: [], load: jest.fn(), selected: signal('en') }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideNoopAnimations(), + provideRouter([]), + provideTranslateService(), + { + provide: SettingsService, + useValue: { + isDisabled: () => false, + loadOnce, + loadPublic, + siteSettings: signal(opts.settings), + }, + }, + { + provide: AuthService, + useValue: { + getProviders: () => of({}), + hasManagedWebhooks: () => false, + isAdmin: () => false, + isAuthenticated: () => opts.authenticated, + loadCurrentUser: jest.fn(), + logout: jest.fn(), + stopImpersonating: jest.fn(), + toggleAlerts: () => of(null), + }, + }, + { provide: AlertLanguageService, useValue: alertLanguage }, + { provide: DashboardService, useValue: { getCounts: () => of({}) } }, + { provide: I18nService, useValue: { init } }, + ], + }); + + const app = TestBed.runInInjectionContext(() => new App()); + app.ngOnInit(); + return { alertLanguage, app, init, loadOnce, loadPublic }; + }; + + it('uses only the anonymous settings endpoint when signed out', () => { + const { loadOnce, loadPublic } = setup({ authenticated: false, settings: {} }); + + expect(loadPublic).toHaveBeenCalled(); + expect(loadOnce).not.toHaveBeenCalled(); + }); + + it('forwards the Poracle locale from the anonymous response to i18n', () => { + const { init } = setup({ authenticated: false, settings: { allowed_languages: 'en,de', poracle_locale: 'de' } }); + + expect(init).toHaveBeenLastCalledWith('en,de', 'de'); + }); + + it('still uses the authenticated endpoint when signed in', () => { + const { loadOnce, loadPublic } = setup({ authenticated: true, settings: { poracle_locale: 'de' } }); + + expect(loadOnce).toHaveBeenCalled(); + expect(loadPublic).not.toHaveBeenCalled(); + }); + + it('passes undefined rather than failing when Poracle reports no locale', () => { + const { init } = setup({ authenticated: false, settings: {} }); + + expect(init).toHaveBeenLastCalledWith(undefined, undefined); + }); + + it('does not reconcile the alert language while signed out (#775)', () => { + // GET /api/location/language is [Authorize]. Calling it here guaranteed a 401 on every login-page + // visit; LocationService swallowing the error is what kept it invisible. + const { alertLanguage } = setup({ authenticated: false, settings: {} }); + + expect(alertLanguage.load).not.toHaveBeenCalled(); + }); + + it('reconciles the alert language when signed in', () => { + const { alertLanguage } = setup({ authenticated: true, settings: {} }); + + expect(alertLanguage.load).toHaveBeenCalled(); }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts index 1f1b5e92..5ef157d3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts @@ -2,22 +2,25 @@ import { Component, inject, signal, computed, effect, HostListener, OnInit } from '@angular/core'; import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatIconModule } from '@angular/material/icon'; import { MatListModule } from '@angular/material/list'; import { MatMenuModule } from '@angular/material/menu'; import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSnackBar } from '@angular/material/snack-bar'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { DashboardCounts } from './core/models'; +import { AlertLanguageService } from './core/services/alert-language.service'; import { AuthService } from './core/services/auth.service'; import { DashboardService } from './core/services/dashboard.service'; import { I18nService } from './core/services/i18n.service'; import { SettingsService } from './core/services/settings.service'; -import { LanguageSelectorComponent } from './shared/components/language-selector/language-selector.component'; +import { AlertDefaultsDialogComponent } from './shared/components/alert-defaults-dialog/alert-defaults-dialog.component'; interface NavItem { adminOnly?: boolean; @@ -46,8 +49,7 @@ interface NavItem { MatDividerModule, MatBadgeModule, MatTooltipModule, - TranslateModule, - LanguageSelectorComponent, + TranslatePipe, ], selector: 'app-root', styleUrl: './app.scss', @@ -63,6 +65,7 @@ export class App implements OnInit { }; private readonly dashboardService = inject(DashboardService); + private readonly dialog = inject(MatDialog); private readonly settingsService = inject(SettingsService); protected readonly faviconUrl = computed(() => this.settingsService.siteSettings()['favicon_url'] || 'favicon.ico'); @@ -81,6 +84,8 @@ export class App implements OnInit { } }); + private readonly snackBar = inject(MatSnackBar); + protected readonly siteTitle = computed(() => this.settingsService.siteSettings()['custom_title'] || 'DM Alerts'); private readonly titleEffect = effect(() => { @@ -88,7 +93,6 @@ export class App implements OnInit { }); protected readonly accentTheme = signal(localStorage.getItem('poracle-accent') || ''); - protected readonly auth = inject(AuthService); protected readonly navItems: NavItem[] = [ @@ -184,7 +188,14 @@ export class App implements OnInit { route: '/profiles', }, { disableKey: 'disable_areas', group: 'settings', icon: 'map', iconColor: '#ff9800', label: 'NAV.AREAS', route: '/areas' }, - { group: 'settings', icon: 'draw', iconColor: '#2196f3', label: 'NAV.MY_GEOFENCES', route: '/geofences' }, + { + disableKey: 'disable_user_geofences', + group: 'settings', + icon: 'draw', + iconColor: '#2196f3', + label: 'NAV.MY_GEOFENCES', + route: '/geofences', + }, { group: 'settings', icon: 'cleaning_services', iconColor: '#795548', label: 'NAV.CLEANING', route: '/cleaning' }, { group: 'support', icon: 'help', iconColor: '#673ab7', label: 'NAV.HELP', route: '/help' }, { adminOnly: true, group: 'admin', icon: 'people', iconColor: '#455a64', label: 'NAV.USERS', route: '/admin/users' }, @@ -192,6 +203,7 @@ export class App implements OnInit { { adminOnly: true, group: 'admin', icon: 'settings', iconColor: '#546e7a', label: 'NAV.SETTINGS', route: '/admin/settings' }, { adminOnly: true, + disableKey: 'disable_user_geofences', group: 'admin', icon: 'rate_review', iconColor: '#ff9800', @@ -202,15 +214,29 @@ export class App implements OnInit { ]; protected readonly adminNavItems = computed(() => - this.navItems.filter(item => item.group === 'admin' && (!item.adminOnly || this.auth.isAdmin())), + this.navItems.filter( + item => item.group === 'admin' && (!item.adminOnly || this.auth.isAdmin()) && !this.isFeatureDisabled(item.disableKey), + ), ); + /** + * A disabled alarm type leaves the nav. Its page is still reachable and still lists and deletes the + * rules a user already has — #784 — but keeping a padlocked item for everyone showed an empty page + * one click away to every user of an instance that never enabled that type. + * + * The way back in is the dashboard, which renders a card per type with its count and already holds + * the numbers this computed does not: the nav is drawn at bootstrap, where counts have not loaded. + * That card is shown for a disabled type only while it still has alarms, so nothing is stranded and + * nothing empty is advertised. See #792. + */ protected readonly alarmNavItems = computed(() => this.navItems.filter( item => item.group === 'alarms' && (!item.adminOnly || this.auth.isAdmin()) && !this.isFeatureDisabled(item.disableKey), ), ); + readonly alertLanguage = inject(AlertLanguageService); + protected readonly counts = signal(null); protected readonly customNavLink = computed(() => { @@ -227,7 +253,6 @@ export class App implements OnInit { protected readonly darkMode = signal(localStorage.getItem('poracle-theme') === 'dark'); protected readonly headerLogoUrl = computed(() => this.settingsService.siteSettings()['header_logo_url'] || ''); - protected readonly hideHeaderLogo = computed(() => this.settingsService.isDisabled('hide_header_logo')); protected readonly i18n = inject(I18nService); @@ -246,6 +271,9 @@ export class App implements OnInit { protected readonly sidenavOpened = signal(!this.isMobile()); + /** Whether the active SSO provider supports single logout ("Sign out everywhere"). */ + protected readonly ssoLogoutAvailable = signal(false); + protected readonly supportNavItems = computed(() => this.navItems.filter(item => item.group === 'support')); protected readonly toolbarGradient = computed(() => { @@ -268,11 +296,27 @@ export class App implements OnInit { this.i18n.init(); } + /** Sets the language Poracle writes alerts in, and says so either way. */ + async chooseAlertLanguage(code: string): Promise { + const ok = await this.alertLanguage.choose(code); + this.snackBar.open( + this.i18n.instant(ok ? 'AREAS.SNACK_LANGUAGE_UPDATED' : 'AREAS.SNACK_LANGUAGE_FAILED'), + this.i18n.instant('TOAST.OK'), + { duration: 3000 }, + ); + } + getCount(item: NavItem): number { if (!item.countKey || !this.counts()) return 0; return this.counts()![item.countKey] ?? 0; } + /** Also read from the template, to mark a disabled alarm type in the nav. */ + protected isFeatureDisabled(key?: string): boolean { + if (!key) return false; + return this.settingsService.isDisabled(key); + } + loadCounts(): void { this.dashboardService.getCounts().subscribe({ error: () => {}, // silently fail for badge counts @@ -280,24 +324,52 @@ export class App implements OnInit { }); } - logout(): void { - this.auth.logout(); + /** @param sso when true, also ends the external provider session (single logout). */ + logout(sso = false): void { + this.auth.logout({ sso }); } ngOnInit(): void { - this.settingsService.loadOnce().subscribe({ + // Only a signed-in user has an alert language to reconcile. GET /api/location/language is + // [Authorize], so calling this unconditionally fired a guaranteed 401 on every visit to the login + // page -- invisible only because LocationService swallows the error. Same shape as #426. The call + // is re-made from AuthService.handleTokenFromCallback once a token exists, so a login inside this + // same page session still reconciles. See #775. + if (this.auth.isAuthenticated()) this.alertLanguage.load(); + // Signed-out visitors get the public subset. loadOnce() hits the authenticated endpoint, so + // calling it on the login page produced a 401 and an uncaught HttpErrorResponse on every visit. + // AuthService re-runs loadOnce() once a token exists, so nothing is lost by deferring. See #426. + const settings$ = this.auth.isAuthenticated() ? this.settingsService.loadOnce() : this.settingsService.loadPublic(); + settings$.subscribe({ + error: () => this.i18n.init(undefined), next: () => { - const allowed = this.settingsService.siteSettings()['allowed_languages']; - this.i18n.init(allowed); + // Both endpoints carry allowed_languages and Poracle's own locale, so the display language can + // settle on the server's locale here rather than sitting on the hardcoded en. /api/config would + // have been the obvious source for the locale and is [Authorize] -- see #426. + const settings = this.settingsService.siteSettings(); + this.i18n.init(settings['allowed_languages'], settings['poracle_locale']); }, }); + + // Determine whether the active SSO provider supports single logout (end-session), + // which gates the "Sign out everywhere" menu item. + this.auth.getProviders().subscribe({ + next: providers => this.ssoLogoutAvailable.set(providers.oidc?.endSession === true), + }); } @HostListener('document:keydown', ['$event']) onKeydown(event: KeyboardEvent): void { const tag = (event.target as HTMLElement)?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; - if (document.querySelector('.cdk-overlay-pane')) { + // Tooltips and snackbars are also .cdk-overlay-pane, so keying off that alone meant hovering a + // toolbar button silently swallowed [, ] and ?. Only overlays that take focus - dialogs, menus, + // selects, autocompletes - should suppress shortcuts. + if ( + document.querySelector( + '.cdk-overlay-pane .mat-mdc-dialog-container, .cdk-overlay-pane .mat-mdc-menu-panel, .cdk-overlay-pane .mat-mdc-select-panel, .cdk-overlay-pane .mat-mdc-autocomplete-panel', + ) + ) { if (event.key === 'Escape') { this.showShortcutHelp.set(false); } @@ -345,6 +417,10 @@ export class App implements OnInit { } } + openAlertDefaults(): void { + this.dialog.open(AlertDefaultsDialogComponent, { width: '480px', autoFocus: false }); + } + setAccentTheme(theme: string): void { this.accentTheme.set(theme); localStorage.setItem('poracle-accent', theme); @@ -401,9 +477,4 @@ export class App implements OnInit { document.body.classList.toggle('light-theme', !this.darkMode()); localStorage.setItem('poracle-theme', scheme); } - - private isFeatureDisabled(key?: string): boolean { - if (!key) return false; - return this.settingsService.isDisabled(key); - } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/locale-parity.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/locale-parity.spec.ts new file mode 100644 index 00000000..0d37274f --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/locale-parity.spec.ts @@ -0,0 +1,60 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +/** + * Every locale had drifted to exactly 12 keys behind English, which the English fallback hid — the + * affected strings simply rendered in English inside otherwise-translated pages, so nothing looked + * broken. This fails the build the next time that happens. See #425. + */ +describe('locale parity', () => { + const dir = path.join(__dirname, '../../../assets/i18n'); + + const flatten = (value: unknown, prefix = ''): Record => { + const out: Record = {}; + for (const [key, child] of Object.entries(value as Record)) { + const dotted = prefix ? `${prefix}.${key}` : key; + if (child !== null && typeof child === 'object') { + Object.assign(out, flatten(child, dotted)); + } else { + out[dotted] = String(child); + } + } + return out; + }; + + const load = (locale: string) => flatten(JSON.parse(fs.readFileSync(path.join(dir, `${locale}.json`), 'utf8'))); + + const english = load('en'); + const locales = fs + .readdirSync(dir) + .filter(f => f.endsWith('.json')) + .map(f => f.replace('.json', '')) + .filter(l => l !== 'en'); + + it('ships more than one locale', () => { + expect(locales.length).toBeGreaterThan(0); + }); + + it.each(locales)('%s has exactly the same keys as en', locale => { + const keys = load(locale); + expect(Object.keys(keys).filter(k => !(k in english))).toEqual([]); + expect(Object.keys(english).filter(k => !(k in keys))).toEqual([]); + }); + + it.each(locales)('%s translates the error toasts the interceptor uses', locale => { + // These are the strings a user sees when something fails, so English here is the most visible + // kind of gap. HTTP_ERROR.* is the single namespace both the interceptor and ToastService use. + const keys = load(locale); + const untranslated = Object.keys(english) + .filter(k => k.startsWith('HTTP_ERROR.') || k === 'ERROR.FEATURE_DISABLED') + .filter(k => keys[k] === english[k]); + + expect(untranslated).toEqual([]); + }); + + it.each(locales)('%s translates the paginator', locale => { + const keys = load(locale); + // RANGE strings are mostly interpolation tokens, so only the prose label is checked. + expect(keys['PAGINATOR.ITEMS_PER_PAGE']).not.toBe(english['PAGINATOR.ITEMS_PER_PAGE']); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/translated-paginator-intl.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/translated-paginator-intl.spec.ts new file mode 100644 index 00000000..6488412a --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/translated-paginator-intl.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslateService, provideTranslateService } from '@ngx-translate/core'; + +import { TranslatedPaginatorIntl } from './translated-paginator-intl'; + +/** + * Without a provided MatPaginatorIntl the paginator keeps Material's built-in English, so the admin + * tables stayed partly English in every other locale. See #425. + */ +describe('TranslatedPaginatorIntl', () => { + let intl: TranslatedPaginatorIntl; + let translate: TranslateService; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService(), TranslatedPaginatorIntl], + }); + translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + PAGINATOR: { + FIRST_PAGE: 'First page', + ITEMS_PER_PAGE: 'Items per page:', + LAST_PAGE: 'Last page', + NEXT_PAGE: 'Next page', + PREVIOUS_PAGE: 'Previous page', + RANGE: '{{start}} - {{end}} of {{total}}', + RANGE_EMPTY: '0 of {{total}}', + }, + }); + translate.setTranslation('de', { + PAGINATOR: { + FIRST_PAGE: 'Erste Seite', + ITEMS_PER_PAGE: 'Einträge pro Seite:', + LAST_PAGE: 'Letzte Seite', + NEXT_PAGE: 'Nächste Seite', + PREVIOUS_PAGE: 'Vorherige Seite', + RANGE: '{{start}} - {{end}} von {{total}}', + RANGE_EMPTY: '0 von {{total}}', + }, + }); + translate.use('en'); + intl = TestBed.inject(TranslatedPaginatorIntl); + }); + + it('translates the labels', () => { + expect(intl.itemsPerPageLabel).toBe('Items per page:'); + expect(intl.nextPageLabel).toBe('Next page'); + expect(intl.firstPageLabel).toBe('First page'); + }); + + it('builds a 1-based inclusive range', () => { + expect(intl.getRangeLabel(0, 25, 120)).toBe('1 - 25 of 120'); + expect(intl.getRangeLabel(1, 25, 120)).toBe('26 - 50 of 120'); + }); + + it('clamps the final page to the total rather than overshooting', () => { + expect(intl.getRangeLabel(4, 25, 110)).toBe('101 - 110 of 110'); + }); + + it('handles an empty table', () => { + expect(intl.getRangeLabel(0, 25, 0)).toBe('0 of 0'); + }); + + it('re-reads its labels when the language changes mid-session', () => { + translate.use('de'); + + expect(intl.itemsPerPageLabel).toBe('Einträge pro Seite:'); + expect(intl.getRangeLabel(0, 25, 120)).toBe('1 - 25 von 120'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/translated-paginator-intl.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/translated-paginator-intl.ts new file mode 100644 index 00000000..06a5a383 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/i18n/translated-paginator-intl.ts @@ -0,0 +1,53 @@ +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { MatPaginatorIntl } from '@angular/material/paginator'; +import { TranslateService } from '@ngx-translate/core'; +import { Subscription } from 'rxjs'; + +/** + * Localises the Material paginator. + * + * Without a provided `MatPaginatorIntl` the paginator keeps Material's built-in English — "Items per + * page:", "1 - 25 of 120", and the English aria-labels on the navigation buttons — so the admin tables + * stayed partly English in every other locale. See #425. + * + * Re-reads its labels whenever the active language changes, because the user can switch language + * without a reload. + */ +@Injectable() +export class TranslatedPaginatorIntl extends MatPaginatorIntl implements OnDestroy { + private readonly subscription: Subscription; + private readonly translate = inject(TranslateService); + + override getRangeLabel = (page: number, pageSize: number, length: number): string => { + const total = Math.max(length, 0); + + if (total === 0 || pageSize === 0) { + return this.translate.instant('PAGINATOR.RANGE_EMPTY', { total }); + } + + const start = page * pageSize; + // The last page is usually short, and a rounding slip here is visible on every table. + const end = start < total ? Math.min(start + pageSize, total) : start + pageSize; + + return this.translate.instant('PAGINATOR.RANGE', { end, start: start + 1, total }); + }; + + constructor() { + super(); + this.subscription = this.translate.onLangChange.subscribe(() => this.applyTranslations()); + this.applyTranslations(); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + private applyTranslations(): void { + this.itemsPerPageLabel = this.translate.instant('PAGINATOR.ITEMS_PER_PAGE'); + this.nextPageLabel = this.translate.instant('PAGINATOR.NEXT_PAGE'); + this.previousPageLabel = this.translate.instant('PAGINATOR.PREVIOUS_PAGE'); + this.firstPageLabel = this.translate.instant('PAGINATOR.FIRST_PAGE'); + this.lastPageLabel = this.translate.instant('PAGINATOR.LAST_PAGE'); + this.changes.next(); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.spec.ts index f2bb5bf2..7ae5f118 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.spec.ts @@ -46,7 +46,58 @@ describe('errorInterceptor', () => { expect(localStorage.getItem('poracle_token')).toBeNull(); expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: {} }); - expect(toast.error).toHaveBeenCalledWith('ERROR.SESSION_EXPIRED'); + expect(toast.error).toHaveBeenCalledWith('HTTP_ERROR.UNAUTHORIZED'); + }); + + it('clears the whole session on a 401, not just the access token', () => { + // The refresh token and its expiry used to survive the app deciding the session was invalid, so the + // next load tried to refresh a session the server had already rejected. See #616. + localStorage.setItem('poracle_token', 'expired-token'); + localStorage.setItem('poracle_refresh_token', 'refresh-token'); + localStorage.setItem('poracle_token_expires_at', '1'); + + http.get('/api/dashboard').subscribe({ error: () => {} }); + httpMock.expectOne('/api/dashboard').flush(null, { status: 401, statusText: 'Unauthorized' }); + + expect(localStorage.getItem('poracle_token')).toBeNull(); + expect(localStorage.getItem('poracle_refresh_token')).toBeNull(); + expect(localStorage.getItem('poracle_token_expires_at')).toBeNull(); + }); + + it('ends the inspection, not the session, when a 401 arrives while impersonating', () => { + // Inspecting a blocked or deleted account 401s /api/auth/me, and clearing the session took the + // stashed admin token with it -- signing the admin out of their own session with nothing to go back + // to. The 401 belongs to the account being inspected, not the admin holding the session. See #706. + localStorage.setItem('poracle_token', 'impersonation-token'); + localStorage.setItem('poracle_admin_token', 'admin-token'); + + http.get('/api/dashboard').subscribe({ error: () => {} }); + httpMock.expectOne('/api/dashboard').flush(null, { status: 401, statusText: 'Unauthorized' }); + + expect(localStorage.getItem('poracle_token')).toBe('admin-token'); + expect(localStorage.getItem('poracle_admin_token')).toBeNull(); + expect(router.navigate).toHaveBeenCalledWith(['/admin']); + expect(toast.error).toHaveBeenCalledWith('HTTP_ERROR.INSPECTION_ENDED'); + expect(toast.error).not.toHaveBeenCalledWith('HTTP_ERROR.UNAUTHORIZED'); + }); + + it('falls back only once, so a dead admin token still ends the session', () => { + // The fallback consumes the stash, so the #616 guarantee still lands: the second 401 finds nothing + // to restore and clears everything. Without that it would loop, or strand a session that cannot work. + localStorage.setItem('poracle_token', 'impersonation-token'); + localStorage.setItem('poracle_admin_token', 'expired-admin-token'); + localStorage.setItem('poracle_refresh_token', 'refresh-token'); + + http.get('/api/dashboard').subscribe({ error: () => {} }); + httpMock.expectOne('/api/dashboard').flush(null, { status: 401, statusText: 'Unauthorized' }); + + http.get('/api/dashboard').subscribe({ error: () => {} }); + httpMock.expectOne('/api/dashboard').flush(null, { status: 401, statusText: 'Unauthorized' }); + + expect(localStorage.getItem('poracle_token')).toBeNull(); + expect(localStorage.getItem('poracle_admin_token')).toBeNull(); + expect(localStorage.getItem('poracle_refresh_token')).toBeNull(); + expect(router.navigate).toHaveBeenLastCalledWith(['/login'], { queryParams: {} }); }); it('should show permission toast for 403 without disableKey', () => { @@ -57,14 +108,15 @@ describe('errorInterceptor', () => { statusText: 'Forbidden', }); - expect(toast.error).toHaveBeenCalledWith('ERROR.PERMISSION_DENIED'); + expect(toast.error).toHaveBeenCalledWith('HTTP_ERROR.FORBIDDEN'); expect(router.navigate).not.toHaveBeenCalled(); }); - it('should show feature-disabled toast and redirect to /dashboard for 403 with disableKey', () => { - // Backend tags "feature disabled" 403s by including disableKey in the body so the - // SPA can distinguish them from generic permission denials and bounce the user off - // the now-broken page. (#236) + it('should show the feature-disabled toast, without moving the user, for 403 with disableKey', () => { + // Backend tags "feature disabled" 403s by including disableKey in the body so the SPA can tell them + // from generic permission denials. It must NOT redirect: these mostly come from shared components + // asking for something incidental, and the redirect moved the route out from under open dialogs and + // off pages whose own feature was enabled. disabledFeatureGuard owns navigation. See #515, #516. http.get('/api/monsters').subscribe({ error: () => {} }); httpMock @@ -75,7 +127,7 @@ describe('errorInterceptor', () => { ); expect(toast.error).toHaveBeenCalledWith('ERROR.FEATURE_DISABLED'); - expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); + expect(router.navigate).not.toHaveBeenCalled(); }); it('should show not found toast for 404', () => { @@ -86,7 +138,7 @@ describe('errorInterceptor', () => { statusText: 'Not Found', }); - expect(toast.error).toHaveBeenCalledWith('ERROR.NOT_FOUND'); + expect(toast.error).toHaveBeenCalledWith('HTTP_ERROR.NOT_FOUND'); }); it('should show server error toast for 500', () => { @@ -97,7 +149,7 @@ describe('errorInterceptor', () => { statusText: 'Error', }); - expect(toast.error).toHaveBeenCalledWith('ERROR.GENERIC'); + expect(toast.error).toHaveBeenCalledWith('HTTP_ERROR.SERVER_ERROR'); }); it('should show unavailable toast for 502/503/504', () => { @@ -110,7 +162,7 @@ describe('errorInterceptor', () => { statusText: 'Unavailable', }); - expect(toast.error).toHaveBeenCalledWith('ERROR.SERVER_UNAVAILABLE'); + expect(toast.error).toHaveBeenCalledWith('HTTP_ERROR.UNAVAILABLE'); } }); @@ -119,7 +171,7 @@ describe('errorInterceptor', () => { httpMock.expectOne('/api/dashboard').error(new ProgressEvent('error'), { status: 0, statusText: 'Unknown Error' }); - expect(toast.error).toHaveBeenCalledWith('ERROR.NETWORK'); + expect(toast.error).toHaveBeenCalledWith('HTTP_ERROR.NETWORK'); }); describe('silent endpoints', () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.ts index 36875d77..c6c724ef 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/error.interceptor.ts @@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core'; import { catchError, throwError } from 'rxjs'; import { ToastService } from '../services/toast.service'; +import { TokenStoreService } from '../services/token-store.service'; /** Endpoints where errors should be silently swallowed (no user-facing toast). */ const SILENT_URL_PATTERNS = [ @@ -29,6 +30,7 @@ function isAuthCallbackRoute(): boolean { export const errorInterceptor: HttpInterceptorFn = (req, next) => { const toast = inject(ToastService); + const tokenStore = inject(TokenStoreService); const router = inject(Router); const translate = inject(TranslateService); @@ -38,42 +40,67 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { // On 401, clear token and redirect — but NOT during OAuth callback flow or login page if (error.status === 401 && !isAuthCallbackRoute()) { - localStorage.removeItem('poracle_token'); + // A 401 while inspecting another account is that account's problem, not the admin's, so end the + // inspection rather than the session. Without this, inspecting a blocked or deleted user hit the + // clearAll() below and signed the admin out with nothing to return to. See #706. + // Toasted even for the silenced endpoints: unlike a background poll, this one explains a + // navigation the admin can see happen. + if (tokenStore.tryRestoreAdminSession()) { + toast.error(translate.instant('HTTP_ERROR.INSPECTION_ENDED')); + router.navigate(['/admin']); + return throwError(() => error); + } + + // The whole session, not just the access token. Three keys used to survive the app deciding the + // session was invalid: poracle_admin_token -- the higher-privilege credential an impersonating + // admin leaves behind, which stopImpersonating() would then install as the active token -- plus + // the refresh token and its expiry, so the next load tried to refresh a session the server had + // already rejected. Navigation is deliberately left alone: routing through AuthService.logout() + // would append loggedout=1 and suppress the OIDC auto-redirect. See #616. + tokenStore.clearAll(); // Preserve any existing query params (e.g. ?error=missing_required_role) const params = new URLSearchParams(window.location.search); router.navigate(['/login'], { queryParams: Object.fromEntries(params) }); } + // Messages come from HTTP_ERROR.*, which ToastService already uses and which is translated in + // every locale. This interceptor used a parallel ERROR.* table carrying verbatim English in all + // ten locales, so a German user saw an English toast for the same status. See #425. // Don't show toasts for silent endpoints if (!silent) { switch (error.status) { case 401: - toast.error(translate.instant('ERROR.SESSION_EXPIRED')); + toast.error(translate.instant('HTTP_ERROR.UNAUTHORIZED')); break; case 403: // The backend tags "feature disabled" 403s by including a `disableKey` in the body // (RequireFeatureEnabledAttribute, FeatureDisabledExceptionFilter, TestAlertController). - // Toast a clearer message and redirect off the now-broken page so the user lands somewhere usable. (#236) + // + // It used to redirect to /dashboard as well, on the assumption that such a 403 meant the page + // itself was dead. Most of them mean nothing of the sort: they come from shared components + // asking for something incidental -- the delivery preview inside every add-alarm dialog, a map + // overlay -- and the redirect then moved the route out from under an open dialog, or bounced + // the user off a page whose own feature was perfectly enabled. Navigation belongs to + // disabledFeatureGuard, which knows which feature the route is for. See #515, #516. if (error.error?.disableKey) { toast.error(translate.instant('ERROR.FEATURE_DISABLED')); - router.navigate(['/dashboard']); } else { - toast.error(translate.instant('ERROR.PERMISSION_DENIED')); + toast.error(translate.instant('HTTP_ERROR.FORBIDDEN')); } break; case 404: - toast.error(translate.instant('ERROR.NOT_FOUND')); + toast.error(translate.instant('HTTP_ERROR.NOT_FOUND')); break; case 0: - toast.error(translate.instant('ERROR.NETWORK')); + toast.error(translate.instant('HTTP_ERROR.NETWORK')); break; case 500: - toast.error(translate.instant('ERROR.GENERIC')); + toast.error(translate.instant('HTTP_ERROR.SERVER_ERROR')); break; case 502: case 503: case 504: - toast.error(translate.instant('ERROR.SERVER_UNAVAILABLE')); + toast.error(translate.instant('HTTP_ERROR.UNAVAILABLE')); break; } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.spec.ts new file mode 100644 index 00000000..c6014a30 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.spec.ts @@ -0,0 +1,111 @@ +import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { oidcRefreshInterceptor } from './oidc-refresh.interceptor'; +import { ConfigService } from '../services/config.service'; + +describe('oidcRefreshInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + const REFRESH_URL = '/api/auth/oidc/refresh'; + + beforeEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([oidcRefreshInterceptor])), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: '' } }, + ], + }); + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('passes through when there is no refresh token', () => { + http.get('/api/areas').subscribe(); + const req = httpMock.expectOne('/api/areas'); + req.flush({}); + }); + + it('passes through the refresh endpoint itself (no recursion)', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + http.post(REFRESH_URL, { refreshToken: 'rt-1' }).subscribe(); + const req = httpMock.expectOne(REFRESH_URL); + req.flush({ expiresIn: 1800, refreshToken: 'rt-2', token: 't' }); + }); + + it('refreshes and retries once on a 401', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + // Not expiring soon, so the proactive path is skipped. + localStorage.setItem('poracle_token_expires_at', String(Date.now() + 600_000)); + + let result: unknown; + http.get('/api/areas').subscribe(r => (result = r)); + + // Original request 401s. + httpMock.expectOne('/api/areas').flush({}, { status: 401, statusText: 'Unauthorized' }); + + // Interceptor refreshes... + httpMock.expectOne(REFRESH_URL).flush({ expiresIn: 1800, refreshToken: 'rt-2', token: 'new-jwt' }); + + // ...then retries the original with the fresh bearer. + const retry = httpMock.expectOne('/api/areas'); + expect(retry.request.headers.get('Authorization')).toBe('Bearer new-jwt'); + retry.flush({ ok: true }); + + expect(result).toEqual({ ok: true }); + }); + + it('proactively refreshes before sending when the token is expiring soon', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + localStorage.setItem('poracle_token_expires_at', String(Date.now() + 10_000)); // within skew + + http.get('/api/areas').subscribe(); + + // Refresh fires first. + httpMock.expectOne(REFRESH_URL).flush({ expiresIn: 1800, refreshToken: 'rt-2', token: 'fresh-jwt' }); + + // Then the original request goes out with the fresh bearer. + const req = httpMock.expectOne('/api/areas'); + expect(req.request.headers.get('Authorization')).toBe('Bearer fresh-jwt'); + req.flush({}); + }); + + it('collapses two concurrent 401s into a single refresh', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + localStorage.setItem('poracle_token_expires_at', String(Date.now() + 600_000)); + + http.get('/api/a').subscribe(); + http.get('/api/b').subscribe(); + + httpMock.expectOne('/api/a').flush({}, { status: 401, statusText: 'Unauthorized' }); + httpMock.expectOne('/api/b').flush({}, { status: 401, statusText: 'Unauthorized' }); + + // Only ONE refresh despite two 401s. + httpMock.expectOne(REFRESH_URL).flush({ expiresIn: 1800, refreshToken: 'rt-2', token: 'new-jwt' }); + + httpMock.expectOne('/api/a').flush({}); + httpMock.expectOne('/api/b').flush({}); + }); + + it('does not loop when the refresh itself 401s', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + localStorage.setItem('poracle_token_expires_at', String(Date.now() + 600_000)); + + let errored = false; + http.get('/api/areas').subscribe({ error: () => (errored = true) }); + + httpMock.expectOne('/api/areas').flush({}, { status: 401, statusText: 'Unauthorized' }); + httpMock.expectOne(REFRESH_URL).flush({ error: 'invalid_grant' }, { status: 401, statusText: 'Unauthorized' }); + + expect(errored).toBe(true); + // No second retry of the original request. + httpMock.expectNone('/api/areas'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.ts new file mode 100644 index 00000000..c9439961 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.ts @@ -0,0 +1,56 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { catchError, switchMap, throwError } from 'rxjs'; + +import { TokenStoreService } from '../services/token-store.service'; + +/** + * Endpoints that must never trigger a refresh — the refresh/login calls themselves (recursion + * guard) and the anonymous providers probe. + */ +const SKIP_PATHS = ['/api/auth/oidc/refresh', '/api/auth/oidc/login', '/api/auth/providers']; + +/** + * Silent OIDC session renewal. Registered closest to the backend so it sees a 401 before the error + * interceptor redirects. Two paths: + * - Proactive: when the JWT is about to expire, refresh first, then send with the fresh bearer. + * - Reactive: on a 401, refresh once and retry the request. + * No-ops entirely when there's no refresh token (Discord/Telegram/local logins, or OIDC without + * refresh) — those keep the existing "401 → logout" behavior via the error interceptor. + */ +export const oidcRefreshInterceptor: HttpInterceptorFn = (req, next) => { + const store = inject(TokenStoreService); + + const isApi = req.url.includes('/api/'); + const skip = SKIP_PATHS.some(path => req.url.includes(path)); + + if (!isApi || skip || !store.hasRefreshToken()) { + return next(req); + } + + const withBearer = (token: string) => req.clone({ setHeaders: { Authorization: `Bearer ${token}` } }); + + // Proactive refresh — token is within the expiry skew window. + if (store.isExpiringSoon()) { + return store.refresh().pipe( + switchMap(token => next(withBearer(token))), + // If the proactive refresh fails, still let the request go; a 401 will surface and the + // failed refresh has already emitted forceLogout$. + catchError(() => next(req)), + ); + } + + // Reactive refresh — retry once after a 401. + return next(req).pipe( + catchError(err => { + if (err.status === 401 && store.hasRefreshToken()) { + return store.refresh().pipe( + switchMap(token => next(withBearer(token))), + catchError(refreshErr => throwError(() => refreshErr)), + ); + } + + return throwError(() => err); + }), + ); +}; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts index 62ac71e8..47ed0bc2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts @@ -23,11 +23,17 @@ export interface Monster { minCp: number; minIv: number; minLevel: number; + /** Seconds a spawn must still have left when it is found. 0 means any. */ + minTime: number; minWeight: number; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; pokemonId: number; profileNo: number; pvpRankingBest: number; + pvpRankingCap: number; + pvpRankingEvolution: number; pvpRankingLeague: number; pvpRankingMinCp: number; pvpRankingWorst: number; @@ -53,7 +59,9 @@ export interface Raid { id: string; level: number; move: number; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; pokemonId: number; profileNo: number; rsvpChanges: number; @@ -77,7 +85,9 @@ export interface MaxBattle { id: string; level: number; move: number; - ping: string; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string; pokemonId: number; profileNo: number; stationId: string | null; @@ -98,7 +108,9 @@ export interface Egg { gymId: string | null; id: string; level: number; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; profileNo: number; rsvpChanges: number; team: number; @@ -113,10 +125,14 @@ export type EggUpdate = Partial; // ─── Quest ───────────────────────────────────────────────────────────────────── export interface Quest { + /** Fewest of the reward the quest must give. Items, candy and mega energy only; 0 means any. */ + amount: number; clean: number; distance: number; id: string; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; pokemonId: number; profileNo: number; reward: number; @@ -138,7 +154,9 @@ export interface Invasion { gender: number; gruntType: string | null; id: string; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; profileNo: number; template: string | null; uid: number; @@ -155,7 +173,9 @@ export interface Lure { distance: number; id: string; lureId: number; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; profileNo: number; template: string | null; uid: number; @@ -172,7 +192,9 @@ export interface Nest { distance: number; id: string; minSpawnAvg: number; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; pokemonId: number; profileNo: number; template: string | null; @@ -187,12 +209,13 @@ export type NestUpdate = Partial; export interface FortChange { changeTypes: string[]; - clean: number; distance: number; fortType: string | null; id: string; includeEmpty: number; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; profileNo: number; template: string | null; uid: number; @@ -210,7 +233,9 @@ export interface Gym { distance: number; gymId: string | null; id: string; - ping: string | null; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; + ping?: string | null; profileNo: number; slotChanges: number; team: number; @@ -247,6 +272,8 @@ export interface AdminUser { language: string | null; lastChecked: string | null; name: string | null; + /** Free-text notes from Poracle; PoracleJS/NG can auto-fill this with the Discord guild + category for channels. */ + notes: string | null; type: string | null; } @@ -315,20 +342,41 @@ export interface TelegramProviderStatus extends AuthProviderStatus { botUsername: string; } +export interface OidcProviderStatus extends AuthProviderStatus { + /** Whether a provider end-session endpoint is configured (enables single logout). */ + endSession?: boolean; + providerName: string; + /** Whether silent refresh is active (server brokers the provider refresh token). */ + refresh?: boolean; +} + export interface AuthProviders { discord: AuthProviderStatus; + // Optional: older API responses (pre-SSO) omit this block; the login page guards for it. + oidc?: OidcProviderStatus; telegram: TelegramProviderStatus; } // ─── Poracle Config ──────────────────────────────────────────────────────────── -export interface PoracleConfig { - areas: AreaDefinition[]; - forms: Record; - grunts: Record; - items: Record; - moves: Record; - pokemon: Record; +/** + * Server-side Poracle config surfaced via GET /api/config (authenticated). + * Mirrors the .NET PublicPoracleConfig projection, which deliberately omits the Poracle admin id + * lists, the webhook delegation map, providerURL and staticKey -- none of which a browser needs. + */ +export interface PoracleServerConfig { + defaultPvpCap: number; + defaultTemplateName: string; + everythingFlagPermissions: string; + locale: string; + maxDistance: number; + poracleVersion: string; + pvpCaps: number[]; + pvpFilterGreatMinCp: number; + pvpFilterLittleMinCp: number; + pvpFilterMaxRank: number; + pvpFilterUltraMinCp: number; + pvpLittleLeagueAllowed: boolean; } export interface AreaDefinition { @@ -424,6 +472,30 @@ export interface DiscordServerConfig { guildId: string; } +// ─── OidcServerConfig ──────────────────────────────────────────────────────── + +export interface OidcServerConfig { + authorizationUrl: string; + /** Masked client id (first/last 4 chars). */ + clientId: string; + /** Masked client secret (last 4 chars only). */ + clientSecret: string; + /** Whether the full provider config (client id + 3 URLs) is present in the server env. */ + configured: boolean; + /** Master OIDC switch from server config (Oidc__Enabled / auto-inferred). */ + enabled: boolean; + /** Optional RP-initiated logout (end-session) endpoint; empty when not configured. */ + endSessionUrl: string; + /** AUTH_FORCE_LOCAL break-glass — when true, OIDC is forced off regardless of mode. */ + forceLocal: boolean; + identityClaim: string; + providerName: string; + scopes: string; + tokenUrl: string; + usePkce: boolean; + userInfoUrl: string; +} + // ─── WebhookDelegate ───────────────────────────────────────────────────────── export interface WebhookDelegate { @@ -527,6 +599,8 @@ export interface QuickPickApplyRequest { clean?: number; distance?: number; excludePokemonIds?: number[]; + overrideAreas?: string[]; + overrideLocationLabel?: string; template?: string; } @@ -608,3 +682,68 @@ export interface ProfileOverviewProfile { name: string; profile_no: number; } + +/** A named coordinate an alarm can be anchored to, instead of the profile pin. */ +export interface SavedPlace { + label: string; + latitude: number; + longitude: number; +} + +/** Everywhere a user's alarms can be anchored: the profile pin, plus whatever they have named. */ +export interface SavedPlaces { + /** The profile pin every alarm falls back to. Absent when the user has never set a location. */ + default?: null | SavedPlace; + named: SavedPlace[]; +} + +/** + * Where an alarm reaches the user. Three answers, and they are mutually exclusive by construction — + * PoracleNG refuses a place with areas, areas with a radius, or a place with no radius, so the UI + * models the choice as one of three rather than as three independent fields. + */ +export type AlarmScopeMode = 'areas' | 'place' | 'profile'; + +export interface AlarmScope { + /** Only for 'areas'. */ + areas?: string[]; + /** Only for 'place', in kilometres, as the dialogs already work in km. */ + distanceKm?: number; + mode: AlarmScopeMode; + /** Only for 'place'. */ + placeLabel?: string; +} + +// ─── PoracleNG server profile ────────────────────────────────────────────────── + +/** + * What PoracleWeb knows about the PoracleNG it is pointed at. Read from that server's `/health` plus + * its applied migration number; used to say when the server is too old for features this build ships. + */ +/** How a running component compares to what has been published. */ +export type UpdateState = 'Behind' | 'PreRelease' | 'Unknown' | 'UpToDate'; + +export interface UpdateStatus { + latest: null | string; + running: null | string; + state: UpdateState; +} + +export interface PoracleServerProfile { + /** True only when the version is known and older than `minimumSupported`. */ + belowMinimum: boolean; + /** PoracleNG's own feature map. A key that is absent means unsupported. */ + capabilities: Record; + checkedAt: string; + minimumSupported: string; + /** Applied migration number, or null when it could not be read. */ + /** Whether the Poracle server is behind its own latest release. */ + poracleUpdate: UpdateStatus; + reachable: boolean; + schemaVersion: null | number; + version: null | string; + /** This site's own build. */ + web: { buildDate: null | string; revision: null | string; version: null | string }; + /** Whether this site is behind its own latest release. */ + webUpdate: UpdateStatus; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts new file mode 100644 index 00000000..6b0bfb3d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts @@ -0,0 +1,114 @@ +import { + ANY_LEVEL, + ANY_LEVEL_VALUE, + EGG_LEVELS, + isKnownLevel, + KNOWN_LEVELS, + makeCustomLevel, + MEGA_LEVELS, + OVERFLOW_RAID_LEVELS, + PRIMARY_RAID_LEVELS, + resolveLevel, + STAR_LEVELS, +} from './raid-level.models'; + +describe('raid-level.models (canonical 19 levels)', () => { + describe('KNOWN_LEVELS', () => { + it('has 19 entries covering integers 1-19 in order', () => { + expect(KNOWN_LEVELS.length).toBe(19); + KNOWN_LEVELS.forEach((opt, i) => expect(opt.value).toBe(i + 1)); + }); + + it('partitions correctly by category', () => { + const byCategory = KNOWN_LEVELS.reduce>((acc, l) => { + (acc[l.category] ||= []).push(l.value); + return acc; + }, {}); + expect(byCategory['star']).toEqual([1, 2, 3, 4, 5]); + expect(byCategory['mega']).toEqual([6, 7]); + expect(byCategory['special']).toEqual([8, 9, 10]); + expect(byCategory['shadow']).toEqual([11, 12, 13, 14, 15]); + expect(byCategory['superMega']).toEqual([16, 17]); + expect(byCategory['coordinated']).toEqual([18, 19]); + }); + + it('points level 7 at Mega Legendary, level 9 at Elite (fixes prior mislabel)', () => { + const seven = KNOWN_LEVELS.find(l => l.value === 7)!; + const nine = KNOWN_LEVELS.find(l => l.value === 9)!; + expect(seven.labelKey).toBe('RAIDS.LEVEL.RAID_7'); + expect(seven.category).toBe('mega'); + expect(nine.labelKey).toBe('RAIDS.LEVEL.RAID_9'); + expect(nine.category).toBe('special'); + }); + + it('every entry uses the RAID_N label key', () => { + KNOWN_LEVELS.forEach(opt => { + expect(opt.labelKey).toBe(`RAIDS.LEVEL.RAID_${opt.value}`); + }); + }); + }); + + describe('derived groupings', () => { + it('STAR_LEVELS is 1-5', () => { + expect(STAR_LEVELS.map(l => l.value)).toEqual([1, 2, 3, 4, 5]); + }); + + it('EGG_LEVELS mirrors STAR_LEVELS (eggs only have star tiers)', () => { + expect(EGG_LEVELS.map(l => l.value)).toEqual([1, 2, 3, 4, 5]); + }); + + it('MEGA_LEVELS is 6-7', () => { + expect(MEGA_LEVELS.map(l => l.value)).toEqual([6, 7]); + }); + + it('PRIMARY_RAID_LEVELS is 1-7', () => { + expect(PRIMARY_RAID_LEVELS.map(l => l.value)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + it('OVERFLOW_RAID_LEVELS is 8-19', () => { + expect(OVERFLOW_RAID_LEVELS.map(l => l.value)).toEqual([8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + }); + + describe('resolveLevel', () => { + it('returns ANY_LEVEL for the 9000 sentinel', () => { + expect(resolveLevel(ANY_LEVEL_VALUE)).toEqual(ANY_LEVEL); + }); + + it('returns the canonical option for any value 1-19', () => { + for (let v = 1; v <= 19; v++) { + const opt = resolveLevel(v); + expect(opt.value).toBe(v); + expect(opt.labelKey).toBe(`RAIDS.LEVEL.RAID_${v}`); + expect(opt.category).not.toBe('custom'); + } + }); + + it('returns a custom option for unrecognized values (20+, negatives, 0)', () => { + expect(resolveLevel(42).category).toBe('custom'); + expect(resolveLevel(20).category).toBe('custom'); + expect(resolveLevel(0).category).toBe('custom'); + expect(resolveLevel(-1).category).toBe('custom'); + }); + }); + + describe('isKnownLevel', () => { + it('is true for 1-19 and 9000', () => { + for (let v = 1; v <= 19; v++) expect(isKnownLevel(v)).toBe(true); + expect(isKnownLevel(ANY_LEVEL_VALUE)).toBe(true); + }); + + it('is false for 0, negatives, and 20+', () => { + expect(isKnownLevel(0)).toBe(false); + expect(isKnownLevel(-3)).toBe(false); + expect(isKnownLevel(20)).toBe(false); + expect(isKnownLevel(42)).toBe(false); + }); + }); + + describe('makeCustomLevel', () => { + it('round-trips through resolveLevel', () => { + expect(resolveLevel(66)).toEqual(makeCustomLevel(66)); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts new file mode 100644 index 00000000..972f093d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts @@ -0,0 +1,106 @@ +// Canonical raid level vocabulary, sourced from the WatWowMap masterfile: +// https://github.com/WatWowMap/Masterfile-Generator/blob/main/master-latest-poracle-v2.json +// +// PoracleNG accepts any positive integer as a raid/egg level. The UI maps the +// 19 currently-known integers to their canonical names; users can still add +// arbitrary integers via the custom input for forward compatibility with new +// raid types that haven't shipped to the frontend yet. +// +// Backend matching is purely integer-keyed — names are pure UI vocabulary. +// `resolveLevel(value)` is the single mapping from stored integer → display +// option, used by the selector dialog and the alarm cards alike so all +// surfaces speak the same vocabulary. + +export type LevelCategory = 'star' | 'mega' | 'special' | 'shadow' | 'superMega' | 'coordinated' | 'any' | 'custom'; + +export interface LevelOption { + /** Coarse grouping for the selector overflow menu and category badges. */ + category: LevelCategory; + /** + * ngx-translate key for the human label. Intentionally short and excludes + * the "Raid" noun ("Mega Legendary", not "Mega Legendary Raid") so it + * composes cleanly into card titles like "All Mega Legendary Raids". + */ + labelKey: string; + /** Backend integer. PoracleNG accepts any positive integer. */ + value: number; +} + +/** PoracleNG's wildcard sentinel for raid matching — matches any raid level. */ +export const ANY_LEVEL_VALUE = 9000 as const; + +/** + * The 19 known raid levels. Order matters for menu rendering; categories cluster + * naturally by integer (1-5 star, 6-7 mega, 8-10 special, 11-15 shadow, + * 16-17 super mega, 18-19 coordinated). + */ +export const KNOWN_LEVELS: readonly LevelOption[] = [ + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_1', value: 1 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_2', value: 2 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_3', value: 3 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_4', value: 4 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_5', value: 5 }, + { category: 'mega', labelKey: 'RAIDS.LEVEL.RAID_6', value: 6 }, + { category: 'mega', labelKey: 'RAIDS.LEVEL.RAID_7', value: 7 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_8', value: 8 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_9', value: 9 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_10', value: 10 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_11', value: 11 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_12', value: 12 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_13', value: 13 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_14', value: 14 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_15', value: 15 }, + { category: 'superMega', labelKey: 'RAIDS.LEVEL.RAID_16', value: 16 }, + { category: 'superMega', labelKey: 'RAIDS.LEVEL.RAID_17', value: 17 }, + { category: 'coordinated', labelKey: 'RAIDS.LEVEL.RAID_18', value: 18 }, + { category: 'coordinated', labelKey: 'RAIDS.LEVEL.RAID_19', value: 19 }, +]; + +/** Values 1-5: the visually star-rendered "N Star Raid" tier. */ +export const STAR_LEVELS: readonly LevelOption[] = KNOWN_LEVELS.filter(l => l.category === 'star'); + +/** Eggs only realistically use 1-5 in current Pokémon GO. */ +export const EGG_LEVELS: readonly LevelOption[] = STAR_LEVELS; + +/** Mega + Mega Legendary (6, 7). The primary "common but not star" tier. */ +export const MEGA_LEVELS: readonly LevelOption[] = KNOWN_LEVELS.filter(l => l.category === 'mega'); + +/** Levels surfaced in the primary chip row of the raid picker. */ +export const PRIMARY_RAID_LEVELS: readonly LevelOption[] = [...STAR_LEVELS, ...MEGA_LEVELS]; + +/** Levels relegated to the "More raid types…" overflow on the raid picker. */ +export const OVERFLOW_RAID_LEVELS: readonly LevelOption[] = KNOWN_LEVELS.filter(l => l.category !== 'star' && l.category !== 'mega'); + +export const ANY_LEVEL: LevelOption = { + category: 'any', + labelKey: 'RAIDS.LEVEL.ANY', + value: ANY_LEVEL_VALUE, +}; + +/** Build a display option for an arbitrary integer level (unknown to the masterfile). */ +export function makeCustomLevel(value: number): LevelOption { + return { category: 'custom', labelKey: 'RAIDS.LEVEL.CUSTOM', value }; +} + +/** + * Resolve a raw stored integer to its display option. Returns the canonical + * known option if recognized, the ANY sentinel for 9000, or a custom-category + * option otherwise. + */ +export function resolveLevel(value: number): LevelOption { + if (value === ANY_LEVEL_VALUE) return ANY_LEVEL; + return KNOWN_LEVELS.find(l => l.value === value) ?? makeCustomLevel(value); +} + +/** True if `value` is one of the masterfile-known levels (1-19) or the ANY sentinel. */ +export function isKnownLevel(value: number): boolean { + return value === ANY_LEVEL_VALUE || KNOWN_LEVELS.some(l => l.value === value); +} + +/** + * Backward-compat alias retained while callers migrate. Equivalent to `isKnownLevel`. + * @deprecated Use isKnownLevel. + */ +export function isBuiltInLevel(value: number): boolean { + return isKnownLevel(value); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin-geofence.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin-geofence.service.ts index a3967433..b7718899 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin-geofence.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin-geofence.service.ts @@ -14,7 +14,7 @@ export class AdminGeofenceService { return this.http.delete(`${this.config.apiHost}/api/admin/geofences/${id}`); } - approveSubmission(id: number, data: { promotedName?: string }): Observable { + approveSubmission(id: number, data: { groupName?: string; parentId?: number; promotedName?: string }): Observable { return this.http.post(`${this.config.apiHost}/api/admin/geofences/submissions/${id}/approve`, data); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.spec.ts index f2ee8455..1fec61f6 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.spec.ts @@ -33,6 +33,7 @@ describe('AdminService', () => { enabled: 1, language: 'en', lastChecked: null, + notes: null, type: 'discord:user', }; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.ts index eb936485..9075ef33 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/admin.service.ts @@ -3,7 +3,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { ConfigService } from './config.service'; -import { AdminUser, Human } from '../models'; +import { AdminUser, Human, PoracleServerProfile } from '../models'; @Injectable({ providedIn: 'root' }) export class AdminService { @@ -45,6 +45,14 @@ export class AdminService { return this.http.get>(`${this.config.apiHost}/api/admin/webhook-delegates/all`); } + /** + * The webhooks the signed-in delegate manages. /my-webhooks used to call getUsers(), which is + * admin-only — so the one page built for delegates 403'd for every delegate. See #564. + */ + getManagedWebhooks(): Observable { + return this.http.get(`${this.config.apiHost}/api/admin/my-webhooks`); + } + getPoracleAdmins(): Observable { return this.http.get(`${this.config.apiHost}/api/admin/poracle-admins`); } @@ -53,6 +61,16 @@ export class AdminService { return this.http.get>(`${this.config.apiHost}/api/admin/poracle-delegates`); } + /** + * Which PoracleNG this deployment talks to, what it can store, and whether that is new enough. + * `refresh` re-probes rather than answering from the five-minute cache. + */ + getServerProfile(refresh = false) { + return this.http.get(`${this.config.apiHost}/api/admin/server-profile`, { + params: refresh ? { refresh: true } : {}, + }); + } + getUser(userId: string): Observable { return this.http.get(`${this.config.apiHost}/api/admin/users/by-id`, { params: { id: userId } }); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.spec.ts new file mode 100644 index 00000000..1ac42251 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.spec.ts @@ -0,0 +1,86 @@ +import { AlertDefaultsService, MAX_DEFAULT_DISTANCE_KM, MIN_DEFAULT_DISTANCE_KM } from './alert-defaults.service'; + +const MODE_KEY = 'poracle-default-alert-mode'; +const DISTANCE_KEY = 'poracle-default-alert-distance-km'; + +describe('AlertDefaultsService', () => { + beforeEach(() => localStorage.clear()); + + it('defaults to areas mode and 1 km when nothing is stored', () => { + const service = new AlertDefaultsService(); + expect(service.defaultMode()).toBe('areas'); + expect(service.defaultDistanceKm()).toBe(1); + }); + + it('reads a previously stored preference on construction', () => { + localStorage.setItem(MODE_KEY, 'distance'); + localStorage.setItem(DISTANCE_KEY, '2.5'); + const service = new AlertDefaultsService(); + expect(service.defaultMode()).toBe('distance'); + expect(service.defaultDistanceKm()).toBe(2.5); + }); + + it('falls back to areas for an unrecognized stored mode', () => { + localStorage.setItem(MODE_KEY, 'nonsense'); + expect(new AlertDefaultsService().defaultMode()).toBe('areas'); + }); + + it('falls back to 1 km for a non-numeric or non-positive stored distance', () => { + localStorage.setItem(DISTANCE_KEY, 'abc'); + expect(new AlertDefaultsService().defaultDistanceKm()).toBe(1); + localStorage.setItem(DISTANCE_KEY, '0'); + expect(new AlertDefaultsService().defaultDistanceKm()).toBe(1); + }); + + it('persists a saved preference to localStorage and updates the signals', () => { + const service = new AlertDefaultsService(); + service.save('distance', 3); + expect(service.defaultMode()).toBe('distance'); + expect(service.defaultDistanceKm()).toBe(3); + expect(localStorage.getItem(MODE_KEY)).toBe('distance'); + expect(localStorage.getItem(DISTANCE_KEY)).toBe('3'); + }); + + it('clamps a saved distance to the allowed range', () => { + const service = new AlertDefaultsService(); + service.save('distance', 9999); + expect(service.defaultDistanceKm()).toBe(MAX_DEFAULT_DISTANCE_KM); + service.save('distance', 0.01); + expect(service.defaultDistanceKm()).toBe(MIN_DEFAULT_DISTANCE_KM); + }); + + it('coerces an invalid saved distance to 1 km', () => { + const service = new AlertDefaultsService(); + service.save('distance', NaN); + expect(service.defaultDistanceKm()).toBe(1); + }); + it('remembers a default place alongside a distance', () => { + const service = new AlertDefaultsService(); + service.save('distance', 2, 'work'); + expect(service.defaultPlaceLabel()).toBe('work'); + expect(new AlertDefaultsService().defaultPlaceLabel()).toBe('work'); + }); + + it('drops the place when the default is areas', () => { + // A place only means anything alongside a radius; the two are mutually exclusive upstream. Keeping + // one here would seed every new alarm with a scope PoracleNG refuses. + const service = new AlertDefaultsService(); + service.save('distance', 2, 'work'); + service.save('areas', 2, 'work'); + expect(service.defaultPlaceLabel()).toBe(''); + }); + + it('forgets a default place that no longer exists', () => { + const service = new AlertDefaultsService(); + service.save('distance', 2, 'work'); + service.reconcilePlace(['home']); + expect(service.defaultPlaceLabel()).toBe(''); + }); + + it('keeps a default place that still exists', () => { + const service = new AlertDefaultsService(); + service.save('distance', 2, 'work'); + service.reconcilePlace(['home', 'work']); + expect(service.defaultPlaceLabel()).toBe('work'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.ts new file mode 100644 index 00000000..7a748e27 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.ts @@ -0,0 +1,79 @@ +import { Injectable, signal } from '@angular/core'; + +/** Location mode applied to a newly created alarm: geofence areas, or a radius from the user's location. */ +export type AlertLocationMode = 'areas' | 'distance'; + +/** Smallest default radius the preference UI accepts (km). */ +export const MIN_DEFAULT_DISTANCE_KM = 0.1; + +/** Largest default radius the preference UI accepts (km). */ +export const MAX_DEFAULT_DISTANCE_KM = 100; + +const MODE_KEY = 'poracle-default-alert-mode'; +const DISTANCE_KEY = 'poracle-default-alert-distance-km'; +const PLACE_KEY = 'poracle-default-alert-place'; + +/** + * Client-side preference for how brand-new alarms default their delivery scope. + * + * Poracle fires an alarm either by the user's subscribed Areas (`distance = 0`) or by a radius + * from their location (`distance > 0`). New alarms have always defaulted to Areas; this service + * lets a user flip that default to Distance and pin a preferred radius, so the add dialogs open + * pre-set instead of forcing the same two clicks every time. Persisted to localStorage, mirroring + * the theme/accent/language preference pattern (see {@link https://github.com/PGAN-Dev/PoracleWeb.NET/discussions/217}). + */ +@Injectable({ providedIn: 'root' }) +export class AlertDefaultsService { + /** Preferred radius (km) used to seed new distance-mode alarms. Always within [min, max]. */ + readonly defaultDistanceKm = signal(this.readDistanceKm()); + + /** Preferred location mode used to seed new alarms. */ + readonly defaultMode = signal(this.readMode()); + + /** + * Saved place new distance-mode alarms measure from. Empty means the profile pin, which is what + * every alarm did before per-alarm scope existed. + */ + readonly defaultPlaceLabel = signal(localStorage.getItem(PLACE_KEY) ?? ''); + + /** + * Drops the remembered place when it no longer exists, so a deleted place cannot keep seeding new + * alarms with a label PoracleNG will reject. + */ + reconcilePlace(known: string[]): void { + const current = this.defaultPlaceLabel(); + if (current && !known.includes(current)) { + this.defaultPlaceLabel.set(''); + localStorage.setItem(PLACE_KEY, ''); + } + } + + /** Persist the user's preferred defaults for new alarms. */ + save(mode: AlertLocationMode, distanceKm: number, placeLabel = ''): void { + const km = this.clampDistance(distanceKm); + // A place only means something alongside a radius. Keeping one on the areas default would seed + // every new alarm with a scope PoracleNG refuses. + const place = mode === 'distance' ? placeLabel : ''; + + this.defaultMode.set(mode); + this.defaultDistanceKm.set(km); + this.defaultPlaceLabel.set(place); + localStorage.setItem(MODE_KEY, mode); + localStorage.setItem(DISTANCE_KEY, String(km)); + localStorage.setItem(PLACE_KEY, place); + } + + private clampDistance(value: number): number { + if (!Number.isFinite(value) || value <= 0) return 1; + return Math.min(MAX_DEFAULT_DISTANCE_KM, Math.max(MIN_DEFAULT_DISTANCE_KM, value)); + } + + private readDistanceKm(): number { + const raw = Number(localStorage.getItem(DISTANCE_KEY)); + return Number.isFinite(raw) && raw > 0 ? this.clampDistance(raw) : 1; + } + + private readMode(): AlertLocationMode { + return localStorage.getItem(MODE_KEY) === 'distance' ? 'distance' : 'areas'; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.spec.ts new file mode 100644 index 00000000..ec69280e --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.spec.ts @@ -0,0 +1,140 @@ +import { TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; +import { Observable, of, throwError } from 'rxjs'; + +import { AlertLanguageService } from './alert-language.service'; +import { I18nService } from './i18n.service'; +import { LocationService } from './location.service'; + +describe('AlertLanguageService', () => { + let locationService: { getLanguage: jest.Mock; setLanguage: jest.Mock }; + let store: Record; + + const create = (): { alert: AlertLanguageService; i18n: I18nService } => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService(), { provide: LocationService, useValue: locationService }], + }); + return { alert: TestBed.inject(AlertLanguageService), i18n: TestBed.inject(I18nService) }; + }; + + beforeEach(() => { + store = {}; + jest.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => store[key] ?? null); + jest.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string, value: string) => { + store[key] = value; + }); + jest.spyOn(Storage.prototype, 'removeItem').mockImplementation((key: string) => { + delete store[key]; + }); + // A browser the UI ships nothing for, so the server locale is the only thing left to fall back on. + jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['ja-JP', 'ja']); + + locationService = { + getLanguage: jest.fn, []>(() => of({ language: null })), + setLanguage: jest.fn, [string]>(() => of(undefined)), + }; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should default to en when nothing else says otherwise', () => { + const { alert, i18n } = create(); + i18n.init(); + + expect(alert.selected()).toBe('en'); + }); + + it("should default to Poracle's locale when the user has never chosen one", () => { + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + expect(alert.selected()).toBe('de'); + }); + + it('should adopt a server locale that arrives after the service is constructed', () => { + const { alert, i18n } = create(); + expect(alert.selected()).toBe('en'); + + i18n.init(undefined, 'de'); + + expect(alert.selected()).toBe('de'); + }); + + it('should keep a stored choice ahead of the server locale', () => { + store['poracle-language'] = 'fr'; + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + expect(alert.selected()).toBe('fr'); + }); + + it('should fall back to en for a locale this UI does not ship', () => { + const { alert, i18n } = create(); + i18n.init(undefined, 'ru'); + + expect(alert.selected()).toBe('en'); + }); + + it('should fall back to en for a locale the admin excluded from allowed_languages', () => { + const { alert, i18n } = create(); + i18n.init('en,fr', 'de'); + + expect(alert.selected()).toBe('en'); + }); + + it('should let humans.language override the server locale', () => { + locationService.getLanguage.mockReturnValue(of({ language: 'it' })); + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + alert.load(); + + expect(alert.selected()).toBe('it'); + expect(store['poracle-language']).toBe('it'); + }); + + it('should keep the server locale when humans.language is unset', () => { + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + alert.load(); + + expect(alert.selected()).toBe('de'); + }); + + it('should store the chosen language and report success', async () => { + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + await expect(alert.choose('sv')).resolves.toBe(true); + + expect(alert.selected()).toBe('sv'); + expect(store['poracle-language']).toBe('sv'); + }); + + it('should roll back to the server locale when the write fails and nothing was stored', async () => { + locationService.setLanguage.mockReturnValue(throwError(() => new Error('nope'))); + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + await expect(alert.choose('sv')).resolves.toBe(false); + + expect(alert.selected()).toBe('de'); + expect(store['poracle-language']).toBeUndefined(); + }); + + it('should roll back to the previous stored choice when the write fails', async () => { + store['poracle-language'] = 'fr'; + locationService.setLanguage.mockReturnValue(throwError(() => new Error('nope'))); + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + await expect(alert.choose('sv')).resolves.toBe(false); + + expect(alert.selected()).toBe('fr'); + expect(store['poracle-language']).toBe('fr'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts new file mode 100644 index 00000000..3a2f73d7 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts @@ -0,0 +1,65 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; + +import { I18nService } from './i18n.service'; +import { LocationService } from './location.service'; + +const STORAGE_KEY = 'poracle-language'; + +/** + * The language Poracle writes your alerts in: DM text and Pokemon names. + * + * Distinct from the display language, which only changes the UI. It lives in a service rather than a + * component so the user menu can render it as flag rows beside the display-language menu, matching it + * row for row — the two are only distinguishable at a glance if they look like siblings. + */ +@Injectable({ providedIn: 'root' }) +export class AlertLanguageService { + /** + * A language this user has actually been given, from localStorage or from humans.language. Null means + * nobody has ever chosen one, which is the only case where Poracle's own locale gets to decide. + */ + private readonly chosen = signal(localStorage.getItem(STORAGE_KEY)); + private readonly i18n = inject(I18nService); + + private readonly locationService = inject(LocationService); + + /** Every language Poracle can write alerts in. */ + readonly languages = this.i18n.allLanguages; + + readonly selected = computed(() => this.chosen() ?? this.i18n.serverDefaultLanguage() ?? 'en'); + + /** Sets the alert language, rolling back if the write fails. Returns whether it stuck. */ + choose(locale: string): Promise { + const previous = this.chosen(); + this.chosen.set(locale); + localStorage.setItem(STORAGE_KEY, locale); + + return new Promise(resolve => { + this.locationService.setLanguage(locale).subscribe({ + error: () => { + this.chosen.set(previous); + if (previous === null) localStorage.removeItem(STORAGE_KEY); + else localStorage.setItem(STORAGE_KEY, previous); + resolve(false); + }, + next: () => resolve(true), + }); + }); + } + + /** + * Reconciles with the authoritative human.Language. The localStorage value is only a hint for an + * instant first render, and the bot can change the real one out of band. + */ + load(): void { + this.locationService.getLanguage().subscribe({ + error: () => undefined, + next: ({ language }) => { + if (language && this.languages.some(l => l.code === language)) { + this.chosen.set(language); + localStorage.setItem(STORAGE_KEY, language); + } + }, + }); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.spec.ts index b533221b..4a253616 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.spec.ts @@ -2,9 +2,11 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; import { AuthService } from './auth.service'; import { ConfigService } from './config.service'; +import { TokenStoreService } from './token-store.service'; import { UserInfo } from '../models'; describe('AuthService', () => { @@ -38,6 +40,9 @@ describe('AuthService', () => { providers: [ provideHttpClient(), provideHttpClientTesting(), + // AuthService reaches TranslateService transitively, via AlertLanguageService -> I18nService, + // since it reconciles the alert language once a token exists (#775). + provideTranslateService(), { provide: ConfigService, useValue: { apiHost: API } }, { provide: Router, @@ -62,6 +67,7 @@ describe('AuthService', () => { providers: [ provideHttpClient(), provideHttpClientTesting(), + provideTranslateService(), { provide: ConfigService, useValue: { apiHost: API } }, { provide: Router, useValue: { navigate: jest.fn() } }, ], @@ -118,6 +124,11 @@ describe('AuthService', () => { // Settings are loaded after token is stored (fixes title not showing after OAuth redirect) const settingsReq = httpMock.expectOne(`${API}/api/settings`); settingsReq.flush([]); + // getAll() also asks which disable_* keys Poracle forces off upstream (#769). + httpMock.expectOne(`${API}/api/settings/upstream-disabled`).flush([]); + // The alert language is reconciled here too: App.ngOnInit skips it while signed out (#775), so a + // login completed inside one page session would otherwise never pick up humans.language. + httpMock.expectOne(`${API}/api/location/language`).flush({ language: 'de' }); // Now navigation should have happened expect(router.navigate).toHaveBeenCalledWith(['/dashboard']); @@ -126,7 +137,7 @@ describe('AuthService', () => { }); describe('logout', () => { - it('should clear tokens, reset user, and navigate to login', () => { + it('should clear tokens, reset user, and navigate to the signed-out login page', () => { localStorage.setItem('poracle_token', 'some-token'); localStorage.setItem('poracle_admin_token', 'admin-token'); @@ -136,7 +147,21 @@ describe('AuthService', () => { expect(localStorage.getItem('poracle_admin_token')).toBeNull(); expect(service.isLoggedIn()).toBe(false); expect(service.isImpersonating()).toBe(false); - expect(router.navigate).toHaveBeenCalledWith(['/login']); + // ?loggedout=1 shows the signed-out panel and suppresses the OIDC auto-redirect. + expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: { loggedout: 1 } }); + }); + + it('should perform single logout (no in-app navigation) when sso=true', () => { + localStorage.setItem('poracle_token', 'some-token'); + + // sso:true takes the window.location bounce to /api/auth/oidc/logout (jsdom no-ops the + // assignment); the distinguishing, observable behaviour is that it does NOT use the + // in-app router (unlike the default RP logout, which navigates to /login?loggedout=1). + service.logout({ sso: true }); + + expect(localStorage.getItem('poracle_token')).toBeNull(); + expect(service.isLoggedIn()).toBe(false); + expect(router.navigate).not.toHaveBeenCalled(); }); }); @@ -153,7 +178,10 @@ describe('AuthService', () => { expect(service.isLoggedIn()).toBe(true); }); - it('should clear token and user on 401 error', async () => { + it('should forget the user on 401 error, leaving the token to the interceptor', async () => { + // Removing poracle_token here as well as in the interceptor deleted the admin token the + // impersonation fallback had just restored, one line after it was written. The interceptor owns + // 401 token handling -- clearAll(), or the fallback -- and this only resets the user. See #706. localStorage.setItem('poracle_token', 'bad-token'); const promise = service.loadCurrentUser(); @@ -162,7 +190,6 @@ describe('AuthService', () => { const result = await promise; expect(result).toBeNull(); - expect(localStorage.getItem('poracle_token')).toBeNull(); expect(service.user()).toBeNull(); }); @@ -274,9 +301,13 @@ describe('AuthService', () => { expect(router.navigate).toHaveBeenCalledWith(['/admin']); }); - it('should do nothing when no admin token exists', async () => { + it('logs out when there is no admin token to go back to', async () => { + // Returning silently left a visible Stop impersonating button that did nothing at all -- reachable + // whenever a 401 discarded the admin token while the banner was still up. See #627. await service.stopImpersonating(); - expect(router.navigate).not.toHaveBeenCalled(); + + expect(service.isImpersonating()).toBe(false); + expect(router.navigate).toHaveBeenCalledWith(['/login'], { queryParams: { loggedout: 1 } }); }); }); @@ -329,4 +360,40 @@ describe('AuthService', () => { expect(result).toBeNull(); }); }); + + describe('a session discarded by a 401', () => { + it('forgets the user and the impersonation state, not just the tokens', () => { + // Left set, currentUser rendered the login page inside the signed-in shell and bounced the user + // to /dashboard on the next navigation; _isImpersonating kept a banner whose button did nothing. + // See #627, #628. + localStorage.setItem('poracle_admin_token', 'admin-jwt'); + + service.clearSession(); + + expect(service.isLoggedIn()).toBe(false); + expect(service.isAuthenticated()).toBe(false); + expect(service.isImpersonating()).toBe(false); + expect(localStorage.getItem('poracle_admin_token')).toBeNull(); + }); + }); + + describe('an inspection ended by a 401', () => { + it('drops the impersonation state and reloads the admin behind the restored token', () => { + // The interceptor puts the admin's own token back rather than ending the session; without picking + // the user back up, the banner kept naming the inspected account and the nav kept its rights. + // See #706. + const tokenStore = TestBed.inject(TokenStoreService); + localStorage.setItem('poracle_token', 'impersonation-jwt'); + localStorage.setItem('poracle_admin_token', 'admin-jwt'); + service.impersonate('impersonation-jwt'); + httpMock.expectOne(`${API}/api/auth/me`).flush(mockUser); + expect(service.isImpersonating()).toBe(true); + + tokenStore.tryRestoreAdminSession(); + + expect(service.isImpersonating()).toBe(false); + httpMock.expectOne(`${API}/api/auth/me`).flush({ ...mockUser, id: 'admin-1', username: 'admin' }); + expect(service.user()?.username).toBe('admin'); + }); + }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.ts index aece3c7e..825cffd1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/auth.service.ts @@ -3,8 +3,10 @@ import { Injectable, inject, signal, computed } from '@angular/core'; import { Router } from '@angular/router'; import { Observable, ReplaySubject, tap, firstValueFrom } from 'rxjs'; +import { AlertLanguageService } from './alert-language.service'; import { ConfigService } from './config.service'; import { SettingsService } from './settings.service'; +import { TokenStoreService } from './token-store.service'; import { UserInfo, LoginResponse, TelegramConfig, AuthProviders } from '../models'; const TOKEN_KEY = 'poracle_token'; @@ -14,12 +16,14 @@ const ADMIN_TOKEN_KEY = 'poracle_admin_token'; export class AuthService { private readonly _isImpersonating = signal(!!localStorage.getItem(ADMIN_TOKEN_KEY)); private readonly _profileResynced = signal(false); + private readonly alertLanguage = inject(AlertLanguageService); private readonly config = inject(ConfigService); private readonly currentUser = signal(null); private readonly http = inject(HttpClient); private readonly router = inject(Router); private readonly settingsService = inject(SettingsService); + private readonly tokenStore = inject(TokenStoreService); private readonly userLoaded$ = new ReplaySubject(1); readonly hasManagedWebhooks = computed(() => (this.currentUser()?.managedWebhooks?.length ?? 0) > 0); @@ -31,6 +35,20 @@ export class AuthService { readonly user = this.currentUser.asReadonly(); constructor() { + // A definitively failed silent refresh ends the session. + this.tokenStore.forceLogout$.subscribe(() => this.logout()); + + // The 401 path discards the session from under us; without this the app kept rendering the + // signed-in shell and an impersonation banner around the login page. See #627, #628. + this.tokenStore.sessionCleared$.subscribe(() => this.clearSession()); + + // A 401 under impersonation drops back to the admin's own token instead of ending the session; + // pick the admin's user back up so the banner and nav match who the token now names. See #706. + this.tokenStore.impersonationEnded$.subscribe(() => { + this._isImpersonating.set(false); + void this.loadCurrentUser(); + }); + const token = localStorage.getItem(TOKEN_KEY); if (token) { this.loadCurrentUser(); @@ -43,6 +61,22 @@ export class AuthService { this._profileResynced.set(false); } + /** + * Discards every trace of the session without navigating. + */ + /* The 401 path used to remove token keys by hand, which left `currentUser` and `_isImpersonating` + * set -- so the login page rendered inside the signed-in shell, complete with an impersonation + * banner whose Stop button did nothing, and bounced back to /dashboard on the next navigation. + * Deliberately does not navigate: the interceptor preserves the current query params, and going + * through logout() would append loggedout=1 and suppress the OIDC auto-redirect. See #627, #628. */ + clearSession(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ADMIN_TOKEN_KEY); + this._isImpersonating.set(false); + this.currentUser.set(null); + this.userLoaded$.next(null); + } + getProviders(): Observable { return this.http.get(`${this.config.apiHost}/api/auth/providers`); } @@ -56,13 +90,17 @@ export class AuthService { return localStorage.getItem(TOKEN_KEY); } - async handleTokenFromCallback(token: string): Promise { - localStorage.setItem(TOKEN_KEY, token); + async handleTokenFromCallback(token: string, refreshToken?: string | null): Promise { + // Stores the JWT plus, for refresh-backed OIDC logins, the opaque refresh token + expiry. + this.tokenStore.storeTokens(token, refreshToken ?? null); await this.loadCurrentUser(); // Load site settings now that we have a valid token — the initial loadOnce() // in App.ngOnInit() fires before the token is stored, so settings (including // custom_title) fail silently and never reload. this.settingsService.loadOnce().subscribe(); + // Same reason, for the same reason: App.ngOnInit skips this while signed out (#775), so without + // it here a login completed inside one page session would never reconcile the alert language. + this.alertLanguage.load(); this.router.navigate(['/dashboard']); } @@ -87,7 +125,10 @@ export class AuthService { this.http.get(`${this.config.apiHost}/api/auth/me`).subscribe({ error: err => { if (err.status === 401) { - localStorage.removeItem(TOKEN_KEY); + // Only the user object. The interceptor owns what happens to the tokens on a 401 -- either + // clearAll(), which already empties this via sessionCleared$, or the impersonation fallback + // that installs the admin's own token. Removing poracle_token here as well deleted the token + // that fallback had just restored, one line after it was written. See #706, #616. this.currentUser.set(null); } this.userLoaded$.next(null); @@ -115,18 +156,38 @@ export class AuthService { window.location.href = `${this.config.apiHost}/api/auth/discord/login`; } + loginWithOidc(): void { + window.location.href = `${this.config.apiHost}/api/auth/oidc/login`; + } + loginWithTelegram(telegramData: Record): Observable { return this.http .post(`${this.config.apiHost}/api/auth/telegram/verify`, telegramData) .pipe(tap(res => this.handleAuthResponse(res))); } - logout(): void { + /** + * Clears the local session. With `sso: true` it then performs an OIDC RP-initiated + * (single) logout — bouncing through the API to the provider's end-session endpoint so + * the provider session is ended too, returning to the signed-out landing. Otherwise it + * navigates to `/login?loggedout=1`, which shows the signed-out panel and (importantly) + * suppresses the OIDC auto-redirect so the user isn't silently logged straight back in. + */ + logout(options?: { sso?: boolean }): void { + // Revoke the server-side refresh session (fire-and-forget) before discarding local state. + this.tokenStore.revoke(); + this.tokenStore.clear(); localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(ADMIN_TOKEN_KEY); this._isImpersonating.set(false); this.currentUser.set(null); - this.router.navigate(['/login']); + + if (options?.sso) { + window.location.href = `${this.config.apiHost}/api/auth/oidc/logout`; + return; + } + + this.router.navigate(['/login'], { queryParams: { loggedout: 1 } }); } /** Store a new JWT token (e.g. after profile switch). */ @@ -137,7 +198,14 @@ export class AuthService { /** Restore the admin's original token. */ async stopImpersonating(): Promise { const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY); - if (adminToken) { + if (!adminToken) { + // Nothing to go back to -- the admin token was discarded with the rest of the session. Silently + // returning left a visible button that did nothing at all. See #627. + this.logout(); + return; + } + + { localStorage.setItem(TOKEN_KEY, adminToken); localStorage.removeItem(ADMIN_TOKEN_KEY); this._isImpersonating.set(false); @@ -156,7 +224,9 @@ export class AuthService { } private handleAuthResponse(res: LoginResponse): void { - localStorage.setItem(TOKEN_KEY, res.token); + // Through the store rather than a bare setItem, so a leftover refresh token and expiry from a + // previous OIDC session are cleared instead of inherited. See #625. + this.tokenStore.storeTokens(res.token, null); this.currentUser.set(res.user); this.userLoaded$.next(res.user); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/cleaning.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/cleaning.service.ts index 7f0f5ab6..9439afbc 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/cleaning.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/cleaning.service.ts @@ -5,16 +5,7 @@ import { Observable } from 'rxjs'; import { ConfigService } from './config.service'; export type CleanAlarmType = - | 'eggs' - | 'fortchanges' - | 'gyms' - | 'invasions' - | 'lures' - | 'maxbattles' - | 'monsters' - | 'nests' - | 'quests' - | 'raids'; + 'eggs' | 'fortchanges' | 'gyms' | 'invasions' | 'lures' | 'maxbattles' | 'monsters' | 'nests' | 'quests' | 'raids'; @Injectable({ providedIn: 'root' }) export class CleaningService { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.spec.ts index cff2a061..5225c55c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.spec.ts @@ -32,7 +32,7 @@ describe('I18nService', () => { it('should set default language to en', () => { service.init(); - expect(translateService.getDefaultLang()).toBe('en'); + expect(translateService.getFallbackLang()).toBe('en'); }); it('should use browser detection when no stored language exists', () => { @@ -71,6 +71,109 @@ describe('I18nService', () => { }); }); + describe("init() with Poracle's locale", () => { + /** Browser languages the UI ships nothing for, so detection returns null and the locale gets a say. */ + const unplaceableBrowser = (): jest.SpyInstance => jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['ja-JP', 'ja']); + + it('should use the server locale when there is no stored choice and no browser match', () => { + unplaceableBrowser(); + + service.init(undefined, 'de'); + + expect(service.currentLang()).toBe('de'); + }); + + it('should keep a stored choice ahead of the server locale', () => { + unplaceableBrowser(); + (Storage.prototype.getItem as jest.Mock).mockReturnValue('fr'); + + service.init(undefined, 'de'); + + expect(service.currentLang()).toBe('fr'); + }); + + it('should keep a browser match ahead of the server locale', () => { + jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['it-IT', 'it']); + + service.init(undefined, 'de'); + + expect(service.currentLang()).toBe('it'); + }); + + it('should fall back to en for a locale this UI does not ship', () => { + unplaceableBrowser(); + + service.init(undefined, 'zh-cn'); + + expect(service.currentLang()).toBe('en'); + expect(service.serverDefaultLanguage()).toBeNull(); + }); + + it('should fall back to en for a locale the admin excluded from allowed_languages', () => { + unplaceableBrowser(); + + service.init('en,fr', 'de'); + + expect(service.currentLang()).toBe('en'); + expect(service.serverDefaultLanguage()).toBeNull(); + }); + + it('should match a regional server locale to its base language', () => { + unplaceableBrowser(); + + service.init(undefined, 'de-AT'); + + expect(service.currentLang()).toBe('de'); + }); + + it('should ignore an empty or absent server locale', () => { + unplaceableBrowser(); + + service.init(undefined, ''); + + expect(service.currentLang()).toBe('en'); + }); + + it('should adopt a server locale that arrives after the first init', () => { + unplaceableBrowser(); + + service.init(); + expect(service.currentLang()).toBe('en'); + + service.init(undefined, 'de'); + + expect(service.currentLang()).toBe('de'); + }); + + it('should not overwrite a browser match when the server locale arrives late', () => { + jest.spyOn(navigator, 'languages', 'get').mockReturnValue(['it-IT', 'it']); + + service.init(); + service.init(undefined, 'de'); + + expect(service.currentLang()).toBe('it'); + }); + + it('should not persist an auto-picked language, so a later visit can re-decide', () => { + unplaceableBrowser(); + + service.init(); + + expect(service.currentLang()).toBe('en'); + expect(localStorage.setItem).not.toHaveBeenCalledWith('poracle-ui-language', expect.anything()); + }); + + it('should not overwrite a stored choice when the server locale arrives late', () => { + unplaceableBrowser(); + (Storage.prototype.getItem as jest.Mock).mockReturnValue('fr'); + + service.init(); + service.init(undefined, 'de'); + + expect(service.currentLang()).toBe('fr'); + }); + }); + describe('use()', () => { beforeEach(() => { service.init(); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.ts index e81da62b..f8f962cb 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/i18n.service.ts @@ -18,9 +18,19 @@ export class I18nService { private initialized = false; + /** Poracle's configured locale, verbatim. Empty until the settings response arrives. */ + private readonly serverLocale = signal(''); + + /** + * How the active language was chosen. Only a language that fell through to the hardcoded default is + * still open to being replaced by the server locale, which arrives after the first init() -- a user's + * stored choice and a browser match both outrank it and must not be overwritten when it lands. + */ + private source: 'browser' | 'fallback' | 'stored' = 'fallback'; + private readonly translate = inject(TranslateService); - /** All languages supported by the UI (matching PoracleWeb PHP). */ + /** All languages supported by the UI. */ readonly allLanguages: UiLanguage[] = [ { name: 'English', code: 'en', countryCode: 'gb', flag: '\u{1F1EC}\u{1F1E7}' }, { name: 'Fran\u00E7ais', code: 'fr', countryCode: 'fr', flag: '\u{1F1EB}\u{1F1F7}' }, @@ -45,11 +55,30 @@ export class I18nService { /** Currently active UI language code. */ readonly currentLang = signal('en'); + /** + * Poracle's locale mapped onto a language this UI actually ships and the admin actually permits, + * or null when it maps onto neither. PoracleNG carries translations we do not (ja, ru, zh-cn), and + * an admin can restrict the UI to a subset, so both filters have to pass before it can be a default. + */ + readonly serverDefaultLanguage = computed(() => { + const raw = this.serverLocale().trim().toLowerCase(); + if (!raw) return null; + + const available = this.availableLanguages(); + const exact = available.find(l => l.code.toLowerCase() === raw); + if (exact) return exact.code; + + const base = raw.split('-')[0]; + return available.find(l => l.code.toLowerCase() === base)?.code ?? null; + }); + /** * Initialize the translation service. Safe to call multiple times. - * First call sets the active language. Subsequent calls only update allowed languages. + * The first call sets the active language. Later calls carry the admin settings, which arrive after + * bootstrap: they update the allowed list, and may swap in Poracle's locale if the first call had + * nothing better than the hardcoded fallback to go on. */ - init(allowedLanguages?: string): void { + init(allowedLanguages?: string, serverLocale?: string): void { if (allowedLanguages) { this.allowedCodes.set( allowedLanguages @@ -59,17 +88,26 @@ export class I18nService { ); } - if (this.initialized) return; + if (serverLocale !== undefined) this.serverLocale.set(serverLocale); + + if (this.initialized) { + if (this.source === 'fallback') { + const fromServer = this.serverDefaultLanguage(); + if (fromServer && fromServer !== this.currentLang()) this.apply(fromServer, false); + } + return; + } + this.initialized = true; this.translate.addLangs(this.allLanguages.map(l => l.code)); - this.translate.setDefaultLang('en'); + this.translate.setFallbackLang('en'); const stored = localStorage.getItem(STORAGE_KEY); - const detected = this.detectBrowserLanguage(); - const lang = stored || detected || 'en'; + const detected = stored ? null : this.detectBrowserLanguage(); + this.source = stored ? 'stored' : detected ? 'browser' : 'fallback'; - this.use(lang); + this.apply(stored || detected || this.serverDefaultLanguage() || 'en', false); } /** Returns a translated string synchronously (for use in TypeScript code). */ @@ -77,13 +115,23 @@ export class I18nService { return this.translate.instant(key, params); } - /** Switch UI language. */ + /** Switch UI language, and remember it as this user's choice. */ use(code: string): void { + this.apply(code, true); + } + + /** + * Switches language, persisting only a deliberate choice. A detected or server-supplied default is + * left unwritten so it can be re-decided next visit: persisting it made the first load authoritative + * forever, which meant a visitor who arrived while Poracle was unreachable, and so fell through to + * English, stayed on English no matter what locale the server reported afterwards. + */ + private apply(code: string, persist: boolean): void { const valid = this.allLanguages.some(l => l.code === code); const lang = valid ? code : 'en'; this.translate.use(lang); this.currentLang.set(lang); - localStorage.setItem(STORAGE_KEY, lang); + if (persist) localStorage.setItem(STORAGE_KEY, lang); document.documentElement.lang = lang; } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/icon.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/icon.service.ts index 06a5dcd2..dae516bd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/icon.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/icon.service.ts @@ -1,30 +1,10 @@ import { Injectable, inject, computed } from '@angular/core'; import { SettingsService } from './settings.service'; +import { POKEMON_TYPE_IDS } from '../../shared/utils/pokemon-types'; const DEFAULT_UICONS = 'https://raw.githubusercontent.com/whitewillem/PogoAssets/main/uicons'; -const TYPE_IDS: Record = { - Bug: 7, - Dark: 17, - Dragon: 16, - Electric: 13, - Fairy: 18, - Fighting: 2, - Fire: 10, - Flying: 3, - Ghost: 8, - Grass: 12, - Ground: 5, - Ice: 15, - Normal: 1, - Poison: 4, - Psychic: 14, - Rock: 6, - Steel: 9, - Water: 11, -}; - @Injectable({ providedIn: 'root' }) export class IconService { private readonly settings = inject(SettingsService); @@ -83,7 +63,7 @@ export class IconService { } getTypeUrl(typeName: string): string { - const id = TYPE_IDS[typeName]; + const id = POKEMON_TYPE_IDS[typeName]; return id ? `${this.typeBase()}/${id}.png` : ''; } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.spec.ts index 3e18d349..16ca98a5 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.spec.ts @@ -84,6 +84,26 @@ describe('LocationService', () => { }); }); + describe('getLanguage', () => { + it('should fetch the current notification language', () => { + service.getLanguage().subscribe(result => { + expect(result.language).toBe('de'); + }); + + const req = httpMock.expectOne(`${API}/api/location/language`); + expect(req.request.method).toBe('GET'); + req.flush({ language: 'de' }); + }); + + it('should return null language on error', () => { + service.getLanguage().subscribe(result => { + expect(result.language).toBeNull(); + }); + + httpMock.expectOne(`${API}/api/location/language`).flush(null, { status: 500, statusText: 'Error' }); + }); + }); + describe('setLanguage', () => { it('should PUT the language', () => { service.setLanguage('de').subscribe(); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.ts index b80788b3..cb37ae7c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/location.service.ts @@ -31,6 +31,12 @@ export class LocationService { .pipe(catchError(() => of(null))); } + getLanguage(): Observable<{ language: string | null }> { + return this.http + .get<{ language: string | null }>(`${this.config.apiHost}/api/location/language`) + .pipe(catchError(() => of({ language: null }))); + } + getLocation(): Observable { return this.http.get(`${this.config.apiHost}/api/location`); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.spec.ts index c41f5a9c..287130ac 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.spec.ts @@ -1,8 +1,10 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; import { ConfigService } from './config.service'; +import { I18nService } from './i18n.service'; import { MasterDataService } from './masterdata.service'; describe('MasterDataService', () => { @@ -13,7 +15,12 @@ describe('MasterDataService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), { provide: ConfigService, useValue: { apiHost: API } }], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideTranslateService(), + { provide: ConfigService, useValue: { apiHost: API } }, + ], }); service = TestBed.inject(MasterDataService); httpMock = TestBed.inject(HttpTestingController); @@ -49,20 +56,34 @@ describe('MasterDataService', () => { expect(ready).toBe(true); }); + const monstersReq = httpMock.expectOne(req => req.url === `${API}/api/masterdata/monsters`); const pokemonReq = httpMock.expectOne(`${API}/api/masterdata/pokemon`); const itemsReq = httpMock.expectOne(`${API}/api/masterdata/items`); + const movesReq = httpMock.expectOne(`${API}/api/masterdata/moves`); pokemonReq.flush({ '25': 'Pikachu', '150': 'Mewtwo' }); itemsReq.flush({ '1': 'Poke Ball', '2': 'Great Ball' }); - - // Also handle the forms request from loadForms() - const formsReq = httpMock.expectOne(req => req.url.includes('master-latest-poracle')); - formsReq.flush({}); + movesReq.flush({ '13': 'Wrap', '14': 'Hyper Beam' }); + monstersReq.flush({}); expect(service.isLoaded()).toBe(true); expect(service.getPokemonName(25)).toBe('Pikachu'); expect(service.getPokemonName(150)).toBe('Mewtwo'); expect(service.getItemName(1)).toBe('Poke Ball'); + expect(service.getMoveName(13)).toBe('Wrap'); + expect(service.getMoveName(14)).toBe('Hyper Beam'); + }); + + it('should fall back to "Move #id" for an unknown move (#396)', () => { + service.loadData().subscribe(); + + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({ '13': 'Wrap' }); + httpMock.expectOne(req => req.url === `${API}/api/masterdata/monsters`).flush({}); + + expect(service.getMoveName(13)).toBe('Wrap'); + expect(service.getMoveName(9999)).toBe('Move #9999'); }); it('should only make one HTTP request even when called multiple times', () => { @@ -72,7 +93,8 @@ describe('MasterDataService', () => { // Should only have one of each request httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '25': 'Pikachu' }); httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); - httpMock.expectOne(req => req.url.includes('master-latest-poracle')).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock.expectOne(req => req.url === `${API}/api/masterdata/monsters`).flush({}); }); it('should handle API errors gracefully', () => { @@ -84,6 +106,8 @@ describe('MasterDataService', () => { httpMock.expectOne(`${API}/api/masterdata/pokemon`).error(new ProgressEvent('error'), { status: 500, statusText: 'Error' }); // The items request gets cancelled by forkJoin, so just match and discard it httpMock.match(`${API}/api/masterdata/items`); + httpMock.match(`${API}/api/masterdata/moves`); + httpMock.match(req => req.url === `${API}/api/masterdata/monsters`); expect(service.isLoaded()).toBe(true); }); @@ -99,7 +123,8 @@ describe('MasterDataService', () => { '150': 'Mewtwo', }); httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); - httpMock.expectOne(req => req.url.includes('master-latest-poracle')).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock.expectOne(req => req.url === `${API}/api/masterdata/monsters`).flush({}); const pokemon = service.getAllPokemon(); expect(pokemon[0]).toEqual({ id: 0, name: 'All Pokemon' }); @@ -123,5 +148,190 @@ describe('MasterDataService', () => { it('should return empty array for pokemon with no forms', () => { expect(service.getFormsForPokemon(1)).toEqual([]); }); + + it('should keep the base "Normal" form when a regional variant exists', () => { + service.loadData().subscribe(); + + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '618': 'Stunfisk' }); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock + .expectOne(req => req.url === `${API}/api/masterdata/monsters`) + .flush({ + '618_0': { id: 618, name: 'Stunfisk', form: { id: 0, name: '' } }, + '618_2246': { id: 618, name: 'Stunfisk', form: { id: 2246, name: 'Normal' } }, + '618_2345': { id: 618, name: 'Stunfisk', form: { id: 2345, name: 'Galarian' } }, + }); + + expect(service.getFormsForPokemon(618)).toEqual([ + { id: 2345, name: 'Galarian' }, + { id: 2246, name: 'Normal' }, + ]); + }); + + it('should drop a lone base form under a translated name (it: "Normale")', () => { + service.loadData().subscribe(); + + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '1': 'Bulbasaur' }); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock + .expectOne(req => req.url === `${API}/api/masterdata/monsters`) + .flush({ + '1_0': { id: 1, name: 'Bulbasaur', form: { id: 0, name: '' } }, + '1_123': { id: 1, name: 'Bulbasaur', form: { id: 123, name: 'Normale' } }, + }); + + expect(service.getFormsForPokemon(1)).toEqual([]); + }); + + // Koraidon and Miraidon are the only two species in live data whose single real form is not + // the base one, so the drop rule has to be about the name and not about the count. + it('should keep a lone form that is not the base form', () => { + service.loadData().subscribe(); + + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '1007': 'Koraidon' }); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock + .expectOne(req => req.url === `${API}/api/masterdata/monsters`) + .flush({ + '1007_0': { id: 1007, name: 'Koraidon', form: { id: 0, name: '' } }, + '1007_3084': { id: 1007, name: 'Koraidon', form: { id: 3084, name: 'Apex Build' } }, + }); + + expect(service.getFormsForPokemon(1007)).toEqual([{ id: 3084, name: 'Apex Build' }]); + }); + + it('should drop a lone "Normal" form covered by "All Forms"', () => { + service.loadData().subscribe(); + + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '1': 'Bulbasaur' }); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock + .expectOne(req => req.url === `${API}/api/masterdata/monsters`) + .flush({ + '1_0': { id: 1, name: 'Bulbasaur', form: { id: 0, name: '' } }, + '1_123': { id: 1, name: 'Bulbasaur', form: { id: 123, name: 'Normal' } }, + }); + + expect(service.getFormsForPokemon(1)).toEqual([]); + }); + }); + describe('localized monster data', () => { + const MONSTERS = `${API}/api/masterdata/monsters`; + + /** Flushes the three English maps, then the monster map, and returns nothing. */ + function load(monsters: unknown, pokemon: Record = {}): void { + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush(pokemon); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock.expectOne(req => req.url === MONSTERS).flush(monsters); + } + + it('should request the monster map for the current display language', () => { + TestBed.inject(I18nService).use('de'); + service.loadData().subscribe(); + + const req = httpMock.expectOne(r => r.url === MONSTERS); + expect(req.request.params.get('locale')).toBe('de'); + + req.flush({}); + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + }); + + it('should prefer the translated name over the English masterfile name', () => { + service.loadData().subscribe(); + load({ '25_0': { id: 25, name: 'Pikachu', form: { id: 0, name: '' } } }, { '25': 'Pikachu' }); + expect(service.getPokemonName(25)).toBe('Pikachu'); + + // Species with names that actually differ between locales, e.g. #001 in German. + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideTranslateService(), + { provide: ConfigService, useValue: { apiHost: API } }, + ], + }); + const german = TestBed.inject(MasterDataService); + const germanHttp = TestBed.inject(HttpTestingController); + german.loadData().subscribe(); + germanHttp.expectOne(`${API}/api/masterdata/pokemon`).flush({ '1': 'Bulbasaur' }); + germanHttp.expectOne(`${API}/api/masterdata/items`).flush({}); + germanHttp.expectOne(`${API}/api/masterdata/moves`).flush({}); + germanHttp.expectOne(req => req.url === MONSTERS).flush({ '1_0': { id: 1, name: 'Bisasam', form: { id: 0, name: '' } } }); + + expect(german.getPokemonName(1)).toBe('Bisasam'); + germanHttp.verify(); + }); + + it('should keep English type names as identity and translate only the label', () => { + service.loadData().subscribe(); + load({ + '25_0': { + id: 25, + name: 'Pikachu', + form: { id: 0, name: '' }, + types: [{ id: 13, name: 'Elektro' }], + }, + }); + + // Icons and the type filter chip both key on the English name, so it has to survive. + expect(service.getPokemonTypes(25)).toEqual(['Electric']); + expect(service.getAllTypes()).toEqual(['Electric']); + expect(service.getTypeLabel('Electric')).toBe('Elektro'); + }); + + it('should fall back to the English name when a translation key comes back untranslated', () => { + service.loadData().subscribe(); + load( + { + '25_0': { + id: 25, + name: 'poke_25', + form: { id: 0, name: '' }, + types: [{ id: 13, name: 'poke_type_13' }], + }, + }, + { '25': 'Pikachu' }, + ); + + expect(service.getPokemonName(25)).toBe('Pikachu'); + expect(service.getTypeLabel('Electric')).toBe('Electric'); + }); + + it('should keep English names when the monster map is unavailable', () => { + service.loadData().subscribe(); + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '25': 'Pikachu' }); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + httpMock.expectOne(req => req.url === MONSTERS).error(new ProgressEvent('error'), { status: 404, statusText: 'Not Found' }); + + expect(service.isLoaded()).toBe(true); + expect(service.getPokemonName(25)).toBe('Pikachu'); + }); + + it('should reload and re-emit when the display language changes', () => { + const seen: string[] = []; + service.getAllPokemon$().subscribe(list => seen.push(list[1]?.name)); + load({ '25_0': { id: 25, name: 'Pikachu', form: { id: 0, name: '' } } }, { '25': 'Pikachu' }); + + TestBed.inject(I18nService).use('fr'); + TestBed.flushEffects(); + + const req = httpMock.expectOne(r => r.url === MONSTERS); + expect(req.request.params.get('locale')).toBe('fr'); + req.flush({ '25_0': { id: 25, name: 'Pikachu (fr)', form: { id: 0, name: '' } } }); + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '25': 'Pikachu' }); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({}); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({}); + + expect(seen).toEqual(['Pikachu', 'Pikachu (fr)']); + }); }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts index 7450cc40..9e9e6701 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts @@ -1,8 +1,10 @@ import { HttpClient } from '@angular/common/http'; -import { Injectable, inject, signal } from '@angular/core'; -import { Observable, ReplaySubject, forkJoin, map } from 'rxjs'; +import { Injectable, effect, inject, signal } from '@angular/core'; +import { Observable, ReplaySubject, catchError, forkJoin, map, of } from 'rxjs'; import { ConfigService } from './config.service'; +import { I18nService } from './i18n.service'; +import { POKEMON_TYPE_NAMES_BY_ID } from '../../shared/utils/pokemon-types'; export interface PokemonEntry { id: number; @@ -10,22 +12,53 @@ export interface PokemonEntry { types?: string[]; } +/** One entry of the monster map, keyed `"{pokemonId}_{formId}"`. */ +interface MonsterEntry { + evolutions?: { evoId: number }[]; + form?: { id: number; name: string }; + id: number; + name: string; + types?: { id: number; name: string }[]; +} + +/** + * A translation key that came back as itself, e.g. `poke_25`. PoracleNG returns the key when neither + * the requested locale nor its English fallback has the string, which happens when its game-data + * locale download failed. Showing "poke_25" would be worse than the English name we already have. + */ +const UNTRANSLATED_KEY = /^(poke|poke_type|form)_\d+$/; + @Injectable({ providedIn: 'root' }) export class MasterDataService { private readonly config = inject(ConfigService); private readonly evoBaseMap = new Map(); - private formsLoaded = false; - private formsLoadRequested = false; private readonly formsMap = signal(new Map()); private readonly http = inject(HttpClient); + private readonly i18n = inject(I18nService); private itemMap = new Map(); private loaded = false; + /** Locale of the data currently in the maps, so a display-language change can be detected. */ + private loadedLocale = ''; private loadRequested = false; + private moveMap = new Map(); private pokemonMap = new Map(); private readonly ready$ = new ReplaySubject(1); + private readonly typeLabels = signal(new Map()); private readonly typesMap = signal(new Map()); + constructor() { + // Pokemon, type and form names are all translated server-side, so a display-language change + // invalidates every map. Re-emitting on ready$ live-updates anything already subscribed + // through getAllPokemon$(), which is how open selectors pick the new names up. + effect(() => { + const locale = this.i18n.currentLang(); + if (this.loadRequested && locale !== this.loadedLocale) { + this.fetch(); + } + }); + } + getAllItems(): { id: number; name: string }[] { const entries: { id: number; name: string }[] = []; this.itemMap.forEach((name, id) => { @@ -77,6 +110,10 @@ export class MasterDataService { return this.itemMap.get(id) ?? `Item #${id}`; } + getMoveName(id: number): string { + return this.moveMap.get(id) ?? `Move #${id}`; + } + getPokemonName(id: number): string { if (id === 0) return 'All Pokemon'; return this.pokemonMap.get(id) ?? `Pokemon #${id}`; @@ -86,6 +123,14 @@ export class MasterDataService { return this.typesMap().get(id) ?? []; } + /** + * The display label for a type. Takes the English name that everything else keys on and returns + * the translation for the current display language, falling back to the English name itself. + */ + getTypeLabel(englishName: string): string { + return this.typeLabels().get(englishName) ?? englishName; + } + isLoaded(): boolean { return this.loaded; } @@ -93,128 +138,165 @@ export class MasterDataService { loadData(): Observable { if (!this.loadRequested) { this.loadRequested = true; + this.fetch(); + } + return this.ready$.asObservable(); + } - forkJoin({ - items: this.http.get>(`${this.config.apiHost}/api/masterdata/items`), - pokemon: this.http.get>(`${this.config.apiHost}/api/masterdata/pokemon`), - }).subscribe({ - error: () => { - // Masterdata unavailable - continue without names - this.loaded = true; - this.loadRequested = false; - this.ready$.next(true); - }, - next: ({ items, pokemon }) => { - this.pokemonMap.clear(); - if (pokemon) { - Object.entries(pokemon).forEach(([id, name]) => { - this.pokemonMap.set(Number(id), name as string); - }); - } + /** + * Rebuilds the maps from the monster payload: localized names, types, forms and evolution chains. + * A null payload (upstream unreachable) leaves the English names from /api/masterdata/pokemon in + * place rather than blanking the selector. + */ + private applyMonsters(monsters: null | Record): void { + if (!monsters) return; + + const namesById = new Map(); + const grouped = new Map(); + const typeMap = new Map(); + const typeLabelMap = new Map(); + + for (const [key, entry] of Object.entries(monsters)) { + if (!entry || typeof entry.id !== 'number') continue; + + // Form 0 is the species' own name; other forms carry it too, so prefer form 0 when present. + if (entry.name && !UNTRANSLATED_KEY.test(entry.name) && (key.endsWith('_0') || !namesById.has(entry.id))) { + namesById.set(entry.id, entry.name); + } + + // Skip only the synthetic id-0 "any" pseudo-form. Real forms (including the + // base "Normal"/regional-default form, e.g. Unova Stunfisk) are kept so they + // can be tracked distinctly from regional variants like Galarian. + if (entry.form && entry.form.id !== 0 && entry.form.name) { + const forms = grouped.get(entry.id) ?? []; + if (!forms.some(f => f.id === entry.form!.id)) { + forms.push({ id: entry.form.id, name: entry.form.name }); + } + grouped.set(entry.id, forms); + } - this.itemMap.clear(); - if (items) { - Object.entries(items).forEach(([id, name]) => { - this.itemMap.set(Number(id), name as string); - }); + // Types come from the base form only; form variants repeat them. + if (entry.types?.length && !typeMap.has(entry.id)) { + const english: string[] = []; + for (const t of entry.types) { + // The id is the stable identity - icons and filters key on the English name, so a + // localized label is only ever recorded alongside it, never in its place. + const name = POKEMON_TYPE_NAMES_BY_ID[t.id] ?? t.name; + if (!name) continue; + english.push(name); + if (t.name && !UNTRANSLATED_KEY.test(t.name)) { + typeLabelMap.set(name, t.name); } + } + if (english.length) typeMap.set(entry.id, english); + } + } - this.loaded = true; - this.ready$.next(true); - this.loadForms(); - }, - }); + // Drop a lone "Normal" form: when a species' only real form is its base/regional + // default, the synthetic "All Forms" option already covers it, so listing it adds + // noise. Keep "Normal" only when sibling variants (Galarian, Alolan, etc.) exist + // so users can target the base form on its own. + // + // The name is matched by prefix because it is now translated. Across the locales this UI + // offers, prod serves "Normal" for en/de/fr/es/pl/sv and "Normale" for it; nl, pt, pt-BR and + // da have no Poracle translation and fall back to English. Species whose only real form is + // something else - Koraidon's "Apex Build", Miraidon's "Ultimate Mode", the two exceptions in + // live data - are unaffected. A locale that translates it to something else entirely shows one + // redundant chip; it cannot hide a form. + for (const [pokemonId, forms] of grouped) { + if (forms.length === 1 && /^normal/i.test(forms[0].name)) { + grouped.delete(pokemonId); + } } - return this.ready$.asObservable(); + + for (const forms of grouped.values()) { + forms.sort((a, b) => a.name.localeCompare(b.name)); + } + + namesById.forEach((name, id) => this.pokemonMap.set(id, name)); + this.formsMap.set(grouped); + this.typesMap.set(typeMap); + this.typeLabels.set(typeLabelMap); + this.buildEvolutionMap(monsters); } - private loadForms(): void { - if (this.formsLoadRequested) return; - this.formsLoadRequested = true; + /** Resolves each species to the first stage of its evolution chain. */ + private buildEvolutionMap(monsters: Record): void { + const evolvesFrom = new Map(); // child -> parent + const seen = new Set(); + for (const entry of Object.values(monsters)) { + if (!entry || seen.has(entry.id)) continue; + seen.add(entry.id); + if (entry.evolutions) { + for (const evo of entry.evolutions) { + if (!evolvesFrom.has(evo.evoId)) { + evolvesFrom.set(evo.evoId, entry.id); + } + } + } + } + // Resolve chains to find the ultimate base + for (const id of [...evolvesFrom.keys(), ...seen]) { + let base = id; + let safety = 5; + while (evolvesFrom.has(base) && safety-- > 0) { + base = evolvesFrom.get(base)!; + } + this.evoBaseMap.set(id, base); + } + } - const url = 'https://raw.githubusercontent.com/WatWowMap/Masterfile-Generator/master/master-latest-poracle.json'; - this.http.get>(url).subscribe({ + /** + * Loads every map for the current display language. + * + * Monsters come from PoracleNG (via our API) because it owns the translations; items and moves + * stay on the English masterfile, which has no translated equivalent upstream. A monster failure + * is caught rather than left to cancel the forkJoin, so English names still land. + */ + private fetch(): void { + const locale = this.i18n.currentLang(); + this.loadedLocale = locale; + + forkJoin({ + items: this.http.get>(`${this.config.apiHost}/api/masterdata/items`), + monsters: this.http + .get>(`${this.config.apiHost}/api/masterdata/monsters`, { params: { locale } }) + .pipe(catchError(() => of(null))), + moves: this.http.get>(`${this.config.apiHost}/api/masterdata/moves`), + pokemon: this.http.get>(`${this.config.apiHost}/api/masterdata/pokemon`), + }).subscribe({ error: () => { - // Forms unavailable - continue without form names - this.formsLoaded = true; - this.formsLoadRequested = false; + // Masterdata unavailable - continue without names + this.loaded = true; + this.loadRequested = false; + this.ready$.next(true); }, - next: data => { - const monsters = data['monsters'] as - | Record< - string, - { - id: number; - name: string; - form?: { id: number; name: string }; - evolutions?: { evoId: number }[]; - types?: { id: number; name: string }[]; - } - > - | undefined; - if (!monsters) { - this.formsLoaded = true; - return; + next: ({ items, monsters, moves, pokemon }) => { + this.pokemonMap.clear(); + if (pokemon) { + Object.entries(pokemon).forEach(([id, name]) => { + this.pokemonMap.set(Number(id), name as string); + }); } - const grouped = new Map(); - for (const entry of Object.values(monsters)) { - if (!entry.form || entry.form.id === 0 || entry.form.name === 'Normal') continue; - const pokemonId = entry.id; - if (!grouped.has(pokemonId)) { - grouped.set(pokemonId, []); - } - const forms = grouped.get(pokemonId)!; - // Avoid duplicates - if (!forms.some(f => f.id === entry.form!.id)) { - forms.push({ id: entry.form.id, name: entry.form.name }); - } + this.itemMap.clear(); + if (items) { + Object.entries(items).forEach(([id, name]) => { + this.itemMap.set(Number(id), name as string); + }); } - // Sort forms alphabetically within each Pokemon - for (const forms of grouped.values()) { - forms.sort((a, b) => a.name.localeCompare(b.name)); - } - this.formsMap.set(grouped); - - // Build types map: Pokemon ID → type names (use base form only, skip form variants) - const typeMap = new Map(); - for (const entry of Object.values(monsters)) { - if (typeMap.has(entry.id)) continue; - if (entry.types?.length) { - typeMap.set( - entry.id, - entry.types.map(t => t.name), - ); - } - } - this.typesMap.set(typeMap); - - // Build evolution base map: evolved Pokemon → base (first stage) Pokemon ID - const evolvesFrom = new Map(); // child → parent - const seen = new Set(); - for (const entry of Object.values(monsters)) { - if (seen.has(entry.id)) continue; - seen.add(entry.id); - if (entry.evolutions) { - for (const evo of entry.evolutions) { - if (!evolvesFrom.has(evo.evoId)) { - evolvesFrom.set(evo.evoId, entry.id); - } - } - } - } - // Resolve chains to find the ultimate base - for (const id of [...evolvesFrom.keys(), ...seen]) { - let base = id; - let safety = 5; - while (evolvesFrom.has(base) && safety-- > 0) { - base = evolvesFrom.get(base)!; - } - this.evoBaseMap.set(id, base); + this.moveMap.clear(); + if (moves) { + Object.entries(moves).forEach(([id, name]) => { + this.moveMap.set(Number(id), name as string); + }); } - this.formsLoaded = true; + this.applyMonsters(monsters); + + this.loaded = true; + this.ready$.next(true); }, }); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.spec.ts index 3d15840f..13c44dc1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.spec.ts @@ -35,6 +35,7 @@ describe('MonsterService', () => { pokemonId: 25, profileNo: 1, pvpRankingBest: 0, + pvpRankingCap: 0, pvpRankingLeague: 0, pvpRankingMinCp: 0, pvpRankingWorst: 0, @@ -87,6 +88,7 @@ describe('MonsterService', () => { pokemonId: 25, profileNo: 1, pvpRankingBest: 0, + pvpRankingCap: 0, pvpRankingLeague: 0, pvpRankingMinCp: 0, pvpRankingWorst: 0, @@ -137,7 +139,9 @@ describe('MonsterService', () => { const req = httpMock.expectOne(`${API}/api/monsters/distance`); expect(req.request.method).toBe('PUT'); - expect(req.request.body).toEqual({ distance: 5000 }); + // A bare number, as every sibling service sends and as [FromBody] int binds. This assertion used + // to require the object shape that 400'd, which is how the bug survived. See #640. + expect(req.request.body).toBe(5000); req.flush(null); }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.ts index 240c6f6d..f7ebb110 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/monster.service.ts @@ -31,7 +31,9 @@ export class MonsterService { } updateAllDistance(distance: number): Observable { - return this.http.put(`${this.config.apiHost}/api/monsters/distance`, { distance }); + // A bare number, as every sibling sends and as [FromBody] int binds. Wrapped in an object it + // could not deserialize, so this 400'd every time. See #640. + return this.http.put(`${this.config.apiHost}/api/monsters/distance`, distance); } updateBulkDistance(uids: number[], distance: number): Observable { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts new file mode 100644 index 00000000..e024ff12 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts @@ -0,0 +1,47 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Observable, tap } from 'rxjs'; + +import { ConfigService } from './config.service'; +import { pinOrNull } from '../../shared/utils/location.utils'; +import { SavedPlace, SavedPlaces } from '../models'; + +/** + * The places a user can point an alarm at. + * + * Held as a signal because the Where sheet, the Places screen and every alarm card read the same + * list, and a place added in one has to show up in the others without a reload. + */ +@Injectable({ providedIn: 'root' }) +export class PlacesService { + private readonly config = inject(ConfigService); + private readonly http = inject(HttpClient); + private readonly places = signal(null); + + /** Named places only. Empty until {@link load} has run. */ + readonly named = computed(() => this.places()?.named ?? []); + + /** The profile pin, which every alarm falls back to. */ + /** The profile pin, or null when it is the 0,0 Poracle stores for "not set". */ + readonly pin = computed(() => pinOrNull(this.places()?.default)); + + add(place: SavedPlace): Observable { + return this.http.post(`${this.config.apiHost}/api/location/places`, place).pipe(tap(updated => this.places.set(updated))); + } + + load(): Observable { + return this.http.get(`${this.config.apiHost}/api/location/places`).pipe(tap(places => this.places.set(places))); + } + + /** + * Deletes a place. Answers 409 with `referencingRules` when alarms still point at it — the caller + * should name them rather than reporting a bare failure. + */ + remove(label: string): Observable { + return this.http + .delete(`${this.config.apiHost}/api/location/places/${encodeURIComponent(label)}`) + .pipe( + tap(() => this.places.update(current => (current ? { ...current, named: current.named.filter(p => p.label !== label) } : current))), + ); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/poracle-config.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/poracle-config.service.ts new file mode 100644 index 00000000..466aaa63 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/poracle-config.service.ts @@ -0,0 +1,51 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject, signal } from '@angular/core'; +import { Observable, ReplaySubject, catchError, of, tap } from 'rxjs'; + +import { ConfigService } from './config.service'; +import { PoracleServerConfig } from '../models'; + +const FALLBACK: PoracleServerConfig = { + defaultPvpCap: 0, + defaultTemplateName: 'default', + everythingFlagPermissions: '', + locale: 'en', + maxDistance: 10726000, + poracleVersion: 'unknown', + pvpCaps: [], + pvpFilterGreatMinCp: 0, + pvpFilterLittleMinCp: 0, + pvpFilterMaxRank: 100, + pvpFilterUltraMinCp: 0, + pvpLittleLeagueAllowed: true, +}; + +@Injectable({ providedIn: 'root' }) +export class PoracleConfigService { + private readonly config = inject(ConfigService); + private readonly http = inject(HttpClient); + private loadRequested = false; + private readonly ready$ = new ReplaySubject(1); + + readonly serverConfig = signal(FALLBACK); + + load(): Observable { + if (!this.loadRequested) { + this.loadRequested = true; + this.http + .get(`${this.config.apiHost}/api/config`) + .pipe( + tap(cfg => { + this.serverConfig.set({ ...FALLBACK, ...cfg }); + this.ready$.next(this.serverConfig()); + }), + catchError(() => { + this.ready$.next(FALLBACK); + return of(FALLBACK); + }), + ) + .subscribe(); + } + return this.ready$.asObservable(); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts new file mode 100644 index 00000000..15df7785 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts @@ -0,0 +1,82 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { RaidLevelService } from './raid-level.service'; +import { KNOWN_LEVELS } from '../models/raid-level.models'; + +describe('RaidLevelService', () => { + let service: RaidLevelService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(RaidLevelService); + http = TestBed.inject(HttpTestingController); + }); + + it('starts with the baked-in 19 levels before any fetch', () => { + expect(service.levels().length).toBe(KNOWN_LEVELS.length); + expect(service.levels()[0].value).toBe(1); + expect(service.loaded()).toBe(false); + }); + + it('replaces the list with the API payload on successful load', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([ + { name: '1 Star Raid', namePlural: '1 Star Raids', category: 'star', value: 1 }, + { name: 'Future Raid', namePlural: 'Future Raids', category: 'special', value: 20 }, + ]); + + expect(service.loaded()).toBe(true); + expect(service.levels().length).toBe(2); + expect(service.levels()[1].value).toBe(20); + expect(service.levels()[1].labelKey).toBe('RAIDS.LEVEL.RAID_20'); + }); + + it('keeps the baked-in fallback when the API fails', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.error(new ProgressEvent('network'), { status: 500, statusText: 'Server Error' }); + + expect(service.loaded()).toBe(true); + expect(service.levels().length).toBe(KNOWN_LEVELS.length); + }); + + it('keeps the baked-in fallback when the API returns an empty list', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([]); + + expect(service.loaded()).toBe(true); + expect(service.levels().length).toBe(KNOWN_LEVELS.length); + }); + + it('coerces unknown category strings to "custom"', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([{ name: 'Whatever', namePlural: 'Whatevers', category: 'invented-category', value: 99 }]); + + expect(service.levels()[0].category).toBe('custom'); + }); + + it('subsequent load() calls are no-ops once loaded', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([{ name: 'Legendary Raid', namePlural: 'Legendary Raids', category: 'star', value: 5 }]); + + service.load(); // second call should not issue another HTTP request + http.expectNone(r => r.url.endsWith('/api/masterdata/raid-levels')); + }); + + it('byValue map exposes a lookup keyed by value', () => { + expect(service.byValue().get(7)?.labelKey).toBe('RAIDS.LEVEL.RAID_7'); + expect(service.byValue().get(9000)).toBeUndefined(); + }); + + afterEach(() => http.verify()); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts new file mode 100644 index 00000000..7b13c76b --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts @@ -0,0 +1,98 @@ +import { HttpClient } from '@angular/common/http'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { catchError, of, take } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { KNOWN_LEVELS, LevelOption } from '../models/raid-level.models'; + +/** API payload shape from GET /api/masterdata/raid-levels. */ +interface RaidLevelInfoDto { + category: string; + name: string; + namePlural: string; + value: number; +} + +/** + * Fetches the canonical raid-level list from the API on app load and caches + * it in a signal. The hardcoded `KNOWN_LEVELS` constant acts as a fallback: + * if the network call fails, or before it resolves, callers still get the + * baked-in 19 levels. New levels appearing in the API response (raid_20+ + * once Niantic ships them) surface automatically without a frontend change. + */ +@Injectable({ providedIn: 'root' }) +export class RaidLevelService { + /** Hot signal of the current level list. Starts with the baked-in defaults. */ + private readonly _levels = signal(KNOWN_LEVELS); + + /** Returns true once the fetch has resolved (success OR failure). */ + private readonly _loaded = signal(false); + + private readonly http = inject(HttpClient); + + /** + * Lookup by value. Used by alarm cards/labels so the displayed name follows + * the live list when the API extends it. + */ + readonly byValue = computed(() => { + const map = new Map(); + for (const l of this._levels()) map.set(l.value, l); + return map; + }); + + /** Reactive read-only handle for components. */ + readonly levels = this._levels.asReadonly(); + + readonly loaded = this._loaded.asReadonly(); + + /** + * Kick off a one-time fetch. Safe to call multiple times — subsequent calls + * are no-ops while a request is in flight or after one has succeeded. + */ + load(): void { + if (this._loaded()) return; + this.http + .get(`${environment.apiUrl}/api/masterdata/raid-levels`) + .pipe( + take(1), + catchError(() => of(null)), + ) + .subscribe(dtos => { + if (dtos && dtos.length > 0) { + this._levels.set(dtos.map(toLevelOption)); + } + this._loaded.set(true); + }); + } +} + +/** + * Map the server-side DTO to the frontend `LevelOption`. We trust the integer + * + category from the server; i18n keys are derived deterministically from the + * value so translations stay in our locale files (the masterfile is English-only). + * The server's `name` / `namePlural` are exposed as backup strings that the + * label pipe can fall back to when an i18n key is missing. + */ +function toLevelOption(dto: RaidLevelInfoDto): LevelOption { + return { + category: normalizeCategory(dto.category), + labelKey: `RAIDS.LEVEL.RAID_${dto.value}`, + value: dto.value, + }; +} + +function normalizeCategory(c: string): LevelOption['category'] { + switch (c) { + case 'star': + case 'mega': + case 'special': + case 'shadow': + case 'superMega': + case 'coordinated': + case 'any': + case 'custom': + return c; + default: + return 'custom'; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.spec.ts index 2ea268dc..0b75bc8e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.spec.ts @@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; import { ScannerService } from './scanner.service'; @@ -13,7 +14,12 @@ describe('ScannerService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - providers: [provideHttpClient(), provideHttpClientTesting(), { provide: MatSnackBar, useValue: { open: jest.fn() } }], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: TranslateService, useValue: { instant: jest.fn((key: string) => key) } }, + ], }); service = TestBed.inject(ScannerService); httpMock = TestBed.inject(HttpTestingController); @@ -44,7 +50,8 @@ describe('ScannerService', () => { service.searchGyms('abc').subscribe(r => (result = r)); const req = httpMock.expectOne(r => r.url === '/api/scanner/gyms'); req.flush('rate limited', { status: 429, statusText: 'Too Many Requests' }); - expect(snackBar.open).toHaveBeenCalledWith(expect.stringContaining('Too many scanner requests'), 'OK', { duration: 4000 }); + // Translated now, not a raw English sentence. See #619. + expect(snackBar.open).toHaveBeenCalledWith('GYM_PICKER.RATE_LIMITED', 'TOAST.OK', { duration: 4000 }); expect(result).toEqual([]); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.ts index 6f23c2a9..b6755ade 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/scanner.service.ts @@ -1,6 +1,7 @@ import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; import { Observable, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -18,6 +19,7 @@ export interface GymSearchResult { export class ScannerService { private readonly http = inject(HttpClient); private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); getGymById(id: string): Observable { return this.http @@ -40,7 +42,10 @@ export class ScannerService { private handleError(err: HttpErrorResponse, fallback: T): Observable { if (err.status === 429) { - this.snackBar.open('Too many scanner requests — please slow down.', 'OK', { duration: 4000 }); + // Raw English in all eleven locales, in a service whose neighbours all translate. See #619. + this.snackBar.open(this.translate.instant('GYM_PICKER.RATE_LIMITED'), this.translate.instant('TOAST.OK'), { + duration: 4000, + }); } return of(fallback); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.spec.ts index df39861f..d4f84499 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.spec.ts @@ -34,6 +34,15 @@ describe('SettingsService', () => { afterEach(() => httpMock.verify()); + /** + * getAll() fans out to two endpoints: the settings themselves and the disable_* keys Poracle + * forces off upstream. Both have to be answered or httpMock.verify() reports the outstanding one. + */ + const flushGetAll = (settings: unknown, upstream: string[] = []): void => { + httpMock.expectOne(`${API}/api/settings`).flush(settings); + httpMock.expectOne(`${API}/api/settings/upstream-disabled`).flush(upstream); + }; + describe('normalize', () => { it('should normalize PwebSetting items', () => { const result = service.normalize(mockPwebSettings); @@ -66,7 +75,7 @@ describe('SettingsService', () => { expect(settings).toHaveLength(3); }); - httpMock.expectOne(`${API}/api/settings`).flush(mockPwebSettings); + flushGetAll(mockPwebSettings); expect(service.siteSettings()['enable_templates']).toBe('true'); expect(service.siteSettings()['site_name']).toBe('My Site'); @@ -77,7 +86,7 @@ describe('SettingsService', () => { expect(settings).toHaveLength(3); }); - httpMock.expectOne(`${API}/api/settings`).flush(mockSiteSettings); + flushGetAll(mockSiteSettings); expect(service.siteSettings()['enable_templates']).toBe('true'); expect(service.siteSettings()['site_name']).toBe('My Site'); @@ -86,15 +95,13 @@ describe('SettingsService', () => { it('should update siteSettings on every call', () => { // First call service.getAll().subscribe(); - httpMock.expectOne(`${API}/api/settings`).flush(mockSiteSettings); + flushGetAll(mockSiteSettings); expect(service.siteSettings()['enable_templates']).toBe('true'); // Second call - signal should update with new data service.getAll().subscribe(); - httpMock - .expectOne(`${API}/api/settings`) - .flush([{ id: 1, category: 'features', key: 'enable_templates', value: 'false', valueType: 'boolean' }]); + flushGetAll([{ id: 1, category: 'features', key: 'enable_templates', value: 'false', valueType: 'boolean' }]); expect(service.siteSettings()['enable_templates']).toBe('false'); }); @@ -102,53 +109,30 @@ describe('SettingsService', () => { it('should handle settings with null values', () => { service.getAll().subscribe(); - httpMock.expectOne(`${API}/api/settings`).flush([{ id: 1, category: 'test', key: 'key1', value: null, valueType: 'string' }]); + flushGetAll([{ id: 1, category: 'test', key: 'key1', value: null, valueType: 'string' }]); expect(service.siteSettings()['key1']).toBe(''); }); }); - describe('getConfig', () => { - it('should fetch poracle config', () => { - service.getConfig().subscribe(config => { - expect(config.areas).toHaveLength(0); - }); - - httpMock.expectOne(`${API}/api/settings/config`).flush({ - areas: [], - forms: {}, - grunts: {}, - items: {}, - moves: {}, - pokemon: {}, - }); - }); - }); - describe('isDisabled', () => { it('should return true when setting is "true"', () => { service.getAll().subscribe(); - httpMock - .expectOne(`${API}/api/settings`) - .flush([{ id: 1, category: 'features', key: 'disable_raids', value: 'true', valueType: 'boolean' }]); + flushGetAll([{ id: 1, category: 'features', key: 'disable_raids', value: 'true', valueType: 'boolean' }]); expect(service.isDisabled('disable_raids')).toBe(true); }); it('should return true case-insensitively', () => { service.getAll().subscribe(); - httpMock - .expectOne(`${API}/api/settings`) - .flush([{ id: 1, category: 'features', key: 'disable_raids', value: 'True', valueType: 'boolean' }]); + flushGetAll([{ id: 1, category: 'features', key: 'disable_raids', value: 'True', valueType: 'boolean' }]); expect(service.isDisabled('disable_raids')).toBe(true); }); it('should return false when setting is not "true"', () => { service.getAll().subscribe(); - httpMock - .expectOne(`${API}/api/settings`) - .flush([{ id: 1, category: 'features', key: 'disable_raids', value: 'false', valueType: 'boolean' }]); + flushGetAll([{ id: 1, category: 'features', key: 'disable_raids', value: 'false', valueType: 'boolean' }]); expect(service.isDisabled('disable_raids')).toBe(false); }); @@ -158,17 +142,74 @@ describe('SettingsService', () => { }); }); + describe('upstream Poracle disable flags (#769)', () => { + it('leaves every type enabled when Poracle disables nothing, which is what prod serves', () => { + service.getAll().subscribe(); + flushGetAll(mockSiteSettings, []); + + for (const key of [ + 'disable_mons', + 'disable_raids', + 'disable_quests', + 'disable_invasions', + 'disable_lures', + 'disable_nests', + 'disable_gyms', + 'disable_maxbattles', + 'disable_fort_changes', + ]) { + expect(service.isDisabled(key)).toBe(false); + expect(service.isForcedByPoracle(key)).toBe(false); + } + }); + + it('reports a type as disabled when Poracle forces it off and the site setting does not', () => { + service.getAll().subscribe(); + flushGetAll([{ id: 1, category: 'features', key: 'disable_raids', value: 'false', valueType: 'boolean' }], ['disable_raids']); + + expect(service.isDisabled('disable_raids')).toBe(true); + expect(service.isForcedByPoracle('disable_raids')).toBe(true); + }); + + it('does not touch the keys Poracle has no opinion about', () => { + service.getAll().subscribe(); + flushGetAll(mockSiteSettings, ['disable_raids']); + + expect(service.isDisabled('disable_areas')).toBe(false); + expect(service.isDisabled('disable_profiles')).toBe(false); + expect(service.isForcedByPoracle('disable_quests')).toBe(false); + }); + + it('leaves the site settings in sole charge when the upstream call fails', () => { + service.getAll().subscribe(); + httpMock.expectOne(`${API}/api/settings`).flush(mockSiteSettings); + httpMock.expectOne(`${API}/api/settings/upstream-disabled`).error(new ProgressEvent('network error')); + + // Failing closed here would blank the nav on any Poracle blip. + expect(service.upstreamDisabled()).toEqual([]); + expect(service.isDisabled('disable_raids')).toBe(false); + expect(service.siteSettings()['site_name']).toBe('My Site'); + }); + + it('never re-enables something the admin disabled here', () => { + service.getAll().subscribe(); + flushGetAll([{ id: 1, category: 'features', key: 'disable_lures', value: 'true', valueType: 'boolean' }], []); + + expect(service.isDisabled('disable_lures')).toBe(true); + }); + }); + describe('loadOnce', () => { it('should call getAll on first invocation', () => { service.loadOnce().subscribe(); - httpMock.expectOne(`${API}/api/settings`).flush(mockSiteSettings); + flushGetAll(mockSiteSettings); }); it('should return empty and not make HTTP call when already loaded', () => { // Load first service.getAll().subscribe(); - httpMock.expectOne(`${API}/api/settings`).flush(mockSiteSettings); + flushGetAll(mockSiteSettings); // loadOnce should not make another request service.loadOnce().subscribe(result => { @@ -176,6 +217,7 @@ describe('SettingsService', () => { }); httpMock.expectNone(`${API}/api/settings`); + httpMock.expectNone(`${API}/api/settings/upstream-disabled`); }); }); @@ -183,7 +225,7 @@ describe('SettingsService', () => { it('should merge public SiteSetting response into existing settings', () => { // Pre-load some settings service.getAll().subscribe(); - httpMock.expectOne(`${API}/api/settings`).flush(mockSiteSettings); + flushGetAll(mockSiteSettings); // Load public settings service.loadPublic().subscribe(); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.ts index 36fc1ad7..e061ee7c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/settings.service.ts @@ -1,9 +1,9 @@ import { HttpClient } from '@angular/common/http'; import { Injectable, inject, signal } from '@angular/core'; -import { Observable, tap } from 'rxjs'; +import { Observable, catchError, forkJoin, map, of, tap } from 'rxjs'; import { ConfigService } from './config.service'; -import { DiscordServerConfig, PoracleConfig, PwebSetting, SiteSetting, TelegramServerConfig } from '../models'; +import { DiscordServerConfig, OidcServerConfig, PwebSetting, SiteSetting, TelegramServerConfig } from '../models'; /** Union of old and new setting response shapes */ type AnySettingItem = PwebSetting | SiteSetting; @@ -17,30 +17,58 @@ export class SettingsService { /** Cached site settings as key→value map, loaded once at app init */ readonly siteSettings = signal>({}); + /** + * The `disable_*` keys the upstream Poracle deployment forces off in its own config, regardless of + * what the site settings say. Poracle's processor drops those webhooks and its bot refuses the + * matching commands, so offering the type here would only produce alarms that save and never fire. + * Empty when Poracle is unreachable or too old to report the flags — the site settings then stay in + * sole charge. See #769. + */ + readonly upstreamDisabled = signal([]); + getAll(): Observable { - return this.http.get(`${this.config.apiHost}/api/settings`).pipe( - tap(settings => { + // Fetched together so a nav item never renders for a type the server will 403. A failure here is + // not fatal: the settings still load and the server-side gate remains the real enforcement point. + return forkJoin({ + settings: this.http.get(`${this.config.apiHost}/api/settings`), + upstream: this.http.get(`${this.config.apiHost}/api/settings/upstream-disabled`).pipe(catchError(() => of([]))), + }).pipe( + tap(({ settings, upstream }) => { this.siteSettings.set(this.normalize(settings)); + this.upstreamDisabled.set(upstream); this.loaded = true; }), + map(({ settings }) => settings), ); } - getConfig(): Observable { - return this.http.get(`${this.config.apiHost}/api/settings/config`); - } - getDiscordConfig(): Observable { return this.http.get(`${this.config.apiHost}/api/settings/discord-config`); } + getOidcConfig(): Observable { + return this.http.get(`${this.config.apiHost}/api/settings/oidc-config`); + } + getTelegramConfig(): Observable { return this.http.get(`${this.config.apiHost}/api/settings/telegram-config`); } - /** Returns true if a feature is disabled via site settings */ + /** + * True when a feature is off — because an admin disabled it here, or because Poracle disabled it + * upstream. Poracle's flags are a floor, never a way to switch something back on. + */ isDisabled(key: string): boolean { - return this.siteSettings()[key]?.toLowerCase() === 'true'; + return this.siteSettings()[key]?.toLowerCase() === 'true' || this.isForcedByPoracle(key); + } + + /** + * True when Poracle's own config disables this type, which the admin page cannot override. Kept + * separate from {@link isDisabled} so that page can explain the switch instead of just showing it + * off, and so nothing mistakes a forced-off type for an admin decision. + */ + isForcedByPoracle(key: string): boolean { + return this.upstreamDisabled().includes(key); } /** Load settings once (idempotent) */ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.spec.ts new file mode 100644 index 00000000..6cbed067 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.spec.ts @@ -0,0 +1,204 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ConfigService } from './config.service'; +import { SummaryScheduleService } from './summary-schedule.service'; +import { ActiveHourEntry } from '../models/active-hours.models'; + +describe('SummaryScheduleService', () => { + let service: SummaryScheduleService; + let httpMock: HttpTestingController; + const API = 'http://test-api'; + const BASE = `${API}/api/summary-schedules`; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), { provide: ConfigService, useValue: { apiHost: API } }], + }); + service = TestBed.inject(SummaryScheduleService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('enabled signal defaults to false', () => { + expect(service.enabled()).toBe(false); + }); + + describe('getSchedules', () => { + it('GETs /api/summary-schedules and parses each activeHours string into entries', () => { + let result: { alertType: string; activeHours: ActiveHourEntry[] }[] | undefined; + service.getSchedules().subscribe(res => (result = res)); + + const req = httpMock.expectOne(BASE); + expect(req.request.method).toBe('GET'); + req.flush([{ activeHours: '[{"day":1,"hours":9,"mins":0}]', alertType: 'quest' }]); + + expect(result).toHaveLength(1); + expect(result![0].alertType).toBe('quest'); + expect(result![0].activeHours).toEqual([{ day: 1, hours: 9, mins: 0 }]); + }); + + it('returns an empty list when the API returns no schedules', () => { + let result: unknown[] | undefined; + service.getSchedules().subscribe(res => (result = res)); + + httpMock.expectOne(BASE).flush([]); + + expect(result).toEqual([]); + }); + + it('coerces PoracleNG string-typed hours/mins to numbers', () => { + let result: { activeHours: ActiveHourEntry[] }[] | undefined; + service.getSchedules().subscribe(res => (result = res)); + + httpMock.expectOne(BASE).flush([{ activeHours: '[{"day":"2","hours":"08","mins":"30"}]', alertType: 'quest' }]); + + expect(result![0].activeHours).toEqual([{ day: 2, hours: 8, mins: 30 }]); + }); + }); + + describe('getSchedule', () => { + it('GETs /{alertType} and maps the response to a SummarySchedule', () => { + let result: { alertType: string; activeHours: ActiveHourEntry[] } | null | undefined; + service.getSchedule('quest').subscribe(res => (result = res)); + + const req = httpMock.expectOne(`${BASE}/quest`); + expect(req.request.method).toBe('GET'); + req.flush({ activeHours: '[{"day":5,"hours":18,"mins":0}]', alertType: 'quest' }); + + expect(result).toEqual({ activeHours: [{ day: 5, hours: 18, mins: 0 }], alertType: 'quest' }); + }); + + it('maps a 404 to null (a missing schedule is normal, not an error)', () => { + let result: unknown = 'unset'; + service.getSchedule('quest').subscribe(res => (result = res)); + + httpMock.expectOne(`${BASE}/quest`).flush({ error: 'schedule not found' }, { status: 404, statusText: 'Not Found' }); + + expect(result).toBeNull(); + }); + + it('maps a 503 outage to null without throwing', () => { + let result: unknown = 'unset'; + let errored = false; + service.getSchedule('quest').subscribe({ + error: () => (errored = true), + next: res => (result = res), + }); + + httpMock.expectOne(`${BASE}/quest`).flush('unavailable', { status: 503, statusText: 'Service Unavailable' }); + + expect(errored).toBe(false); + expect(result).toBeNull(); + }); + }); + + describe('setSchedule', () => { + it('PUTs /{alertType} with the stringified entries array', () => { + const hours: ActiveHourEntry[] = [ + { day: 1, hours: 9, mins: 0 }, + { day: 2, hours: 9, mins: 0 }, + ]; + service.setSchedule('quest', hours).subscribe(); + + const req = httpMock.expectOne(`${BASE}/quest`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ activeHours: JSON.stringify(hours) }); + req.flush(null); + }); + + it('PUTs "[]" when passed null (clear without deleting the row)', () => { + service.setSchedule('quest', null).subscribe(); + + const req = httpMock.expectOne(`${BASE}/quest`); + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual({ activeHours: '[]' }); + req.flush(null); + }); + + it('PUTs "[]" when passed an empty array', () => { + service.setSchedule('quest', []).subscribe(); + + const req = httpMock.expectOne(`${BASE}/quest`); + expect(req.request.body).toEqual({ activeHours: '[]' }); + req.flush(null); + }); + }); + + describe('deleteSchedule', () => { + it('DELETEs /{alertType}', () => { + service.deleteSchedule('quest').subscribe(); + + const req = httpMock.expectOne(`${BASE}/quest`); + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); + + describe('trigger', () => { + it('POSTs /{alertType}/trigger', () => { + service.trigger('quest').subscribe(); + + const req = httpMock.expectOne(`${BASE}/quest/trigger`); + expect(req.request.method).toBe('POST'); + req.flush(null); + }); + }); + + describe('loadCapability', () => { + it('GETs /capability and sets enabled=true from a 200 body', () => { + service.loadCapability(); + + const req = httpMock.expectOne(`${BASE}/capability`); + expect(req.request.method).toBe('GET'); + req.flush({ enabled: true }); + + expect(service.enabled()).toBe(true); + }); + + it('sets enabled=false when the API reports the feature off', () => { + service.loadCapability(); + + httpMock.expectOne(`${BASE}/capability`).flush({ enabled: false }); + + expect(service.enabled()).toBe(false); + }); + + it('defaults to false and does not throw on a 503 outage', () => { + service.loadCapability(); + + httpMock.expectOne(`${BASE}/capability`).flush('unavailable', { status: 503, statusText: 'Service Unavailable' }); + + expect(service.enabled()).toBe(false); + }); + + it('preserves the prior enabled value on a transient capability error', () => { + // First load succeeds and flips the signal on. + service.loadCapability(); + httpMock.expectOne(`${BASE}/capability`).flush({ enabled: true }); + expect(service.enabled()).toBe(true); + + // A later refresh that errors must not flip the panel off. + (service as unknown as { fetchCapability: () => void }).fetchCapability(); + httpMock.expectOne(`${BASE}/capability`).flush('boom', { status: 500, statusText: 'Internal Server Error' }); + + expect(service.enabled()).toBe(true); + }); + + it('is idempotent — two calls issue exactly one capability request', () => { + service.loadCapability(); + service.loadCapability(); + + const requests = httpMock.match(`${BASE}/capability`); + expect(requests.length).toBe(1); + requests[0].flush({ enabled: true }); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.ts new file mode 100644 index 00000000..aeb7c14d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.ts @@ -0,0 +1,85 @@ +import { HttpClient } from '@angular/common/http'; +import { DestroyRef, Injectable, inject, signal } from '@angular/core'; +import { Observable, catchError, map, of } from 'rxjs'; + +import { ConfigService } from './config.service'; +import { ActiveHourEntry, parseActiveHours } from '../models/active-hours.models'; + +export interface SummarySchedule { + activeHours: ActiveHourEntry[]; + alertType: string; // 'quest' only today +} + +interface CapabilityResponse { + enabled: boolean; +} + +interface SummaryScheduleResponse { + activeHours: string; + alertType: string; +} + +const REFRESH_INTERVAL_MS = 300_000; + +@Injectable({ providedIn: 'root' }) +export class SummaryScheduleService { + private readonly config = inject(ConfigService); + private readonly destroyRef = inject(DestroyRef); + private readonly http = inject(HttpClient); + + private loaded = false; + readonly enabled = signal(false); // false => hide panel + annotate hint + + private get base(): string { + return `${this.config.apiHost}/api/summary-schedules`; + } + + deleteSchedule(alertType: string): Observable { + return this.http.delete(`${this.base}/${alertType}`); + } + + getSchedule(alertType: string): Observable { + return this.http.get(`${this.base}/${alertType}`).pipe( + map(res => this.mapSchedule(res)), + catchError(() => of(null)), + ); + } + + getSchedules(): Observable { + return this.http.get(this.base).pipe(map(list => list.map(res => this.mapSchedule(res)))); + } + + loadCapability(): void { + if (this.loaded) return; + this.loaded = true; + + this.fetchCapability(); + + const intervalId = setInterval(() => this.fetchCapability(), REFRESH_INTERVAL_MS); + this.destroyRef.onDestroy(() => clearInterval(intervalId)); + } + + setSchedule(alertType: string, hours: ActiveHourEntry[] | null): Observable { + return this.http.put(`${this.base}/${alertType}`, { activeHours: JSON.stringify(hours ?? []) }); + } + + trigger(alertType: string): Observable { + return this.http.post(`${this.base}/${alertType}/trigger`, {}); + } + + private fetchCapability(): void { + const wasEnabled = this.enabled(); + + this.http + .get(`${this.base}/capability`) + .pipe(catchError(() => of({ enabled: wasEnabled }))) + .subscribe(res => this.enabled.set(res.enabled)); + } + + private mapSchedule(res: SummaryScheduleResponse): SummarySchedule { + return { + activeHours: parseActiveHours(res.activeHours), + alertType: res.alertType, + }; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.spec.ts new file mode 100644 index 00000000..9b22f6f0 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.spec.ts @@ -0,0 +1,168 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ConfigService } from './config.service'; +import { TokenStoreService } from './token-store.service'; + +describe('TokenStoreService', () => { + let service: TokenStoreService; + let httpMock: HttpTestingController; + + const REFRESH_URL = '/api/auth/oidc/refresh'; + + beforeEach(() => { + localStorage.clear(); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), { provide: ConfigService, useValue: { apiHost: '' } }], + }); + service = TestBed.inject(TokenStoreService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('reports hasRefreshToken based on storage', () => { + expect(service.hasRefreshToken()).toBe(false); + localStorage.setItem('poracle_refresh_token', 'rt'); + expect(service.hasRefreshToken()).toBe(true); + }); + + it('storeTokens persists token, refresh token and computed expiry', () => { + service.storeTokens('jwt', 'rt', 1800); + expect(localStorage.getItem('poracle_token')).toBe('jwt'); + expect(localStorage.getItem('poracle_refresh_token')).toBe('rt'); + const exp = Number(localStorage.getItem('poracle_token_expires_at')); + expect(exp).toBeGreaterThan(Date.now()); + }); + + it('isExpiringSoon is true within the skew window and false outside it', () => { + localStorage.setItem('poracle_token_expires_at', String(Date.now() + 30_000)); + expect(service.isExpiringSoon()).toBe(true); + + localStorage.setItem('poracle_token_expires_at', String(Date.now() + 120_000)); + expect(service.isExpiringSoon()).toBe(false); + }); + + it('single-flights concurrent refresh() calls into one HTTP request', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + + const tokens: string[] = []; + service.refresh().subscribe(t => tokens.push(t)); + service.refresh().subscribe(t => tokens.push(t)); + + const req = httpMock.expectOne(REFRESH_URL); + expect(req.request.body).toEqual({ refreshToken: 'rt-1' }); + req.flush({ expiresIn: 1800, refreshToken: 'rt-2', token: 'new-jwt' }); + + expect(tokens).toEqual(['new-jwt', 'new-jwt']); + expect(localStorage.getItem('poracle_token')).toBe('new-jwt'); + expect(localStorage.getItem('poracle_refresh_token')).toBe('rt-2'); + }); + + it('clears tokens and emits forceLogout$ on refresh failure', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + + let forced = false; + service.forceLogout$.subscribe(() => (forced = true)); + + service.refresh().subscribe({ error: () => undefined }); + httpMock.expectOne(REFRESH_URL).flush({ error: 'invalid_grant' }, { status: 401, statusText: 'Unauthorized' }); + + expect(forced).toBe(true); + expect(localStorage.getItem('poracle_refresh_token')).toBeNull(); + expect(localStorage.getItem('poracle_token_expires_at')).toBeNull(); + }); + + it('emits forceLogout$ immediately when refresh is called with no refresh token', () => { + let forced = false; + service.forceLogout$.subscribe(() => (forced = true)); + + service.refresh().subscribe({ error: () => undefined }); + + expect(forced).toBe(true); + httpMock.expectNone(REFRESH_URL); + }); + + it('revoke() posts to the revoke endpoint when a refresh token exists', () => { + localStorage.setItem('poracle_refresh_token', 'rt-1'); + service.revoke(); + const req = httpMock.expectOne('/api/auth/oidc/refresh/revoke'); + expect(req.request.body).toEqual({ refreshToken: 'rt-1' }); + req.flush({}); + }); + + it('revoke() does nothing without a refresh token', () => { + service.revoke(); + httpMock.expectNone('/api/auth/oidc/refresh/revoke'); + }); + + describe('a login that carries no refresh token', () => { + it('clears a refresh token left behind by a previous session', () => { + // A Discord or Telegram login on a browser that had held an OIDC session used to inherit that + // session's refresh token, and the refresh interceptor then swapped in a JWT minted for the + // previous user. See #625. + localStorage.setItem('poracle_refresh_token', 'previous-users-token'); + localStorage.setItem('poracle_token_expires_at', '1'); + + service.storeTokens('new-jwt', null); + + expect(localStorage.getItem('poracle_refresh_token')).toBeNull(); + expect(service.hasRefreshToken()).toBe(false); + }); + + it('still keeps a refresh token the new login supplies', () => { + service.storeTokens('new-jwt', 'new-refresh', 1800); + + expect(localStorage.getItem('poracle_refresh_token')).toBe('new-refresh'); + }); + }); + + describe('clearAll', () => { + it('discards every key a session consists of and announces it', () => { + localStorage.setItem('poracle_token', 'jwt'); + localStorage.setItem('poracle_admin_token', 'admin-jwt'); + localStorage.setItem('poracle_refresh_token', 'refresh'); + localStorage.setItem('poracle_token_expires_at', '1'); + let announced = false; + service.sessionCleared$.subscribe(() => (announced = true)); + + service.clearAll(); + + expect(localStorage.getItem('poracle_token')).toBeNull(); + expect(localStorage.getItem('poracle_admin_token')).toBeNull(); + expect(localStorage.getItem('poracle_refresh_token')).toBeNull(); + expect(localStorage.getItem('poracle_token_expires_at')).toBeNull(); + expect(announced).toBe(true); + }); + }); + + describe('tryRestoreAdminSession', () => { + it('installs the stashed admin token and announces the end of the inspection', () => { + localStorage.setItem('poracle_token', 'impersonation-jwt'); + localStorage.setItem('poracle_admin_token', 'admin-jwt'); + let announced = false; + service.impersonationEnded$.subscribe(() => (announced = true)); + + expect(service.tryRestoreAdminSession()).toBe(true); + + expect(localStorage.getItem('poracle_token')).toBe('admin-jwt'); + expect(localStorage.getItem('poracle_admin_token')).toBeNull(); + expect(announced).toBe(true); + }); + + it('leaves an ordinary session alone when there is nothing stashed', () => { + // The common case: an expired token on a session that was never impersonating. Touching it here + // would strand a session the 401 path is about to clear anyway. See #706. + localStorage.setItem('poracle_token', 'expired-jwt'); + let announced = false; + service.impersonationEnded$.subscribe(() => (announced = true)); + + expect(service.tryRestoreAdminSession()).toBe(false); + + expect(localStorage.getItem('poracle_token')).toBe('expired-jwt'); + expect(announced).toBe(false); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.ts new file mode 100644 index 00000000..f6cf1703 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.ts @@ -0,0 +1,181 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, Subject, catchError, finalize, map, shareReplay, tap, throwError } from 'rxjs'; + +import { ConfigService } from './config.service'; + +const TOKEN_KEY = 'poracle_token'; +const REFRESH_KEY = 'poracle_refresh_token'; +const EXPIRES_KEY = 'poracle_token_expires_at'; +// Owned by AuthService, cleared here so the 401 path can end a session without constructing it. +const ADMIN_TOKEN_KEY = 'poracle_admin_token'; + +/** Refresh proactively this many ms before the access token's `exp`. */ +const EXPIRY_SKEW_MS = 60_000; + +interface RefreshResponse { + expiresIn: number; + refreshToken: string; + token: string; +} + +/** + * Single source of truth for the OIDC silent-refresh tokens (the short-lived JWT, the opaque + * refresh token, and the JWT expiry) plus the refresh call itself. The refresh is single-flighted + * via `shareReplay` so concurrent 401s collapse to one network round-trip. Only relevant when the + * user logged in through an OIDC provider with refresh enabled; otherwise no refresh token is + * stored and every consumer falls back to the plain "401 → logout" path. + */ +@Injectable({ providedIn: 'root' }) +export class TokenStoreService { + private readonly config = inject(ConfigService); + private readonly http = inject(HttpClient); + + private refreshInFlight$: Observable | null = null; + + /** Emits when a refresh definitively fails — AuthService subscribes and logs the user out. */ + readonly forceLogout$ = new Subject(); + + /** Emits when a 401 dropped an impersonation session back to the admin's own token. */ + readonly impersonationEnded$ = new Subject(); + + /** Emits when the session was discarded from under the app — AuthService resets its own state. */ + readonly sessionCleared$ = new Subject(); + + /** Clears the refresh token + expiry (the main JWT is owned by AuthService). */ + clear(): void { + localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem(EXPIRES_KEY); + } + + /** + * Discards every key a session consists of, and tells AuthService to forget the user. + */ + /* The 401 path used to remove poracle_token by hand, leaving the admin impersonation token, the + * refresh token and the expiry behind, and leaving AuthService still holding a user. It lives here + * rather than on AuthService because an interceptor that injects AuthService constructs it, and + * constructing it fires a /api/auth/me request. See #616, #627, #628. */ + clearAll(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(ADMIN_TOKEN_KEY); + this.clear(); + this.sessionCleared$.next(); + } + + getAccessToken(): string | null { + return localStorage.getItem(TOKEN_KEY); + } + + getRefreshToken(): string | null { + return localStorage.getItem(REFRESH_KEY); + } + + hasRefreshToken(): boolean { + return !!this.getRefreshToken()?.trim(); + } + + /** True when the access token is within the skew window of expiry (or already expired). */ + isExpiringSoon(): boolean { + const stored = Number(localStorage.getItem(EXPIRES_KEY)); + const expiresAt = stored || this.decodeExpiry(this.getAccessToken() ?? ''); + return !!expiresAt && expiresAt - Date.now() < EXPIRY_SKEW_MS; + } + + /** + * Refreshes the session against the API, rotating both the JWT and the opaque refresh token. + * Concurrent callers share one in-flight request. On failure it clears tokens and emits + * `forceLogout$`, then rethrows. + */ + refresh(): Observable { + if (this.refreshInFlight$) { + return this.refreshInFlight$; + } + + const refreshToken = this.getRefreshToken(); + if (!refreshToken) { + this.forceLogout$.next(); + return throwError(() => new Error('no_refresh_token')); + } + + this.refreshInFlight$ = this.http.post(`${this.config.apiHost}/api/auth/oidc/refresh`, { refreshToken }).pipe( + tap(res => this.storeTokens(res.token, res.refreshToken, res.expiresIn)), + map(res => res.token), + catchError(err => { + this.clear(); + this.forceLogout$.next(); + return throwError(() => err); + }), + finalize(() => { + this.refreshInFlight$ = null; + }), + shareReplay(1), + ); + + return this.refreshInFlight$; + } + + /** Best-effort server-side revoke of the current session family (logout). Fire-and-forget. */ + revoke(): void { + const refreshToken = this.getRefreshToken(); + if (!refreshToken) { + return; + } + + this.http.post(`${this.config.apiHost}/api/auth/oidc/refresh/revoke`, { refreshToken }).subscribe({ + error: () => { + /* logout must proceed regardless */ + }, + }); + } + + /** Persists the JWT, the opaque refresh token (when present), and the computed expiry. */ + storeTokens(token: string, refreshToken: string | null, expiresInSeconds?: number): void { + localStorage.setItem(TOKEN_KEY, token); + + // Cleared, not left alone, when the new login has no refresh token of its own. A Discord or + // Telegram login on a browser that previously held an OIDC session used to inherit that session's + // refresh token: the refresh interceptor then saw hasRefreshToken() true, posted the stale token, + // and replaced the JWT with one minted for the previous user. See #625. + if (refreshToken) { + localStorage.setItem(REFRESH_KEY, refreshToken); + } else { + localStorage.removeItem(REFRESH_KEY); + localStorage.removeItem(EXPIRES_KEY); + } + + const expiresAt = expiresInSeconds ? Date.now() + expiresInSeconds * 1000 : this.decodeExpiry(token); + if (expiresAt) { + localStorage.setItem(EXPIRES_KEY, String(expiresAt)); + } + } + + /** + * Puts the stashed admin token back as the active one, if there is one. Returns whether it did. + */ + /* A 401 belongs to whoever the token names, and while inspecting an account that is the inspected + * user, not the admin holding the session. clearAll() treats every 401 as the end of the session and + * discards poracle_admin_token with the rest, so one blocked or deleted account signed the admin out + * of their own session with nothing to return to -- inspecting exactly the accounts an admin most + * needs to inspect. Falling back is self-limiting: if the restored admin token is itself dead, the + * next 401 finds no stash and clears normally. See #706, #616. */ + tryRestoreAdminSession(): boolean { + const adminToken = localStorage.getItem(ADMIN_TOKEN_KEY); + if (!adminToken) { + return false; + } + + localStorage.setItem(TOKEN_KEY, adminToken); + localStorage.removeItem(ADMIN_TOKEN_KEY); + this.impersonationEnded$.next(); + return true; + } + + private decodeExpiry(token: string): number | null { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return typeof payload.exp === 'number' ? payload.exp * 1000 : null; + } catch { + return null; + } + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/user-geofence.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/user-geofence.service.ts index 7befeae0..38176859 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/user-geofence.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/user-geofence.service.ts @@ -46,6 +46,14 @@ export class UserGeofenceService { return this.http.post(`${this.config.apiHost}/api/geofences/import/geojson`, formData); } + /** + * Renames a geofence in place. Editing used to be delete-then-recreate, and the recreate re-subscribed + * only the active profile — silently switching the geofence off for every other profile. See #543. + */ + renameGeofence(id: number, data: { displayName: string; groupName?: string | null; parentId?: number | null }): Observable { + return this.http.put(`${this.config.apiHost}/api/geofences/custom/${id}`, data); + } + submitForReview(kojiName: string): Observable { return this.http.post(`${this.config.apiHost}/api/geofences/custom/${encodeURIComponent(kojiName)}/submit`, {}); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.html index b1ae4586..358ec7ab 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.html @@ -3,220 +3,432 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

{{ 'ADMIN.SETTINGS_DESC' | translate }}

- @if (modifiedSettings().size > 0) { - - }
+ + @if (settingsLoading()) {
} @else { - @for (group of visibleGroups(); track group.labelKey; let last = $last) { + + + + + @if (authSectionVisible()) {
-
- {{ group.icon }} - +
+ vpn_key +
-
- @for (meta of group.settings; track meta.key; let rowLast = $last) { - @if (getSettingValue(meta.key) !== null && isSettingVisible(meta)) { -
-
- {{ meta.labelKey | translate }} - {{ meta.descriptionKey | translate }} -
-
- @if (meta.type === 'boolean') { - - } @else { - - - - @if (meta.key === 'favicon_url') { -
- favicon preview -
- } - } -
-
- @if (meta.key === 'favicon_url') { -
- info_outline - {{ 'ADMIN_SETTINGS.FAVICON_URL_CACHE_WARNING' | translate }} -
-
- info_outline - {{ 'ADMIN_SETTINGS.FAVICON_URL_CSP_NOTE' | translate }} -
- } - @if (!rowLast) { - - } - } - } +
+
+ {{ 'ADMIN_SETTINGS.AUTH_MODE_LABEL' | translate }} + {{ + (authMode() === 'oidc' ? 'ADMIN_SETTINGS.AUTH_MODE_OIDC_DESC' : 'ADMIN_SETTINGS.AUTH_MODE_LOCAL_DESC') | translate + }} +
+ + + group + {{ 'ADMIN_SETTINGS.AUTH_MODE_LOCAL' | translate }} + + + vpn_key + {{ 'ADMIN_SETTINGS.AUTH_MODE_OIDC' | translate }} + +
- @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && telegramConfig()) { - @if (!telegramConfig()!.enabled) { + @if (!oidcConfigured()) { +
+ info_outline + {{ 'ADMIN_SETTINGS.AUTH_OIDC_NOT_CONFIGURED' | translate }} +
+ } + + @if (authMode() === 'oidc') { +
+ info_outline + {{ 'ADMIN_SETTINGS.AUTH_OIDC_HIDES_LOCAL' | translate }} +
+ + @if (oidcConfig()?.forceLocal) {
- info_outline - + warning + {{ 'ADMIN_SETTINGS.AUTH_FORCE_LOCAL_ACTIVE' | translate }}
} -
-
- dns - {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} - - {{ 'ADMIN.READ_ONLY' | translate }} - + + @if (oidcEndSessionConfigured()) { +
+
+ {{ 'ADMIN_SETTINGS.AUTH_SLO_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.AUTH_SLO_DESC' | translate }} +
+
+ +
+
+ } @else { +
+ info_outline + {{ 'ADMIN_SETTINGS.AUTH_SLO_UNAVAILABLE' | translate }}
-
-
-
- {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_DESC' | translate }} + } + + @if (oidcConfig(); as oidc) { +
+
+ dns + {{ 'ADMIN_SETTINGS.OIDC_SERVER_CONFIG' | translate }} + + {{ 'ADMIN.READ_ONLY' | translate }} + +
+
+
+
+ {{ 'ADMIN_SETTINGS.OIDC_PROVIDER_LABEL' | translate }} +
+
+ {{ oidc.providerName || ('ADMIN.NOT_CONFIGURED' | translate) }} +
-
- {{ telegramConfig()!.enabled ? 'true' : 'false' }} + +
+
+ {{ 'ADMIN_SETTINGS.OIDC_AUTHORIZATION_URL_LABEL' | translate }} +
+
+ {{ oidc.authorizationUrl || ('ADMIN.NOT_CONFIGURED' | translate) }} +
-
- -
-
- {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_DESC' | translate }} + +
+
+ {{ 'ADMIN_SETTINGS.OIDC_TOKEN_URL_LABEL' | translate }} +
+
+ {{ oidc.tokenUrl || ('ADMIN.NOT_CONFIGURED' | translate) }} +
-
- {{ - telegramConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) - }} + +
+
+ {{ 'ADMIN_SETTINGS.OIDC_USERINFO_URL_LABEL' | translate }} +
+
+ {{ oidc.userInfoUrl || ('ADMIN.NOT_CONFIGURED' | translate) }} +
-
- -
-
- {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_DESC' | translate }} + +
+
+ {{ 'ADMIN_SETTINGS.OIDC_CLIENT_ID_LABEL' | translate }} +
+
+ {{ oidc.clientId || ('ADMIN.NOT_CONFIGURED' | translate) }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.OIDC_SCOPES_LABEL' | translate }} +
+
+ {{ oidc.scopes || ('ADMIN.NOT_CONFIGURED' | translate) }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.OIDC_IDENTITY_CLAIM_LABEL' | translate }} +
+
+ {{ oidc.identityClaim || ('ADMIN.NOT_CONFIGURED' | translate) }} +
-
- {{ - telegramConfig()!.botUsername || ('ADMIN.NOT_CONFIGURED' | translate) - }} + +
+
+ {{ 'ADMIN_SETTINGS.OIDC_USE_PKCE_LABEL' | translate }} +
+
+ {{ oidc.usePkce ? 'true' : 'false' }} +
-
+ } } +
+ } +
- @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_DISCORD' && discordConfig()) { -
-
- dns - {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} - - {{ 'ADMIN.READ_ONLY' | translate }} - -
-
-
-
- {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_DESC' | translate }} -
-
- {{ - discordConfig()!.clientId || ('ADMIN.NOT_CONFIGURED' | translate) - }} + @for (group of visibleGroups(); track group.labelKey; let last = $last; let i = $index) { +
+ + + @if (!isCollapsed(group.labelKey)) { +
+ @for (meta of group.settings; track meta.key; let rowLast = $last) { + @if (getSettingValue(meta.key) !== null && isSettingVisible(meta)) { +
+
+ + +
+
+ @if (meta.type === 'boolean') { + + } @else { + + + + @if (meta.key === 'favicon_url') { +
+ favicon preview +
+ } + } +
+ @if (forcedByPoracle(meta)) { +
+ lock + {{ 'ADMIN_SETTINGS.FORCED_BY_PORACLE' | translate }} +
+ } + @if (meta.key === 'allowed_languages' && poracleLocale()) { +
+ language + {{ 'ADMIN_SETTINGS.PORACLE_LOCALE_HINT' | translate: { locale: poracleLocale() } }} +
+ } + @if (meta.key === 'favicon_url') { +
+ info_outline + {{ 'ADMIN_SETTINGS.FAVICON_URL_CACHE_WARNING' | translate }} +
+
+ info_outline + {{ 'ADMIN_SETTINGS.FAVICON_URL_CSP_NOTE' | translate }} +
+ } + @if (!rowLast) { + + } + } + } +
+ + @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && telegramConfig()) { + @if (!telegramConfig()!.enabled) { +
+ info_outline +
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_DESC' | translate }} -
-
- {{ - discordConfig()!.clientSecret || ('ADMIN.NOT_CONFIGURED' | translate) - }} -
+ } +
+
+ dns + {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} + + {{ 'ADMIN.READ_ONLY' | translate }} +
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_DESC' | translate }} +
+
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_DESC' | translate }} +
+
+ {{ telegramConfig()!.enabled ? 'true' : 'false' }} +
+
+ +
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_DESC' | translate }} +
+
+ {{ + telegramConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
- {{ - discordConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) - }} + +
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_DESC' | translate }} +
+
+ {{ + telegramConfig()!.botUsername || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_DESC' | translate }} +
+ } + + @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_DISCORD' && discordConfig()) { +
+
+ dns + {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} + + {{ 'ADMIN.READ_ONLY' | translate }} + +
+
+
+
+ {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_DESC' | translate }} +
+
+ {{ + discordConfig()!.clientId || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
- {{ - discordConfig()!.guildId || ('ADMIN.NOT_CONFIGURED' | translate) - }} + +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_DESC' | translate }} +
+
+ {{ + discordConfig()!.clientSecret || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_DESC' | translate }} + +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_DESC' | translate }} +
+
+ {{ + discordConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
- {{ - discordConfig()!.adminIds || ('ADMIN.NOT_CONFIGURED' | translate) - }} + +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_GUILD_ID_DESC' | translate }} +
+
+ {{ + discordConfig()!.guildId || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_DESC' | translate }} + +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_ADMIN_IDS_DESC' | translate }} +
+
+ {{ + discordConfig()!.adminIds || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
- {{ - discordConfig()!.geofenceForumChannelId || ('ADMIN.NOT_CONFIGURED' | translate) - }} + +
+
+ {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_GEOFENCE_FORUM_DESC' | translate }} +
+
+ {{ + discordConfig()!.geofenceForumChannelId || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
+ } }
@@ -226,47 +438,49 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

} -
-
-
- image - -
-
- @for (repo of iconRepos; track repo.name) { -
-
-
- @if (isRepoActive(repo)) { - check_circle - } @else { - radio_button_unchecked - } + @if (!searchQuery()) { +
+
+
+ image + +
+
+ @for (repo of iconRepos; track repo.name) { +
+
+
+ @if (isRepoActive(repo)) { + check_circle + } @else { + radio_button_unchecked + } +
+
+ {{ repo.name }} + {{ repo.base }} +
-
- {{ repo.name }} - {{ repo.base }} +
+ @for (img of repo.previewImages; track img.path) { +
+ + {{ img.name }} +
+ }
-
- @for (img of repo.previewImages; track img.path) { -
- - {{ img.name }} -
- } -
-
- } -
-
+ } +
+
+ } - @if (unknownSettings().length > 0) { + @if (!searchQuery() && unknownSettings().length > 0) {
@@ -292,5 +506,24 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

} + + @if (modifiedSettings().size > 0) { +
+ {{ 'ADMIN_SETTINGS.UNSAVED_CHANGES' | translate: { count: modifiedSettings().size } }} + + + +
+ } }
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss index d7a94887..cdeb6fbe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.scss @@ -25,7 +25,7 @@ padding-left: 12px; } .page-content { - padding: 0 24px 48px; + padding: 0 24px 80px; max-width: 860px; } .loading-container { @@ -363,3 +363,168 @@ .favicon-cache-warning { margin-top: 4px; } + +// Shown under a toggle Poracle's own config forces off, so the locked switch has a reason. +.forced-by-poracle-hint { + margin-top: 4px; + margin-bottom: 4px; +} + +// ─── Live search bar ──────────────────────────────────────────────────────── +.settings-search { + position: sticky; + top: 8px; + z-index: 5; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px 4px 14px; + margin-bottom: 16px; + border-radius: 24px; + background: var(--mat-app-surface, var(--surface-variant, rgba(255, 255, 255, 0.96))); + border: 1px solid var(--divider, rgba(0, 0, 0, 0.12)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} +.search-leading { + flex-shrink: 0; + font-size: 20px; + width: 20px; + height: 20px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); +} +.search-input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 14px; + color: var(--text-primary, rgba(0, 0, 0, 0.87)); + padding: 8px 0; + + &::placeholder { + color: var(--text-hint, rgba(0, 0, 0, 0.4)); + } +} +.search-clear { + flex-shrink: 0; +} + +// ─── Collapsible section header ───────────────────────────────────────────── +.section-header-toggle { + width: 100%; + border: none; + border-left: 4px solid transparent; + text-align: left; + cursor: pointer; + font: inherit; + transition: background 0.15s; + + &:hover { + filter: brightness(0.98); + } +} +.section-header-spacer { + flex: 1; +} +.section-summary { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.02em; + text-transform: none; + color: var(--text-hint, rgba(0, 0, 0, 0.45)); +} +.section-badge { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; + padding: 2px 8px; + border-radius: 10px; + background: rgba(25, 118, 210, 0.12); + color: #1976d2; + white-space: nowrap; +} +.section-chevron { + flex-shrink: 0; + font-size: 20px; + width: 20px; + height: 20px; + color: var(--text-secondary, rgba(0, 0, 0, 0.5)); +} + +// ─── Sticky save / discard bar ────────────────────────────────────────────── +.settings-actionbar { + position: sticky; + bottom: 16px; + z-index: 6; + display: flex; + align-items: center; + gap: 12px; + margin-top: 24px; + padding: 12px 16px; + border-radius: 12px; + background: var(--mat-app-surface, var(--surface-variant, rgba(255, 255, 255, 0.98))); + border: 1px solid var(--divider, rgba(0, 0, 0, 0.12)); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16); + animation: actionbar-slide-up 0.2s ease-out; +} +.actionbar-count { + font-size: 13px; + font-weight: 600; + color: var(--text-primary, rgba(0, 0, 0, 0.87)); +} +.actionbar-spacer { + flex: 1; +} + +@keyframes actionbar-slide-up { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// ─── Staggered section entrance ───────────────────────────────────────────── +.fade-in-up { + animation: fade-in-up 0.3s ease-out both; +} + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .fade-in-up, + .settings-actionbar { + animation: none; + } +} + +.auth-mode-row { + display: flex; + align-items: center; + gap: 24px; + padding: 16px 20px; + flex-wrap: wrap; +} +.auth-mode-toggle { + flex-shrink: 0; + mat-icon { + margin-right: 6px; + font-size: 18px; + height: 18px; + width: 18px; + vertical-align: text-bottom; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts index c649907e..9bc67542 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.component.ts @@ -1,7 +1,20 @@ -import { ChangeDetectionStrategy, Component, OnInit, DestroyRef, inject, signal, computed } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + OnInit, + DestroyRef, + ElementRef, + HostListener, + ViewChild, + inject, + signal, + computed, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatDividerModule } from '@angular/material/divider'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; @@ -10,11 +23,13 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; -import { DiscordServerConfig, PwebSetting, SiteSetting, TelegramServerConfig } from '../../core/models'; +import { DiscordServerConfig, OidcServerConfig, PwebSetting, SiteSetting, TelegramServerConfig } from '../../core/models'; import { I18nService } from '../../core/services/i18n.service'; import { SettingsService } from '../../core/services/settings.service'; +import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; +import { ServerProfileCardComponent } from '../../shared/components/server-profile-card/server-profile-card.component'; /** Union type for backward compatibility during migration */ type AnySettingItem = PwebSetting | SiteSetting; @@ -40,7 +55,70 @@ interface SettingGroup { settings: SettingMeta[]; } -const SETTING_GROUPS: SettingGroup[] = [ +/** + * Keys deliberately withdrawn from the admin UI: they saved, persisted and read back while nothing in + * the product consumed them, and their descriptions promised behaviour the app does not have. Rows are + * left in the database rather than deleted. See #547, #560. + */ +/** + * Keys the API synthesizes onto the settings response rather than storing: projections of Poracle's own + * config, present so the SPA can read them like any other setting. They are declared here for the same + * reason as RETIRED_KEYS -- an undeclared key falls through to the "Other" catch-all and is rendered as + * an editable control, which for a projection is worse than useless: a real row wins over the + * synthesized value, so one save pins it forever and stops tracking Poracle. Writes are refused + * server-side too; this only keeps the box off the page. See #780. + */ +export const PROJECTED_KEYS = ['poracle_locale']; + +const RETIRED_KEYS = [ + // Legacy Poracle keys describing a map picker this app does not have. Removed from the settings UI and + // from SettingsMigrationService when they were retired, but rows persist in existing databases and were + // still rendering in the "Other" catch-all. See #589. + 'disable_geomap', + 'disable_geomap_select', + 'register_command', + 'location_command', + 'provider_url', + 'gAnalyticsId', + 'patreonUrl', + 'paypalUrl', + 'site_is_https', + 'debug', +]; + +export const SETTING_GROUPS: SettingGroup[] = [ + { + color: '#0088cc', + icon: 'send', + labelKey: 'ADMIN_SETTINGS.GROUP_TELEGRAM', + settings: [ + { + descriptionKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_DESC', + key: 'enable_telegram', + labelKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_LABEL', + type: 'boolean', + }, + { + descriptionKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_DESC', + key: 'telegram_bot', + labelKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_LABEL', + type: 'text', + }, + ], + }, + { + color: '#5865F2', + icon: 'forum', + labelKey: 'ADMIN_SETTINGS.GROUP_DISCORD', + settings: [ + { + descriptionKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_DESC', + key: 'enable_discord', + labelKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_LABEL', + type: 'boolean', + }, + ], + }, { color: '#1976d2', icon: 'palette', @@ -181,15 +259,15 @@ const SETTING_GROUPS: SettingGroup[] = [ type: 'boolean', }, { - descriptionKey: 'ADMIN_SETTINGS.DISABLE_GEOMAP_DESC', - key: 'disable_geomap', - labelKey: 'ADMIN_SETTINGS.DISABLE_GEOMAP_LABEL', + descriptionKey: 'ADMIN_SETTINGS.DISABLE_UPDATE_CHECK_DESC', + key: 'disable_update_check', + labelKey: 'ADMIN_SETTINGS.DISABLE_UPDATE_CHECK_LABEL', type: 'boolean', }, { - descriptionKey: 'ADMIN_SETTINGS.DISABLE_GEOMAP_SELECT_DESC', - key: 'disable_geomap_select', - labelKey: 'ADMIN_SETTINGS.DISABLE_GEOMAP_SELECT_LABEL', + descriptionKey: 'ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_DESC', + key: 'disable_user_geofences', + labelKey: 'ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_LABEL', type: 'boolean', }, { @@ -224,76 +302,6 @@ const SETTING_GROUPS: SettingGroup[] = [ showWhen: 'enable_roles', type: 'text', }, - { - descriptionKey: 'ADMIN_SETTINGS.ADMIN_ALLOWED_LANGUAGES_DESC', - key: 'allowed_languages', - labelKey: 'ADMIN_SETTINGS.ADMIN_ALLOWED_LANGUAGES_LABEL', - type: 'text', - }, - ], - }, - { - color: '#607d8b', - icon: 'terminal', - labelKey: 'ADMIN_SETTINGS.GROUP_COMMANDS', - settings: [ - { - descriptionKey: 'ADMIN_SETTINGS.REGISTER_COMMAND_DESC', - key: 'register_command', - labelKey: 'ADMIN_SETTINGS.REGISTER_COMMAND_LABEL', - type: 'text', - }, - { - descriptionKey: 'ADMIN_SETTINGS.LOCATION_COMMAND_DESC', - key: 'location_command', - labelKey: 'ADMIN_SETTINGS.LOCATION_COMMAND_LABEL', - type: 'text', - }, - ], - }, - { - color: '#0088cc', - icon: 'send', - labelKey: 'ADMIN_SETTINGS.GROUP_TELEGRAM', - settings: [ - { - descriptionKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_DESC', - key: 'enable_telegram', - labelKey: 'ADMIN_SETTINGS.ENABLE_TELEGRAM_LABEL', - type: 'boolean', - }, - { - descriptionKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_DESC', - key: 'telegram_bot', - labelKey: 'ADMIN_SETTINGS.TELEGRAM_BOT_LABEL', - type: 'text', - }, - ], - }, - { - color: '#5865F2', - icon: 'forum', - labelKey: 'ADMIN_SETTINGS.GROUP_DISCORD', - settings: [ - { - descriptionKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_DESC', - key: 'enable_discord', - labelKey: 'ADMIN_SETTINGS.ENABLE_DISCORD_LABEL', - type: 'boolean', - }, - ], - }, - { - color: '#2e7d32', - icon: 'map', - labelKey: 'ADMIN_SETTINGS.GROUP_MAPS_ASSETS', - settings: [ - { - descriptionKey: 'ADMIN_SETTINGS.PROVIDER_URL_DESC', - key: 'provider_url', - labelKey: 'ADMIN_SETTINGS.PROVIDER_URL_LABEL', - type: 'url', - }, ], }, { @@ -307,28 +315,6 @@ const SETTING_GROUPS: SettingGroup[] = [ labelKey: 'ADMIN_SETTINGS.SIGNUP_URL_LABEL', type: 'url', }, - { - descriptionKey: 'ADMIN_SETTINGS.GANALYTICSID_DESC', - key: 'gAnalyticsId', - labelKey: 'ADMIN_SETTINGS.GANALYTICSID_LABEL', - type: 'text', - }, - { descriptionKey: 'ADMIN_SETTINGS.PATREONURL_DESC', key: 'patreonUrl', labelKey: 'ADMIN_SETTINGS.PATREONURL_LABEL', type: 'url' }, - { descriptionKey: 'ADMIN_SETTINGS.PAYPALURL_DESC', key: 'paypalUrl', labelKey: 'ADMIN_SETTINGS.PAYPALURL_LABEL', type: 'url' }, - ], - }, - { - color: '#ff5722', - icon: 'bug_report', - labelKey: 'ADMIN_SETTINGS.GROUP_DEBUG', - settings: [ - { - descriptionKey: 'ADMIN_SETTINGS.SITE_IS_HTTPS_DESC', - key: 'site_is_https', - labelKey: 'ADMIN_SETTINGS.SITE_IS_HTTPS_LABEL', - type: 'boolean', - }, - { descriptionKey: 'ADMIN_SETTINGS.DEBUG_DESC', key: 'debug', labelKey: 'ADMIN_SETTINGS.DEBUG_LABEL', type: 'boolean' }, ], }, ]; @@ -338,6 +324,8 @@ const SETTING_GROUPS: SettingGroup[] = [ imports: [ FormsModule, MatButtonModule, + MatButtonToggleModule, + MatDialogModule, MatIconModule, MatInputModule, MatFormFieldModule, @@ -346,7 +334,8 @@ const SETTING_GROUPS: SettingGroup[] = [ MatSlideToggleModule, MatDividerModule, MatTooltipModule, - TranslateModule, + ServerProfileCardComponent, + TranslatePipe, ], selector: 'app-admin-settings', standalone: true, @@ -354,16 +343,32 @@ const SETTING_GROUPS: SettingGroup[] = [ templateUrl: './admin-settings.component.html', }) export class AdminSettingsComponent implements OnInit { + private static readonly COLLAPSED_STORAGE_KEY = 'poracle-admin-settings-collapsed'; + private readonly allDefinedKeys = new Set([ ...SETTING_GROUPS.flatMap(g => g.settings.map(s => s.key)), 'uicons_pkmn', 'uicons_gym', 'uicons_raid', 'uicons_reward', + // Driven by the Authentication mode switch rather than a generic group row, but still + // a known key so it doesn't fall through to the "Other" catch-all section. + 'enable_oidc', + // Single-logout toggle, surfaced as a dedicated control in the Authentication section. + 'enable_oidc_slo', + // Withdrawn from the UI because nothing reads them (#547). Listed here so they do not reappear + // in the "Other" catch-all, which is what happened when they were only removed from their groups: + // the same editable controls, one section lower, still promising behaviour that does not exist. + // Their rows stay in the database, unread. See #560. + ...RETIRED_KEYS, + ...PROJECTED_KEYS, ]); private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); + private readonly i18n = inject(I18nService); + private readonly internalPrefixes = [ 'webhook_delegates:', 'quick_pick:', @@ -379,9 +384,16 @@ export class AdminSettingsComponent implements OnInit { 'admin_disable_userlist', 'admin_channel_id', 'migration_completed', + // Installation sentinel, like migration_completed above. Listed in SettingsController's + // InternalKeys equivalent would hide it from admins too, and the quick-picks guard needs to read + // it -- so it is hidden here instead of there. See #668. + 'quick_picks_seeded', ]; + private readonly originalSnapshot = signal([]); + readonly settings = signal([]); + private readonly settingMap = computed(() => { const map = new Map(); for (const s of this.settings()) map.set(settingKey(s), s.value); @@ -389,8 +401,32 @@ export class AdminSettingsComponent implements OnInit { }); private readonly settingsService = inject(SettingsService); + private readonly snackBar = inject(MatSnackBar); + + /** Current sign-in mode, derived from enable_oidc (opt-in; absent/false = local). */ + readonly authMode = computed<'local' | 'oidc'>(() => (this.getBool('enable_oidc') ? 'oidc' : 'local')); + + readonly searchQuery = signal(''); + + /** + * The Authentication section is hand-written rather than driven by SETTING_GROUPS, so the search + * box never touched it and a nonsense query still left it on screen. See #426. + */ + readonly authSectionVisible = computed(() => { + const query = this.searchQuery().trim().toLowerCase(); + if (!query) return true; + return [ + 'ADMIN_SETTINGS.GROUP_AUTH', + 'ADMIN_SETTINGS.AUTH_MODE_LABEL', + 'ADMIN_SETTINGS.AUTH_MODE_LOCAL', + 'ADMIN_SETTINGS.AUTH_MODE_OIDC', + ].some(key => this.i18n.instant(key).toLowerCase().includes(query)); + }); + readonly bulkSaving = signal(false); + + readonly collapsedGroups = signal>(AdminSettingsComponent.loadCollapsed()); readonly discordConfig = signal(null); readonly iconRepos = [ @@ -453,7 +489,27 @@ export class AdminSettingsComponent implements OnInit { readonly modifiedSettings = signal>(new Map()); + readonly oidcConfig = signal(null); + + /** Whether the OIDC provider is fully configured in the server env (gates the SSO option). */ + readonly oidcConfigured = computed(() => this.oidcConfig()?.configured ?? false); + + /** Whether a provider end-session endpoint is configured (gates the single-logout toggle). */ + readonly oidcEndSessionConfigured = computed(() => !!this.oidcConfig()?.endSessionUrl); + + /** Single-logout admin toggle state — absent defaults to ON once the end-session URL is wired. */ + readonly oidcSloEnabled = computed(() => (this.getSettingValue('enable_oidc_slo') ?? '').toLowerCase() !== 'false'); + + /** + * Poracle's own locale, shown beside Allowed UI Languages because that is the setting it interacts + * with: it decides what a user who has never chosen a language, and whose browser we cannot place, + * sees. Read-only -- it is Poracle's to set, and writes to it are refused (#780). + */ + readonly poracleLocale = computed(() => this.settingMap().get('poracle_locale') ?? ''); + + @ViewChild('searchInput') searchInput?: ElementRef; readonly settingsLoading = signal(true); + readonly telegramConfig = signal(null); readonly unknownSettings = computed(() => @@ -463,14 +519,63 @@ export class AdminSettingsComponent implements OnInit { }), ); - readonly visibleGroups = computed(() => - SETTING_GROUPS.filter( - g => - g.settings.some(s => this.settingMap().has(s.key)) || - (g.labelKey === 'ADMIN_SETTINGS.GROUP_DISCORD' && this.discordConfig() !== null) || - (g.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && this.telegramConfig() !== null), - ), - ); + readonly visibleGroups = computed(() => { + // In OIDC sign-in mode the local provider sections are moot — hide them; the read-only + // OIDC config card is shown by the bespoke Authentication section instead. + const localProviderGroups = new Set(['ADMIN_SETTINGS.GROUP_DISCORD', 'ADMIN_SETTINGS.GROUP_TELEGRAM']); + const oidcMode = this.authMode() === 'oidc'; + const query = this.searchQuery().trim(); + const base = SETTING_GROUPS.filter(g => { + if (oidcMode && localProviderGroups.has(g.labelKey)) return false; + // A group that declares no settings can never render anything, so it is a header and a chevron + // over nothing. Three shipped that way -- Maps & Assets was left behind when #452 deleted the two + // toggles it held. Note this is NOT the filter #629 reverted: that one dropped a group whose keys + // had no DB row yet, which a fresh install always has; this one only drops groups with nothing + // declared in the code at all. + if (g.settings.length === 0) return false; + // Deliberately not gated on a key already having a row. A fresh install seeds exactly one + // (custom_title), so Alarm Types, Features, Administration and Analytics were filtered out of the + // DOM entirely -- and since this page is the only writer, the row could never appear. The + // row-level guard below already renders an absent key correctly. See #629. + return true; + }); + if (!query) return base; + return base.map(g => ({ ...g, settings: g.settings.filter(s => this.settingMatches(s)) })).filter(g => g.settings.length > 0); + }); + + private static loadCollapsed(): Set { + try { + const raw = localStorage.getItem(AdminSettingsComponent.COLLAPSED_STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === 'string')); + } catch { + // Ignore malformed/inaccessible storage. + } + return new Set(); + } + + discardAllModified(): void { + this.settings.set(this.originalSnapshot().map(s => ({ ...s }))); + this.modifiedSettings.set(new Map()); + } + + /** Positive-framing checked state for a boolean setting (ON = enabled). */ + featureEnabled(meta: SettingMeta): boolean { + // A type Poracle has switched off in its own config is off no matter what this row stores, so + // the switch must read off. Showing it on would promise something every write will 403 on (#769). + if (this.forcedByPoracle(meta)) return false; + return this.isInverted(meta) ? !this.getBool(meta.key) : this.getBool(meta.key); + } + + /** + * True when the upstream Poracle deployment disables this feature in its own `config.toml`. The + * admin page cannot override that — Poracle's processor drops the webhook and its bot refuses the + * command — so the row is shown off, locked, and explained rather than left looking adjustable. + */ + forcedByPoracle(meta: SettingMeta): boolean { + return this.settingsService.isForcedByPoracle(meta.key); + } getBool(key: string): boolean { return (this.getSettingValue(key) ?? '').toLowerCase() === 'true'; @@ -481,6 +586,47 @@ export class AdminSettingsComponent implements OnInit { return map.has(key) ? (map.get(key) ?? null) : undefined; } + groupModifiedCount(group: SettingGroup): number { + const modified = this.modifiedSettings(); + return group.settings.reduce((acc, s) => acc + (modified.has(s.key) ? 1 : 0), 0); + } + + groupSummary(group: SettingGroup): string { + const disableKeys = group.settings.filter(s => s.key.startsWith('disable_')); + if (disableKeys.length === 0) return ''; + // Positive framing: report how many features are enabled (i.e. NOT disabled). Counts a + // Poracle-forced type as off, so the header agrees with the switches underneath it. + const count = disableKeys.reduce((acc, s) => acc + (this.featureEnabled(s) ? 1 : 0), 0); + return this.i18n.instant('ADMIN_SETTINGS.SUMMARY_ENABLED', { count, total: disableKeys.length }); + } + + /** Translated label/description with current search matches wrapped in . */ + highlight(key: string): string { + const text = this.i18n.instant(key); + const escaped = this.escapeHtml(text); + const query = this.searchQuery().trim(); + if (!query) return escaped; + const pattern = new RegExp(`(${this.escapeRegExp(this.escapeHtml(query))})`, 'gi'); + return escaped.replace(pattern, '$1'); + } + + /** Search-active force-expands; otherwise read collapsed membership. */ + isCollapsed(labelKey: string): boolean { + if (this.searchQuery().trim()) return false; + return this.collapsedGroups().has(labelKey); + } + + /** + * Boolean settings are presented in positive framing: a toggle ON always means "feature + * enabled". The stored `disable_*` keys have inverted semantics (true = disabled), so they are + * displayed and written inverted. `enable_*`/other booleans pass through unchanged. The stored + * value is never changed in meaning — only the presentation — so backend feature-gating is + * unaffected. + */ + isInverted(meta: SettingMeta): boolean { + return meta.key.startsWith('disable_'); + } + isRepoActive(repo: { base: string }): boolean { const current = (this.getSettingValue('uicons_pkmn') ?? '').toLowerCase(); return current.startsWith(repo.base.toLowerCase()); @@ -507,6 +653,7 @@ export class AdminSettingsComponent implements OnInit { }, next: settings => { this.settings.set(settings); + this.originalSnapshot.set(settings.map(s => ({ ...s }))); this.settingsLoading.set(false); }, }); @@ -520,12 +667,41 @@ export class AdminSettingsComponent implements OnInit { .getTelegramConfig() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ next: config => this.telegramConfig.set(config) }); + + this.settingsService + .getOidcConfig() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ next: config => this.oidcConfig.set(config) }); } onBoolChange(key: string, value: boolean): void { this.applyChange(key, value ? 'True' : 'False'); } + /** Persist a positive-framing toggle, converting back to the stored (possibly inverted) value. */ + onFeatureToggle(meta: SettingMeta, checked: boolean): void { + this.onBoolChange(meta.key, this.isInverted(meta) ? !checked : checked); + } + + @HostListener('document:keydown', ['$event']) + onKeydown(event: KeyboardEvent): void { + const target = event.target as HTMLElement | null; + const tag = target?.tagName?.toLowerCase(); + const isEditable = tag === 'input' || tag === 'textarea' || tag === 'select' || target?.isContentEditable === true; + + if (event.key === 'Escape' && this.searchInput && target === this.searchInput.nativeElement) { + this.searchQuery.set(''); + return; + } + + const isSlash = event.key === '/' && !isEditable; + const isCmdK = (event.ctrlKey || event.metaKey) && (event.key === 'k' || event.key === 'K'); + if (isSlash || isCmdK) { + event.preventDefault(); + this.searchInput?.nativeElement.focus(); + } + } + onPreviewError(event: Event): void { const img = event.target as HTMLImageElement; img.classList.add('preview-error'); @@ -541,7 +717,13 @@ export class AdminSettingsComponent implements OnInit { } saveAllModified(): void { - const entries = Array.from(this.modifiedSettings().entries()); + // Enables first. The anti-lockout guard on the server reads the *other* login key from the + // database, so turning Discord off and Telegram on in one batch failed on whichever request landed + // first: the Discord PUT still saw enable_telegram false and answered 400, leaving a partial save + // and a message about a state the admin was in the middle of leaving. See #633. + const entries = Array.from(this.modifiedSettings().entries()).sort( + ([, a], [, b]) => Number(this.isDisablingLogin(b)) - Number(this.isDisablingLogin(a)), + ); if (!entries.length) return; this.bulkSaving.set(true); let done = 0, @@ -593,6 +775,46 @@ export class AdminSettingsComponent implements OnInit { ); } + /** + * Switch the sign-in mode. OIDC is gated on the provider being configured in env, and a + * confirmation warns that local login is bypassed (and about the AUTH_FORCE_LOCAL recovery + * path). The change is staged like any other setting — it persists on Save. + */ + setAuthMode(mode: 'local' | 'oidc'): void { + if (mode === this.authMode()) return; + + if (mode === 'oidc') { + if (!this.oidcConfigured()) return; + const provider = this.oidcConfig()?.providerName || this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_OIDC'); + const ref = this.dialog.open(ConfirmDialogComponent, { + data: { + confirmText: this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_SWITCH_CONFIRM'), + message: this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_OIDC_CONFIRM_MSG', { provider }), + title: this.i18n.instant('ADMIN_SETTINGS.AUTH_MODE_OIDC_CONFIRM_TITLE'), + warn: true, + } as ConfirmDialogData, + }); + ref.afterClosed().subscribe(confirmed => { + if (confirmed) this.applyChange('enable_oidc', 'True'); + }); + return; + } + + this.applyChange('enable_oidc', 'False'); + } + + toggleGroup(labelKey: string): void { + const next = new Set(this.collapsedGroups()); + if (next.has(labelKey)) next.delete(labelKey); + else next.add(labelKey); + this.collapsedGroups.set(next); + try { + localStorage.setItem(AdminSettingsComponent.COLLAPSED_STORAGE_KEY, JSON.stringify([...next])); + } catch { + // Ignore persistence failures (e.g. private mode quota). + } + } + private applyChange(key: string, value: string): void { this.settings.update(list => { const exists = list.some(s => settingKey(s) === key); @@ -606,8 +828,20 @@ export class AdminSettingsComponent implements OnInit { }); } + private escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + private escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + private finish(done: number, errors: number, errorMessages: string[] = []): void { this.bulkSaving.set(false); + if (errors === 0) { + // Saved values become the new baseline for discard. + this.originalSnapshot.set(this.settings().map(s => ({ ...s }))); + } const msg = errors === 0 ? this.i18n.instant('ADMIN_SETTINGS.SAVE_SUCCESS', { count: done }) @@ -616,4 +850,16 @@ export class AdminSettingsComponent implements OnInit { : this.i18n.instant('ADMIN_SETTINGS.SAVE_PARTIAL', { done, errors }); this.snackBar.open(msg, this.i18n.instant('COMMON.OK'), { duration: errors ? 5000 : 3000 }); } + + /** Whether a pending value would switch a login method off. See #633. */ + private isDisablingLogin(value: unknown): boolean { + return String(value).toLowerCase() === 'false'; + } + + private settingMatches(meta: SettingMeta): boolean { + const query = this.searchQuery().trim().toLowerCase(); + if (!query) return true; + const haystack = `${this.i18n.instant(meta.labelKey)} ${this.i18n.instant(meta.descriptionKey)} ${meta.key}`.toLowerCase(); + return haystack.includes(query); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.groups.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.groups.spec.ts new file mode 100644 index 00000000..34923367 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-settings.groups.spec.ts @@ -0,0 +1,49 @@ +import { PROJECTED_KEYS, SETTING_GROUPS } from './admin-settings.component'; + +/** + * The admin settings page renders one expansion panel per group. A group that declares no settings + * renders as a header and a chevron over nothing, which is what three of them did: Maps & Assets was + * left behind when #452 deleted the two toggles it held, and Commands and Debug never had any. + * + * This asserts the data rather than the rendering because the recurrence is always a data edit -- + * someone removes the last setting from a group and does not notice the shell. + */ +describe('SETTING_GROUPS', () => { + it('declares no group without settings', () => { + const empty = SETTING_GROUPS.filter(g => g.settings.length === 0).map(g => g.labelKey); + + expect(empty).toEqual([]); + }); + + it('gives every group a distinct label key', () => { + const keys = SETTING_GROUPS.map(g => g.labelKey); + + expect(new Set(keys).size).toBe(keys.length); + }); + + it('gives every setting a distinct key across all groups', () => { + // A duplicated key would bind two rows to one value, so the second silently shadows the first. + const keys = SETTING_GROUPS.flatMap(g => g.settings.map(s => s.key)); + + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +/** + * A projection is not a setting. `poracle_locale` is synthesized by the API from Poracle's config so the + * SPA can default the display language; undeclared, it fell through to the "Other" catch-all and rendered + * as an editable text box. Because a real row wins over the synthesized value, one save would have pinned + * the language default for good. Same mistake as #560, for a key that was never in a group. See #780. + */ +describe('PROJECTED_KEYS', () => { + it('covers poracle_locale, so it cannot reach the "Other" catch-all', () => { + expect(PROJECTED_KEYS).toContain('poracle_locale'); + }); + + it('declares nothing that is also a real, editable setting', () => { + const editable = new Set(SETTING_GROUPS.flatMap(g => g.settings.map(s => s.key))); + const overlap = PROJECTED_KEYS.filter(k => editable.has(k)); + + expect(overlap).toEqual([]); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html index 19e506d5..1c96cc4a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.html @@ -58,7 +58,13 @@

{{ 'ADMIN.USERS_TITLE' | translate }}

[defaultUrl]="user.avatarUrl || 'https://cdn.discordapp.com/embed/avatars/0.png'" [userType]="user.type || ''"> - {{ user.name || ('ADMIN.UNNAMED' | translate) }} +
+ {{ user.name || ('ADMIN.UNNAMED' | translate) }} + @let notes = notesLabel(user.notes); + @if (notes) { + {{ notes }} + } +
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss index 50ce4b16..d420e60e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.scss @@ -62,6 +62,19 @@ align-items: center; gap: 10px; } +.user-name-text { + display: flex; + flex-direction: column; + min-width: 0; +} +.user-notes { + font-size: 12px; + color: var(--mat-sys-on-surface-variant, rgba(0, 0, 0, 0.6)); + max-width: 240px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .status-chip { display: inline-block; padding: 2px 10px; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts index d03b9c06..ca27cc42 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-users.component.ts @@ -14,7 +14,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSortModule, Sort } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { AdminUser } from '../../core/models'; import { AdminService } from '../../core/services/admin.service'; @@ -42,7 +42,7 @@ type StatusFilter = 'all' | 'active' | 'stopped' | 'blocked'; MatPaginatorModule, MatSortModule, MatSelectModule, - TranslateModule, + TranslatePipe, DiscordAvatarComponent, ], selector: 'app-admin-users', @@ -56,7 +56,11 @@ export class AdminUsersComponent implements OnInit { private readonly auth = inject(AuthService); private readonly destroyRef = inject(DestroyRef); private readonly dialog = inject(MatDialog); - private readonly discordUsers = computed(() => this.allUsers().filter(u => u.type?.startsWith('discord'))); + // Everything that is not a webhook, so this tab and the Webhooks tab together account for every + // account. Filtering to discord* meant a Telegram user appeared in neither list, and every admin + // action here is list-driven -- so they could not be blocked, paused, purged or impersonated at all. + // See #632. + private readonly discordUsers = computed(() => this.allUsers().filter(u => u.type !== 'webhook')); private readonly i18n = inject(I18nService); @@ -72,7 +76,12 @@ export class AdminUsersComponent implements OnInit { let users = this.discordUsers(); if (term) { - users = users.filter(u => u.id.toLowerCase().includes(term) || (u.name || '').toLowerCase().includes(term)); + users = users.filter( + u => + u.id.toLowerCase().includes(term) || + (u.name || '').toLowerCase().includes(term) || + (this.notesLabel(u.notes) || '').toLowerCase().includes(term), + ); } if (status !== 'all') { @@ -216,6 +225,20 @@ export class AdminUsersComponent implements OnInit { this.loadUsers(); } + /** + * Normalizes the Poracle `notes` value for display. PoracleJS/NG can leave a quoted-empty + * sentinel (`""`) or whitespace in the column for users that aren't channels — those should + * render nothing. Strips a single layer of surrounding quotes so a JSON-quoted note shows clean. + */ + notesLabel(notes: string | null): string | null { + if (!notes) return null; + let s = notes.trim(); + if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) { + s = s.slice(1, -1).trim(); + } + return s.length > 0 ? s : null; + } + onPageChange(event: PageEvent): void { this.pageIndex.set(event.pageIndex); this.pageSize.set(event.pageSize); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-webhooks.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-webhooks.component.ts index 1ceee9fb..441d9b79 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-webhooks.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin-webhooks.component.ts @@ -16,7 +16,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatSortModule, Sort } from '@angular/material/sort'; import { MatTableModule } from '@angular/material/table'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { forkJoin } from 'rxjs'; import { AdminUser } from '../../core/models'; @@ -28,7 +28,7 @@ import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/componen // ─── Add Webhook Dialog ─────────────────────────────────────────────────────── @Component({ - imports: [ReactiveFormsModule, MatDialogModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatIconModule, TranslateModule], + imports: [ReactiveFormsModule, MatDialogModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatIconModule, TranslatePipe], selector: 'app-add-webhook-dialog', standalone: true, styleUrl: './add-webhook-dialog.component.scss', @@ -71,7 +71,7 @@ interface DelegatesDialogData { MatProgressSpinnerModule, MatAutocompleteModule, MatTooltipModule, - TranslateModule, + TranslatePipe, ], selector: 'app-webhook-delegates-dialog', standalone: true, @@ -180,7 +180,7 @@ type StatusFilter = 'all' | 'active' | 'stopped' | 'blocked'; MatSortModule, MatSelectModule, MatChipsModule, - TranslateModule, + TranslatePipe, ], selector: 'app-admin-webhooks', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.html deleted file mode 100644 index 8ac575c2..00000000 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.html +++ /dev/null @@ -1,11 +0,0 @@ - - - people Users - webhook Webhooks - settings Settings - diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.scss deleted file mode 100644 index 6aafd4ed..00000000 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.scss +++ /dev/null @@ -1,24 +0,0 @@ -.page-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - padding: 16px 24px; - gap: 16px; -} -.page-header-text { - flex: 1; - min-width: 0; -} -.page-header h1 { - margin: 0; - font-size: 24px; - font-weight: 400; -} -.page-description { - margin: 4px 0 0; - color: var(--text-secondary, rgba(0, 0, 0, 0.54)); - font-size: 13px; - line-height: 1.5; - border-left: 3px solid #1976d2; - padding-left: 12px; -} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.ts deleted file mode 100644 index 840d8d3e..00000000 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/admin.component.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; -import { RouterLink } from '@angular/router'; - -@Component({ - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, MatListModule, MatIconModule], - selector: 'app-admin', - standalone: true, - styleUrl: './admin.component.scss', - templateUrl: './admin.component.html', -}) -export class AdminComponent {} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.html index 765068f2..5ce43546 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.html @@ -111,7 +111,7 @@

{{ 'ADMIN.GEOFENCES_TITLE' | translate }}

@@ -151,7 +151,7 @@

{{ 'ADMIN.GEOFENCES_TITLE' | translate }}

- @if (geofence.status === 'pending_review') { + @if (canReview(geofence)) {
@@ -285,7 +285,7 @@

{{ 'ADMIN.GEOFENCES_TITLE' | translate }}

[matTooltip]="'ADMIN.VIEW_GEOFENCE_DETAILS' | translate"> visibility - @if (geofence.status === 'pending_review') { + @if (canReview(geofence)) { - @if (geofence.status === 'pending_review') { + @if (canReview(geofence)) {
+ +

map @@ -199,9 +201,13 @@

@if (hasChanges()) {
- {{ 'AREAS.SELECTION_SUMMARY' | translate: { count: selectedAreas().length } }} + @if (selectionUnknown()) { + {{ 'AREAS.SELECTION_UNKNOWN' | translate }} + } @else { + {{ 'AREAS.SELECTION_SUMMARY' | translate: { count: selectedAreas().length } }} + } - +
+ } @else if (!configLoaded()) {
} @else { @@ -53,7 +67,23 @@

} } - @if (!discordVisible() && !telegramVisible()) { + @if ((discordVisible() || telegramVisible()) && oidcVisible()) { +
+ {{ 'AUTH.OR' | translate }} +
+ } + + @if (oidcVisible()) { + + @if (!oidcActive()) { +

{{ 'AUTH.PROVIDER_DISABLED_HINT' | translate }}

+ } + } + + @if (!discordVisible() && !telegramVisible() && !oidcVisible()) {
lock_outline {{ 'AUTH.NO_METHODS' | translate }} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.scss index af175374..f8bce863 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.scss @@ -362,3 +362,18 @@ animation: none; } } + +.signed-out-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; + padding: 8px 0 4px; + text-align: center; +} +.signed-out-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #2e7d32; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.spec.ts index 070bd12d..841125ba 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.spec.ts @@ -25,7 +25,7 @@ describe('LoginComponent', () => { telegram: { botUsername: '', configured: false, enabledByAdmin: true }, }; - const setup = (opts?: { providers?: AuthProviders; providersError?: boolean }) => { + const setup = (opts?: { providers?: AuthProviders; providersError?: boolean; loggedOut?: boolean }) => { settingsSignal = signal>({}); const providers = opts?.providers ?? defaultProviders; @@ -46,14 +46,24 @@ describe('LoginComponent', () => { provide: AuthService, useValue: { getProviders: jest.fn(() => (opts?.providersError ? throwError(() => new Error('fail')) : of(providers))), + loginWithOidc: jest.fn(), getTelegramConfig: jest.fn(() => of({ botUsername: '', enabled: false })), + isAuthenticated: jest.fn(() => false), isLoggedIn: jest.fn(() => false), loginWithDiscord: jest.fn(), loginWithTelegram: jest.fn(), }, }, { provide: Router, useValue: { navigate: jest.fn() } }, - { provide: ActivatedRoute, useValue: { snapshot: { fragment: '' } } }, + { + provide: ActivatedRoute, + useValue: { + snapshot: { + fragment: '', + queryParamMap: { get: (k: string) => (k === 'loggedout' && opts?.loggedOut ? '1' : null) }, + }, + }, + }, ], imports: [LoginComponent], }); @@ -142,6 +152,89 @@ describe('LoginComponent', () => { expect(widget).toBeNull(); expect(btn).toBeNull(); }); + + it('should auto-redirect to OIDC (no local page) when configured and enabled', () => { + setup({ + providers: { + oidc: { providerName: 'PogoAlerts', configured: true, enabledByAdmin: true }, + discord: { configured: true, enabledByAdmin: true }, + telegram: { botUsername: '', configured: false, enabledByAdmin: true }, + }, + }); + fixture.detectChanges(); + const auth = TestBed.inject(AuthService); + // OIDC is the active sign-in method, so we redirect to the provider instead of + // rendering the local login page (configLoaded never flips true — we navigate away). + expect(auth.loginWithOidc).toHaveBeenCalled(); + expect(component['configLoaded']()).toBe(false); + expect(fixture.nativeElement.querySelector('.oidc-btn')).toBeNull(); + }); + + it('should show OIDC button with hint when configured but admin-disabled', () => { + setup({ + providers: { + oidc: { providerName: 'PogoAlerts', configured: true, enabledByAdmin: false }, + discord: { configured: false, enabledByAdmin: true }, + telegram: { botUsername: '', configured: false, enabledByAdmin: true }, + }, + }); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.oidc-btn')).toBeTruthy(); + expect(fixture.nativeElement.querySelector('.provider-disabled-hint')).toBeTruthy(); + }); + + it('should hide OIDC button when not configured', () => { + setup(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.oidc-btn')).toBeNull(); + }); + + it('should delegate to AuthService.loginWithOidc on click', () => { + // Admin-disabled OIDC stays on the local page (no auto-redirect), so the button + // renders and we can verify its click handler delegates to the service. + setup({ + providers: { + oidc: { providerName: 'PogoAlerts', configured: true, enabledByAdmin: false }, + discord: { configured: false, enabledByAdmin: true }, + telegram: { botUsername: '', configured: false, enabledByAdmin: true }, + }, + }); + fixture.detectChanges(); + const auth = TestBed.inject(AuthService); + fixture.nativeElement.querySelector('.oidc-btn').click(); + expect(auth.loginWithOidc).toHaveBeenCalled(); + }); + + it('should not auto-redirect to OIDC when an auth error is present in the fragment', () => { + window.location.hash = '#error=oidc_userinfo_failed'; + setup({ + providers: { + oidc: { providerName: 'PogoAlerts', configured: true, enabledByAdmin: true }, + discord: { configured: true, enabledByAdmin: true }, + telegram: { botUsername: '', configured: false, enabledByAdmin: true }, + }, + }); + fixture.detectChanges(); + const auth = TestBed.inject(AuthService); + expect(auth.loginWithOidc).not.toHaveBeenCalled(); + expect(component['configLoaded']()).toBe(true); + }); + + it('shows the signed-out panel and does not auto-redirect when ?loggedout=1', () => { + setup({ + providers: { + oidc: { providerName: 'PogoAlerts', configured: true, enabledByAdmin: true }, + discord: { configured: true, enabledByAdmin: true }, + telegram: { botUsername: '', configured: false, enabledByAdmin: true }, + }, + loggedOut: true, + }); + fixture.detectChanges(); + const auth = TestBed.inject(AuthService); + expect(auth.loginWithOidc).not.toHaveBeenCalled(); + expect(component['signedOut']()).toBe(true); + expect(fixture.nativeElement.querySelector('.signed-out-panel')).toBeTruthy(); + }); }); describe('no-methods message', () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.ts index c2f17422..c7b936f0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/auth/login.component.ts @@ -5,7 +5,7 @@ import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { catchError, forkJoin, of, timeout } from 'rxjs'; import { AuthProviders } from '../../core/models'; @@ -23,7 +23,7 @@ declare global { } @Component({ - imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule, TranslateModule], + imports: [MatButtonModule, MatCardModule, MatIconModule, MatProgressSpinnerModule, TranslatePipe], selector: 'app-login', standalone: true, styleUrl: './login.component.scss', @@ -62,6 +62,24 @@ export class LoginComponent implements OnInit { protected readonly error = signal(null); protected readonly loading = signal(false); + /** Whether a generic external OIDC provider is configured in the server's .env / appsettings. */ + protected readonly oidcConfigured = signal(false); + + /** Whether OIDC login is enabled by the admin (site setting `enable_oidc`). */ + protected readonly oidcEnabledByAdmin = signal(true); + + /** Computed: can the user actually use OIDC login without admin rejection? */ + protected readonly oidcActive = computed(() => this.oidcConfigured() && this.oidcEnabledByAdmin()); + + /** Display name of the configured OIDC provider, shown on the button. */ + protected readonly oidcProviderName = signal(''); + + /** Computed: should the OIDC button be shown at all? Only if configured in .env. */ + protected readonly oidcVisible = computed(() => this.oidcConfigured()); + + /** True when arriving via logout (?loggedout=1): show the signed-out panel, no auto-redirect. */ + protected readonly signedOut = signal(false); + protected readonly signupUrl = computed(() => { return this.settingsService.siteSettings()['signup_url'] || null; }); @@ -103,7 +121,26 @@ export class LoginComponent implements OnInit { this.auth.loginWithDiscord(); } + loginWithOidc(): void { + this.loading.set(true); + this.error.set(null); + this.auth.loginWithOidc(); + } + ngOnInit(): void { + // Parse any auth error from the URL fragment (e.g. /login#error=missing_required_role) + // up front, so the provider-config handler below can decide whether to auto-redirect + // to an external OIDC provider. + const fragment = window.location.hash?.substring(1) ?? ''; + const fragmentParams = new URLSearchParams(fragment); + const errorCode = fragmentParams.get('error'); + + // /login?loggedout=1 (set by logout / the OIDC end-session return) shows the signed-out + // panel and SUPPRESSES the OIDC auto-redirect, so the user isn't bounced straight back + // into the provider and silently re-logged in. + const loggedOut = this.route.snapshot.queryParamMap.get('loggedout') === '1'; + this.signedOut.set(loggedOut); + // Load public site settings (custom_title, signup_url) and provider config in parallel. // Both calls use a 10s timeout and fallback to defaults on error so the login page // never gets stuck in an unrecoverable state. @@ -126,15 +163,25 @@ export class LoginComponent implements OnInit { this.discordConfigured.set(true); this.discordEnabledByAdmin.set(true); } + + // When a generic external OIDC provider is the active sign-in method, skip the + // local login page and send the user straight to the provider. Guarded so we + // never loop: not after an auth-error bounce and not when already logged in. + if (this.oidcActive() && !errorCode && !loggedOut && !this.auth.isLoggedIn()) { + this.auth.loginWithOidc(); + return; + } + this.configLoaded.set(true); }); // Show error from URL fragment (e.g. /login#error=missing_required_role) - const fragment = window.location.hash?.substring(1) ?? ''; - const fragmentParams = new URLSearchParams(fragment); - const errorCode = fragmentParams.get('error'); if (errorCode) { const errorKeys: Record = { + oidc_disabled: 'AUTH.ERR_OIDC_DISABLED', + oidc_no_identity: 'AUTH.ERR_OIDC_NO_IDENTITY', + oidc_token_exchange_failed: 'AUTH.ERR_OIDC_TOKEN_EXCHANGE', + oidc_userinfo_failed: 'AUTH.ERR_OIDC_USERINFO', discord_disabled: 'AUTH.ERR_DISCORD_DISABLED', discord_user_fetch_failed: 'AUTH.ERR_DISCORD_FETCH', missing_code: 'AUTH.ERR_MISSING_CODE', @@ -151,13 +198,27 @@ export class LoginComponent implements OnInit { localStorage.removeItem('poracle_admin_token'); } - // If already logged in and no error, redirect - if (!errorCode && this.auth.isLoggedIn()) { + // Keyed on the token, not on a currentUser that outlived it. After a 401 the user object was still + // set, so login sent the user to /dashboard, authGuard found no token and sent them back, and the + // OIDC auto-redirect below never ran. See #628. + if (!errorCode && this.auth.isAuthenticated()) { this.router.navigate(['/dashboard']); return; } } + /** + * Sign in again from the signed-out panel. In OIDC mode this re-initiates the provider + * flow (full credentials if single logout ended the provider session, silent otherwise); + * in local mode it simply reveals the Discord/Telegram buttons. + */ + signInAgain(): void { + this.signedOut.set(false); + if (this.oidcActive()) { + this.loginWithOidc(); + } + } + private applyProviders(providers: AuthProviders): void { // Discord this.discordConfigured.set(providers.discord.configured); @@ -168,6 +229,13 @@ export class LoginComponent implements OnInit { this.telegramBotUsername = providers.telegram.botUsername; this.telegramConfigured.set(providers.telegram.configured); this.telegramEnabledByAdmin.set(providers.telegram.enabledByAdmin); + + // OIDC (generic external SSO provider) — optional; older API responses omit it. + if (providers.oidc) { + this.oidcConfigured.set(providers.oidc.configured); + this.oidcEnabledByAdmin.set(providers.oidc.enabledByAdmin); + this.oidcProviderName.set(providers.oidc.providerName); + } } private handleTelegramAuth(telegramData: Record): void { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.html index 57ee2dfe..0b7ed62a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.html @@ -30,7 +30,7 @@

{{ 'CLEANING.PAGE_TITLE' | translate }}

} @else {
- @for (item of cleaningItems; track item.type) { + @for (item of visibleItems(); track item.type) {
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.ts index 57e03f66..347e6197 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/cleaning/cleaning.component.ts @@ -7,15 +7,21 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { CleaningService, CleanAlarmType } from '../../core/services/cleaning.service'; import { DashboardService } from '../../core/services/dashboard.service'; import { I18nService } from '../../core/services/i18n.service'; +import { SettingsService } from '../../core/services/settings.service'; interface CleaningItem { color: string; descriptionKey: string; + /** + * The site setting that switches this alarm type off. Eggs share the raid switch — there is no + * separate disable_eggs, and eggs share the raid UI. See #509. + */ + disableKey: string; enabled: ReturnType>; hasAlarms: ReturnType>; icon: string; @@ -23,6 +29,12 @@ interface CleaningItem { type: CleanAlarmType; } +/** Cleaning row type -> the matching field on the dashboard counts payload. */ +const DASHBOARD_COUNT_KEYS: Record = { + maxbattles: 'maxBattles', + monsters: 'pokemon', +}; + @Component({ changeDetection: ChangeDetectionStrategy.OnPush, imports: [ @@ -33,7 +45,7 @@ interface CleaningItem { MatSnackBarModule, MatProgressSpinnerModule, MatTooltipModule, - TranslateModule, + TranslatePipe, ], selector: 'app-cleaning', standalone: true, @@ -45,12 +57,14 @@ export class CleaningComponent implements OnInit { private readonly dashboardService = inject(DashboardService); private readonly destroyRef = inject(DestroyRef); private readonly i18n = inject(I18nService); + private readonly settingsService = inject(SettingsService); private readonly snackBar = inject(MatSnackBar); readonly cleaningItems: CleaningItem[] = [ { color: '#4CAF50', descriptionKey: 'CLEANING.POKEMON_DESC', + disableKey: 'disable_mons', enabled: signal(false), hasAlarms: signal(false), icon: 'catching_pokemon', @@ -60,6 +74,7 @@ export class CleaningComponent implements OnInit { { color: '#F44336', descriptionKey: 'CLEANING.RAIDS_DESC', + disableKey: 'disable_raids', enabled: signal(false), hasAlarms: signal(false), icon: 'shield', @@ -69,6 +84,7 @@ export class CleaningComponent implements OnInit { { color: '#FF9800', descriptionKey: 'CLEANING.EGGS_DESC', + disableKey: 'disable_raids', enabled: signal(false), hasAlarms: signal(false), icon: 'egg', @@ -78,6 +94,7 @@ export class CleaningComponent implements OnInit { { color: '#9C27B0', descriptionKey: 'CLEANING.QUESTS_DESC', + disableKey: 'disable_quests', enabled: signal(false), hasAlarms: signal(false), icon: 'assignment', @@ -87,6 +104,7 @@ export class CleaningComponent implements OnInit { { color: '#607D8B', descriptionKey: 'CLEANING.INVASIONS_DESC', + disableKey: 'disable_invasions', enabled: signal(false), hasAlarms: signal(false), icon: 'warning', @@ -96,6 +114,7 @@ export class CleaningComponent implements OnInit { { color: '#E91E63', descriptionKey: 'CLEANING.LURES_DESC', + disableKey: 'disable_lures', enabled: signal(false), hasAlarms: signal(false), icon: 'place', @@ -105,6 +124,7 @@ export class CleaningComponent implements OnInit { { color: '#8BC34A', descriptionKey: 'CLEANING.NESTS_DESC', + disableKey: 'disable_nests', enabled: signal(false), hasAlarms: signal(false), icon: 'park', @@ -114,24 +134,17 @@ export class CleaningComponent implements OnInit { { color: '#00BCD4', descriptionKey: 'CLEANING.GYMS_DESC', + disableKey: 'disable_gyms', enabled: signal(false), hasAlarms: signal(false), icon: 'fitness_center', labelKey: 'NAV.GYMS', type: 'gyms', }, - { - color: '#795548', - descriptionKey: 'CLEANING.FORT_CHANGES_DESC', - enabled: signal(false), - hasAlarms: signal(false), - icon: 'domain', - labelKey: 'NAV.FORT_CHANGES', - type: 'fortchanges', - }, { color: '#d500f9', descriptionKey: 'CLEANING.MAX_BATTLES_DESC', + disableKey: 'disable_maxbattles', enabled: signal(false), hasAlarms: signal(false), icon: 'flash_on', @@ -140,7 +153,12 @@ export class CleaningComponent implements OnInit { }, ]; - readonly allEnabled = computed(() => this.cleaningItems.every(i => i.enabled())); + // The page rendered a row per alarm type regardless of the site settings, and the toggle's only + // disabled condition was a request already being in flight. Pressing a row for a type an admin had + // switched off produced a 403 whose only outcome was an error toast. See #509. + readonly visibleItems = computed(() => this.cleaningItems.filter(i => !this.settingsService.isDisabled(i.disableKey))); + + readonly allEnabled = computed(() => this.visibleItems().every(i => i.enabled())); readonly loading = signal(true); readonly toggling = signal(false); @@ -205,7 +223,9 @@ export class CleaningComponent implements OnInit { error: () => this.loading.set(false), next: counts => { for (const item of this.cleaningItems) { - const key = item.type === 'monsters' ? 'pokemon' : item.type === 'fortchanges' ? 'fortChanges' : item.type; + // The dashboard counts are camelCase; the cleaning rows use the API route names. Without + // this map, 'maxbattles' never matched 'maxBattles' and the row was permanently greyed out. + const key = DASHBOARD_COUNT_KEYS[item.type] ?? item.type; const count = (counts as unknown as Record)[key] ?? 0; item.hasAlarms.set(count > 0); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard-cards.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard-cards.spec.ts new file mode 100644 index 00000000..07e886c9 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard-cards.spec.ts @@ -0,0 +1,104 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { DashboardComponent } from './dashboard.component'; +import { DashboardCounts } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; +import { AuthService } from '../../core/services/auth.service'; +import { DashboardService } from '../../core/services/dashboard.service'; +import { LocationService } from '../../core/services/location.service'; +import { ProfileService } from '../../core/services/profile.service'; +import { SettingsService } from '../../core/services/settings.service'; + +/** + * A disabled alarm type is gone everywhere: the sidebar item, the dashboard card, the route and the + * API all refuse it (#792). The card list is the last of those with any conditional logic, so this + * pins that it drops a disabled type regardless of what is stored on it — a card linking to a page + * that does not answer would be worse than no card. + */ +describe('DashboardComponent cards', () => { + const EMPTY: DashboardCounts = { + raids: 0, + eggs: 0, + fortChanges: 0, + gyms: 0, + invasions: 0, + lures: 0, + maxBattles: 0, + nests: 0, + pokemon: 0, + quests: 0, + }; + + const setup = (disabled: string[], counts: Partial) => { + const siteSettings = signal>(Object.fromEntries(disabled.map(k => [k, 'true']))); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideNoopAnimations(), + provideRouter([]), + provideTranslateService(), + { + provide: SettingsService, + useValue: { + isDisabled: (key: string) => siteSettings()[key]?.toLowerCase() === 'true', + isForcedByPoracle: () => false, + siteSettings, + }, + }, + { provide: DashboardService, useValue: { getCounts: () => of({ ...EMPTY, ...counts }) } }, + { provide: AuthService, useValue: { isAdmin: () => false, user: signal({ username: 'someone' }) } }, + { provide: AreaService, useValue: { getAreas: () => of([]), getGeofence: () => of([]) } }, + { provide: ProfileService, useValue: { getProfiles: () => of([]) } }, + { provide: LocationService, useValue: { getLocation: () => of({ latitude: 0, longitude: 0 }) } }, + ], + }); + + const fixture = TestBed.createComponent(DashboardComponent); + fixture.componentInstance.counts.set({ ...EMPTY, ...counts }); + return fixture.componentInstance; + }; + + const keys = (component: DashboardComponent) => component.visibleCards().map(c => c.key); + + it('shows every card while nothing is disabled', () => { + const component = setup([], {}); + + expect(keys(component)).toEqual([ + 'pokemon', + 'raids', + 'eggs', + 'quests', + 'invasions', + 'lures', + 'nests', + 'gyms', + 'fortChanges', + 'maxBattles', + ]); + }); + + it('drops a disabled type that has no alarms', () => { + const component = setup(['disable_lures'], {}); + + expect(keys(component)).not.toContain('lures'); + }); + + /** Alarms stored on a disabled type do not bring its card back — the page they link to is gated. */ + it('drops a disabled type even when it still has alarms', () => { + const component = setup(['disable_lures'], { lures: 3 }); + + expect(keys(component)).not.toContain('lures'); + }); + + /** Eggs share the raid key, so disabling raids takes the egg card with it. */ + it('treats eggs as raids', () => { + expect(keys(setup(['disable_raids'], {}))).not.toContain('eggs'); + expect(keys(setup(['disable_raids'], { eggs: 2 }))).not.toContain('eggs'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.html index dc1bfd6e..aa819426 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.html @@ -13,51 +13,55 @@

{{ 'DASHBOARD.TITLE' | translate }}

- - - @if (locationMapUrl()) { - - } -
-
- my_location -
-
- {{ 'DASHBOARD.LOCATION' | translate }} - @if (location(); as loc) { - @if (locationAddress()) { - {{ - locationAddress() - }} + @if (locationEnabled()) { + + + @if (locationMapUrl()) { + + } +
+
+ my_location +
+
+ {{ 'DASHBOARD.LOCATION' | translate }} + @if (location(); as loc) { + @if (locationAddress()) { + {{ + locationAddress() + }} + } @else { + {{ loc.latitude.toFixed(4) }}, {{ loc.longitude.toFixed(4) }} + } } @else { - {{ loc.latitude.toFixed(4) }}, {{ loc.longitude.toFixed(4) }} + {{ 'DASHBOARD.LOCATION_NOT_SET' | translate }} } +
+ chevron_right +
+
+ } + + @if (areasEnabled()) { + + +
+ map +
+
+ {{ 'DASHBOARD.ACTIVE_AREAS' | translate }} + @if (selectedAreas().length > 0) { + {{ 'DASHBOARD.AREAS_COUNT' | translate: { count: selectedAreas().length } }} } @else { - {{ 'DASHBOARD.LOCATION_NOT_SET' | translate }} + {{ 'DASHBOARD.NO_AREAS' | translate }} }
chevron_right -
- - - - -
- map -
-
- {{ 'DASHBOARD.ACTIVE_AREAS' | translate }} - @if (selectedAreas().length > 0) { - {{ 'DASHBOARD.AREAS_COUNT' | translate: { count: selectedAreas().length } }} - } @else { - {{ 'DASHBOARD.NO_AREAS' | translate }} - } -
- chevron_right -
+ + } - @if (profiles().length > 1) { + @if (profilesEnabled() && profiles().length > 1) {
person @@ -227,9 +231,11 @@

{{ 'DASHBOARD.QUICK_ACTIONS' | translate }}

- + @if (areasEnabled()) { + + } @@ -243,7 +249,7 @@

{{ 'DASHBOARD.ACTIVE_FILTERS' | translate }}

@if (counts(); as c) { - @for (card of cards; track card.key) { + @for (card of visibleCards(); track card.key) {
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.ts index 0ffd7d10..bfa175b2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/dashboard/dashboard.component.ts @@ -10,8 +10,8 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Router, RouterModule } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; -import { switchMap, forkJoin, EMPTY } from 'rxjs'; +import { TranslatePipe } from '@ngx-translate/core'; +import { switchMap, forkJoin, EMPTY, catchError } from 'rxjs'; import { DashboardCounts, GeofenceData, Location, Profile, WeatherData } from '../../core/models'; import { AreaService } from '../../core/services/area.service'; @@ -20,6 +20,7 @@ import { DashboardService } from '../../core/services/dashboard.service'; import { I18nService } from '../../core/services/i18n.service'; import { LocationService } from '../../core/services/location.service'; import { ProfileService } from '../../core/services/profile.service'; +import { SettingsService } from '../../core/services/settings.service'; import { AreaOverviewMapComponent } from '../../shared/components/area-overview-map/area-overview-map.component'; import { LocationDialogComponent } from '../../shared/components/location-dialog/location-dialog.component'; import { OnboardingComponent } from '../../shared/components/onboarding/onboarding.component'; @@ -27,6 +28,8 @@ import { polygonCentroid } from '../../shared/utils/geo.utils'; interface DashboardCard { colorClass: string; + /** The `disable_*` key that governs this alarm type. Eggs share the raid one. */ + disableKey: string; icon: string; key: keyof DashboardCounts; label: string; @@ -57,7 +60,7 @@ interface Tip { AreaOverviewMapComponent, OnboardingComponent, RouterModule, - TranslateModule, + TranslatePipe, ], selector: 'app-dashboard', standalone: true, @@ -95,6 +98,7 @@ export class DashboardComponent implements OnInit { private readonly locationService = inject(LocationService); private readonly profileService = inject(ProfileService); private readonly router = inject(Router); + private readonly settingsService = inject(SettingsService); private readonly snackBar = inject(MatSnackBar); @@ -103,11 +107,16 @@ export class DashboardComponent implements OnInit { return user ? !user.enabled : false; }); + // The dashboard used to load every one of these regardless, so with any of the three switches on it + // opened with an error toast for a feature the user had not touched -- and kept rendering buttons that + // bounced straight back with the same toast. A disabled feature is simply absent here. See #516. + readonly areasEnabled = computed(() => !this.settingsService.isDisabled('disable_areas')); readonly areaWeather = signal>({}); readonly cards: DashboardCard[] = [ { colorClass: 'card-pokemon', + disableKey: 'disable_mons', icon: 'catching_pokemon', key: 'pokemon', label: 'DASHBOARD.CARD_POKEMON', @@ -116,6 +125,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-raids', + disableKey: 'disable_raids', icon: 'shield', key: 'raids', label: 'DASHBOARD.CARD_RAIDS', @@ -124,6 +134,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-eggs', + disableKey: 'disable_raids', icon: 'egg', key: 'eggs', label: 'DASHBOARD.CARD_EGGS', @@ -132,6 +143,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-quests', + disableKey: 'disable_quests', icon: 'explore', key: 'quests', label: 'DASHBOARD.CARD_QUESTS', @@ -140,6 +152,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-invasions', + disableKey: 'disable_invasions', icon: 'warning', key: 'invasions', label: 'DASHBOARD.CARD_INVASIONS', @@ -148,6 +161,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-lures', + disableKey: 'disable_lures', icon: 'location_on', key: 'lures', label: 'DASHBOARD.CARD_LURES', @@ -156,6 +170,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-nests', + disableKey: 'disable_nests', icon: 'park', key: 'nests', label: 'DASHBOARD.CARD_NESTS', @@ -164,6 +179,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-gyms', + disableKey: 'disable_gyms', icon: 'fitness_center', key: 'gyms', label: 'DASHBOARD.CARD_GYMS', @@ -172,6 +188,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-fort-changes', + disableKey: 'disable_fort_changes', icon: 'domain', key: 'fortChanges', label: 'DASHBOARD.CARD_FORT_CHANGES', @@ -180,6 +197,7 @@ export class DashboardComponent implements OnInit { }, { colorClass: 'card-maxbattles', + disableKey: 'disable_maxbattles', icon: 'flash_on', key: 'maxBattles', label: 'DASHBOARD.CARD_MAX_BATTLES', @@ -191,14 +209,18 @@ export class DashboardComponent implements OnInit { readonly counts = signal(null); readonly dismissedTips = signal(JSON.parse(sessionStorage.getItem('dismissed-tips') || '[]')); + readonly geofencePolygons = signal([]); + readonly location = signal(null); + readonly locationAddress = signal(''); + readonly locationEnabled = computed(() => !this.settingsService.isDisabled('disable_location')); readonly locationMapUrl = signal(''); - readonly profileNo = computed(() => this.authService.user()?.profileNo ?? 1); readonly profiles = signal([]); + readonly profileName = computed(() => { const profiles = this.profiles(); if (profiles.length === 0) return this.i18n.instant('DASHBOARD.DEFAULT_PROFILE'); @@ -207,7 +229,9 @@ export class DashboardComponent implements OnInit { return match?.name ?? this.i18n.instant('DASHBOARD.DEFAULT_PROFILE'); }); + readonly profilesEnabled = computed(() => !this.settingsService.isDisabled('disable_profiles')); readonly selectedAreas = signal([]); + readonly showOnboarding = signal(!localStorage.getItem('poracle-onboarding-complete')); readonly skeletonItems = [1, 2, 3, 4, 5, 6, 7, 8, 9]; @@ -276,6 +300,14 @@ export class DashboardComponent implements OnInit { readonly username = computed(() => this.authService.user()?.username ?? 'Trainer'); + /** + * Cards for the alarm types this instance actually offers. A disabled type is gone from here as it + * is from the sidebar, the route and the API — a card linking to a page that answers 403 would be + * worse than no card. Rules already stored on a disabled type stay dormant and come back intact if + * it is switched on again. See #792. + */ + readonly visibleCards = computed(() => this.cards.filter(card => !this.settingsService.isDisabled(card.disableKey))); + readonly weather = signal(null); readonly weatherLoading = signal(false); @@ -338,7 +370,6 @@ export class DashboardComponent implements OnInit { openLocationDialog(): void { const loc = this.location(); const dialogRef = this.dialog.open(LocationDialogComponent, { - width: '600px', data: loc && (loc.latitude !== 0 || loc.longitude !== 0) ? loc : null, }); dialogRef @@ -436,20 +467,26 @@ export class DashboardComponent implements OnInit { .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(c => this.counts.set(c)); - forkJoin([this.areaService.getSelected(), this.areaService.getGeofencePolygons()]) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(([areas, geofences]) => { - this.selectedAreas.set(areas); - this.geofencePolygons.set(geofences); - this.loadAreaWeather(areas, geofences); - }); + if (this.areasEnabled()) { + forkJoin([this.areaService.getSelected(), this.areaService.getGeofencePolygons()]) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(([areas, geofences]) => { + this.selectedAreas.set(areas); + this.geofencePolygons.set(geofences); + this.loadAreaWeather(areas, geofences); + }); + } - this.profileService - .getAll() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(p => this.profiles.set(p)); + if (this.profilesEnabled()) { + this.profileService + .getAll() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(p => this.profiles.set(p)); + } - this.loadLocation(); + if (this.locationEnabled()) { + this.loadLocation(); + } } private loadLocation(): void { @@ -478,6 +515,9 @@ export class DashboardComponent implements OnInit { } return EMPTY; }), + // Same 403 as #617: without this the disabled-location case threw instead of leaving the + // location panel empty. + catchError(() => EMPTY), ) .subscribe(); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.html index b974c981..3076119e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.html @@ -9,7 +9,7 @@

{{ 'FORT_CHANGES.ADD_DIALOG_TITLE' | translate }}

- Fort Type + {{ 'FORT_CHANGES.FORT_TYPE_LABEL' | translate }} {{ 'FORT_CHANGES.FORT_EVERYTHING' | translate }} {{ 'FORT_CHANGES.FORT_POKESTOP' | translate }} @@ -17,13 +17,16 @@

{{ 'FORT_CHANGES.ADD_DIALOG_TITLE' | translate }}

-

Change Types

+

{{ 'FORT_CHANGES.CHANGE_TYPES_LABEL' | translate }}

{{ 'FORT_CHANGES.CHANGE_NAME' | translate }} {{ 'FORT_CHANGES.CHANGE_LOCATION' | translate }} {{ 'FORT_CHANGES.CHANGE_IMAGE' | translate }} {{ 'FORT_CHANGES.CHANGE_REMOVAL' | translate }} {{ 'FORT_CHANGES.CHANGE_NEW' | translate }} + {{ + 'FORT_CHANGES.CHANGE_DESCRIPTION' | translate + }}
@@ -39,51 +42,12 @@

Change Types

{{ 'RAIDS.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } - {{ 'ALARM.CLEAN_MODE' | translate }} -

{{ 'ALARM.CLEAN_HINT_FORT' | translate }}

diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.scss index 3dacfdc6..3e829eb4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.scss @@ -67,3 +67,9 @@ mat-slide-toggle { min-width: 0; } } + +.scope-near-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.ts index 21a91a4e..970851bf 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-add-dialog.component.ts @@ -11,13 +11,15 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; +import { AlertDefaultsService } from '../../core/services/alert-defaults.service'; import { AuthService } from '../../core/services/auth.service'; import { FortChangeService } from '../../core/services/fort-change.service'; import { I18nService } from '../../core/services/i18n.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeToFields } from '../../shared/utils/alarm-scope'; @Component({ imports: [ @@ -33,9 +35,9 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatSlideToggleModule, MatTabsModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-fort-change-add-dialog', standalone: true, @@ -43,23 +45,23 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele templateUrl: './fort-change-add-dialog.component.html', }) export class FortChangeAddDialogComponent { + private readonly alertDefaults = inject(AlertDefaultsService); + private readonly fb = inject(FormBuilder); + private readonly fortChangeService = inject(FortChangeService); private readonly i18n = inject(I18nService); private readonly snackBar = inject(MatSnackBar); readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ + changeTypeDescription: [false], changeTypeImageUrl: [false], changeTypeLocation: [true], changeTypeName: [true], changeTypeNew: [true], changeTypeRemoval: [true], - clean: [false], - distanceKm: [1], - distanceMode: ['areas' as 'areas' | 'distance'], fortType: ['everything'], includeEmpty: [false], - ping: [''], template: [''], }); @@ -67,35 +69,49 @@ export class FortChangeAddDialogComponent { saving = signal(false); - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); save(): void { this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const changeTypes: string[] = []; if (v.changeTypeName) changeTypes.push('name'); if (v.changeTypeLocation) changeTypes.push('location'); if (v.changeTypeImageUrl) changeTypes.push('image_url'); if (v.changeTypeRemoval) changeTypes.push('removal'); if (v.changeTypeNew) changeTypes.push('new'); + if (v.changeTypeDescription) changeTypes.push('description'); this.fortChangeService .create({ + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, changeTypes, - clean: v.clean ? 1 : 0, - distance: dist, + distance: scope.distance, fortType: v.fortType, includeEmpty: v.includeEmpty ? 1 : 0, - ping: v.ping || null, template: v.template || null, }) .subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('FORT_CHANGES.CREATE_FAILED'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('FORT_CHANGES.CREATE_FAILED'), this.i18n.instant('COMMON.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.html index 19617175..c9caf643 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.html @@ -9,7 +9,7 @@

{{ 'FORT_CHANGES.EDIT_DIALOG_TITLE' | translate }}

- Fort Type + {{ 'FORT_CHANGES.FORT_TYPE_LABEL' | translate }} {{ 'FORT_CHANGES.FORT_EVERYTHING' | translate }} {{ 'FORT_CHANGES.FORT_POKESTOP' | translate }} @@ -17,13 +17,16 @@

{{ 'FORT_CHANGES.EDIT_DIALOG_TITLE' | translate }}

-

Change Types

+

{{ 'FORT_CHANGES.CHANGE_TYPES_LABEL' | translate }}

{{ 'FORT_CHANGES.CHANGE_NAME' | translate }} {{ 'FORT_CHANGES.CHANGE_LOCATION' | translate }} {{ 'FORT_CHANGES.CHANGE_IMAGE' | translate }} {{ 'FORT_CHANGES.CHANGE_REMOVAL' | translate }} {{ 'FORT_CHANGES.CHANGE_NEW' | translate }} + {{ + 'FORT_CHANGES.CHANGE_DESCRIPTION' | translate + }}
@@ -39,57 +42,15 @@

Change Types

{{ 'RAIDS.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } - - {{ 'ALARM.CLEAN_MODE' | translate }} -

{{ 'ALARM.CLEAN_HINT_FORT' | translate }}

diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.scss index 3dacfdc6..8331fcd6 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.scss @@ -67,3 +67,16 @@ mat-slide-toggle { min-width: 0; } } + +.scope-current { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; +} + +.scope-hint { + color: var(--mat-sys-on-surface-variant, rgb(0 0 0 / 60%)); + font-size: 0.75rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.spec.ts new file mode 100644 index 00000000..4fe4bdbc --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.spec.ts @@ -0,0 +1,102 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { provideRouter } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { FortChangeEditDialogComponent } from './fort-change-edit-dialog.component'; +import { FortChange, FortChangeUpdate } from '../../core/models'; +import { AuthService } from '../../core/services/auth.service'; +import { FortChangeService } from '../../core/services/fort-change.service'; +import { I18nService } from '../../core/services/i18n.service'; + +/** + * The change type list is rebuilt from checkboxes on every save, so anything the dialog cannot draw is + * a candidate for silent deletion. PoracleNG's `!fort` command accepts six types and this UI drew five: + * a rule set with `description` lost it the next time its owner changed the radius. + */ +describe('FortChangeEditDialogComponent', () => { + let component: FortChangeEditDialogComponent; + let fortChangeService: { update: jest.Mock }; + + const base: FortChange = { + id: 'u1', + uid: 9, + changeTypes: [], + distance: 0, + fortType: 'everything', + includeEmpty: false, + profileNo: 0, + template: null, + }; + + function setup(fort: Partial) { + fortChangeService = { update: jest.fn().mockReturnValue(of(void 0)) }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: MAT_DIALOG_DATA, useValue: { ...base, ...fort } }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: FortChangeService, useValue: fortChangeService }, + { provide: I18nService, useValue: { instant: (k: string) => k } }, + { provide: AuthService, useValue: { isImpersonating: () => false, user: () => ({ type: 'discord:user' }) } }, + ], + imports: [FortChangeEditDialogComponent], + }); + + TestBed.overrideComponent(FortChangeEditDialogComponent, { + add: { providers: [{ provide: MatSnackBar, useValue: { open: jest.fn() } }] }, + }); + + component = TestBed.createComponent(FortChangeEditDialogComponent).componentInstance; + } + + function saved(): string[] { + return (fortChangeService.update.mock.calls[0][1] as FortChangeUpdate).changeTypes ?? []; + } + + it('reads a description rule back into its checkbox', () => { + setup({ changeTypes: ['name', 'description'] }); + + expect(component.form.controls.changeTypeDescription.value).toBe(true); + }); + + it('saves description when it is ticked', () => { + setup({ changeTypes: ['description'] }); + component.save(); + + expect(saved()).toContain('description'); + }); + + it('drops description when it is unticked', () => { + // The legitimate twin: preserving unknown types must not make a ticked box unremovable. + setup({ changeTypes: ['name', 'description'] }); + component.form.controls.changeTypeDescription.setValue(false); + component.save(); + + expect(saved()).toEqual(['name']); + }); + + it('carries through a change type this dialog has no box for', () => { + // Whatever PoracleNG grows next. Losing it on an unrelated save is the failure mode. + setup({ changeTypes: ['name', 'teleport'] }); + component.save(); + + expect(saved()).toEqual(['name', 'teleport']); + }); + + it('leaves an ordinary rule exactly as it was', () => { + setup({ changeTypes: ['name', 'location'] }); + component.save(); + + expect(saved().sort()).toEqual(['location', 'name']); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.ts index 929a44cb..f0badaa7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-edit-dialog.component.ts @@ -11,14 +11,18 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { FortChange, FortChangeUpdate } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; import { FortChangeService } from '../../core/services/fort-change.service'; import { I18nService } from '../../core/services/i18n.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; + +/** The change types this dialog has a checkbox for. Anything else is carried through untouched. */ +const KNOWN_CHANGE_TYPES = new Set(['name', 'location', 'image_url', 'removal', 'new', 'description']); @Component({ imports: [ @@ -34,9 +38,9 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatSlideToggleModule, MatTabsModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-fort-change-edit-dialog', standalone: true, @@ -50,18 +54,16 @@ export class FortChangeEditDialogComponent { private readonly snackBar = inject(MatSnackBar); readonly data = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); + form = this.fb.group({ + changeTypeDescription: [this.data.changeTypes?.includes('description') ?? false], changeTypeImageUrl: [this.data.changeTypes?.includes('image_url') ?? false], changeTypeLocation: [this.data.changeTypes?.includes('location') ?? false], changeTypeName: [this.data.changeTypes?.includes('name') ?? false], changeTypeNew: [this.data.changeTypes?.includes('new') ?? false], changeTypeRemoval: [this.data.changeTypes?.includes('removal') ?? false], - clean: [this.data.clean === 1], - distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], - distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], fortType: [this.data.fortType ?? 'everything'], includeEmpty: [this.data.includeEmpty === 1], - ping: [this.data.ping ?? ''], template: [this.data.template ?? ''], }); @@ -69,35 +71,45 @@ export class FortChangeEditDialogComponent { saving = signal(false); - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.overrideLocationLabel, this.data.overrideAreas, this.data.distance)); save(): void { this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const changeTypes: string[] = []; if (v.changeTypeName) changeTypes.push('name'); if (v.changeTypeLocation) changeTypes.push('location'); if (v.changeTypeImageUrl) changeTypes.push('image_url'); if (v.changeTypeRemoval) changeTypes.push('removal'); if (v.changeTypeNew) changeTypes.push('new'); + if (v.changeTypeDescription) changeTypes.push('description'); + + // Anything stored that this dialog has no box for stays. The list is rebuilt from the boxes, so a + // change type PoracleNG grows before PoracleWeb does would be dropped by the next save someone + // makes for an unrelated reason. + for (const stored of this.data.changeTypes ?? []) { + if (!KNOWN_CHANGE_TYPES.has(stored) && !changeTypes.includes(stored)) changeTypes.push(stored); + } this.fortChangeService .update(this.data.uid, { + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, changeTypes, - clean: v.clean ? 1 : 0, - distance: dist, + distance: scope.distance, fortType: v.fortType, includeEmpty: v.includeEmpty ? 1 : 0, - ping: v.ping || null, - template: v.template || null, + template: v.template || '', } as FortChangeUpdate) .subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('FORT_CHANGES.UPDATE_FAILED'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('FORT_CHANGES.UPDATE_FAILED'), this.i18n.instant('COMMON.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.html index ed253b31..eeb0ed3a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.html @@ -34,6 +34,7 @@

{{ 'FORT_CHANGES.PAGE_TITLE' | translate }}

+ @@ -64,12 +65,9 @@

{{ 'FORT_CHANGES.PAGE_TITLE' | translate }}

{{ formatFortType(item.fortType) }}

- Fort Change Tracking + {{ 'FORT_CHANGES.TRACKING_SUBTITLE' | translate }}
- @if (item.clean === 1) { - clean - } @if (item.includeEmpty === 1) { incl. empty } @@ -78,25 +76,22 @@

{{ formatFortType(item.fortType) }}

- Change Types + {{ 'FORT_CHANGES.CHANGE_TYPES_LABEL' | translate }} {{ formatChangeTypes(item.changeTypes) }}
- @if (item.distance === 0) { - map {{ 'ALARM.USING_AREAS' | translate }} - } @else { - straighten {{ formatDistance(item.distance) }} - } +
- @if (item.ping) { -
- notifications{{ item.ping }} -
- }
+ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.ts index 3b6aaa70..7a458c9d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/fort-changes/fort-change-list.component.ts @@ -9,16 +9,20 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { FortChangeAddDialogComponent } from './fort-change-add-dialog.component'; import { FortChangeEditDialogComponent } from './fort-change-edit-dialog.component'; import { FortChange } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { FortChangeService } from '../../core/services/fort-change.service'; import { I18nService } from '../../core/services/i18n.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,7 +36,8 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTooltipModule, MatSnackBarModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, + WhereChipComponent, ], selector: 'app-fort-change-list', standalone: true, @@ -40,14 +45,20 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './fort-change-list.component.html', }) export class FortChangeListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly fortChangeService = inject(FortChangeService); private readonly i18n = inject(I18nService); private readonly snackBar = inject(MatSnackBar); readonly fortChanges = signal([]); readonly loading = signal(true); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); readonly selectedIds = signal(new Set()); + readonly selectMode = signal(false); async bulkDelete(): Promise { @@ -62,11 +73,22 @@ export class FortChangeListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; - for (const uid of ids) await firstValueFrom(this.fortChangeService.delete(uid)); + // Settled one at a time: a stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so deletes that had already happened went unreported and the list never + // reloaded. See #603. + let deleted = 0; + for (const uid of ids) { + try { + await firstValueFrom(this.fortChangeService.delete(uid)); + deleted++; + } catch { + // Already gone, which is what the user asked for. + } + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadItems(); - this.snackBar.open(this.i18n.instant('FORT_CHANGES.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('COMMON.OK'), { + this.snackBar.open(this.i18n.instant('FORT_CHANGES.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('COMMON.OK'), { duration: 3000, }); } @@ -77,7 +99,18 @@ export class FortChangeListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const uids = [...this.selectedIds()]; - await firstValueFrom(this.fortChangeService.updateBulkDistance(uids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.fortChangeService.updateBulkDistance(uids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('FORT_CHANGES.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadItems(); @@ -148,6 +181,29 @@ export class FortChangeListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(item: FortChange): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + this.fortChangeService.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadItems(); + }, + }); + }); + } + formatChangeTypes(types: string[]): string { if (!types || types.length === 0) return this.i18n.instant('FORT_CHANGES.ALL_CHANGES'); return types @@ -200,6 +256,7 @@ export class FortChangeListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); this.loadItems(); } @@ -243,4 +300,8 @@ export class FortChangeListComponent implements OnInit { } }); } + + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/geofences/geofence-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/geofences/geofence-list.component.html index beddfa77..a3e13c00 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/geofences/geofence-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/geofences/geofence-list.component.html @@ -138,9 +138,11 @@

send } - + @if (geofence.status === 'active' || geofence.status === 'rejected') { + + } @if (geofence.status !== 'pending_review' && geofence.status !== 'approved') { + @@ -72,24 +73,20 @@

{{ getTeamName(gym.team) }}

- @if (gym.clean === 1) { + @if (isAutoDelete(gym.clean)) { clean }
- @if (gym.distance === 0) { - map {{ 'ALARM.USING_AREAS' | translate }} - } @else { - straighten {{ formatDistance(gym.distance) }} - } +
- @if (gym.ping) { -
- notifications{{ gym.ping }} -
- }
+ + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts index b6b5560e..ca1cb89a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.ts @@ -9,18 +9,22 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom, forkJoin } from 'rxjs'; import { GymAddDialogComponent } from './gym-add-dialog.component'; import { GymEditDialogComponent } from './gym-edit-dialog.component'; import { Gym } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { GymService } from '../../core/services/gym.service'; import { I18nService } from '../../core/services/i18n.service'; import { ScannerService } from '../../core/services/scanner.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,7 +38,8 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTooltipModule, MatSnackBarModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, + WhereChipComponent, ], selector: 'app-gym-list', standalone: true, @@ -42,7 +47,10 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './gym-list.component.html', }) export class GymListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly gymService = inject(GymService); private readonly i18n = inject(I18nService); @@ -51,8 +59,11 @@ export class GymListComponent implements OnInit { readonly gymNames = signal>({}); readonly gyms = signal([]); readonly loading = signal(true); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); readonly selectedIds = signal(new Set()); readonly selectMode = signal(false); + readonly testAlertService = inject(TestAlertService); async bulkDelete(): Promise { @@ -67,11 +78,22 @@ export class GymListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; - for (const uid of ids) await firstValueFrom(this.gymService.delete(uid)); + // Settled one at a time: a stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so deletes that had already happened went unreported and the list never + // reloaded. See #603. + let deleted = 0; + for (const uid of ids) { + try { + await firstValueFrom(this.gymService.delete(uid)); + deleted++; + } catch { + // Already gone, which is what the user asked for. + } + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadGyms(); - this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('COMMON.OK'), { + this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('COMMON.OK'), { duration: 3000, }); } @@ -82,7 +104,18 @@ export class GymListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const uids = [...this.selectedIds()]; - await firstValueFrom(this.gymService.updateBulkDistance(uids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.gymService.updateBulkDistance(uids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('GYMS.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadGyms(); @@ -122,7 +155,7 @@ export class GymListComponent implements OnInit { data: { confirmText: this.i18n.instant('COMMON.DELETE'), message: `${this.i18n.instant('COMMON.DELETE')} ${this.getTeamName(gym.team)}?`, - title: this.i18n.instant('GYMS.EDIT_DIALOG_TITLE'), + title: this.i18n.instant('GYMS.CONFIRM_DELETE_TITLE'), warn: true, } as ConfirmDialogData, }) @@ -153,6 +186,29 @@ export class GymListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(item: Gym): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + this.gymService.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadGyms(); + }, + }); + }); + } + formatDistance(meters: number): string { return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`; } @@ -191,6 +247,11 @@ export class GymListComponent implements OnInit { } } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return (clean & 1) !== 0; + } + loadGyms(): void { this.loading.set(true); this.gymService @@ -207,6 +268,7 @@ export class GymListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); this.loadGyms(); } @@ -255,6 +317,10 @@ export class GymListComponent implements OnInit { }); } + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } + private resolveGymNames(gyms: Gym[]): void { const ids = [...new Set(gyms.filter(g => g.gymId).map(g => g.gymId!))]; if (ids.length === 0) return; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help-sections.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help-sections.ts index f54c1132..b784b593 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help-sections.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help-sections.ts @@ -72,6 +72,14 @@ export const HELP_SECTIONS: HelpSection[] = [ subtitleKey: 'HELP.SECTION_DELIVERY_SUB', titleKey: 'HELP.SECTION_DELIVERY', }, + { + id: 'quest-summary', + contentKey: 'HELP.CONTENT_QUEST_SUMMARY', + icon: 'schedule_send', + iconColor: '#d97706', + subtitleKey: 'HELP.SECTION_QUEST_SUMMARY_SUB', + titleKey: 'HELP.SECTION_QUEST_SUMMARY', + }, { id: 'test-alerts', contentKey: 'HELP.CONTENT_TEST_ALERTS', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.html index c467265c..f9228d24 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.html @@ -83,7 +83,14 @@

{{ 'HELP.PAGE_TITLE' | translate }}

-
+ +
} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.scss index 631884d3..bf3c7c6b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.scss @@ -315,10 +315,28 @@ border: 1px solid var(--card-border, rgba(0, 0, 0, 0.1)); box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08); margin: 12px 0 16px; + cursor: zoom-in; + transition: + box-shadow 0.2s ease, + transform 0.2s ease; + + &:hover, + &:focus-visible { + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18); + transform: translateY(-1px); + } + + &:focus-visible { + outline: 2px solid var(--accent-primary, #1976d2); + outline-offset: 2px; + } } +// A bare 480px cap beats the base rule's max-width:100%, so on a phone these rendered 482px in a +// 390px viewport and ran 92px off the screen. min() keeps the intent — never wider than 480 — while +// still yielding to the viewport. Measured at 390px. ::ng-deep .help-screenshot-sm { - max-width: 480px; + max-width: min(480px, 100%); } // ─── Callout boxes ────────────────────────────────────────── diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.spec.ts new file mode 100644 index 00000000..249886f1 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.spec.ts @@ -0,0 +1,73 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideTranslateService, TranslateService } from '@ngx-translate/core'; + +import { HelpComponent } from './help.component'; +import { ImageViewerDialogComponent } from '../../shared/components/image-viewer-dialog/image-viewer-dialog.component'; + +describe('HelpComponent screenshots', () => { + let fixture: ComponentFixture; + let dialog: { open: jest.Mock }; + + const SHOT = 'Dashboard overview

Body copy.

'; + + beforeEach(() => { + dialog = { open: jest.fn() }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService(), { provide: MatDialog, useValue: dialog }], + imports: [HelpComponent, NoopAnimationsModule], + }); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { HELP: { CONTENT_DASHBOARD: SHOT, IMAGE_ENLARGE: 'Click to enlarge' } }, true); + translate.use('en'); + + fixture = TestBed.createComponent(HelpComponent); + fixture.detectChanges(); + }); + + function screenshot(): HTMLImageElement { + const img = fixture.nativeElement.querySelector('img.help-screenshot') as HTMLImageElement | null; + expect(img).not.toBeNull(); + return img as HTMLImageElement; + } + + it('marks injected screenshots as focusable buttons', () => { + const img = screenshot(); + + expect(img.tabIndex).toBe(0); + expect(img.getAttribute('role')).toBe('button'); + expect(img.getAttribute('aria-label')).toBe('Dashboard overview — Click to enlarge'); + }); + + it('opens the viewer when a screenshot is clicked', () => { + screenshot().click(); + + expect(dialog.open).toHaveBeenCalledWith( + ImageViewerDialogComponent, + expect.objectContaining({ + data: { alt: 'Dashboard overview', src: expect.stringContaining('assets/help/dashboard-overview.png') }, + }), + ); + }); + + it('opens the viewer on Enter and Space', () => { + const img = screenshot(); + + img.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); + img.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: ' ' })); + + expect(dialog.open).toHaveBeenCalledTimes(2); + }); + + it('ignores other keys and clicks on surrounding prose', () => { + const img = screenshot(); + img.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'a' })); + (fixture.nativeElement.querySelector('.section-content p') as HTMLElement).click(); + + expect(dialog.open).not.toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.ts index 685e0b22..982f8ba2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/help/help.component.ts @@ -1,25 +1,31 @@ -import { ChangeDetectionStrategy, Component, computed, inject, signal, viewChildren } from '@angular/core'; +import { afterRenderEffect, ChangeDetectionStrategy, Component, computed, ElementRef, inject, signal, viewChildren } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; import { MatExpansionModule, MatExpansionPanel } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { HELP_SECTIONS, HelpSection } from './help-sections'; import { I18nService } from '../../core/services/i18n.service'; +import { ImageViewerDialogComponent } from '../../shared/components/image-viewer-dialog/image-viewer-dialog.component'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatExpansionModule, MatIconModule, MatButtonModule, MatFormFieldModule, MatInputModule, TranslateModule], + imports: [MatExpansionModule, MatIconModule, MatButtonModule, MatFormFieldModule, MatInputModule, TranslatePipe], selector: 'app-help', styleUrl: './help.component.scss', templateUrl: './help.component.html', }) export class HelpComponent { + private readonly contentHosts = viewChildren>('sectionContent'); + private readonly dialog = inject(MatDialog); protected readonly i18n = inject(I18nService); protected readonly searchQuery = signal(''); + protected readonly sections = HELP_SECTIONS; + protected readonly filteredSections = computed(() => { const query = this.searchQuery().toLowerCase().trim(); if (!query) return this.sections; @@ -33,10 +39,41 @@ export class HelpComponent { protected readonly panels = viewChildren(MatExpansionPanel); + constructor() { + // Section bodies are injected as raw HTML from the translation bundles, so the screenshots + // inside them can't carry template bindings. Re-runs whenever the rendered set or the + // language changes, both of which replace that HTML. + afterRenderEffect(() => { + this.filteredSections(); + this.i18n.currentLang(); + const hint = this.i18n.instant('HELP.IMAGE_ENLARGE'); + for (const host of this.contentHosts()) { + for (const img of host.nativeElement.querySelectorAll('img.help-screenshot')) { + img.tabIndex = 0; + img.setAttribute('role', 'button'); + img.setAttribute('aria-label', img.alt ? `${img.alt} — ${hint}` : hint); + } + } + }); + } + protected isUntranslated(section: HelpSection): boolean { return this.i18n.currentLang() !== 'en' && this.i18n.instant(section.contentKey) === section.contentKey; } + protected onContentClick(event: Event): void { + const img = this.screenshotFrom(event.target); + if (img) this.openViewer(img); + } + + protected onContentKeydown(event: KeyboardEvent): void { + if (event.key !== 'Enter' && event.key !== ' ') return; + const img = this.screenshotFrom(event.target); + if (!img) return; + event.preventDefault(); + this.openViewer(img); + } + protected scrollToSection(sectionId: string): void { const el = document.getElementById('section-' + sectionId); if (el) { @@ -49,6 +86,21 @@ export class HelpComponent { } } + private openViewer(img: HTMLImageElement): void { + this.dialog.open(ImageViewerDialogComponent, { + maxWidth: '96vw', + ariaLabel: img.alt, + data: { alt: img.alt, src: img.src }, + maxHeight: '96vh', + panelClass: 'image-viewer-panel', + }); + } + + private screenshotFrom(target: EventTarget | null): HTMLImageElement | null { + const el = target as HTMLElement | null; + return el?.tagName === 'IMG' && el.classList.contains('help-screenshot') ? (el as HTMLImageElement) : null; + } + private stripHtml(html: string): string { return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.html index 37f6f3db..a59fc26e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.html @@ -63,46 +63,8 @@

{{ 'INVASIONS.GENDER_LABEL' | translate }}

{{ 'ALARM.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
- @if (form.controls.distanceMode.value === 'distance') { - {{ 'ALARM.DISTANCE_LABEL' | translate }}{{ - 'ALARM.DISTANCE_SUFFIX' | translate - }} - } - - +

{{ 'ALARM.COMMON_SETTINGS' | translate }}

- @if (isWebhook) { - {{ 'ALARM.PING_ROLE' | translate }} - } { + let created: { gruntType: string | null }[]; + + const setup = (): InvasionAddDialogComponent => { + created = []; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: I18nService, useValue: { instant: (k: string) => k } }, + { provide: AuthService, useValue: { isImpersonating: () => false } }, + { provide: MasterDataService, useValue: { getPokemon: () => of([]) } }, + { + provide: AlertDefaultsService, + useValue: { defaultDistanceKm: () => 1, defaultMode: () => 'areas', defaultPlaceLabel: () => '' }, + }, + { + provide: InvasionService, + useValue: { + create: (payload: { gruntType: string | null }) => { + created.push(payload); + return of({ uid: created.length }); + }, + }, + }, + ], + imports: [InvasionAddDialogComponent], + }); + + const fixture = TestBed.createComponent(InvasionAddDialogComponent); + const component = fixture.componentInstance; + component.ngOnInit(); + return component; + }; + + it('never sends a null or empty gruntType', () => { + const component = setup(); + component.trackAll.set(true); + + component.save(); + + expect(created.length).toBeGreaterThan(0); + for (const payload of created) { + expect(payload.gruntType).toBeTruthy(); + } + }); + + it('creates one alarm per Team Rocket grunt', () => { + const component = setup(); + const expected = component.rocketGrunts().length; + component.trackAll.set(true); + + component.save(); + + expect(created).toHaveLength(expected); + }); + + it('covers the leaders and Giovanni, as the hint promises', () => { + const component = setup(); + component.trackAll.set(true); + + component.save(); + + const types = created.map(c => c.gruntType); + for (const boss of ['cliff', 'arlo', 'sierra', 'giovanni']) { + expect(types).toContain(boss); + } + }); + + it('excludes Pokestop event types, which are not Rocket invasions', () => { + const component = setup(); + component.trackAll.set(true); + + component.save(); + + const types = created.map(c => c.gruntType); + for (const event of ['kecleon', 'gold-stop', 'showcase']) { + expect(types).not.toContain(event); + } + }); + + it('still creates only what was ticked when track all is off', () => { + const component = setup(); + component.toggleGrunt('fire'); + component.toggleGrunt('water'); + + component.save(); + + expect(created.map(c => c.gruntType).sort()).toEqual(['fire', 'water']); + }); + + it('does nothing when nothing is selected', () => { + const component = setup(); + + component.save(); + + expect(created).toHaveLength(0); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.ts index 0e94a34b..edb27a1a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.ts @@ -11,16 +11,18 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; -import { forkJoin } from 'rxjs'; +import { TranslatePipe } from '@ngx-translate/core'; +import { catchError, forkJoin, of } from 'rxjs'; import { getGruntDisplayName, isGenderFixed, UICONS_BASE } from './invasion.constants'; +import { AlertDefaultsService } from '../../core/services/alert-defaults.service'; import { AuthService } from '../../core/services/auth.service'; import { I18nService } from '../../core/services/i18n.service'; import { InvasionService } from '../../core/services/invasion.service'; import { MasterDataService } from '../../core/services/masterdata.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeToFields } from '../../shared/utils/alarm-scope'; interface GruntOption { color?: string; @@ -54,9 +56,9 @@ interface GruntOption { MatSelectModule, MatTabsModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-invasion-add-dialog', standalone: true, @@ -105,29 +107,44 @@ export class InvasionAddDialogComponent implements OnInit { { gruntType: 'giovanni', invasionId: 44, key: 'giovanni', typeId: 0 }, ]; + private readonly alertDefaults = inject(AlertDefaultsService); + private readonly fb = inject(FormBuilder); + private readonly i18n = inject(I18nService); private readonly invasionService = inject(InvasionService); private readonly masterData = inject(MasterDataService); private readonly snackBar = inject(MatSnackBar); readonly dialogRef = inject(MatDialogRef); - gruntOptions = signal([]); - readonly eventGrunts = computed(() => this.gruntOptions().filter(g => g.isEvent)); + form = this.fb.group({ clean: [false], - distanceKm: [1], - distanceMode: ['areas' as 'areas' | 'distance'], gender: [0], - ping: [''], template: [''], }); readonly isWebhook = inject(AuthService).isImpersonating(); + readonly rocketGrunts = computed(() => this.gruntOptions().filter(g => !g.isEvent)); saving = signal(false); + + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + selectedCount = signal(0); readonly trackAll = signal(false); @@ -172,66 +189,64 @@ export class InvasionAddDialogComponent implements OnInit { this.gruntOptions.set([...grunts, ...events]); } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } - save(): void { if (!this.canSave()) return; - if (this.trackAll()) { - this.saving.set(true); - const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); - this.invasionService - .create({ - clean: v.clean ? 1 : 0, - distance: dist, - gender: v.gender ?? 0, - gruntType: null, - ping: v.ping || null, - template: v.template || null, - }) - .subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('INVASIONS.SNACK_FAILED_CREATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); - this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('INVASIONS.SNACK_ALL_CREATED'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); - this.dialogRef.close(true); - }, - }); - return; - } + // "Track all" used to post a single alarm with gruntType: null. PoracleNG has no catch-all -- + // it rejects an empty grunt_type with "Grunt type mandatory" -- so that call failed every time. + // The toggle now means what its hint always claimed: one alarm per Team Rocket grunt type. + // Pokestop events are excluded; they are not Rocket invasions and have their own section. + const targets = this.trackAll() ? this.rocketGrunts() : this.gruntOptions().filter(o => o.selected); + if (targets.length === 0) return; - const selected = this.gruntOptions().filter(o => o.selected); - if (selected.length === 0) return; this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); - const creates = selected.map(g => + const scope = scopeToFields(this.scope()); + const creates = targets.map(g => this.invasionService.create({ + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, clean: v.clean ? 1 : 0, - distance: dist, + distance: scope.distance, // Split variants (Mixed Male/Female) carry an implicit gender; typed grunts // fall back to the user's dropdown choice; fixed-gender rows force 0. gender: g.gender ?? (isGenderFixed(g.gruntType) ? 0 : (v.gender ?? 0)), gruntType: g.gruntType, - ping: v.ping || null, template: v.template || null, }), ); - forkJoin(creates).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('INVASIONS.SNACK_FAILED_CREATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already + // succeeded were never reported, the dialog stayed open and the list never reloaded. Each request + // settles on its own now, and the toast says how many landed. See #577. + forkJoin(creates.map(c => c.pipe(catchError((err: { error?: { error?: string } }) => of({ failed: err }))))).subscribe({ + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + // Each create settles on its own, so a refused one no longer hides the ones that landed. + // The first refusal's message is shown, because it names what is in the way. See #577. + next: (results: ({ uid?: number } | { failed: { error?: { error?: string } } })[]) => { + const refused = results.filter((r): r is { failed: { error?: { error?: string } } } => 'failed' in r); + // Three outcomes, not two: refused (409), already tracked (200 with no uid), and created. The + // pokemon dialog has split these since #495; the rest reported duplicates as creations. See #605. + const landed = results.filter((r): r is { uid?: number } => !('failed' in r)); + const created = landed.filter(r => (r.uid ?? 0) > 0).length; + const duplicates = landed.length - created; this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('INVASIONS.SNACK_CREATED_COUNT', { count: creates.length }), this.i18n.instant('TOAST.OK'), { - duration: 3000, - }); + + if (refused.length > 0) { + this.snackBar.open( + refused[0].failed?.error?.error ?? this.i18n.instant('INVASIONS.SNACK_FAILED_CREATE'), + this.i18n.instant('COMMON.OK'), + { duration: 6000 }, + ); + } else { + const message = + duplicates > 0 + ? this.i18n.instant('ALARM.SNACK_CREATED_WITH_DUPLICATES', { count: created, duplicates }) + : this.i18n.instant('INVASIONS.SNACK_CREATED_COUNT', { count: created }); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + // Close either way: whatever was created is real, and the list must reload to show it. this.dialogRef.close(true); }, }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.html index 9215bae4..bb955954 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.html @@ -52,48 +52,9 @@

{{ getDisplayName() }}

{{ 'ALARM.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } (MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); + form = this.fb.group({ - clean: [this.data.clean === 1], - distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], - distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], + clean: [isAutoDelete(this.data.clean)], gender: [this.data.gender], - ping: [this.data.ping ?? ''], template: [this.data.template ?? ''], }); readonly hideGender = isGenderFixed(this.data.gruntType); + readonly isEvent = isEventType(this.data.gruntType); readonly isWebhook = inject(AuthService).isImpersonating(); saving = signal(false); + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.overrideLocationLabel, this.data.overrideAreas, this.data.distance)); readonly selectedGender = toSignal(this.form.controls.gender.valueChanges, { initialValue: this.data.gender }); getDisplayName(): string { @@ -98,30 +101,30 @@ export class InvasionEditDialogComponent { return getGruntIconUrl(this.data.gruntType, this.selectedGender()); } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } - save(): void { this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); this.invasionService .update(this.data.uid, { - clean: v.clean ? 1 : 0, - distance: dist, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), + distance: scope.distance, // Preserve the stored gender when the dropdown is hidden — a Mixed Male alarm // (gender=1) must stay at 1 across edits; zeroing it would widen the filter to // also match female Mixed grunts. gender: this.hideGender ? (this.data.gender ?? 0) : (v.gender ?? 0), gruntType: this.data.gruntType ?? '', - ping: v.ping || null, - template: v.template || null, + template: v.template || '', } as InvasionUpdate) .subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('INVASIONS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('INVASIONS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html index 0cb6ccce..2537d867 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.html @@ -35,6 +35,7 @@

{{ 'INVASIONS.PAGE_TITLE' | translate }}

+ @@ -89,24 +90,20 @@

{{ getDisplayName(invasion.gruntType, invasion.gender) }}

}
- @if (invasion.clean === 1) { + @if (isAutoDelete(invasion.clean)) { {{ 'INVASIONS.CLEAN_TAG' | translate }} }
- @if (invasion.distance === 0) { - map {{ 'ALARM.USING_AREAS' | translate }} - } @else { - straighten {{ formatDistance(invasion.distance) }} - } +
- @if (invasion.ping) { -
- notifications{{ invasion.ping }} -
- }
+ + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.ts index 46999fb8..1ccead87 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-list.component.ts @@ -9,7 +9,7 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { InvasionAddDialogComponent } from './invasion-add-dialog.component'; @@ -22,12 +22,17 @@ import { isGenderFixed as checkGenderFixed, } from './invasion.constants'; import { Invasion } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { InvasionService } from '../../core/services/invasion.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; +import { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flags'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -41,7 +46,8 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTooltipModule, MatSnackBarModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, + WhereChipComponent, ], selector: 'app-invasion-list', standalone: true, @@ -49,7 +55,10 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './invasion-list.component.html', }) export class InvasionListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); private readonly invasionService = inject(InvasionService); @@ -57,8 +66,11 @@ export class InvasionListComponent implements OnInit { private readonly snackBar = inject(MatSnackBar); readonly invasions = signal([]); readonly loading = signal(true); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); readonly selectedIds = signal(new Set()); readonly selectMode = signal(false); + readonly testAlertService = inject(TestAlertService); async bulkDelete(): Promise { @@ -73,11 +85,22 @@ export class InvasionListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; - for (const uid of ids) await firstValueFrom(this.invasionService.delete(uid)); + // Settled one at a time: a stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so deletes that had already happened went unreported and the list never + // reloaded. See #603. + let deleted = 0; + for (const uid of ids) { + try { + await firstValueFrom(this.invasionService.delete(uid)); + deleted++; + } catch { + // Already gone, which is what the user asked for. + } + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadInvasions(); - this.snackBar.open(this.i18n.instant('INVASIONS.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('TOAST.OK'), { + this.snackBar.open(this.i18n.instant('INVASIONS.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('TOAST.OK'), { duration: 3000, }); } @@ -88,7 +111,18 @@ export class InvasionListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const uids = [...this.selectedIds()]; - await firstValueFrom(this.invasionService.updateBulkDistance(uids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.invasionService.updateBulkDistance(uids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('INVASIONS.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadInvasions(); @@ -161,6 +195,29 @@ export class InvasionListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(item: Invasion): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + this.invasionService.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadInvasions(); + }, + }); + }); + } + formatDistance(meters: number): string { return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`; } @@ -194,6 +251,11 @@ export class InvasionListComponent implements OnInit { return checkGenderFixed(gruntType); } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return cleanIsAutoDelete(clean); + } + isEventType(gruntType: string | null): boolean { return checkEventType(gruntType); } @@ -213,6 +275,7 @@ export class InvasionListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); this.loadInvasions(); } @@ -261,4 +324,8 @@ export class InvasionListComponent implements OnInit { } }); } + + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.html index c2b821d6..9464dcc0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.html @@ -29,52 +29,16 @@

{{ 'LURES.LURE_TYPES' | translate }}

{{ 'POKEMON.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
- @if (form.controls.distanceMode.value === 'distance') { - {{ 'ALARM.DISTANCE_LABEL' | translate }}{{ - 'ALARM.DISTANCE_SUFFIX' | translate - }} - } - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - {{ 'ALARM.PING_ROLE' | translate }} - } {{ 'ALARM.CLEAN_MODE' | translate }}

{{ 'ALARM.CLEAN_HINT_LURE' | translate }}

+ {{ 'LURES.EDIT_MODE' | translate }} +

{{ 'LURES.EDIT_HINT' | translate }}

diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.scss index a155846d..b4cf8199 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.scss @@ -75,3 +75,9 @@ mat-slide-toggle { min-width: 0; } } + +.scope-near-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.spec.ts new file mode 100644 index 00000000..f6835743 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.spec.ts @@ -0,0 +1,104 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; +import { provideRouter } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { LureAddDialogComponent } from './lure-add-dialog.component'; +import { Lure, LureCreate } from '../../core/models'; +import { AuthService } from '../../core/services/auth.service'; +import { ConfigService } from '../../core/services/config.service'; +import { LureService } from '../../core/services/lure.service'; + +describe('LureAddDialogComponent', () => { + let component: LureAddDialogComponent; + let dialogRef: { close: jest.Mock }; + let lureService: { create: jest.Mock }; + + function setup() { + dialogRef = { close: jest.fn() }; + lureService = { create: jest.fn().mockReturnValue(of({} as Lure)) }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + // delivery-preview links to /areas with routerLink, so a router is required. + provideRouter([]), + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: LureService, useValue: lureService }, + { provide: AuthService, useValue: { isImpersonating: () => false, user: () => ({ type: 'discord:user' }) } }, + ], + imports: [LureAddDialogComponent], + }); + + const fixture = TestBed.createComponent(LureAddDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + function createdClean(): number { + const create = lureService.create.mock.calls[0][0] as LureCreate; + return create.clean; + } + + beforeEach(() => setup()); + + it('defaults the edit-in-place toggle to off', () => { + expect(component.form.controls.editInPlace.value).toBe(false); + }); + + it('defaults the clean (auto-delete) toggle to off', () => { + expect(component.form.controls.clean.value).toBe(false); + }); + + it('composes clean=0 when neither toggle is set', () => { + component.selectedLureIds.set([501]); + component.save(); + expect(createdClean()).toBe(0); + }); + + it('composes bit 2 when only edit-in-place is on', () => { + component.selectedLureIds.set([501]); + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(createdClean()).toBe(2); + }); + + it('composes bit 1 when only auto-delete is on', () => { + component.selectedLureIds.set([501]); + component.form.controls.clean.setValue(true); + component.save(); + expect(createdClean()).toBe(1); + }); + + it('composes bits 1|2 = 3 when both toggles are on', () => { + component.selectedLureIds.set([501]); + component.form.controls.clean.setValue(true); + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(createdClean()).toBe(3); + }); + + it('applies the same composed clean to every selected lure', () => { + component.selectedLureIds.set([501, 502, 503]); + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(lureService.create).toHaveBeenCalledTimes(3); + for (const call of lureService.create.mock.calls) { + expect((call[0] as LureCreate).clean).toBe(2); + } + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('does nothing when no lure types are selected', () => { + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(lureService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.ts index 1cbf4652..bcd1d724 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.ts @@ -7,17 +7,21 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; -import { forkJoin } from 'rxjs'; +import { TranslatePipe } from '@ngx-translate/core'; +import { catchError, forkJoin, of } from 'rxjs'; +import { AlertDefaultsService } from '../../core/services/alert-defaults.service'; import { AuthService } from '../../core/services/auth.service'; import { I18nService } from '../../core/services/i18n.service'; import { LureService } from '../../core/services/lure.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeToFields } from '../../shared/utils/alarm-scope'; +import { compose } from '../../shared/utils/clean-flags'; interface LureOption { color: string; @@ -38,9 +42,10 @@ interface LureOption { MatRadioModule, MatTabsModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, TemplateSelectorComponent, - DeliveryPreviewComponent, + MatSelectModule, + ScopePickerComponent, ], selector: 'app-lure-add-dialog', standalone: true, @@ -48,13 +53,22 @@ interface LureOption { templateUrl: './lure-add-dialog.component.html', }) export class LureAddDialogComponent { + private readonly alertDefaults = inject(AlertDefaultsService); + private readonly fb = inject(FormBuilder); + private readonly i18n = inject(I18nService); private readonly lureService = inject(LureService); private readonly snackBar = inject(MatSnackBar); readonly dialogRef = inject(MatDialogRef); - form = this.fb.group({ clean: [false], distanceKm: [1], distanceMode: ['areas' as 'areas' | 'distance'], ping: [''], template: [''] }); + form = this.fb.group({ + clean: [false], + editInPlace: [false], + template: [''], + }); + readonly isWebhook = inject(AuthService).isImpersonating(); + lureTypes: LureOption[] = [ { id: 501, name: 'Normal', color: '#FF9800' }, { id: 502, name: 'Glacial', color: '#03A9F4' }, @@ -65,38 +79,75 @@ export class LureAddDialogComponent { ]; saving = signal(false); + + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + selectedLureIds = signal([]); getLureIcon(lureId: number): string { return `https://raw.githubusercontent.com/whitewillem/PogoAssets/main/uicons/reward/item/${lureId}.png`; } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } - save(): void { if (this.selectedLureIds().length === 0) return; this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const creates = this.selectedLureIds().map(lureId => this.lureService.create({ - clean: v.clean ? 1 : 0, - distance: dist, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + // New lures have no prior bits, so compose bits 1 (auto-delete) and 2 (edit-in-place) directly. + clean: compose(!!v.clean, !!v.editInPlace, false), + distance: scope.distance, lureId, - ping: v.ping || null, template: v.template || null, }), ); - forkJoin(creates).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('LURES.SNACK_FAILED_CREATE'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already + // succeeded were never reported, the dialog stayed open and the list never reloaded. Each request + // settles on its own now, and the toast says how many landed. See #577. + forkJoin(creates.map(c => c.pipe(catchError((err: { error?: { error?: string } }) => of({ failed: err }))))).subscribe({ + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + // Each create settles on its own, so a refused one no longer hides the ones that landed. + // The first refusal's message is shown, because it names what is in the way. See #577. + next: (results: ({ uid?: number } | { failed: { error?: { error?: string } } })[]) => { + const refused = results.filter((r): r is { failed: { error?: { error?: string } } } => 'failed' in r); + // Three outcomes, not two: refused (409), already tracked (200 with no uid), and created. The + // pokemon dialog has split these since #495; the rest reported duplicates as creations. See #605. + const landed = results.filter((r): r is { uid?: number } => !('failed' in r)); + const created = landed.filter(r => (r.uid ?? 0) > 0).length; + const duplicates = landed.length - created; this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('LURES.SNACK_CREATED'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + + if (refused.length > 0) { + this.snackBar.open( + refused[0].failed?.error?.error ?? this.i18n.instant('LURES.SNACK_FAILED_CREATE'), + this.i18n.instant('COMMON.OK'), + { duration: 6000 }, + ); + } else { + const message = + duplicates > 0 + ? this.i18n.instant('ALARM.SNACK_CREATED_WITH_DUPLICATES', { count: created, duplicates }) + : this.i18n.instant('LURES.SNACK_CREATED', { count: created }); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + // Close either way: whatever was created is real, and the list must reload to show it. this.dialogRef.close(true); }, }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.html index e6ff44f7..89293e1c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.html @@ -28,48 +28,9 @@

{{ getLureName(data.lureId) }} {{ 'LURES.LURE_SUFFIX' | translate }}

{{ 'POKEMON.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } {{ 'ALARM.MESSAGE_SETTINGS' | translate }} {{ 'ALARM.CLEAN_MODE' | translate }}

{{ 'ALARM.CLEAN_HINT_LURE' | translate }}

+ + {{ 'LURES.EDIT_MODE' | translate }} +

{{ 'LURES.EDIT_HINT' | translate }}

diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.scss index e6b51a65..5425acf8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.scss @@ -56,3 +56,16 @@ mat-slide-toggle { min-width: 0; } } + +.scope-current { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; +} + +.scope-hint { + color: var(--mat-sys-on-surface-variant, rgb(0 0 0 / 60%)); + font-size: 0.75rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.spec.ts new file mode 100644 index 00000000..73aefde3 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.spec.ts @@ -0,0 +1,140 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { provideRouter } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { LureEditDialogComponent } from './lure-edit-dialog.component'; +import { Lure, LureUpdate } from '../../core/models'; +import { AuthService } from '../../core/services/auth.service'; +import { ConfigService } from '../../core/services/config.service'; +import { LureService } from '../../core/services/lure.service'; + +describe('LureEditDialogComponent', () => { + let component: LureEditDialogComponent; + let dialogRef: { close: jest.Mock }; + let lureService: { update: jest.Mock }; + + const baseLure: Lure = { + id: 'lure-1', + uid: 42, + clean: 0, + distance: 0, + lureId: 501, + ping: null, + profileNo: 1, + template: null, + }; + + function setup(data: Lure) { + dialogRef = { close: jest.fn() }; + lureService = { update: jest.fn().mockReturnValue(of(void 0)) }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + // delivery-preview links to /areas with routerLink, so a router is required. + provideRouter([]), + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: LureService, useValue: lureService }, + { provide: AuthService, useValue: { isImpersonating: () => false, user: () => ({ type: 'discord:user' }) } }, + ], + imports: [LureEditDialogComponent], + }); + + const fixture = TestBed.createComponent(LureEditDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + function savedClean(): number { + const update = lureService.update.mock.calls[0][1] as LureUpdate; + return update.clean as number; + } + + describe('form init from clean bits', () => { + it('initializes both toggles off when clean=0', () => { + setup({ ...baseLure, clean: 0 }); + expect(component.form.controls.clean.value).toBe(false); + expect(component.form.controls.editInPlace.value).toBe(false); + }); + + it('initializes auto-delete on, edit off when clean=1', () => { + setup({ ...baseLure, clean: 1 }); + expect(component.form.controls.clean.value).toBe(true); + expect(component.form.controls.editInPlace.value).toBe(false); + }); + + it('initializes edit-in-place on from bit 2 when clean=2', () => { + setup({ ...baseLure, clean: 2 }); + expect(component.form.controls.clean.value).toBe(false); + expect(component.form.controls.editInPlace.value).toBe(true); + }); + + it('initializes both on when clean=3', () => { + setup({ ...baseLure, clean: 3 }); + expect(component.form.controls.clean.value).toBe(true); + expect(component.form.controls.editInPlace.value).toBe(true); + }); + + it('initializes edit-in-place on from bit 2 even when a summary bit is also set (clean=6)', () => { + setup({ ...baseLure, clean: 6 }); + expect(component.form.controls.clean.value).toBe(false); + expect(component.form.controls.editInPlace.value).toBe(true); + }); + }); + + describe('save composes bit 2 while preserving other bits', () => { + it('sets bit 2 when toggled on, leaving auto-delete off (clean 0 -> 2)', () => { + setup({ ...baseLure, clean: 0 }); + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(savedClean()).toBe(2); + }); + + it('combines auto-delete + edit (clean 0 -> 3)', () => { + setup({ ...baseLure, clean: 0 }); + component.form.controls.clean.setValue(true); + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(savedClean()).toBe(3); + }); + + it('clears bit 2 when toggled off (clean 3 -> 1)', () => { + setup({ ...baseLure, clean: 3 }); + component.form.controls.editInPlace.setValue(false); + component.save(); + expect(savedClean()).toBe(1); + }); + + it('preserves an unsurfaced summary bit when editing (clean 5 -> 7)', () => { + // clean=5 => auto-delete (1) + summary (4); turning edit-in-place on must keep bit 4. + setup({ ...baseLure, clean: 5 }); + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(savedClean()).toBe(7); + }); + + it('preserves the summary bit when turning auto-delete off (clean 5 -> 4)', () => { + setup({ ...baseLure, clean: 5 }); + component.form.controls.clean.setValue(false); + component.save(); + expect(savedClean()).toBe(4); + }); + + it('passes the composed clean and uid to the service', () => { + setup({ ...baseLure, clean: 0 }); + component.form.controls.editInPlace.setValue(true); + component.save(); + expect(lureService.update).toHaveBeenCalledWith(42, expect.objectContaining({ clean: 2 })); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.ts index 7a4fcc2d..4776e4e4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.ts @@ -9,14 +9,16 @@ import { MatRadioModule } from '@angular/material/radio'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Lure, LureUpdate } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; import { I18nService } from '../../core/services/i18n.service'; import { LureService } from '../../core/services/lure.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; +import { AUTO_DELETE, compose, EDIT, isAutoDelete, isEdit, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -30,9 +32,9 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatRadioModule, MatTabsModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-lure-edit-dialog', standalone: true, @@ -46,17 +48,19 @@ export class LureEditDialogComponent { private readonly snackBar = inject(MatSnackBar); readonly data = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); + form = this.fb.group({ - clean: [this.data.clean === 1], - distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], - distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], - ping: [this.data.ping ?? ''], + clean: [isAutoDelete(this.data.clean)], + editInPlace: [isEdit(this.data.clean)], template: [this.data.template ?? ''], }); readonly isWebhook = inject(AuthService).isImpersonating(); saving = signal(false); + + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.overrideLocationLabel, this.data.overrideAreas, this.data.distance)); getLureIcon(): string { return `https://raw.githubusercontent.com/whitewillem/PogoAssets/main/uicons/reward/item/${this.data.lureId}.png`; } @@ -80,26 +84,28 @@ export class LureEditDialogComponent { } } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } - save(): void { this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); this.lureService .update(this.data.uid, { - clean: v.clean ? 1 : 0, - distance: dist, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + // Only bits 1 (auto-delete) and 2 (edit-in-place) are user-editable here; preserve + // any summary bit (4) or future bits the bot may have set on this alarm. + clean: preserve(this.data.clean, AUTO_DELETE | EDIT, compose(!!v.clean, !!v.editInPlace, false)), + distance: scope.distance, lureId: this.data.lureId, - ping: v.ping || null, - template: v.template || null, + template: v.template || '', } as LureUpdate) .subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('LURES.SNACK_FAILED_UPDATE'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('LURES.SNACK_FAILED_UPDATE'), this.i18n.instant('COMMON.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.html index 2c83f402..84cda13d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.html @@ -34,6 +34,7 @@

{{ 'LURES.PAGE_TITLE' | translate }}

+ @@ -62,9 +63,12 @@

{{ 'LURES.PAGE_TITLE' | translate }}

{{ getLureName(lure.lureId) }} {{ 'LURES.LURE_SUFFIX' | translate }}

- @if (lure.clean === 1) { + @if (isAutoDelete(lure.clean)) { clean } + @if (isEdit(lure.clean)) { + {{ 'LURES.EDIT_BADGE' | translate }} + }
@@ -75,17 +79,13 @@

{{ getLureName(lure.lureId) }} {{ 'LURES.LURE_SUFFIX' | translate }}

- @if (lure.distance === 0) { - map {{ 'ALARM.USING_AREAS' | translate }} - } @else { - straighten {{ formatDistance(lure.distance) }} - } +
- @if (lure.ping) { -
- notifications{{ lure.ping }} -
- } + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.scss index 56c8a45a..ee85487e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.scss @@ -85,6 +85,21 @@ flex-shrink: 0; line-height: 16px; } +// Edit-in-place status badge — mirrors .clean-tag geometry, themed with the M3 secondary +// so it reads as a sibling status indicator next to the auto-delete (clean) tag. +.edit-tag { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + line-height: 16px; + background: var(--mat-sys-secondary); + color: var(--mat-sys-on-secondary); + flex-shrink: 0; +} .template-chip { display: inline-block; background: #e8eaf6; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.ts index cf81f291..94f0d0ea 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-list.component.ts @@ -9,17 +9,21 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { LureAddDialogComponent } from './lure-add-dialog.component'; import { LureEditDialogComponent } from './lure-edit-dialog.component'; import { Lure } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { LureService } from '../../core/services/lure.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -33,7 +37,8 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTooltipModule, MatSnackBarModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, + WhereChipComponent, ], selector: 'app-lure-list', standalone: true, @@ -41,15 +46,21 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './lure-list.component.html', }) export class LureListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); private readonly lureService = inject(LureService); private readonly snackBar = inject(MatSnackBar); readonly loading = signal(true); readonly lures = signal([]); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); readonly selectedIds = signal(new Set()); readonly selectMode = signal(false); + readonly testAlertService = inject(TestAlertService); async bulkDelete(): Promise { @@ -64,11 +75,22 @@ export class LureListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; - for (const uid of ids) await firstValueFrom(this.lureService.delete(uid)); + // Settled one at a time: a stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so deletes that had already happened went unreported and the list never + // reloaded. See #603. + let deleted = 0; + for (const uid of ids) { + try { + await firstValueFrom(this.lureService.delete(uid)); + deleted++; + } catch { + // Already gone, which is what the user asked for. + } + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadLures(); - this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('COMMON.OK'), { + this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('COMMON.OK'), { duration: 3000, }); } @@ -79,7 +101,18 @@ export class LureListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const uids = [...this.selectedIds()]; - await firstValueFrom(this.lureService.updateBulkDistance(uids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.lureService.updateBulkDistance(uids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('LURES.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadLures(); @@ -119,7 +152,7 @@ export class LureListComponent implements OnInit { data: { confirmText: this.i18n.instant('COMMON.DELETE'), message: `${this.i18n.instant('COMMON.DELETE')} ${this.getLureName(lure.lureId)} ${this.i18n.instant('LURES.LURE_SUFFIX')}?`, - title: this.i18n.instant('LURES.EDIT_DIALOG_TITLE'), + title: this.i18n.instant('LURES.CONFIRM_DELETE_TITLE'), warn: true, } as ConfirmDialogData, }) @@ -150,6 +183,29 @@ export class LureListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(item: Lure): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + this.lureService.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadLures(); + }, + }); + }); + } + formatDistance(meters: number): string { return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`; } @@ -196,6 +252,16 @@ export class LureListComponent implements OnInit { } } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return (clean & 1) !== 0; + } + + /** True when the edit-in-place bit (clean bit 2) is set, ignoring the auto-delete / summary bits. */ + isEdit(clean: number): boolean { + return (clean & 2) !== 0; + } + loadLures(): void { this.loading.set(true); this.lureService @@ -211,6 +277,7 @@ export class LureListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); this.loadLures(); } @@ -258,4 +325,8 @@ export class LureListComponent implements OnInit { } }); } + + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.html index 578a7293..c4ea62fd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.html @@ -12,7 +12,7 @@

{{ 'MAX_BATTLES.ADD_DIALOG_TITLE' | translate }}

-

Track any Pokemon at these battle tiers. One alarm is created per selected level.

+

{{ 'MAX_BATTLES.HINT_BY_LEVEL' | translate }}

@for (level of levels; track level.value) { @@ -40,7 +40,7 @@

{{ 'MAX_BATTLES.ADD_DIALOG_TITLE' | translate }}

-

Track specific Pokemon in Max Battles regardless of level.

+

{{ 'MAX_BATTLES.HINT_BY_POKEMON' | translate }}

@if (selectedPokemonIds().length > 0) {

{{ selectedPokemonIds().length }} Pokemon selected

@@ -48,7 +48,7 @@

{{ 'MAX_BATTLES.ADD_DIALOG_TITLE' | translate }}

{{ 'MAX_BATTLES.GIGANTAMAX_ONLY' | translate }} -

When enabled, only notifies about Gigantamax battles for the selected Pokemon.

+

{{ 'MAX_BATTLES.HINT_GMAX_ONLY_ADD' | translate }}

@@ -62,48 +62,9 @@

{{ 'MAX_BATTLES.ADD_DIALOG_TITLE' | translate }}

{{ 'RAIDS.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (commonForm.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } (null); + saving = signal(false); + + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + selectedLevels = signal([]); selectedPokemonIds = signal([]); @@ -108,16 +126,6 @@ export class MaxBattleAddDialogComponent { return this.selectedPokemonIds().length > 0; } - onDistanceModeChange(): void { - if (this.commonForm.controls.distanceMode.value === 'areas') { - this.commonForm.controls.distanceKm.setValue(0); - } else { - if (!this.commonForm.controls.distanceKm.value) { - this.commonForm.controls.distanceKm.setValue(1); - } - } - } - onPokemonSelected(ids: number[]): void { this.selectedPokemonIds.set(ids); } @@ -126,7 +134,7 @@ export class MaxBattleAddDialogComponent { if (!this.canSave()) return; this.saving.set(true); const common = this.commonForm.getRawValue(); - const distanceMeters = common.distanceMode === 'areas' ? 0 : Math.round((common.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const creates: ReturnType[] = []; @@ -135,14 +143,15 @@ export class MaxBattleAddDialogComponent { for (const levelVal of this.selectedLevels()) { const levelDef = this.levels.find(l => l.value === levelVal); const maxBattle: MaxBattleCreate = { - clean: common.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean: compose(!!common.clean, false, false), + distance: scope.distance, evolution: 9000, form: common.form ?? 0, gmax: levelDef?.gmax ? 1 : 0, level: levelVal, move: 9000, - ping: common.ping || '', pokemonId: 9000, stationId: null, template: common.template || '', @@ -153,14 +162,15 @@ export class MaxBattleAddDialogComponent { // By Pokemon — one alarm per selected Pokemon, level = 9000 (any) for (const pokemonId of this.selectedPokemonIds()) { const maxBattle: MaxBattleCreate = { - clean: common.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean: compose(!!common.clean, false, false), + distance: scope.distance, evolution: 9000, form: common.form ?? 0, gmax: common.gmaxOnly ? 1 : 0, level: 9000, move: 9000, - ping: common.ping || '', pokemonId, stationId: null, template: common.template || '', @@ -169,15 +179,40 @@ export class MaxBattleAddDialogComponent { } } - forkJoin(creates).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('MAX_BATTLES.CREATE_FAILED'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already + // succeeded were never reported, the dialog stayed open and the list never reloaded. Each request + // settles on its own now, and the toast says how many landed. See #577. + forkJoin(creates.map(c => c.pipe(catchError((err: { error?: { error?: string } }) => of({ failed: err }))))).subscribe({ + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + // Each create settles on its own, so a refused one no longer hides the ones that landed. + // The first refusal's message is shown, because it names what is in the way. See #577. + next: (results: ({ uid?: number } | { failed: { error?: { error?: string } } })[]) => { + const refused = results.filter((r): r is { failed: { error?: { error?: string } } } => 'failed' in r); + // Three outcomes, not two: refused (409), already tracked (200 with no uid), and created. The + // pokemon dialog has split these since #495; the rest reported duplicates as creations. See #605. + const landed = results.filter((r): r is { uid?: number } => !('failed' in r)); + const created = landed.filter(r => (r.uid ?? 0) > 0).length; + const duplicates = landed.length - created; this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('MAX_BATTLES.CREATE_SUCCESS', { count: creates.length }), this.i18n.instant('COMMON.OK'), { - duration: 3000, - }); + + if (refused.length > 0) { + this.snackBar.open( + refused[0].failed?.error?.error ?? this.i18n.instant('MAX_BATTLES.CREATE_FAILED'), + this.i18n.instant('COMMON.OK'), + { + duration: 6000, + }, + ); + } else { + const message = + duplicates > 0 + ? this.i18n.instant('ALARM.SNACK_CREATED_WITH_DUPLICATES', { count: created, duplicates }) + : this.i18n.instant('MAX_BATTLES.CREATE_SUCCESS', { count: created }); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + // Close either way: whatever was created is real, and the list must reload to show it. this.dialogRef.close(true); }, }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.html index af4583d0..58896941 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.html @@ -34,18 +34,18 @@

{{ getTitle() }}

{{ level.label }} @if (level.gmax) { - (Gigantamax) + {{ 'MAX_BATTLES.GMAX_OPTION_SUFFIX' | translate }} } } } @else { -

This alarm tracks a specific Pokemon across all Max Battle levels.

+

{{ 'MAX_BATTLES.HINT_ALL_LEVELS' | translate }}

{{ 'MAX_BATTLES.GIGANTAMAX_ONLY' | translate }} -

When enabled, only notifies about Gigantamax battles for this Pokemon.

+

{{ 'MAX_BATTLES.HINT_GMAX_ONLY_EDIT' | translate }}

}
@@ -57,48 +57,9 @@

{{ getTitle() }}

{{ 'RAIDS.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } { + const ANY = 9000; + + /** Charizard, filtered to a specific move and evolution. */ + const item: MaxBattle = { + id: 'user1', + uid: 79, + clean: 0, + distance: 100, + evolution: 3, + form: 0, + gmax: 1, + level: 7, + move: 14, + ping: '', + pokemonId: 6, + profileNo: 1, + stationId: null, + template: 'default', + }; + + const setup = (overrides: Partial = {}) => { + const sent: MaxBattleUpdate[] = []; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, + { provide: MAT_DIALOG_DATA, useValue: { item: { ...item, ...overrides } } }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { provide: I18nService, useValue: { instant: (k: string) => k } }, + { provide: IconService, useValue: { getPokemonUrl: () => '' } }, + { provide: MasterDataService, useValue: { getPokemonName: () => 'Charizard' } }, + { provide: AuthService, useValue: { isImpersonating: () => false } }, + { + provide: MaxBattleService, + useValue: { + update: (_uid: number, payload: MaxBattleUpdate) => { + sent.push(payload); + return of({}); + }, + }, + }, + ], + imports: [MaxBattleEditDialogComponent], + }); + + const component = TestBed.createComponent(MaxBattleEditDialogComponent).componentInstance; + return { component, sent }; + }; + + it('keeps the move filter when only the distance changes', () => { + const { component, sent } = setup(); + component.scope.set({ distanceKm: 8, mode: 'profile' }); + + component.save(); + + expect(sent[0].move).toBe(14); + expect(sent[0].move).not.toBe(ANY); + }); + + it('keeps the evolution filter when only the distance changes', () => { + const { component, sent } = setup(); + component.scope.set({ distanceKm: 8, mode: 'profile' }); + + component.save(); + + expect(sent[0].evolution).toBe(3); + expect(sent[0].evolution).not.toBe(ANY); + }); + + it('still sends the edited distance', () => { + const { component, sent } = setup(); + component.scope.set({ distanceKm: 8, mode: 'profile' }); + + component.save(); + + expect(sent[0].distance).toBe(8000); + }); + + it('leaves an unfiltered alarm unfiltered', () => { + const { component, sent } = setup({ evolution: ANY, move: ANY }); + + component.save(); + + expect(sent[0].move).toBe(ANY); + expect(sent[0].evolution).toBe(ANY); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.ts index df0da75a..68d81a2f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-edit-dialog.component.ts @@ -10,7 +10,7 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { MaxBattle, MaxBattleUpdate } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; @@ -18,8 +18,10 @@ import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { MaxBattleService } from '../../core/services/max-battle.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; export interface MaxBattleEditDialogData { item: MaxBattle; @@ -56,9 +58,9 @@ const LEVEL_OPTION_KEYS: { gmax: boolean; i18nKey: string; value: number }[] = [ MatRadioModule, MatTabsModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-max-battle-edit-dialog', standalone: true, @@ -77,16 +79,14 @@ export class MaxBattleEditDialogComponent { readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.item.clean === 1], - distanceKm: [this.data.item.distance > 0 ? this.data.item.distance / 1000 : 1], - distanceMode: [this.data.item.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], + clean: [isAutoDelete(this.data.item.clean)], gmax: [this.data.item.gmax === 1], level: [this.data.item.level], - ping: [this.data.item.ping ?? ''], template: [this.data.item.template ?? ''], }); readonly isLevelBased = this.data.item.pokemonId === 9000; + readonly isWebhook = inject(AuthService).isImpersonating(); readonly levelOptions: MaxBattleLevelOption[] = LEVEL_OPTION_KEYS.map(k => ({ gmax: k.gmax, @@ -96,6 +96,9 @@ export class MaxBattleEditDialogComponent { saving = signal(false); + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.item.overrideLocationLabel, this.data.item.overrideAreas, this.data.item.distance)); + getImage(): string { const item = this.data.item; if (item.pokemonId && item.pokemonId !== 9000) { @@ -123,16 +126,6 @@ export class MaxBattleEditDialogComponent { return this.data.item.gmax === 1 || this.data.item.level === 7 || this.data.item.level === 8; } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') { - this.form.controls.distanceKm.setValue(0); - } else { - if (!this.form.controls.distanceKm.value) { - this.form.controls.distanceKm.setValue(1); - } - } - } - onImageError(event: Event): void { (event.target as HTMLImageElement).style.display = 'none'; } @@ -140,7 +133,7 @@ export class MaxBattleEditDialogComponent { save(): void { this.saving.set(true); const values = this.form.getRawValue(); - const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const item = this.data.item; const levelVal = this.isLevelBased ? (values.level ?? item.level) : 9000; @@ -150,21 +143,30 @@ export class MaxBattleEditDialogComponent { const gmaxVal = this.isLevelBased ? (levelDef?.gmax ? 1 : 0) : values.gmax ? 1 : 0; const update: MaxBattleUpdate = { - clean: values.clean ? 1 : 0, - distance: distanceMeters, - evolution: 9000, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean: preserve(item.clean, AUTO_DELETE, values.clean ? 1 : 0), + distance: scope.distance, + // Carried through from the existing alarm, not reset to the 9000 "any" sentinel. Neither + // dialog can set these -- they come from the bot -- so hardcoding 9000 here meant any + // unrelated edit, a distance change included, silently destroyed the user's filter and + // widened what they get alerted on. See #412. + evolution: item.evolution, form: item.form, gmax: gmaxVal, level: levelVal, - move: 9000, - ping: values.ping || '', + move: item.move, pokemonId: item.pokemonId, stationId: null, template: values.template || '', }; this.maxBattleService.update(this.data.item.uid, update).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('MAX_BATTLES.SNACK_FAILED_UPDATE'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('MAX_BATTLES.SNACK_FAILED_UPDATE'), this.i18n.instant('COMMON.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.html index ed24cdc2..a20b23df 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.html @@ -37,6 +37,7 @@

{{ 'MAX_BATTLES.PAGE_TITLE' | translate }}

+ @@ -102,7 +103,7 @@

{{ getTitle(mb) }}

@if (isGmax(mb.level) || mb.gmax === 1) { {{ 'MAX_BATTLES.GMAX_TAG' | translate }} } - @if (mb.clean === 1) { + @if (isAutoDelete(mb.clean)) { clean }
@@ -119,12 +120,21 @@

{{ getTitle(mb) }}

{{ getFormName(mb.pokemonId, mb.form) }} }
- + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.ts index 966e0818..db02a06e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-list.component.ts @@ -7,20 +7,22 @@ import { MatDialogModule, MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { MaxBattleAddDialogComponent } from './max-battle-add-dialog.component'; import { MaxBattleEditDialogComponent, MaxBattleEditDialogData } from './max-battle-edit-dialog.component'; import { MaxBattle } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { MaxBattleService } from '../../core/services/max-battle.service'; -import { SettingsService } from '../../core/services/settings.service'; import { AlarmInfoComponent } from '../../shared/components/alarm-info/alarm-info.component'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -32,7 +34,7 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatDialogModule, MatTooltipModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, AlarmInfoComponent, ], selector: 'app-max-battle-list', @@ -41,21 +43,24 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './max-battle-list.component.html', }) export class MaxBattleListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); private readonly masterData = inject(MasterDataService); private readonly maxBattleService = inject(MaxBattleService); - private moves: Record = {}; - private readonly settingsService = inject(SettingsService); - private readonly snackBar = inject(MatSnackBar); readonly loading = signal(true); readonly maxBattles = signal([]); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); readonly selectedIds = signal(new Set()); readonly selectMode = signal(false); + readonly skeletonCards = Array.from({ length: 6 }); async bulkDelete(): Promise { @@ -70,13 +75,22 @@ export class MaxBattleListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; + // Settled one at a time. A stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so the deletes that had already happened went unreported and the list + // never reloaded: the user saw nothing at all. See #603. + let deleted = 0; for (const uid of ids) { - await firstValueFrom(this.maxBattleService.delete(uid)); + try { + await firstValueFrom(this.maxBattleService.delete(uid)); + deleted++; + } catch { + // Already gone, which is the outcome the user asked for. + } } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadData(); - this.snackBar.open(this.i18n.instant('MAX_BATTLES.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('COMMON.OK'), { + this.snackBar.open(this.i18n.instant('MAX_BATTLES.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('COMMON.OK'), { duration: 3000, }); } @@ -87,7 +101,18 @@ export class MaxBattleListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const ids = [...this.selectedIds()]; - await firstValueFrom(this.maxBattleService.updateBulkDistance(ids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.maxBattleService.updateBulkDistance(ids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('MAX_BATTLES.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadData(); @@ -159,6 +184,29 @@ export class MaxBattleListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(item: MaxBattle): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + this.maxBattleService.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadData(); + }, + }); + }); + } + getFormName(pokemonId: number, formId: number): string { return this.masterData.getFormName(pokemonId, formId); } @@ -216,7 +264,7 @@ export class MaxBattleListComponent implements OnInit { } getMoveName(moveId: number): string { - return this.moves[String(moveId)] ?? `Move #${moveId}`; + return this.masterData.getMoveName(moveId); } getTitle(maxBattle: MaxBattle): string { @@ -226,6 +274,11 @@ export class MaxBattleListComponent implements OnInit { return this.i18n.instant('MAX_BATTLES.ANY_POKEMON'); } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return (clean & 1) !== 0; + } + isGmax(level: number): boolean { return level === 7 || level === 8; } @@ -247,11 +300,8 @@ export class MaxBattleListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); - this.settingsService - .getConfig() - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(config => (this.moves = config.moves ?? {})); this.loadData(); } @@ -305,4 +355,8 @@ export class MaxBattleListComponent implements OnInit { } }); } + + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-add-dialog.component.html index 394afa82..df25cdbe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-add-dialog.component.html @@ -26,46 +26,8 @@

{{ 'NESTS.ADD_DIALOG_TITLE' | translate }}

{{ 'POKEMON.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
- @if (form.controls.distanceMode.value === 'distance') { - {{ 'ALARM.DISTANCE_LABEL' | translate }}{{ - 'ALARM.DISTANCE_SUFFIX' | translate - }} - } - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - {{ 'ALARM.PING_ROLE' | translate }} - } ); form = this.fb.group({ clean: [false], - distanceKm: [1], - distanceMode: ['areas' as 'areas' | 'distance'], minSpawnAvg: [0], - ping: [''], template: [''], }); readonly isWebhook = inject(AuthService).isImpersonating(); saving = signal(false); + + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + selectedPokemonIds = signal([]); - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } onPokemonSelected(ids: number[]): void { this.selectedPokemonIds.set(ids); @@ -73,24 +88,50 @@ export class NestAddDialogComponent { if (this.selectedPokemonIds().length === 0) return; this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const creates = this.selectedPokemonIds().map(pokemonId => this.nestService.create({ + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, clean: v.clean ? 1 : 0, - distance: dist, + distance: scope.distance, minSpawnAvg: v.minSpawnAvg ?? 0, - ping: v.ping || null, pokemonId, template: v.template || null, }), ); - forkJoin(creates).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('NESTS.SNACK_FAILED_CREATE'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already + // succeeded were never reported, the dialog stayed open and the list never reloaded. Each request + // settles on its own now, and the toast says how many landed. See #577. + forkJoin(creates.map(c => c.pipe(catchError((err: { error?: { error?: string } }) => of({ failed: err }))))).subscribe({ + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + // Each create settles on its own, so a refused one no longer hides the ones that landed. + // The first refusal's message is shown, because it names what is in the way. See #577. + next: (results: ({ uid?: number } | { failed: { error?: { error?: string } } })[]) => { + const refused = results.filter((r): r is { failed: { error?: { error?: string } } } => 'failed' in r); + // Three outcomes, not two: refused (409), already tracked (200 with no uid), and created. The + // pokemon dialog has split these since #495; the rest reported duplicates as creations. See #605. + const landed = results.filter((r): r is { uid?: number } => !('failed' in r)); + const created = landed.filter(r => (r.uid ?? 0) > 0).length; + const duplicates = landed.length - created; this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('NESTS.SNACK_CREATED'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + + if (refused.length > 0) { + this.snackBar.open( + refused[0].failed?.error?.error ?? this.i18n.instant('NESTS.SNACK_FAILED_CREATE'), + this.i18n.instant('COMMON.OK'), + { duration: 6000 }, + ); + } else { + const message = + duplicates > 0 + ? this.i18n.instant('ALARM.SNACK_CREATED_WITH_DUPLICATES', { count: created, duplicates }) + : this.i18n.instant('NESTS.SNACK_CREATED', { count: created }); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + // Close either way: whatever was created is real, and the list must reload to show it. this.dialogRef.close(true); }, }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.html index 211ad5ba..a72582c2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.html @@ -30,48 +30,9 @@

{{ pokemonName }}

{{ 'POKEMON.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } (MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); + form = this.fb.group({ - clean: [this.data.clean === 1], - distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], - distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], + clean: [isAutoDelete(this.data.clean)], minSpawnAvg: [this.data.minSpawnAvg], - ping: [this.data.ping ?? ''], template: [this.data.template ?? ''], }); readonly isWebhook = inject(AuthService).isImpersonating(); pokemonName = this.masterData.getPokemonName(this.data.pokemonId); + saving = signal(false); + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.overrideLocationLabel, this.data.overrideAreas, this.data.distance)); getPokemonImage(): string { return this.iconService.getPokemonUrl(this.data.pokemonId); } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') this.form.controls.distanceKm.setValue(0); - else if (!this.form.controls.distanceKm.value) this.form.controls.distanceKm.setValue(1); - } - onImageError(event: Event): void { (event.target as HTMLImageElement).style.display = 'none'; } @@ -79,19 +77,24 @@ export class NestEditDialogComponent { save(): void { this.saving.set(true); const v = this.form.getRawValue(); - const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); this.nestService .update(this.data.uid, { - clean: v.clean ? 1 : 0, - distance: dist, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), + distance: scope.distance, minSpawnAvg: v.minSpawnAvg ?? 0, - ping: v.ping || null, pokemonId: this.data.pokemonId, - template: v.template || null, + template: v.template || '', } as NestUpdate) .subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('NESTS.SNACK_FAILED_UPDATE'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('NESTS.SNACK_FAILED_UPDATE'), this.i18n.instant('COMMON.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.html index 7c90406e..1a369127 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.html @@ -34,6 +34,7 @@

{{ 'NESTS.PAGE_TITLE' | translate }}

+ @@ -67,7 +68,7 @@

{{ getPokemonName(nest.pokemonId) }}

#{{ nest.pokemonId }}
- @if (nest.clean === 1) { + @if (isAutoDelete(nest.clean)) { clean }
@@ -80,17 +81,13 @@

{{ getPokemonName(nest.pokemonId) }}

- @if (nest.distance === 0) { - map {{ 'ALARM.USING_AREAS' | translate }} - } @else { - straighten {{ formatDistance(nest.distance) }} - } +
- @if (nest.ping) { -
- notifications{{ nest.ping }} -
- } + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.ts index 75c09c6d..fd8c9aa0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-list.component.ts @@ -9,12 +9,13 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { NestAddDialogComponent } from './nest-add-dialog.component'; import { NestEditDialogComponent } from './nest-edit-dialog.component'; import { Nest } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; @@ -22,6 +23,10 @@ import { NestService } from '../../core/services/nest.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; +import { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flags'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -35,7 +40,8 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTooltipModule, MatSnackBarModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, + WhereChipComponent, ], selector: 'app-nest-list', standalone: true, @@ -43,7 +49,10 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './nest-list.component.html', }) export class NestListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); @@ -52,8 +61,11 @@ export class NestListComponent implements OnInit { private readonly snackBar = inject(MatSnackBar); readonly loading = signal(true); readonly nests = signal([]); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); readonly selectedIds = signal(new Set()); readonly selectMode = signal(false); + readonly testAlertService = inject(TestAlertService); async bulkDelete(): Promise { @@ -68,11 +80,22 @@ export class NestListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; - for (const uid of ids) await firstValueFrom(this.nestService.delete(uid)); + // Settled one at a time: a stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so deletes that had already happened went unreported and the list never + // reloaded. See #603. + let deleted = 0; + for (const uid of ids) { + try { + await firstValueFrom(this.nestService.delete(uid)); + deleted++; + } catch { + // Already gone, which is what the user asked for. + } + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadNests(); - this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('COMMON.OK'), { + this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('COMMON.OK'), { duration: 3000, }); } @@ -83,7 +106,18 @@ export class NestListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const uids = [...this.selectedIds()]; - await firstValueFrom(this.nestService.updateBulkDistance(uids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.nestService.updateBulkDistance(uids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('NESTS.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadNests(); @@ -123,7 +157,7 @@ export class NestListComponent implements OnInit { data: { confirmText: this.i18n.instant('COMMON.DELETE'), message: this.i18n.instant('POKEMON.CONFIRM_DELETE_MSG', { name: this.getPokemonName(nest.pokemonId) }), - title: this.i18n.instant('NESTS.EDIT_DIALOG_TITLE'), + title: this.i18n.instant('NESTS.CONFIRM_DELETE_TITLE'), warn: true, } as ConfirmDialogData, }) @@ -154,6 +188,29 @@ export class NestListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(item: Nest): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + this.nestService.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadNests(); + }, + }); + }); + } + formatDistance(meters: number): string { return meters >= 1000 ? `${(meters / 1000).toFixed(1)} km` : `${meters} m`; } @@ -166,6 +223,11 @@ export class NestListComponent implements OnInit { return this.masterData.getPokemonName(id); } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return cleanIsAutoDelete(clean); + } + loadNests(): void { this.loading.set(true); this.nestService @@ -181,6 +243,7 @@ export class NestListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); this.loadNests(); } @@ -233,4 +296,8 @@ export class NestListComponent implements OnInit { } }); } + + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.html index fff2045a..0fcfa032 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.html @@ -119,12 +119,12 @@

{{ 'POKEMON.FILTER_FORM_GENDER' | translate }}

{{ 'POKEMON.LABEL_FORM' | translate }} - - {{ 'POKEMON.ALL_FORMS' | translate }} + @for (f of availableForms(); track f.id) { {{ f.name }} } + {{ 'POKEMON.FORM_MULTI_HINT' | translate }} {{ 'POKEMON.LABEL_GENDER' | translate }} @@ -142,6 +142,18 @@

{{ 'POKEMON.FILTER_FORM_GENDER' | translate }}

more_horiz {{ 'POKEMON.MORE_FILTERS' | translate }} +

{{ 'POKEMON.FILTER_TIME_LEFT' | translate }}

+
+ + {{ 'POKEMON.LABEL_MIN_TIME' | translate }} + + @for (option of minTimeChoices(); track option) { + {{ minTimeText(option) | translate: minTimeParams(option) }} + } + + {{ 'POKEMON.MIN_TIME_HINT' | translate }} + +

{{ 'POKEMON.FILTER_SIZE' | translate }}

@@ -217,6 +229,38 @@

{{ 'POKEMON.FILTER_SIZE' | translate }}

@if (pvpForm.controls.pvpRankingLeague.value !== 0) { + @if (showCapPicker()) { +
+ {{ 'POKEMON.PVP_CAP' | translate }} + + {{ 'POKEMON.PVP_CAP_ALL' | translate }} + @for (cap of pvpCaps(); track cap) { + {{ 'POKEMON.PVP_CAP_LEVEL' | translate: { level: cap } }} + } + + @if (!capTouched()) { +

+ {{ 'POKEMON.PVP_CAP_HINT_DEFAULT' | translate }} +

+ } +
+ } + +
+ {{ 'POKEMON.PVP_EVOLUTION' | translate }} + + {{ 'POKEMON.PVP_EVO_BASE' | translate }} + {{ 'POKEMON.PVP_EVO_MEGA' | translate }} + {{ 'POKEMON.PVP_EVO_MEGA_X' | translate }} + {{ 'POKEMON.PVP_EVO_MEGA_Y' | translate }} + +

+ {{ 'POKEMON.PVP_EVOLUTION_HINT' | translate }} +

+
{{ 'POKEMON.PVP_BEST_RANK' | translate }} @@ -250,49 +294,9 @@

{{ 'POKEMON.FILTER_SIZE' | translate }}

{{ 'POKEMON.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (notifForm.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - {{ 'ALARM.PING_HINT' | translate }} - - } { + let component: PokemonAddDialogComponent; + let dialogRef: { close: jest.Mock }; + let monsterService: { create: jest.Mock }; + let snackBar: { open: jest.Mock }; + let masterData: { getFormsForPokemon: jest.Mock }; + + /** Meowth (52) with two non-Normal forms: Alolan + Galarian. */ + const MEOWTH = 52; + const ALOLAN = 78; + const GALARIAN = 79; + + function setup() { + dialogRef = { close: jest.fn() }; + // A create answers 200 with uid 0 when the submission duplicates an alarm the user already has, so + // the uid is what says whether anything was made. See #495. + let nextUid = 100; + monsterService = { create: jest.fn().mockImplementation(() => of({ uid: nextUid++ } as Monster)) }; + snackBar = { open: jest.fn() }; + masterData = { + getFormsForPokemon: jest.fn().mockReturnValue([ + { id: ALOLAN, name: 'Alolan' }, + { id: GALARIAN, name: 'Galarian' }, + ]), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: MonsterService, useValue: monsterService }, + { provide: MasterDataService, useValue: masterData }, + { provide: I18nService, useValue: { instant: (k: string) => k } }, + { + provide: AlertDefaultsService, + useValue: { defaultDistanceKm: () => 1, defaultMode: () => 'areas', defaultPlaceLabel: () => '' }, + }, + { + provide: PoracleConfigService, + useValue: { load: () => of({ defaultPvpCap: 0 }), serverConfig: () => ({ pvpCaps: [] }) }, + }, + { provide: AuthService, useValue: { isImpersonating: () => false } }, + ], + imports: [PokemonAddDialogComponent], + }); + + // MatSnackBar is providedIn MatSnackBarModule, which the standalone component imports, so + // it resolves from the component's element injector and shadows an environment-level + // useValue. Override at the component level to inject our mock. + TestBed.overrideComponent(PokemonAddDialogComponent, { + add: { providers: [{ provide: MatSnackBar, useValue: snackBar }] }, + }); + + // No detectChanges(): we exercise save() logic directly and skip rendering the heavy + // app-pokemon-selector child. Computed signals (availableForms) evaluate lazily on read. + const fixture = TestBed.createComponent(PokemonAddDialogComponent); + component = fixture.componentInstance; + } + + function createdForms(): number[] { + return monsterService.create.mock.calls.map(call => (call[0] as MonsterCreate).form); + } + + beforeEach(() => setup()); + + it('defaults the multi-select forms control to empty', () => { + expect(component.filtersForm.controls.forms.value).toEqual([]); + }); + + it('creates one alarm per selected form (multi-select fan-out)', () => { + component.selectedPokemonIds.set([MEOWTH]); + component.filtersForm.controls.forms.setValue([ALOLAN, GALARIAN]); + component.save(); + + expect(monsterService.create).toHaveBeenCalledTimes(2); + expect(createdForms()).toEqual([ALOLAN, GALARIAN]); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('treats an empty form selection as all forms (form 0)', () => { + component.selectedPokemonIds.set([MEOWTH]); + component.filtersForm.controls.forms.setValue([]); + component.save(); + + expect(monsterService.create).toHaveBeenCalledTimes(1); + expect(createdForms()).toEqual([0]); + }); + + it('fans out the cartesian product of pokemon x forms', () => { + component.selectedPokemonIds.set([MEOWTH, MEOWTH + 1]); + // Two pokemon selected => no specific forms list is available, so the multi-select + // is hidden and an empty selection means "all forms" for each pokemon. + component.save(); + + expect(monsterService.create).toHaveBeenCalledTimes(2); + expect(createdForms()).toEqual([0, 0]); + }); + + it('falls back to the manual numeric form id when no form list is available', () => { + // Two pokemon => availableForms() is empty => the numeric `form` control is used. + component.selectedPokemonIds.set([MEOWTH, MEOWTH + 1]); + component.filtersForm.controls.form.setValue(42); + component.save(); + + expect(createdForms()).toEqual([42, 42]); + }); + + it('reports how many alarms were actually created', () => { + component.selectedPokemonIds.set([MEOWTH]); + component.filtersForm.controls.forms.setValue([ALOLAN, GALARIAN]); + component.save(); + + expect(snackBar.open).toHaveBeenCalledWith('POKEMON.SNACK_CREATED', 'COMMON.OK', expect.objectContaining({ duration: 4000 })); + }); + + // Counting the submissions claimed every selected Pokemon was added while the list grew by fewer, or + // by none at all. See #495. + it('says how many submissions were already tracked', () => { + monsterService.create.mockReturnValueOnce(of({ uid: 0 } as Monster)); + component.selectedPokemonIds.set([MEOWTH]); + component.filtersForm.controls.forms.setValue([ALOLAN, GALARIAN]); + component.save(); + + expect(snackBar.open).toHaveBeenCalledWith( + 'POKEMON.SNACK_CREATED_WITH_DUPLICATES', + 'COMMON.OK', + expect.objectContaining({ duration: 4000 }), + ); + }); + + it('sends the mega mode with a PVP rule', () => { + component.selectedPokemonIds.set([MEOWTH]); + component.pvpForm.controls.pvpRankingLeague.setValue(1500); + component.pvpForm.controls.pvpRankingEvolution.setValue(2); + + component.save(); + + expect((monsterService.create.mock.calls[0][0] as MonsterCreate).pvpRankingEvolution).toBe(2); + }); + + it('does not send a mega mode on a rule with no league', () => { + // Mega mode only means something inside a PVP rule. Carrying it on a non-PVP alarm would be a + // filter the user never asked for, on a field PoracleNG still reads. + component.selectedPokemonIds.set([MEOWTH]); + component.pvpForm.controls.pvpRankingEvolution.setValue(2); + + component.save(); + + expect((monsterService.create.mock.calls[0][0] as MonsterCreate).pvpRankingEvolution).toBe(0); + }); + + it('sends the minimum time left with the rule', () => { + component.selectedPokemonIds.set([MEOWTH]); + component.filtersForm.controls.minTime.setValue(300); + + component.save(); + + expect((monsterService.create.mock.calls[0][0] as MonsterCreate).minTime).toBe(300); + }); + + it('sends no time floor by default', () => { + // The legitimate twin: an ordinary rule must not arrive with a filter nobody chose. + component.selectedPokemonIds.set([MEOWTH]); + + component.save(); + + expect((monsterService.create.mock.calls[0][0] as MonsterCreate).minTime).toBe(0); + }); + + it('offers the presets for the time left', () => { + expect(component.minTimeChoices()).toEqual([0, 60, 120, 300, 600, 900, 1200]); + }); + + it('sends the scope fields for an alarm aimed at a saved place', () => { + component.selectedPokemonIds.set([MEOWTH]); + component.scope.set({ distanceKm: 2, mode: 'place', placeLabel: 'work' }); + + component.save(); + + const created = monsterService.create.mock.calls[0][0] as MonsterCreate; + expect(created.overrideLocationLabel).toBe('work'); + expect(created.distance).toBe(2000); + expect(created.overrideAreas).toEqual([]); + }); + + it('leaves the radius on the pin when no place is chosen', () => { + // The legitimate-case half: "within 2 km of me" is the alarm most people make, and it must not + // acquire a location override just because the field exists. + component.selectedPokemonIds.set([MEOWTH]); + component.scope.set({ distanceKm: 2, mode: 'profile' }); + + component.save(); + + const created = monsterService.create.mock.calls[0][0] as MonsterCreate; + expect(created.overrideLocationLabel).toBe(''); + expect(created.distance).toBe(2000); + }); + + it('clears the radius and any place when the alarm inherits the profile', () => { + component.selectedPokemonIds.set([MEOWTH]); + component.scope.set({ mode: 'profile' }); + + component.save(); + + const created = monsterService.create.mock.calls[0][0] as MonsterCreate; + expect(created.distance).toBe(0); + expect(created.overrideLocationLabel).toBe(''); + }); + + it('does nothing when no pokemon are selected', () => { + component.filtersForm.controls.forms.setValue([ALOLAN]); + component.save(); + expect(monsterService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.ts index 0dcefb9b..5d0807ce 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.ts @@ -1,6 +1,7 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -12,23 +13,28 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; -import { forkJoin } from 'rxjs'; +import { TranslatePipe } from '@ngx-translate/core'; +import { catchError, forkJoin, of } from 'rxjs'; import { MonsterCreate } from '../../core/models'; +import { AlertDefaultsService } from '../../core/services/alert-defaults.service'; import { AuthService } from '../../core/services/auth.service'; import { I18nService } from '../../core/services/i18n.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { MonsterService } from '../../core/services/monster.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { PoracleConfigService } from '../../core/services/poracle-config.service'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeToFields } from '../../shared/utils/alarm-scope'; +import { minTimeLabel, minTimeOptions } from '../../shared/utils/min-time'; @Component({ imports: [ ReactiveFormsModule, MatDialogModule, MatButtonModule, + MatButtonToggleModule, MatFormFieldModule, MatInputModule, MatSelectModule, @@ -41,19 +47,23 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatProgressSpinnerModule, PokemonSelectorComponent, TemplateSelectorComponent, - DeliveryPreviewComponent, - TranslateModule, + TranslatePipe, + ScopePickerComponent, ], selector: 'app-pokemon-add-dialog', standalone: true, styleUrl: './pokemon-add-dialog.component.scss', templateUrl: './pokemon-add-dialog.component.html', }) -export class PokemonAddDialogComponent { +export class PokemonAddDialogComponent implements OnInit { + private readonly alertDefaults = inject(AlertDefaultsService); + private readonly fb = inject(FormBuilder); + private readonly i18n = inject(I18nService); private readonly masterData = inject(MasterDataService); private readonly monsterService = inject(MonsterService); + private readonly poracleConfig = inject(PoracleConfigService); private readonly snackBar = inject(MatSnackBar); selectedPokemonIds = signal([]); readonly availableForms = computed(() => { @@ -62,12 +72,16 @@ export class PokemonAddDialogComponent { return this.masterData.getFormsForPokemon(ids[0]); }); + /** Tracks whether the user has manually changed the cap since the default was applied. */ + readonly capTouched = signal(false); + readonly dialogRef = inject(MatDialogRef); filtersForm = this.fb.group({ atk: [0, [Validators.min(0), Validators.max(15)]], def: [0, [Validators.min(0), Validators.max(15)]], form: [0], + forms: [[] as number[]], gender: [0], maxAtk: [15, [Validators.min(0), Validators.max(15)]], maxCp: [9000, [Validators.min(0), Validators.max(9000)]], @@ -80,6 +94,7 @@ export class PokemonAddDialogComponent { minCp: [0, [Validators.min(0), Validators.max(9000)]], minIv: [0, [Validators.min(0), Validators.max(100)]], minLevel: [0, [Validators.min(0), Validators.max(55)]], + minTime: [0], minWeight: [0], size: [-1], sta: [0, [Validators.min(0), Validators.max(15)]], @@ -89,14 +104,20 @@ export class PokemonAddDialogComponent { notifForm = this.fb.group({ clean: [false], - distanceKm: [1], - distanceMode: ['areas' as 'areas' | 'distance'], - ping: [''], + // Empty means the profile pin, which is what "set a distance" has always meant. A label points the + // radius at a saved place instead. + placeLabel: [''], template: [''], }); + /** Caps offered by Poracle (e.g. [50] or [50, 51]). Empty = hide the cap picker entirely. */ + readonly pvpCaps = computed(() => this.poracleConfig.serverConfig().pvpCaps); + pvpForm = this.fb.group({ pvpRankingBest: [1], + pvpRankingCap: [0], + // 0 base, 1 any mega, 2 Mega X, 3 Mega Y — PoracleNG's pvp_ranking_evolution. + pvpRankingEvolution: [0], pvpRankingLeague: [0], pvpRankingMinCp: [0], pvpRankingWorst: [100], @@ -104,16 +125,49 @@ export class PokemonAddDialogComponent { saving = signal(false); + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + + /** Whether to render the cap picker at all — only when Poracle offers more than one cap. */ + readonly showCapPicker = computed(() => this.pvpCaps().length > 1); + isFormValid(): boolean { return this.selectedPokemonIds().length > 0 && this.filtersForm.valid && this.notifForm.valid; } - onDistanceModeChange(): void { - if (this.notifForm.controls.distanceMode.value === 'areas') { - this.notifForm.controls.distanceKm.setValue(0); - } else if (!this.notifForm.controls.distanceKm.value) { - this.notifForm.controls.distanceKm.setValue(1); - } + /** The preset list, widened to keep whatever the rule already holds. */ + minTimeChoices(): number[] { + return minTimeOptions(this.filtersForm.controls.minTime.value ?? 0); + } + + minTimeParams(seconds: number): Record | undefined { + return minTimeLabel(seconds).params; + } + + minTimeText(seconds: number): string { + return minTimeLabel(seconds).key; + } + + ngOnInit(): void { + // Pre-fill the cap from Poracle's admin-configured default. Users can still override. + this.poracleConfig.load().subscribe(cfg => { + this.pvpForm.controls.pvpRankingCap.setValue(cfg.defaultPvpCap); + }); + + this.pvpForm.controls.pvpRankingCap.valueChanges.subscribe(() => { + this.capTouched.set(true); + }); } onPokemonSelected(ids: number[]): void { @@ -127,50 +181,84 @@ export class PokemonAddDialogComponent { const filters = this.filtersForm.getRawValue(); const pvp = this.pvpForm.getRawValue(); const notif = this.notifForm.getRawValue(); - const distanceMeters = notif.distanceMode === 'areas' ? 0 : Math.round((notif.distanceKm ?? 1) * 1000); - - const creates = this.selectedPokemonIds().map(pokemonId => { - const monster: MonsterCreate = { - atk: filters.atk ?? 0, - clean: notif.clean ? 1 : 0, - def: filters.def ?? 0, - distance: distanceMeters, - form: filters.form ?? 0, - gender: filters.gender ?? 0, - maxAtk: filters.maxAtk ?? 15, - maxCp: filters.maxCp ?? 9000, - maxDef: filters.maxDef ?? 15, - maxIv: filters.maxIv ?? 100, - maxLevel: filters.maxLevel ?? 55, - maxSize: filters.maxSize ?? 5, - maxSta: filters.maxSta ?? 15, - maxWeight: filters.maxWeight ?? 9000000, - minCp: filters.minCp ?? 0, - minIv: filters.minIv ?? 0, - minLevel: filters.minLevel ?? 0, - minWeight: filters.minWeight ?? 0, - ping: notif.ping || null, - pokemonId, - pvpRankingBest: pvp.pvpRankingLeague ? (pvp.pvpRankingBest ?? 1) : 0, - pvpRankingLeague: pvp.pvpRankingLeague ?? 0, - pvpRankingMinCp: pvp.pvpRankingLeague ? (pvp.pvpRankingMinCp ?? 0) : 0, - pvpRankingWorst: pvp.pvpRankingLeague ? (pvp.pvpRankingWorst ?? 100) : 4096, - size: filters.size ?? -1, - sta: filters.sta ?? 0, - template: notif.template || null, - }; - return this.monsterService.create(monster); - }); + const scope = scopeToFields(this.scope()); - forkJoin(creates).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('POKEMON.SNACK_FAILED_CREATE'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // PoracleNG models `form` as a single int per tracking entry, so a multi-form + // selection fans out into one alarm per form. When specific forms are available we + // use the multi-select; an empty selection means "all forms" (0). Otherwise we fall + // back to the manual form-id number input. + const formIds = + this.availableForms().length > 0 ? (filters.forms && filters.forms.length > 0 ? filters.forms : [0]) : [filters.form ?? 0]; + + const creates = this.selectedPokemonIds().flatMap(pokemonId => + formIds.map(form => { + const monster: MonsterCreate = { + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + atk: filters.atk ?? 0, + clean: notif.clean ? 1 : 0, + def: filters.def ?? 0, + distance: scope.distance, + form, + gender: filters.gender ?? 0, + maxAtk: filters.maxAtk ?? 15, + maxCp: filters.maxCp ?? 9000, + maxDef: filters.maxDef ?? 15, + maxIv: filters.maxIv ?? 100, + maxLevel: filters.maxLevel ?? 55, + maxSize: filters.maxSize ?? 5, + maxSta: filters.maxSta ?? 15, + maxWeight: filters.maxWeight ?? 9000000, + minCp: filters.minCp ?? 0, + minIv: filters.minIv ?? 0, + minLevel: filters.minLevel ?? 0, + minTime: filters.minTime ?? 0, + minWeight: filters.minWeight ?? 0, + pokemonId, + pvpRankingBest: pvp.pvpRankingLeague ? (pvp.pvpRankingBest ?? 1) : 0, + pvpRankingCap: pvp.pvpRankingLeague ? (pvp.pvpRankingCap ?? 0) : 0, + pvpRankingEvolution: pvp.pvpRankingLeague ? (pvp.pvpRankingEvolution ?? 0) : 0, + pvpRankingLeague: pvp.pvpRankingLeague ?? 0, + pvpRankingMinCp: pvp.pvpRankingLeague ? (pvp.pvpRankingMinCp ?? 0) : 0, + pvpRankingWorst: pvp.pvpRankingLeague ? (pvp.pvpRankingWorst ?? 100) : 4096, + size: filters.size ?? -1, + sta: filters.sta ?? 0, + template: notif.template || null, + }; + return this.monsterService.create(monster); + }), + ); + + // forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already + // succeeded were never reported, the dialog stayed open and the list never reloaded. Each request + // settles on its own now, and the toast says how many landed. See #577. + forkJoin(creates.map(c => c.pipe(catchError((err: { error?: { error?: string } }) => of({ failed: err }))))).subscribe({ + // Each create settles on its own, so a refused one no longer hides the ones that landed. + // The first refusal's message is shown, because it names what is in the way. See #577. + next: (results: ({ uid?: number } | { failed: { error?: { error?: string } } })[]) => { + const refused = results.filter((r): r is { failed: { error?: { error?: string } } } => 'failed' in r); + // Three outcomes, not two: a refusal (409), an alarm already tracked (200 with no uid, see #495), + // and a genuine creation. Counting the first two together would misreport both. + const landed = results.filter((r): r is { uid?: number } => !('failed' in r)); + const created = landed.filter(r => (r.uid ?? 0) > 0).length; + const duplicates = landed.length - created; this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('POKEMON.SNACK_CREATED', { count: creates.length }), this.i18n.instant('COMMON.OK'), { - duration: 3000, - }); + + if (refused.length > 0) { + this.snackBar.open( + refused[0].failed?.error?.error ?? this.i18n.instant('POKEMON.SNACK_FAILED_CREATE'), + this.i18n.instant('COMMON.OK'), + { duration: 6000 }, + ); + } else { + const message = + duplicates > 0 + ? this.i18n.instant('POKEMON.SNACK_CREATED_WITH_DUPLICATES', { count: created, duplicates }) + : this.i18n.instant('POKEMON.SNACK_CREATED', { count: created }); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + // Close either way: whatever was created is real, and the list must reload to show it. this.dialogRef.close(true); }, }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.html index 980bec16..2e0c49d3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.html @@ -118,6 +118,18 @@

{{ 'POKEMON.FILTER_FORM_GENDER' | translate }}

more_horiz {{ 'POKEMON.MORE_FILTERS' | translate }} +

{{ 'POKEMON.FILTER_TIME_LEFT' | translate }}

+
+ + {{ 'POKEMON.LABEL_MIN_TIME' | translate }} + + @for (option of minTimeChoices(); track option) { + {{ minTimeText(option) | translate: minTimeParams(option) }} + } + + {{ 'POKEMON.MIN_TIME_HINT' | translate }} + +

{{ 'POKEMON.FILTER_SIZE' | translate }}

@@ -193,6 +205,39 @@

{{ 'POKEMON.FILTER_SIZE' | translate }}

@if (form.controls.pvpRankingLeague.value !== 0) { + @if (showCapPicker()) { +
+ {{ 'POKEMON.PVP_CAP' | translate }} + + {{ 'POKEMON.PVP_CAP_ALL' | translate }} + @for (cap of pvpCaps(); track cap) { + {{ 'POKEMON.PVP_CAP_LEVEL' | translate: { level: cap } }} + } + +
+ } + + +
+ {{ 'POKEMON.PVP_EVOLUTION' | translate }} + + {{ 'POKEMON.PVP_EVO_BASE' | translate }} + {{ 'POKEMON.PVP_EVO_MEGA' | translate }} + {{ 'POKEMON.PVP_EVO_MEGA_X' | translate }} + {{ 'POKEMON.PVP_EVO_MEGA_Y' | translate }} + +

+ {{ 'POKEMON.PVP_EVOLUTION_HINT' | translate }} +

+
{{ 'POKEMON.PVP_BEST_RANK' | translate }} @@ -218,48 +263,9 @@

{{ 'POKEMON.FILTER_SIZE' | translate }}

{{ 'POKEMON.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } { + let component: PokemonEditDialogComponent; + let monsterService: { update: jest.Mock }; + + function setup(monster: Partial) { + monsterService = { update: jest.fn().mockReturnValue(of({})) }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: MAT_DIALOG_DATA, useValue: { uid: 7, maxIv: 100, minIv: 0, pokemonId: 52, ...monster } }, + { provide: MonsterService, useValue: monsterService }, + { provide: MasterDataService, useValue: { getFormsForPokemon: () => [], getPokemonName: () => 'Meowth' } }, + { provide: I18nService, useValue: { instant: (k: string) => k } }, + { + provide: PoracleConfigService, + useValue: { load: () => of({ defaultPvpCap: 0 }), serverConfig: () => ({ pvpCaps: [] }) }, + }, + { provide: AuthService, useValue: { isImpersonating: () => false, user: () => ({ type: 'discord:user' }) } }, + ], + imports: [PokemonEditDialogComponent], + }); + + TestBed.overrideComponent(PokemonEditDialogComponent, { + add: { providers: [{ provide: MatSnackBar, useValue: { open: jest.fn() } }] }, + }); + + component = TestBed.createComponent(PokemonEditDialogComponent).componentInstance; + } + + function sent(): MonsterUpdate { + return monsterService.update.mock.calls[0][1] as MonsterUpdate; + } + + it('seeds the time filter from the alarm', () => { + setup({ minTime: 300 }); + + expect(component.form.controls.minTime.value).toBe(300); + }); + + it('keeps a time the bot set that is not one of the presets', () => { + // A select that does not offer the stored value renders blank and clears it on the next save. + setup({ minTime: 137 }); + + expect(component.minTimeChoices()).toContain(137); + + component.save(); + + expect(sent().minTime).toBe(137); + }); + + it('saves a changed time filter', () => { + setup({ minTime: 300 }); + component.form.controls.minTime.setValue(600); + + component.save(); + + expect(sent().minTime).toBe(600); + }); + + it('leaves an alarm with no time filter without one', () => { + setup({}); + component.save(); + + expect(sent().minTime).toBe(0); + }); + + it('keeps the mega mode of a PVP rule it did not change', () => { + setup({ pvpRankingEvolution: 2, pvpRankingLeague: 1500 }); + component.save(); + + expect(sent().pvpRankingEvolution).toBe(2); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.ts index b48d2d67..b13db4f6 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.ts @@ -1,6 +1,7 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -11,7 +12,7 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Monster, MonsterUpdate } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; @@ -19,14 +20,19 @@ import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { MonsterService } from '../../core/services/monster.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { PoracleConfigService } from '../../core/services/poracle-config.service'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; +import { minTimeLabel, minTimeOptions } from '../../shared/utils/min-time'; @Component({ imports: [ ReactiveFormsModule, MatDialogModule, MatButtonModule, + MatButtonToggleModule, MatFormFieldModule, MatInputModule, MatSelectModule, @@ -37,20 +43,21 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatTabsModule, MatSnackBarModule, TemplateSelectorComponent, - DeliveryPreviewComponent, - TranslateModule, + TranslatePipe, + ScopePickerComponent, ], selector: 'app-pokemon-edit-dialog', standalone: true, styleUrl: './pokemon-edit-dialog.component.scss', templateUrl: './pokemon-edit-dialog.component.html', }) -export class PokemonEditDialogComponent { +export class PokemonEditDialogComponent implements OnInit { private readonly fb = inject(FormBuilder); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); private readonly masterData = inject(MasterDataService); private readonly monsterService = inject(MonsterService); + private readonly poracleConfig = inject(PoracleConfigService); private readonly snackBar = inject(MatSnackBar); readonly data = inject(MAT_DIALOG_DATA); readonly availableForms = computed(() => { @@ -61,10 +68,8 @@ export class PokemonEditDialogComponent { form = this.fb.group({ atk: [this.data.atk], - clean: [this.data.clean === 1], + clean: [isAutoDelete(this.data.clean)], def: [this.data.def], - distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], - distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], form: [this.data.form], gender: [this.data.gender], maxAtk: [this.data.maxAtk], @@ -78,9 +83,15 @@ export class PokemonEditDialogComponent { minCp: [this.data.minCp], minIv: [this.data.minIv], minLevel: [this.data.minLevel], + minTime: [this.data.minTime ?? 0], minWeight: [this.data.minWeight], - ping: [this.data.ping ?? ''], + // Read-only here on purpose: an alarm's place or areas are changed from the card chip, which is one + // control in one place. This dialog only has to avoid destroying them, which it does by keeping + // the stored label and sending the radius against it. pvpRankingBest: [this.data.pvpRankingBest], + pvpRankingCap: [this.data.pvpRankingCap ?? 0], + // 0 base, 1 any mega, 2 Mega X, 3 Mega Y — PoracleNG's pvp_ranking_evolution. + pvpRankingEvolution: [this.data.pvpRankingEvolution ?? 0], pvpRankingLeague: [this.data.pvpRankingLeague], pvpRankingMinCp: [this.data.pvpRankingMinCp], pvpRankingWorst: [this.data.pvpRankingWorst], @@ -93,20 +104,34 @@ export class PokemonEditDialogComponent { pokemonName = this.data.pokemonId === 0 ? this.i18n.instant('POKEMON.ALL_POKEMON') : this.masterData.getPokemonName(this.data.pokemonId); + readonly pvpCaps = computed(() => this.poracleConfig.serverConfig().pvpCaps); + saving = signal(false); + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.overrideLocationLabel, this.data.overrideAreas, this.data.distance)); + + readonly showCapPicker = computed(() => this.pvpCaps().length > 1); + getPokemonImage(): string { return this.iconService.getPokemonUrl(this.data.pokemonId, this.data.form); } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') { - this.form.controls.distanceKm.setValue(0); - } else { - if (!this.form.controls.distanceKm.value) { - this.form.controls.distanceKm.setValue(1); - } - } + /** The preset list, widened to keep whatever the rule already holds. */ + minTimeChoices(): number[] { + return minTimeOptions(this.form.controls.minTime.value ?? 0); + } + + minTimeParams(seconds: number): Record | undefined { + return minTimeLabel(seconds).params; + } + + minTimeText(seconds: number): string { + return minTimeLabel(seconds).key; + } + + ngOnInit(): void { + this.poracleConfig.load().subscribe(); } onImageError(event: Event): void { @@ -123,13 +148,15 @@ export class PokemonEditDialogComponent { this.saving.set(true); const values = this.form.getRawValue(); - const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const update: MonsterUpdate = { + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, atk: values.atk ?? 0, - clean: values.clean ? 1 : 0, + clean: preserve(this.data.clean, AUTO_DELETE, values.clean ? 1 : 0), def: values.def ?? 0, - distance: distanceMeters, + distance: scope.distance, form: values.form ?? 0, gender: values.gender ?? 0, maxAtk: values.maxAtk ?? 15, @@ -143,20 +170,26 @@ export class PokemonEditDialogComponent { minCp: values.minCp ?? 0, minIv: values.minIv ?? 0, minLevel: values.minLevel ?? 0, + minTime: values.minTime ?? 0, minWeight: values.minWeight ?? 0, - ping: values.ping || null, pvpRankingBest: values.pvpRankingLeague ? (values.pvpRankingBest ?? 1) : 0, + pvpRankingCap: values.pvpRankingLeague ? (values.pvpRankingCap ?? 0) : 0, + pvpRankingEvolution: values.pvpRankingLeague ? (values.pvpRankingEvolution ?? 0) : 0, pvpRankingLeague: values.pvpRankingLeague ?? 0, pvpRankingMinCp: values.pvpRankingLeague ? (values.pvpRankingMinCp ?? 0) : 0, pvpRankingWorst: values.pvpRankingLeague ? (values.pvpRankingWorst ?? 100) : 4096, size: values.size ?? -1, sta: values.sta ?? 0, - template: values.template || null, + template: values.template || '', }; this.monsterService.update(this.data.uid, update).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('POKEMON.SNACK_FAILED_UPDATE'), this.i18n.instant('COMMON.OK'), { duration: 3000 }); + // The server explains exactly what is wrong with a filter it refuses -- which min/max pair is + // inverted, say. That message never reached anyone: this handler showed a fixed string, so a + // transposed pair produced "failed" with no clue which field to fix. See #496. + error: (err: { error?: { error?: string } }) => { + const message = err?.error?.error ?? this.i18n.instant('POKEMON.SNACK_FAILED_UPDATE'); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 6000 }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html index 4442493c..f7258881 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html @@ -92,6 +92,7 @@

{{ 'POKEMON.PAGE_TITLE' | translate }}

+ @@ -158,7 +159,7 @@

{{ getPokemonName(monster.pokemonId) }}

}
- @if (monster.clean === 1) { + @if (isAutoDelete(monster.clean)) { {{ 'POKEMON.CLEAN_TAG' | translate }} }
@@ -199,31 +200,32 @@

{{ getPokemonName(monster.pokemonId) }}

@if (monster.size > 0 || monster.maxSize < 5) { {{ getSizePillText(monster) }} } + @if (monster.minTime > 0) { + {{ minTimeText(monster.minTime) | translate: minTimeParams(monster.minTime) }} + }
- @if (monster.distance === 0) { - map {{ 'POKEMON.USING_AREAS' | translate }} - } @else { - straighten {{ formatDistance(monster.distance) }} - } +
@if (monster.pvpRankingLeague > 0) {
emoji_events {{ getLeagueName(monster.pvpRankingLeague) }} {{ 'POKEMON.PVP_LEAGUE_SUFFIX' | translate }} + @if (monster.pvpRankingEvolution) { + · {{ megaLabel(monster.pvpRankingEvolution) | translate }} + } {{ 'POKEMON.PVP_RANK_RANGE' | translate: { best: monster.pvpRankingBest, worst: monster.pvpRankingWorst } }}
} - @if (monster.ping) { -
- notifications - {{ monster.ping }} -
- } + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.scss index 36116179..c246583d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.scss @@ -482,6 +482,11 @@ background: #e8f5e9; color: #2e7d32; } +// Teal is the one hue the other six pills leave free, and it reads as a clock rather than a threshold. +.time-pill { + background: #e0f2f1; + color: #00695c; +} // Skeleton loading @keyframes pulse { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts index 4d62a622..b511a18e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts @@ -14,12 +14,13 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { PokemonAddDialogComponent } from './pokemon-add-dialog.component'; import { PokemonEditDialogComponent } from './pokemon-edit-dialog.component'; import { Monster } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; @@ -27,6 +28,11 @@ import { MonsterService } from '../../core/services/monster.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; +import { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flags'; +import { minTimePillLabel } from '../../shared/utils/min-time'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -45,7 +51,8 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatFormFieldModule, MatInputModule, MatSelectModule, - TranslateModule, + TranslatePipe, + WhereChipComponent, ], selector: 'app-pokemon-list', standalone: true, @@ -53,7 +60,10 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './pokemon-list.component.html', }) export class PokemonListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); @@ -62,13 +72,13 @@ export class PokemonListComponent implements OnInit { // Search & quick filters readonly searchControl = new FormControl(''); private readonly searchValue = toSignal(this.searchControl.valueChanges, { initialValue: '' }); - private readonly snackBar = inject(MatSnackBar); readonly activeFilter = signal(null); - readonly activeGen = signal<{ label: string; min: number; max: number } | null>(null); + readonly activeGen = signal<{ label: string; min: number; max: number } | null>(null); readonly monsters = signal([]); readonly sortBy = signal<'name' | 'id' | 'evolution' | 'generation'>('name'); + readonly filteredMonsters = computed(() => { const gen = this.activeGen(); const sort = this.sortBy(); @@ -144,14 +154,16 @@ export class PokemonListComponent implements OnInit { readonly loading = signal(true); + readonly profileAreas = signal([]); + readonly selectedIds = signal(new Set()); // Bulk operations readonly selectMode = signal(false); readonly skeletonCards = Array.from({ length: 8 }); - readonly testAlertService = inject(TestAlertService); + readonly testAlertService = inject(TestAlertService); async bulkDelete(): Promise { const ref = this.dialog.open(ConfirmDialogComponent, { data: { @@ -164,13 +176,22 @@ export class PokemonListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; + // Settled one at a time. A stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so the deletes that had already happened went unreported and the list + // never reloaded: the user saw nothing at all. See #603. + let deleted = 0; for (const uid of ids) { - await firstValueFrom(this.monsterService.delete(uid)); + try { + await firstValueFrom(this.monsterService.delete(uid)); + deleted++; + } catch { + // Already gone, which is the outcome the user asked for. + } } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadMonsters(); - this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('COMMON.OK'), { + this.snackBar.open(this.i18n.instant('POKEMON.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('COMMON.OK'), { duration: 3000, }); } @@ -181,7 +202,18 @@ export class PokemonListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const uids = [...this.selectedIds()]; - await firstValueFrom(this.monsterService.updateBulkDistance(uids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.monsterService.updateBulkDistance(uids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('POKEMON.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadMonsters(); @@ -258,6 +290,22 @@ export class PokemonListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(monster: Monster): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(monster.overrideLocationLabel, monster.overrideAreas, monster.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + this.applyScope(monster, scope); + }); + } + formatDistance(meters: number): string { if (meters >= 1000) { return `${(meters / 1000).toFixed(1)} km`; @@ -343,6 +391,11 @@ export class PokemonListComponent implements OnInit { return '#607D8B'; // Gen 9+ } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return cleanIsAutoDelete(clean); + } + loadMonsters(): void { this.loading.set(true); this.monsterService @@ -359,10 +412,40 @@ export class PokemonListComponent implements OnInit { }); } + /** The i18n key for a rule's mega mode, or an empty string for a base-forms rule. */ + megaLabel(evolution: number): string { + switch (evolution) { + case 1: + return 'POKEMON.PVP_EVO_MEGA'; + case 2: + return 'POKEMON.PVP_EVO_MEGA_X'; + case 3: + return 'POKEMON.PVP_EVO_MEGA_Y'; + default: + return ''; + } + } + + /** The pill only appears when a filter is set, so "any" never needs rendering here. */ + minTimeParams(seconds: number): Record { + return minTimePillLabel(seconds).params; + } + + minTimeText(seconds: number): string { + return minTimePillLabel(seconds).key; + } + ngOnInit(): void { // Ensure masterdata is loaded this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); this.loadMonsters(); + + // Only used to word the inherited scope honestly: "anywhere in my areas" is a lie for a user who + // has none selected. A failure leaves it empty, which produces the more cautious wording. + this.areaService + .getSelected() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); } onImageError(event: Event, pokemonId: number): void { @@ -433,6 +516,18 @@ export class PokemonListComponent implements OnInit { }); } + private applyScope(monster: Monster, scope: AlarmScope): void { + const fields = scopeToFields(scope); + + this.monsterService.update(monster.uid, fields).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadMonsters(); + }, + }); + } + private getBaseEvolution(id: number): number { return this.masterData.getBaseEvolution(id); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pvp-controls-parity.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pvp-controls-parity.spec.ts new file mode 100644 index 00000000..a4707e36 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pvp-controls-parity.spec.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * The add and edit dialogs must offer the same PVP controls. + * + * The mega evolution picker was added to both, but in the edit dialog it was nested *inside* the + * level-cap fieldset, which renders only when the server advertises PVP level caps. On a server with + * none — the common case, and the one this deployment runs — the add dialog offered the picker and the + * edit dialog silently did not, so a mega rule could be created and then never changed. + * + * Asserted against the templates rather than the DOM: Material keeps only the active tab body mounted, + * and driving a tab switch in this zoneless harness costs more machinery than the assertion is worth. + * The defect was structural — a control in the wrong block — so the structure is what this checks. + */ +describe('PVP controls, add dialog versus edit dialog', () => { + const read = (name: string) => readFileSync(join(__dirname, name), 'utf8'); + + const templates = { + add: read('pokemon-add-dialog.component.html'), + edit: read('pokemon-edit-dialog.component.html'), + }; + + /** The body of the `@if (showCapPicker())` block, which only exists on a caps-advertising server. */ + function capPickerBlock(template: string): string { + const start = template.indexOf('@if (showCapPicker()) {'); + if (start === -1) return ''; + + // Prettier keeps the closing brace at the opening line's indent, so that is the block's end. + const indent = ' '.repeat(template.slice(0, start).length - template.lastIndexOf('\n', start) - 1); + const end = template.indexOf(`\n${indent}}`, start); + return template.slice(start, end === -1 ? undefined : end); + } + + it.each(Object.entries(templates))('%s offers the mega evolution picker', (_name, template) => { + expect(template).toContain('POKEMON.PVP_EVOLUTION'); + }); + + it.each(Object.entries(templates))('%s does not hide it behind the level-cap picker', (_name, template) => { + expect(capPickerBlock(template)).not.toContain('POKEMON.PVP_EVOLUTION'); + }); + + it('has a cap-picker block to be outside of, in the edit dialog', () => { + // Guards the guard: if the block is renamed, capPickerBlock returns '' and the test above passes + // for the wrong reason. + expect(capPickerBlock(templates.edit)).toContain('POKEMON.PVP_CAP'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.html index d039f8ca..bad6b424 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.html @@ -249,7 +249,7 @@

{{ getAlarmDescription(alarm, type.key) }}

}
- @if (alarm.clean === 1) { + @if (isAutoDelete(alarm.clean ?? 0)) { {{ 'POKEMON.CLEAN_TAG' | translate }} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.ts index 40068a74..a5446813 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles-overview/profile-overview.component.ts @@ -13,7 +13,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { forkJoin } from 'rxjs'; import { @@ -76,7 +76,7 @@ interface DuplicateInfo { MatProgressBarModule, MatSnackBarModule, MatTooltipModule, - TranslateModule, + TranslatePipe, ActiveHoursChipComponent, LocationWarningComponent, ], @@ -298,6 +298,11 @@ export class ProfileOverviewComponent implements OnInit { this.snackBar.open(this.i18n.instant('PROFILES.SNACK_FAILED_DELETE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }), next: () => { this.snackBar.open(this.i18n.instant('PROFILES.SNACK_DELETED'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // PoracleNG reassigns current_profile_no when the active profile goes, and /api/auth/me is the + // only place the refreshed token is stored. Switch, duplicate and import all do this; delete did + // not, so the profileNo claim kept naming a profile that no longer exists and every alarm read + // and write in between targeted it. See #651. + void this.authService.loadCurrentUser(); this.loadAll(); }, }); @@ -357,8 +362,12 @@ export class ProfileOverviewComponent implements OnInit { ref.afterClosed().subscribe((result: ActiveHourEntry[] | null | undefined) => { if (result !== null && result !== undefined) { this.profileService.updateActiveHours(profile.profile_no, result).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('PROFILES.SNACK_FAILED_SCHEDULE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('PROFILES.SNACK_FAILED_SCHEDULE'), this.i18n.instant('TOAST.OK'), { + duration: 6000, + }); }, next: () => { this.snackBar.open(this.i18n.instant('PROFILES.SNACK_SCHEDULE_UPDATED'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); @@ -616,9 +625,15 @@ export class ProfileOverviewComponent implements OnInit { .importProfile({ ...backup, profileName: name }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ - error: () => { + // The server names the alarm and the field a file got wrong; a fixed string threw that + // away and left the user to guess which of hundreds of alarms was the problem. See #588. + error: (err: { error?: { error?: string } }) => { this.switching.set(false); - this.snackBar.open(this.i18n.instant('PROFILES.SNACK_FAILED_IMPORT'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + this.snackBar.open( + err?.error?.error ?? this.i18n.instant('PROFILES.SNACK_FAILED_IMPORT'), + this.i18n.instant('TOAST.OK'), + { duration: 6000 }, + ); }, next: res => { this.switching.set(false); @@ -644,6 +659,11 @@ export class ProfileOverviewComponent implements OnInit { return this.activeProfileNo() === profileNo; } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return (clean & 1) !== 0; + } + isDuplicate(alarm: ProfileOverviewAlarm): boolean { return this.duplicateUids().has(alarm.uid); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.html index 1b90ea4d..11f8258b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.html @@ -11,8 +11,8 @@

{{ 'PROFILES.CREATE_DIALOG_TITLE' | translate }}

[placeholder]="'PROFILES.PROFILE_NAME_PLACEHOLDER' | translate" maxlength="32" (keyup.enter)="save()" /> - @if (nameError()) { - {{ nameError() }} + @if (hasNameConflict()) { + {{ 'DIALOG.PROMPT_CONFLICT' | translate }} } @else { {{ profileName.length }}/32 } @@ -20,7 +20,7 @@

{{ 'PROFILES.CREATE_DIALOG_TITLE' | translate }}

- diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.scss index 3db0dee7..51f7d6c9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.scss @@ -1,3 +1,9 @@ .full-width { width: 100%; } + +// The conflict message rides in the hint slot because only renders when the form +// field control reports errorState, which a bare ngModel never does. See #427. +.name-conflict { + color: var(--mat-sys-error, #b3261e); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.ts index d479fa64..36485faf 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-add-dialog.component.ts @@ -8,7 +8,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { I18nService } from '../../core/services/i18n.service'; import { ProfileService } from '../../core/services/profile.service'; @@ -23,7 +23,7 @@ import { ProfileService } from '../../core/services/profile.service'; MatIconModule, MatSnackBarModule, MatProgressBarModule, - TranslateModule, + TranslatePipe, ], selector: 'app-profile-add-dialog', standalone: true, @@ -51,11 +51,20 @@ export class ProfileAddDialogComponent { }); } + /** + * Checked as the user types so the Create button disables before the click, rather than the click + * silently doing nothing. + */ + hasNameConflict(): boolean { + const name = this.profileName.trim().toLowerCase(); + return name.length > 0 && this.existingNames().has(name); + } + save(): void { const name = this.profileName.trim(); if (!name) return; - if (this.existingNames().has(name.toLowerCase())) { + if (this.hasNameConflict()) { this.nameError.set(this.i18n.instant('DIALOG.PROMPT_CONFLICT')); return; } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-duplicate-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-duplicate-dialog.component.ts index b8c4ef94..82b12120 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-duplicate-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-duplicate-dialog.component.ts @@ -7,7 +7,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Profile } from '../../core/models'; import { I18nService } from '../../core/services/i18n.service'; @@ -23,7 +23,7 @@ import { ProfileService } from '../../core/services/profile.service'; MatInputModule, MatProgressBarModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, ], selector: 'app-profile-duplicate-dialog', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-edit-dialog.component.ts index 8fe63310..6d8f763e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/profiles/profile-edit-dialog.component.ts @@ -8,7 +8,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Profile } from '../../core/models'; import { I18nService } from '../../core/services/i18n.service'; @@ -24,7 +24,7 @@ import { ProfileService } from '../../core/services/profile.service'; MatIconModule, MatSnackBarModule, MatProgressBarModule, - TranslateModule, + TranslatePipe, ], selector: 'app-profile-edit-dialog', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html index f223af6e..ad315e84 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html @@ -31,6 +31,11 @@

{{ 'QUESTS.ADD_DIALOG_TITLE' | translate }}

} + + {{ 'QUESTS.MIN_AMOUNT' | translate }} + + {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} +
@@ -39,6 +44,11 @@

{{ 'QUESTS.ADD_DIALOG_TITLE' | translate }}

@if (selectedMegaPokemonIds().length > 0) {

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedMegaPokemonIds().length } }}

} + + {{ 'QUESTS.MIN_AMOUNT' | translate }} + + {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} +
@@ -47,6 +57,20 @@

{{ 'QUESTS.ADD_DIALOG_TITLE' | translate }}

@if (selectedCandyPokemonIds().length > 0) {

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedCandyPokemonIds().length } }}

} + + {{ 'QUESTS.MIN_AMOUNT' | translate }} + + {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} + +
+ + +
+ + {{ 'QUESTS.MIN_STARDUST' | translate }} + + {{ 'QUESTS.MIN_STARDUST_HINT' | translate }} +
@@ -60,48 +84,9 @@

{{ 'QUESTS.ADD_DIALOG_TITLE' | translate }}

{{ 'ALARM.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (commonForm.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.COMMON_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } {{ 'ALARM.COMMON_SETTINGS' | translate }} {{ 'ALARM.CLEAN_MODE' | translate }}

{{ 'ALARM.CLEAN_HINT_QUEST' | translate }}

+ + {{ 'QUESTS.SUMMARY_MODE' | translate }} +

{{ 'QUESTS.SUMMARY_HINT' | translate }}

+ @if (!summaryService.enabled()) { +

{{ 'QUESTS.SUMMARY_DISABLED_HINT' | translate }}

+ }
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss index 1f87ac77..7b8d7526 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss @@ -34,6 +34,13 @@ mat-dialog-content { :host ::ng-deep .reward-tabs .mat-mdc-tab-label-container { overflow: visible; } +// Five reward types want 520px of Material's default tab padding in a 464px rail, so the group +// paginated and put Stardust behind an arrow at every width. Trimming the padding fits all five on a +// desktop dialog with room to spare; phone width still scrolls, as it did with four. +:host ::ng-deep .reward-tabs .mdc-tab { + min-width: 0; + padding: 0 12px; +} @media (max-width: 599px) { mat-dialog-content { min-width: unset; @@ -84,3 +91,9 @@ mat-slide-toggle { color: var(--text-secondary, rgba(0, 0, 0, 0.54)); font-weight: normal; } + +.scope-near-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts new file mode 100644 index 00000000..8f4dcd1d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts @@ -0,0 +1,183 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; +import { provideRouter } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { QuestAddDialogComponent } from './quest-add-dialog.component'; +import { Quest, QuestCreate } from '../../core/models'; +import { AuthService } from '../../core/services/auth.service'; +import { IconService } from '../../core/services/icon.service'; +import { MasterDataService } from '../../core/services/masterdata.service'; +import { PokemonAvailabilityService } from '../../core/services/pokemon-availability.service'; +import { QuestService } from '../../core/services/quest.service'; + +describe('QuestAddDialogComponent', () => { + let component: QuestAddDialogComponent; + let dialogRef: { close: jest.Mock }; + let questService: { create: jest.Mock }; + + function setup() { + dialogRef = { close: jest.fn() }; + questService = { create: jest.fn().mockReturnValue(of({} as Quest)) }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + // delivery-preview links to /areas with routerLink, so a router is required. + provideRouter([]), + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: MatDialogRef, useValue: dialogRef }, + { provide: QuestService, useValue: questService }, + { provide: AuthService, useValue: { isImpersonating: () => false, user: () => ({ type: 'discord:user' }) } }, + { + provide: MasterDataService, + useValue: { + getAllItems: () => [], + getAllPokemon: () => [], + getAllPokemon$: () => of([]), + getAllTypes: () => [], + getPokemonTypes: () => [], + loadData: () => of(void 0), + }, + }, + { provide: PokemonAvailabilityService, useValue: { enabled: () => false, isAvailable: () => true, load: () => undefined } }, + { provide: IconService, useValue: { getItemUrl: () => '' } }, + ], + imports: [QuestAddDialogComponent], + }); + + const fixture = TestBed.createComponent(QuestAddDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + function createdClean(): number { + const create = questService.create.mock.calls[0][0] as QuestCreate; + return create.clean; + } + + beforeEach(() => setup()); + + it('defaults the summary toggle to off', () => { + expect(component.commonForm.controls.summary.value).toBe(false); + }); + + it('defaults the clean (auto-delete) toggle to off', () => { + expect(component.commonForm.controls.clean.value).toBe(false); + }); + + it('composes clean=0 when neither toggle is set', () => { + component.selectedPokemonIds.set([25]); + component.save(); + expect(createdClean()).toBe(0); + }); + + it('composes bit 4 when only summary is on', () => { + component.selectedPokemonIds.set([25]); + component.commonForm.controls.summary.setValue(true); + component.save(); + expect(createdClean()).toBe(4); + }); + + it('composes bit 1 when only auto-delete is on', () => { + component.selectedPokemonIds.set([25]); + component.commonForm.controls.clean.setValue(true); + component.save(); + expect(createdClean()).toBe(1); + }); + + it('composes bits 1|4 = 5 when both toggles are on', () => { + component.selectedPokemonIds.set([25]); + component.commonForm.controls.clean.setValue(true); + component.commonForm.controls.summary.setValue(true); + component.save(); + expect(createdClean()).toBe(5); + }); + + it('applies the same composed clean to every selected pokemon reward', () => { + component.selectedPokemonIds.set([25, 133, 1]); + component.commonForm.controls.summary.setValue(true); + component.save(); + expect(questService.create).toHaveBeenCalledTimes(3); + for (const call of questService.create.mock.calls) { + expect((call[0] as QuestCreate).clean).toBe(4); + } + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + it('creates a stardust rule from the amount alone', () => { + // PoracleNG matches stardust on the reward column, not the amount one, so the floor travels there. + component.tabIndex = 4; + component.stardustForm.controls.reward.setValue(1500); + + component.save(); + + const created = questService.create.mock.calls[0][0] as QuestCreate; + expect(created.rewardType).toBe(3); + expect(created.reward).toBe(1500); + }); + + it('treats a stardust rule with no floor as every stardust quest', () => { + component.tabIndex = 4; + + component.save(); + + expect((questService.create.mock.calls[0][0] as QuestCreate).reward).toBe(0); + }); + + it('sends the minimum amount with an item rule', () => { + component.tabIndex = 1; + component.itemForm.controls.reward.setValue(1301); + component.itemForm.controls.amount.setValue(3); + + component.save(); + + const created = questService.create.mock.calls[0][0] as QuestCreate; + expect(created.rewardType).toBe(2); + expect(created.amount).toBe(3); + }); + + it('sends the minimum amount with a mega energy rule, on every selected pokemon', () => { + component.tabIndex = 2; + component.selectedMegaPokemonIds.set([6, 9]); + component.megaForm.controls.amount.setValue(50); + + component.save(); + + expect(questService.create).toHaveBeenCalledTimes(2); + for (const call of questService.create.mock.calls) { + expect((call[0] as QuestCreate).amount).toBe(50); + } + }); + + it('sends the minimum amount with a candy rule', () => { + component.tabIndex = 3; + component.selectedCandyPokemonIds.set([133]); + component.candyForm.controls.amount.setValue(5); + + component.save(); + + expect((questService.create.mock.calls[0][0] as QuestCreate).amount).toBe(5); + }); + + it('asks for no minimum on a pokemon encounter, which has no quantity', () => { + // The legitimate twin: an encounter rule carrying an amount would be a filter PoracleNG reads for + // other reward types and nobody chose here. + component.selectedPokemonIds.set([25]); + + component.save(); + + expect((questService.create.mock.calls[0][0] as QuestCreate).amount).toBe(0); + }); + + it('does nothing when no rewards are selected', () => { + component.commonForm.controls.summary.setValue(true); + component.save(); + expect(questService.create).not.toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts index 69600026..1ea87a96 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts @@ -10,17 +10,21 @@ import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; -import { forkJoin } from 'rxjs'; +import { TranslatePipe } from '@ngx-translate/core'; +import { catchError, forkJoin, of } from 'rxjs'; +import { AlertDefaultsService } from '../../core/services/alert-defaults.service'; import { AuthService } from '../../core/services/auth.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { QuestService } from '../../core/services/quest.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { SummaryScheduleService } from '../../core/services/summary-schedule.service'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeToFields } from '../../shared/utils/alarm-scope'; +import { compose } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -35,10 +39,10 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatTabsModule, MatRadioModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, PokemonSelectorComponent, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-quest-add-dialog', standalone: true, @@ -49,39 +53,74 @@ export class QuestAddDialogComponent { private static readonly FALLBACK_ICON = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999'%3E%3Cpath d='M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z'/%3E%3C/svg%3E"; + private readonly alertDefaults = inject(AlertDefaultsService); + private readonly fb = inject(FormBuilder); + private readonly i18n = inject(I18nService); private readonly masterData = inject(MasterDataService); private readonly questService = inject(QuestService); - private readonly snackBar = inject(MatSnackBar); + candyForm = this.fb.group({ + amount: [0], + }); commonForm = this.fb.group({ clean: [false], - distanceKm: [1], - distanceMode: ['areas' as 'areas' | 'distance'], - ping: [''], + summary: [false], template: [''], }); readonly dialogRef = inject(MatDialogRef); + readonly iconService = inject(IconService); readonly isWebhook = inject(AuthService).isImpersonating(); itemForm = this.fb.group({ + amount: [0], reward: [0], }); + /** Mega energy and candy arrive in quantities too, and each tab keeps its own answer. */ + megaForm = this.fb.group({ + amount: [0], + }); + /** Quest-relevant items (balls, berries, potions, revives, TMs, etc.) */ readonly questItems = signal<{ id: number; name: string }[]>([]); + saving = signal(false); + + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + selectedCandyPokemonIds = signal([]); selectedMegaPokemonIds = signal([]); - selectedPokemonIds = signal([]); + /** + * Stardust is the one reward PoracleNG matches on the amount alone, so it has no selector: the + * number is the whole rule. PoracleNG reads it from `reward`, not `amount`. + */ + stardustForm = this.fb.group({ + reward: [0], + }); + + readonly summaryService = inject(SummaryScheduleService); + tabIndex = 0; constructor() { @@ -106,6 +145,9 @@ export class QuestAddDialogComponent { return this.selectedMegaPokemonIds().length > 0; case 3: return this.selectedCandyPokemonIds().length > 0; + case 4: + // 0 is a rule in its own right: every stardust quest, whatever it pays. + return true; default: return false; } @@ -115,16 +157,6 @@ export class QuestAddDialogComponent { this.selectedCandyPokemonIds.set(ids); } - onDistanceModeChange(): void { - if (this.commonForm.controls.distanceMode.value === 'areas') { - this.commonForm.controls.distanceKm.setValue(0); - } else { - if (!this.commonForm.controls.distanceKm.value) { - this.commonForm.controls.distanceKm.setValue(1); - } - } - } - onMegaPokemonSelected(ids: number[]): void { this.selectedMegaPokemonIds.set(ids); } @@ -142,7 +174,9 @@ export class QuestAddDialogComponent { if (!this.canSave()) return; this.saving.set(true); const common = this.commonForm.getRawValue(); - const distanceMeters = common.distanceMode === 'areas' ? 0 : Math.round((common.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); + // New alarms have no prior bits, so compose directly from the two surfaced toggles (edit-in-place unsupported for quests). + const cleanValue = compose(!!common.clean, false, !!common.summary); const creates: ReturnType[] = []; @@ -151,9 +185,11 @@ export class QuestAddDialogComponent { for (const pokemonId of this.selectedPokemonIds()) { creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, - distance: distanceMeters, - ping: common.ping || null, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + amount: 0, + clean: cleanValue, + distance: scope.distance, pokemonId, reward: pokemonId, rewardType: 7, @@ -166,9 +202,11 @@ export class QuestAddDialogComponent { case 1: creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, - distance: distanceMeters, - ping: common.ping || null, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + amount: this.itemForm.controls.amount.value ?? 0, + clean: cleanValue, + distance: scope.distance, pokemonId: 0, reward: this.itemForm.controls.reward.value ?? 0, rewardType: 2, @@ -181,9 +219,11 @@ export class QuestAddDialogComponent { for (const pokemonId of this.selectedMegaPokemonIds()) { creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, - distance: distanceMeters, - ping: common.ping || null, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + amount: this.megaForm.controls.amount.value ?? 0, + clean: cleanValue, + distance: scope.distance, pokemonId, reward: pokemonId, rewardType: 12, @@ -197,9 +237,11 @@ export class QuestAddDialogComponent { for (const pokemonId of this.selectedCandyPokemonIds()) { creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, - distance: distanceMeters, - ping: common.ping || null, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + amount: this.candyForm.controls.amount.value ?? 0, + clean: cleanValue, + distance: scope.distance, pokemonId, reward: pokemonId, rewardType: 4, @@ -209,17 +251,58 @@ export class QuestAddDialogComponent { ); } break; + case 4: + creates.push( + this.questService.create({ + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + amount: 0, + clean: cleanValue, + distance: scope.distance, + pokemonId: 0, + // PoracleNG compares the stored reward against the dust the quest pays, so the floor + // travels in reward rather than amount for this one type. + reward: this.stardustForm.controls.reward.value ?? 0, + rewardType: 3, + shiny: 0, + template: common.template || null, + }), + ); + break; } - forkJoin(creates).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('QUESTS.SNACK_FAILED_CREATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already + // succeeded were never reported, the dialog stayed open and the list never reloaded. Each request + // settles on its own now, and the toast says how many landed. See #577. + forkJoin(creates.map(c => c.pipe(catchError((err: { error?: { error?: string } }) => of({ failed: err }))))).subscribe({ + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + // Each create settles on its own, so a refused one no longer hides the ones that landed. + // The first refusal's message is shown, because it names what is in the way. See #577. + next: (results: ({ uid?: number } | { failed: { error?: { error?: string } } })[]) => { + const refused = results.filter((r): r is { failed: { error?: { error?: string } } } => 'failed' in r); + // Three outcomes, not two: refused (409), already tracked (200 with no uid), and created. The + // pokemon dialog has split these since #495; the rest reported duplicates as creations. See #605. + const landed = results.filter((r): r is { uid?: number } => !('failed' in r)); + const created = landed.filter(r => (r.uid ?? 0) > 0).length; + const duplicates = landed.length - created; this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('QUESTS.SNACK_CREATED_COUNT', { count: creates.length }), this.i18n.instant('TOAST.OK'), { - duration: 3000, - }); + + if (refused.length > 0) { + this.snackBar.open( + refused[0].failed?.error?.error ?? this.i18n.instant('QUESTS.SNACK_FAILED_CREATE'), + this.i18n.instant('COMMON.OK'), + { duration: 6000 }, + ); + } else { + const message = + duplicates > 0 + ? this.i18n.instant('ALARM.SNACK_CREATED_WITH_DUPLICATES', { count: created, duplicates }) + : this.i18n.instant('QUESTS.SNACK_CREATED_COUNT', { count: created }); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + // Close either way: whatever was created is real, and the list must reload to show it. this.dialogRef.close(true); }, }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html index 834fee68..aa12ae0c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.html @@ -23,6 +23,21 @@

{{ getTitle() }}

{{ 'QUESTS.QUEST_TYPE_LABEL' | translate }} {{ getRewardTypeLabel() }}

+ + @if (hasAmount) { + + {{ 'QUESTS.MIN_AMOUNT' | translate }} + + {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} + + } + @if (isStardust) { + + {{ 'QUESTS.MIN_STARDUST' | translate }} + + {{ 'QUESTS.MIN_STARDUST_HINT' | translate }} + + }
@@ -33,48 +48,9 @@

{{ getTitle() }}

{{ 'ALARM.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } {{ 'ALARM.MESSAGE_SETTINGS' | translate }} {{ 'ALARM.CLEAN_MODE' | translate }}

{{ 'ALARM.CLEAN_HINT_QUEST' | translate }}

+ + {{ 'QUESTS.SUMMARY_MODE' | translate }} +

{{ 'QUESTS.SUMMARY_HINT' | translate }}

+ @if (!summaryService.enabled()) { +

{{ 'QUESTS.SUMMARY_DISABLED_HINT' | translate }}

+ }
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.scss index 5151e02e..441fb177 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.scss @@ -69,3 +69,16 @@ mat-slide-toggle { min-width: 0; } } + +.scope-current { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 1rem; +} + +.scope-hint { + color: var(--mat-sys-on-surface-variant, rgb(0 0 0 / 60%)); + font-size: 0.75rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts new file mode 100644 index 00000000..e716224c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts @@ -0,0 +1,205 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { provideRouter } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { QuestEditDialogComponent } from './quest-edit-dialog.component'; +import { Quest, QuestUpdate } from '../../core/models'; +import { AuthService } from '../../core/services/auth.service'; +import { IconService } from '../../core/services/icon.service'; +import { MasterDataService } from '../../core/services/masterdata.service'; +import { QuestService } from '../../core/services/quest.service'; + +describe('QuestEditDialogComponent', () => { + let component: QuestEditDialogComponent; + let dialogRef: { close: jest.Mock }; + let questService: { update: jest.Mock }; + + const baseQuest: Quest = { + id: 'quest-1', + uid: 77, + amount: 0, + clean: 0, + distance: 0, + ping: null, + pokemonId: 25, + profileNo: 1, + reward: 25, + rewardType: 7, + shiny: 0, + template: null, + }; + + function setup(data: Quest) { + dialogRef = { close: jest.fn() }; + questService = { update: jest.fn().mockReturnValue(of(void 0)) }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + // delivery-preview links to /areas with routerLink, so a router is required. + provideRouter([]), + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: QuestService, useValue: questService }, + { provide: AuthService, useValue: { isImpersonating: () => false, user: () => ({ type: 'discord:user' }) } }, + { + provide: MasterDataService, + useValue: { getItemName: () => 'Item', getPokemonName: () => 'Pikachu', loadData: () => of(void 0) }, + }, + { provide: IconService, useValue: { getItemUrl: () => '', getPokemonUrl: () => '', getRewardUrl: () => '' } }, + ], + imports: [QuestEditDialogComponent], + }); + + const fixture = TestBed.createComponent(QuestEditDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + function savedClean(): number { + const update = questService.update.mock.calls[0][1] as QuestUpdate; + return update.clean as number; + } + + describe('form init from clean bits', () => { + it('initializes both toggles off when clean=0', () => { + setup({ ...baseQuest, clean: 0 }); + expect(component.form.controls.clean.value).toBe(false); + expect(component.form.controls.summary.value).toBe(false); + }); + + it('initializes auto-delete on, summary off when clean=1', () => { + setup({ ...baseQuest, clean: 1 }); + expect(component.form.controls.clean.value).toBe(true); + expect(component.form.controls.summary.value).toBe(false); + }); + + it('initializes summary on from bit 4 when clean=4', () => { + setup({ ...baseQuest, clean: 4 }); + expect(component.form.controls.clean.value).toBe(false); + expect(component.form.controls.summary.value).toBe(true); + }); + + it('initializes both on when clean=5', () => { + setup({ ...baseQuest, clean: 5 }); + expect(component.form.controls.clean.value).toBe(true); + expect(component.form.controls.summary.value).toBe(true); + }); + + it('initializes summary on from bit 4 even when an edit-in-place bit is also set (clean=6)', () => { + setup({ ...baseQuest, clean: 6 }); + expect(component.form.controls.clean.value).toBe(false); + expect(component.form.controls.summary.value).toBe(true); + }); + }); + + describe('save composes bit 4 while preserving other bits', () => { + it('sets bit 4 when toggled on, leaving auto-delete off (clean 0 -> 4)', () => { + setup({ ...baseQuest, clean: 0 }); + component.form.controls.summary.setValue(true); + component.save(); + expect(savedClean()).toBe(4); + }); + + it('combines auto-delete + summary (clean 0 -> 5)', () => { + setup({ ...baseQuest, clean: 0 }); + component.form.controls.clean.setValue(true); + component.form.controls.summary.setValue(true); + component.save(); + expect(savedClean()).toBe(5); + }); + + it('clears bit 4 when toggled off (clean 5 -> 1)', () => { + setup({ ...baseQuest, clean: 5 }); + component.form.controls.summary.setValue(false); + component.save(); + expect(savedClean()).toBe(1); + }); + + it('preserves an unsurfaced edit-in-place bit when editing (clean 3 -> 7)', () => { + // clean=3 => auto-delete (1) + edit-in-place (2); turning summary on must keep bit 2. + setup({ ...baseQuest, clean: 3 }); + component.form.controls.summary.setValue(true); + component.save(); + expect(savedClean()).toBe(7); + }); + + it('preserves the edit-in-place bit when turning auto-delete off (clean 3 -> 2)', () => { + setup({ ...baseQuest, clean: 3 }); + component.form.controls.clean.setValue(false); + component.save(); + expect(savedClean()).toBe(2); + }); + + it('passes the composed clean and uid to the service', () => { + setup({ ...baseQuest, clean: 0 }); + component.form.controls.summary.setValue(true); + component.save(); + expect(questService.update).toHaveBeenCalledWith(77, expect.objectContaining({ clean: 4 })); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + }); + describe('thresholds are editable, identity is not', () => { + // The minimum shipped in the add dialog only, which made it create-only: the card said "3x Rare + // Candy" and there was no way back to the 3. Same shape as the mega picker in #751. + it('offers the minimum amount on an item reward', () => { + setup({ ...baseQuest, amount: 3, reward: 1301, rewardType: 2 }); + + expect(component.hasAmount).toBe(true); + expect(component.form.controls.amount.value).toBe(3); + }); + + it('saves a changed minimum amount', () => { + setup({ ...baseQuest, amount: 3, reward: 1301, rewardType: 2 }); + component.form.controls.amount.setValue(5); + + component.save(); + + expect((questService.update.mock.calls[0][1] as QuestUpdate).amount).toBe(5); + }); + + it('offers the stardust floor, which PoracleNG keeps in reward', () => { + setup({ ...baseQuest, pokemonId: 0, reward: 1000, rewardType: 3 }); + + expect(component.isStardust).toBe(true); + expect(component.form.controls.stardust.value).toBe(1000); + + component.form.controls.stardust.setValue(1500); + component.save(); + + expect((questService.update.mock.calls[0][1] as QuestUpdate).reward).toBe(1500); + }); + + it('leaves a pokemon encounter alone: it has no quantity and its reward is its identity', () => { + // The legitimate twin. Offering "at least N Pikachu" would be a filter PoracleNG never applies to + // this type, and letting the reward be edited would turn the alarm into a different one. + setup({ ...baseQuest, reward: 25, rewardType: 7 }); + + expect(component.hasAmount).toBe(false); + expect(component.isStardust).toBe(false); + + component.save(); + + const sent = questService.update.mock.calls[0][1] as QuestUpdate; + expect(sent.reward).toBe(25); + expect(sent.amount).toBe(0); + }); + + it('does not touch the amount of a reward type that has none', () => { + // A stardust rule carries amount 0 and must keep it: PoracleNG ignores the column for this type, + // and writing something else there would be a value no screen can explain. + setup({ ...baseQuest, amount: 0, pokemonId: 0, reward: 1000, rewardType: 3 }); + + component.save(); + + expect((questService.update.mock.calls[0][1] as QuestUpdate).amount).toBe(0); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts index c8db4992..841ae251 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts @@ -9,7 +9,7 @@ import { MatRadioModule } from '@angular/material/radio'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Quest, QuestUpdate } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; @@ -17,8 +17,17 @@ import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { QuestService } from '../../core/services/quest.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; +import { SummaryScheduleService } from '../../core/services/summary-schedule.service'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; +import { AUTO_DELETE, compose, isAutoDelete, isSummary, preserve, SUMMARY } from '../../shared/utils/clean-flags'; + +/** Item, candy and mega energy: the three PoracleNG compares `amount` against. */ +const QUANTITY_REWARD_TYPES = new Set([2, 4, 12]); + +/** Stardust reads its floor from `reward`; `amount` is ignored for this type. */ +const STARDUST = 3; @Component({ imports: [ @@ -32,9 +41,9 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatRadioModule, MatTabsModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-quest-edit-dialog', standalone: true, @@ -55,17 +64,30 @@ export class QuestEditDialogComponent { readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.clean === 1], - distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], - distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], - ping: [this.data.ping ?? ''], + // Minimum quantity for the rewards that have one, and the stardust floor, which PoracleNG keeps in + // reward rather than amount. Both are thresholds rather than identity: they narrow the rule without + // changing which reward it is about, so unlike the reward itself they are editable here. + amount: [this.data.amount ?? 0], + clean: [isAutoDelete(this.data.clean)], + stardust: [this.data.rewardType === STARDUST ? (this.data.reward ?? 0) : 0], + summary: [isSummary(this.data.clean)], template: [this.data.template ?? ''], }); + /** Reward types that come in quantities, so "at least N" means something. */ + readonly hasAmount = QUANTITY_REWARD_TYPES.has(this.data.rewardType); + + readonly isStardust = this.data.rewardType === STARDUST; + readonly isWebhook = inject(AuthService).isImpersonating(); saving = signal(false); + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.overrideLocationLabel, this.data.overrideAreas, this.data.distance)); + + readonly summaryService = inject(SummaryScheduleService); + private get questPokemonId(): number { return this.data.pokemonId > 0 ? this.data.pokemonId : this.data.reward; } @@ -126,16 +148,6 @@ export class QuestEditDialogComponent { return this.getRewardTypeLabel(); } - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') { - this.form.controls.distanceKm.setValue(0); - } else { - if (!this.form.controls.distanceKm.value) { - this.form.controls.distanceKm.setValue(1); - } - } - } - onImageError(event: Event): void { (event.target as HTMLImageElement).style.display = 'none'; } @@ -147,22 +159,28 @@ export class QuestEditDialogComponent { save(): void { this.saving.set(true); const values = this.form.getRawValue(); - const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); const update: QuestUpdate = { - clean: values.clean ? 1 : 0, - distance: distanceMeters, - ping: values.ping || null, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + amount: this.hasAmount ? (values.amount ?? 0) : this.data.amount, + clean: preserve(this.data.clean, AUTO_DELETE | SUMMARY, compose(!!values.clean, false, !!values.summary)), + distance: scope.distance, pokemonId: this.data.pokemonId, - reward: this.data.reward, + reward: this.isStardust ? (values.stardust ?? 0) : this.data.reward, rewardType: this.data.rewardType, shiny: this.data.shiny, - template: values.template || null, + template: values.template || '', }; this.questService.update(this.data.uid, update).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('QUESTS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('QUESTS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.html index a7db5102..6a2ebade 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.html @@ -4,6 +4,17 @@

{{ 'QUESTS.PAGE_TITLE' | translate }}

{{ 'QUESTS.PAGE_DESC' | translate }}

+ @if (summaryService.enabled()) { + + + + + } @@ -37,6 +48,7 @@

{{ 'QUESTS.PAGE_TITLE' | translate }}

+ @@ -84,13 +96,24 @@

{{ getQuestTitle(quest) }}

- @if (quest.clean === 1) { + @if (isAutoDelete(quest.clean)) { {{ 'QUESTS.CLEAN_TAG' | translate }} } + @if (isSummary(quest.clean)) { + {{ 'QUESTS.SUMMARY_BADGE' | translate }} + }
- + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.scss index 52e43c19..0bd9b0d4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.scss @@ -86,6 +86,19 @@ flex-shrink: 0; line-height: 16px; } +.summary-tag { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + background: var(--mat-sys-tertiary); + color: var(--mat-sys-on-tertiary); + flex-shrink: 0; + line-height: 16px; +} .template-chip { display: inline-block; background: #e8eaf6; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts index eb75a5d6..2c7df962 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-list.component.ts @@ -8,20 +8,25 @@ import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom } from 'rxjs'; import { QuestAddDialogComponent } from './quest-add-dialog.component'; import { QuestEditDialogComponent } from './quest-edit-dialog.component'; +import { SummaryScheduleDialogComponent, SummaryScheduleDialogData } from './summary-schedule-dialog/summary-schedule-dialog.component'; import { Quest } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { QuestService } from '../../core/services/quest.service'; +import { SummaryScheduleService } from '../../core/services/summary-schedule.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { AlarmInfoComponent } from '../../shared/components/alarm-info/alarm-info.component'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -34,7 +39,7 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatDialogModule, MatTooltipModule, MatSnackBarModule, - TranslateModule, + TranslatePipe, AlarmInfoComponent, ], selector: 'app-quest-list', @@ -46,19 +51,27 @@ export class QuestListComponent implements OnInit { private static readonly FALLBACK = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23999'%3E%3Cpath d='M11 18h2v-2h-2v2zm1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z'/%3E%3C/svg%3E"; + private readonly areaService = inject(AreaService); + + /** True while this alarm type is switched off: the page reads and deletes, but cannot create or edit. */ + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); private readonly masterData = inject(MasterDataService); private readonly questService = inject(QuestService); - private readonly snackBar = inject(MatSnackBar); readonly loading = signal(true); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); readonly quests = signal([]); readonly selectedIds = signal(new Set()); readonly selectMode = signal(false); + readonly skeletonCards = Array.from({ length: 6 }); + readonly summaryService = inject(SummaryScheduleService); readonly testAlertService = inject(TestAlertService); @@ -74,11 +87,22 @@ export class QuestListComponent implements OnInit { const result = await firstValueFrom(ref.afterClosed()); if (result) { const ids = [...this.selectedIds()]; - for (const uid of ids) await firstValueFrom(this.questService.delete(uid)); + // Settled one at a time: a stale uid -- the row re-keyed by an edit, or removed in another tab -- + // threw out of the loop, so deletes that had already happened went unreported and the list never + // reloaded. See #603. + let deleted = 0; + for (const uid of ids) { + try { + await firstValueFrom(this.questService.delete(uid)); + deleted++; + } catch { + // Already gone, which is what the user asked for. + } + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadQuests(); - this.snackBar.open(this.i18n.instant('QUESTS.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('TOAST.OK'), { + this.snackBar.open(this.i18n.instant('QUESTS.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('TOAST.OK'), { duration: 3000, }); } @@ -89,7 +113,18 @@ export class QuestListComponent implements OnInit { const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { const uids = [...this.selectedIds()]; - await firstValueFrom(this.questService.updateBulkDistance(uids, distance)); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + await firstValueFrom(this.questService.updateBulkDistance(uids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('QUESTS.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadQuests(); @@ -162,6 +197,29 @@ export class QuestListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(item: Quest): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + this.questService.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadQuests(); + }, + }); + }); + } + formatDistance(meters: number): string { if (meters >= 1000) { return `${(meters / 1000).toFixed(1)} km`; @@ -190,29 +248,11 @@ export class QuestListComponent implements OnInit { } getQuestTitle(quest: Quest): string { - // Pokemon encounter: ID may be in pokemonId or reward field - const pokemonId = quest.pokemonId > 0 ? quest.pokemonId : quest.reward; - if (quest.rewardType === 7 && pokemonId > 0) { - return this.masterData.getPokemonName(pokemonId); - } - if (quest.rewardType === 7 && pokemonId === 0) { - return this.i18n.instant('QUESTS.ANY_POKEMON_ENCOUNTER'); - } - if (quest.rewardType === 12 && pokemonId > 0) { - return this.i18n.instant('QUESTS.MEGA_ENERGY_SUFFIX', { name: this.masterData.getPokemonName(pokemonId) }); - } - if (quest.rewardType === 4 && pokemonId > 0) { - return this.i18n.instant('QUESTS.CANDY_SUFFIX', { name: this.masterData.getPokemonName(pokemonId) }); - } - if (quest.rewardType === 2) { - return this.masterData.getItemName(quest.reward); - } - if (quest.rewardType === 3) { - return quest.reward > 0 - ? this.i18n.instant('QUESTS.STARDUST_AMOUNT', { amount: quest.reward }) - : this.i18n.instant('QUESTS.STARDUST'); - } - return this.getRewardTypeLabel(quest.rewardType); + const reward = this.describeReward(quest); + + // The minimum only applies to the rewards that come in quantities, and saying "1x" for a rule that + // asks for one of something is noise. + return quest.amount > 1 ? this.i18n.instant('QUESTS.AMOUNT_PREFIX', { count: quest.amount, reward }) : reward; } getRewardColor(rewardType: number): string { @@ -225,6 +265,10 @@ export class QuestListComponent implements OnInit { return '#9C27B0'; case 4: return '#FF9800'; + // Stardust used to fall through to the grey "unknown reward" colour, which is what every other + // type it does not recognise gets. + case 3: + return '#FBC02D'; default: return '#9E9E9E'; } @@ -245,6 +289,16 @@ export class QuestListComponent implements OnInit { } } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return (clean & 1) !== 0; + } + + /** True when the summary bit (clean bit 4) is set. */ + isSummary(clean: number): boolean { + return (clean & 4) !== 0; + } + loadQuests(): void { this.loading.set(true); this.questService @@ -262,6 +316,8 @@ export class QuestListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); + this.summaryService.loadCapability(); this.masterData .loadData() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -288,6 +344,15 @@ export class QuestListComponent implements OnInit { }); } + openSummaryDialog(): void { + this.dialog.open(SummaryScheduleDialogComponent, { + maxWidth: '95vw', + width: '560px', + data: { alertType: 'quest' } as SummaryScheduleDialogData, + maxHeight: '90vh', + }); + } + selectAll(): void { const ids = new Set(this.quests().map(i => i.uid)); this.selectedIds.set(ids); @@ -324,4 +389,36 @@ export class QuestListComponent implements OnInit { } }); } + + private describeReward(quest: Quest): string { + // Pokemon encounter: ID may be in pokemonId or reward field + const pokemonId = quest.pokemonId > 0 ? quest.pokemonId : quest.reward; + if (quest.rewardType === 7 && pokemonId > 0) { + return this.masterData.getPokemonName(pokemonId); + } + if (quest.rewardType === 7 && pokemonId === 0) { + return this.i18n.instant('QUESTS.ANY_POKEMON_ENCOUNTER'); + } + if (quest.rewardType === 12 && pokemonId > 0) { + return this.i18n.instant('QUESTS.MEGA_ENERGY_SUFFIX', { name: this.masterData.getPokemonName(pokemonId) }); + } + if (quest.rewardType === 4 && pokemonId > 0) { + return this.i18n.instant('QUESTS.CANDY_SUFFIX', { name: this.masterData.getPokemonName(pokemonId) }); + } + if (quest.rewardType === 2) { + // reward 0 is the dialog default, meaning any item. getItemName has no id 0, so this used to + // render as "Item #0" -- the Pokemon branch above already has the equivalent "any" case. + return quest.reward > 0 ? this.masterData.getItemName(quest.reward) : this.i18n.instant('QUESTS.ANY_ITEM'); + } + if (quest.rewardType === 3) { + return quest.reward > 0 + ? this.i18n.instant('QUESTS.STARDUST_AMOUNT', { amount: quest.reward }) + : this.i18n.instant('QUESTS.STARDUST'); + } + return this.getRewardTypeLabel(quest.rewardType); + } + + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.html new file mode 100644 index 00000000..98c48b98 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.html @@ -0,0 +1,66 @@ +

+ schedule_send + {{ 'QUESTS.SUMMARY_SCHEDULE' | translate }} +

+ + +

{{ 'QUESTS.SUMMARY_SCHEDULE_ALERT_LABEL' | translate }}

+ + @if (loading()) { +
+ + {{ 'COMMON.LOADING' | translate }} +
+ } @else { + + + @if (hasSchedule()) { +
+ +
+ @for (pill of pills(); track pill.label; let i = $index) { + + schedule + {{ pill.label }} + + } +
+
+

{{ 'QUESTS.SUMMARY_SCHEDULE_SEND_NOW_HINT' | translate }}

+ } @else { +
+ event_available +

{{ 'QUESTS.SUMMARY_SCHEDULE_EMPTY' | translate }}

+
+ } + } +
+ + + + + + + + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.scss new file mode 100644 index 00000000..4af448fd --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.scss @@ -0,0 +1,246 @@ +:host { + display: block; +} + +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-weight: 400; +} + +.title-icon { + color: #d97706; +} + +mat-dialog-content { + min-width: 360px; + max-width: 520px; +} + +.dialog-caption { + margin: 0 0 16px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); +} + +.section-label { + display: block; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + margin-bottom: 10px; +} + +// Sets expectations for "Send summary now": it flushes only what PoracleNG has buffered. +.send-hint { + margin: 14px 0 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-hint, rgba(0, 0, 0, 0.38)); +} + +// ── Shared state blocks (loading / empty) ────────────────────────────────── +.state-block { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + padding: 28px 20px; + border-radius: 12px; +} + +.loading-block { + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + font-size: 13px; +} + +// ── Refined empty state ──────────────────────────────────────────────────── +.empty-block { + border: 1px dashed var(--divider, rgba(0, 0, 0, 0.12)); + background: var(--skeleton-bg, rgba(0, 0, 0, 0.02)); + + .empty-icon { + font-size: 36px; + width: 36px; + height: 36px; + color: var(--text-hint, rgba(0, 0, 0, 0.38)); + } + + .empty-text { + margin: 0; + max-width: 280px; + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + } +} + +// ── Populated state: amber active-hours pills with depth ─────────────────── +.schedule-panel { + display: block; + padding: 14px 16px; + border-radius: 12px; + background: var(--card-bg, #fff); + border: 1px solid var(--divider, rgba(0, 0, 0, 0.08)); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); +} + +.pill-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 12px; + border-radius: 14px; + font-size: 12px; + font-weight: 500; + line-height: 20px; + white-space: nowrap; + background: #fef3c7; + color: #92400e; + opacity: 0; + transform: translateY(6px); + animation: pill-fade-in 0.26s ease forwards; + + .pill-icon { + font-size: 14px; + width: 14px; + height: 14px; + color: #d97706; + } +} + +@keyframes pill-fade-in { + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .pill { + animation: none; + opacity: 1; + transform: none; + } +} + +// ── Action row ───────────────────────────────────────────────────────────── +.spacer { + flex: 1; +} + +mat-dialog-actions { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.send-now-btn { + --mdc-filled-button-container-color: #f59e0b; + --mdc-filled-button-label-text-color: #fff; + display: inline-flex; + align-items: center; + gap: 4px; + transition: + transform 0.1s ease, + filter 0.15s ease; + + mat-spinner { + margin-right: 2px; + } + + &:not([disabled]):hover { + filter: brightness(1.05); + } + + &:not([disabled]):active { + transform: scale(0.97); + } + + &.is-cooling { + --mdc-filled-button-container-color: var(--skeleton-bg, rgba(0, 0, 0, 0.12)); + --mdc-filled-button-label-text-color: var(--text-hint, rgba(0, 0, 0, 0.38)); + } +} + +// Visible keyboard focus across the action row. +mat-dialog-actions button:focus-visible { + outline: 2px solid var(--accent-primary, #1976d2); + outline-offset: 2px; +} + +// ── Dark theme overrides via the existing CSS-variable bridge ────────────── +:host-context(.dark-theme) { + .schedule-panel { + background: var(--card-bg, rgba(255, 255, 255, 0.04)); + border-color: var(--divider, rgba(255, 255, 255, 0.1)); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); + } + + .empty-block { + border-color: var(--divider, rgba(255, 255, 255, 0.12)); + background: rgba(255, 255, 255, 0.02); + + .empty-icon { + color: var(--text-hint, rgba(255, 255, 255, 0.38)); + } + } + + .pill { + background: rgba(245, 158, 11, 0.15); + color: #fbbf24; + + .pill-icon { + color: #f59e0b; + } + } + + .send-now-btn { + --mdc-filled-button-container-color: #d97706; + + &.is-cooling { + --mdc-filled-button-container-color: rgba(255, 255, 255, 0.12); + --mdc-filled-button-label-text-color: var(--text-hint, rgba(255, 255, 255, 0.38)); + } + } +} + +// ── Mobile / touch refinements ───────────────────────────────────────────── +@media (max-width: 600px) { + mat-dialog-content { + min-width: unset; + } + + // Comfortable thumb targets (Material default text buttons are 36px tall). + mat-dialog-actions button { + min-height: 44px; + } + + // Stack a full-width primary "Send summary now" above the secondary row so the + // main action is unmissable and easy to tap; the flex spacer is redundant here. + mat-dialog-actions { + gap: 8px; + } + + .send-now-btn { + width: 100%; + justify-content: center; + } + + .spacer { + display: none; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.spec.ts new file mode 100644 index 00000000..c2ee6612 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.spec.ts @@ -0,0 +1,261 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideTranslateService } from '@ngx-translate/core'; +import { Subject, of, throwError } from 'rxjs'; + +import { SummaryScheduleDialogComponent, SummaryScheduleDialogData } from './summary-schedule-dialog.component'; +import { ActiveHourEntry } from '../../../core/models/active-hours.models'; +import { I18nService } from '../../../core/services/i18n.service'; +import { LocationService } from '../../../core/services/location.service'; +import { SummarySchedule, SummaryScheduleService } from '../../../core/services/summary-schedule.service'; +import { ActiveHoursEditorDialogComponent } from '../../../shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component'; + +describe('SummaryScheduleDialogComponent', () => { + let component: SummaryScheduleDialogComponent; + let dialogRef: { close: jest.Mock }; + let summaryService: { + enabled: jest.Mock; + getSchedule: jest.Mock; + setSchedule: jest.Mock; + deleteSchedule: jest.Mock; + trigger: jest.Mock; + }; + let matDialog: { open: jest.Mock }; + let locationService: { getLocation: jest.Mock }; + let snackBar: { open: jest.Mock }; + + const QUEST_SCHEDULE: SummarySchedule = { + activeHours: [ + { day: 1, hours: 9, mins: 0 }, + { day: 2, hours: 9, mins: 0 }, + ], + alertType: 'quest', + }; + + function makeAfterClosed(value: T): { afterClosed: jest.Mock } { + return { afterClosed: jest.fn(() => of(value)) }; + } + + function setup( + overrides: { + enabled?: boolean; + schedule?: SummarySchedule | null; + location?: { latitude: number; longitude: number }; + data?: SummaryScheduleDialogData; + } = {}, + ) { + const enabled = overrides.enabled ?? true; + const schedule = overrides.schedule === undefined ? QUEST_SCHEDULE : overrides.schedule; + const location = overrides.location ?? { latitude: 0, longitude: 0 }; + + dialogRef = { close: jest.fn() }; + summaryService = { + deleteSchedule: jest.fn(() => of(undefined)), + enabled: jest.fn(() => enabled), + getSchedule: jest.fn(() => of(schedule)), + setSchedule: jest.fn(() => of(undefined)), + trigger: jest.fn(() => of(undefined)), + }; + matDialog = { open: jest.fn(() => makeAfterClosed(undefined)) }; + locationService = { getLocation: jest.fn(() => of(location)) }; + snackBar = { open: jest.fn() }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: MAT_DIALOG_DATA, useValue: overrides.data ?? ({ alertType: 'quest' } as SummaryScheduleDialogData) }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: SummaryScheduleService, useValue: summaryService }, + { provide: MatDialog, useValue: matDialog }, + { provide: LocationService, useValue: locationService }, + { provide: MatSnackBar, useValue: snackBar }, + { provide: I18nService, useValue: { instant: (key: string) => key } }, + ], + imports: [SummaryScheduleDialogComponent, NoopAnimationsModule], + }).overrideComponent(SummaryScheduleDialogComponent, { + // MatDialogModule provides MatDialog at the component injector, which shadows the + // module-level test provider — override at component scope so the nested-editor open is mocked. + add: { providers: [{ provide: MatDialog, useValue: matDialog }] }, + }); + + const fixture = TestBed.createComponent(SummaryScheduleDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + return fixture; + } + + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); + + it('loads the schedule for the injected alert type with exactly one proxy call', () => { + setup(); + expect(summaryService.getSchedule).toHaveBeenCalledTimes(1); + expect(summaryService.getSchedule).toHaveBeenCalledWith('quest'); + expect(component.schedule()).toEqual(QUEST_SCHEDULE); + }); + + it('derives entries and hasSchedule from the loaded schedule', () => { + setup(); + expect(component.entries()).toEqual(QUEST_SCHEDULE.activeHours); + expect(component.hasSchedule()).toBe(true); + }); + + it('reports no schedule when the upstream returns null (404 mapped to null)', () => { + setup({ schedule: null }); + expect(component.entries()).toEqual([]); + expect(component.hasSchedule()).toBe(false); + }); + + it('seeds user coordinates from the location service', () => { + setup({ location: { latitude: 47.5, longitude: -122.3 } }); + expect(locationService.getLocation).toHaveBeenCalled(); + expect(component.userLat()).toBe(47.5); + expect(component.userLon()).toBe(-122.3); + }); + + describe('editor reuse', () => { + // The editor's default empty state says the profile will need a manual start, which contradicts this + // dialog's own line about quests being delivered individually. See #457. + it('passes the quest empty-state line to the shared editor', () => { + setup(); + component.editSchedule(); + + const [, openConfig] = matDialog.open.mock.calls[0]; + expect(openConfig.data.emptyStateKey).toBe('QUESTS.SUMMARY_SCHEDULE_EMPTY'); + }); + + it('opens ActiveHoursEditorDialogComponent seeded with the current entries and a profileName label', () => { + setup(); + component.editSchedule(); + + expect(matDialog.open).toHaveBeenCalledTimes(1); + const [openedComponent, openConfig] = matDialog.open.mock.calls[0]; + expect(openedComponent).toBe(ActiveHoursEditorDialogComponent); + expect(openConfig.data.activeHours).toEqual(QUEST_SCHEDULE.activeHours); + // profileName is mandatory on the editor; the dialog passes a translated alert label. + expect(openConfig.data.profileName).toBe('QUESTS.SUMMARY_SCHEDULE_ALERT_LABEL'); + }); + + it('persists the editor result by calling setSchedule with the returned entries array', () => { + const edited: ActiveHourEntry[] = [{ day: 3, hours: 18, mins: 30 }]; + matDialog.open.mockReturnValue(makeAfterClosed(edited)); + setup(); + // Re-point the editor open to the edited result for this case. + matDialog.open.mockReturnValue(makeAfterClosed(edited)); + + component.editSchedule(); + + expect(summaryService.setSchedule).toHaveBeenCalledWith('quest', edited); + }); + + it('does not call setSchedule when the editor is cancelled (afterClosed -> undefined)', () => { + matDialog.open.mockReturnValue(makeAfterClosed(undefined)); + setup(); + matDialog.open.mockReturnValue(makeAfterClosed(undefined)); + + component.editSchedule(); + + expect(summaryService.setSchedule).not.toHaveBeenCalled(); + }); + + it('updates in-memory state and shows a snackbar after a successful save', () => { + const edited: ActiveHourEntry[] = [{ day: 4, hours: 7, mins: 0 }]; + setup(); + matDialog.open.mockReturnValue(makeAfterClosed(edited)); + + component.editSchedule(); + + expect(summaryService.setSchedule).toHaveBeenCalledWith('quest', edited); + // Optimistic update — no refetch round-trip; the editor returns the canonical entries. + expect(component.entries()).toEqual(edited); + expect(component.hasSchedule()).toBe(true); + expect(snackBar.open).toHaveBeenCalled(); + }); + }); + + describe('send summary now (trigger)', () => { + it('calls trigger for the injected alert type', () => { + setup(); + component.sendNow(); + expect(summaryService.trigger).toHaveBeenCalledWith('quest'); + }); + + it('shows a success snackbar after a successful trigger', () => { + setup(); + component.sendNow(); + expect(snackBar.open).toHaveBeenCalled(); + }); + + it('shows an error snackbar when the trigger fails', () => { + setup(); + summaryService.trigger.mockReturnValue(throwError(() => ({ status: 503 }))); + snackBar.open.mockClear(); + + component.sendNow(); + + expect(snackBar.open).toHaveBeenCalled(); + }); + + it('dedupes an in-flight trigger so a double-click cannot double-deliver', () => { + const gate = new Subject(); + summaryService.trigger.mockReturnValue(gate.asObservable()); + setup(); + summaryService.trigger.mockReturnValue(gate.asObservable()); + + component.sendNow(); + component.sendNow(); + + expect(summaryService.trigger).toHaveBeenCalledTimes(1); + gate.complete(); + }); + + it('blocks a second trigger during the cooldown window after a successful send', () => { + setup(); + component.sendNow(); + expect(summaryService.trigger).toHaveBeenCalledTimes(1); + + component.sendNow(); + expect(summaryService.trigger).toHaveBeenCalledTimes(1); + }); + }); + + describe('clear / delete', () => { + it('deletes the schedule for the injected alert type', () => { + setup(); + component.clearSchedule(); + expect(summaryService.deleteSchedule).toHaveBeenCalledWith('quest'); + }); + + it('clears in-memory state and shows a snackbar after a successful delete', () => { + setup(); + + component.clearSchedule(); + + expect(summaryService.deleteSchedule).toHaveBeenCalledWith('quest'); + // Optimistic clear — no refetch round-trip. + expect(component.entries()).toEqual([]); + expect(component.hasSchedule()).toBe(false); + expect(snackBar.open).toHaveBeenCalled(); + }); + }); + + describe('location warning', () => { + it('flags an active schedule with 0,0 coordinates (warning condition met)', () => { + setup({ location: { latitude: 0, longitude: 0 }, schedule: QUEST_SCHEDULE }); + expect(component.hasSchedule()).toBe(true); + expect(component.userLat()).toBe(0); + expect(component.userLon()).toBe(0); + }); + + it('does not flag when coordinates are set', () => { + setup({ location: { latitude: 51.5, longitude: -0.12 }, schedule: QUEST_SCHEDULE }); + expect(component.userLat()).toBe(51.5); + expect(component.userLon()).toBe(-0.12); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.ts new file mode 100644 index 00000000..7fc3157c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.ts @@ -0,0 +1,167 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; +import { catchError, finalize, of } from 'rxjs'; + +import { ActiveHourEntry, compressDayRange, formatTime12h, groupActiveHours } from '../../../core/models/active-hours.models'; +import { I18nService } from '../../../core/services/i18n.service'; +import { LocationService } from '../../../core/services/location.service'; +import { SummarySchedule, SummaryScheduleService } from '../../../core/services/summary-schedule.service'; +import { + ActiveHoursEditorDialogComponent, + ActiveHoursEditorData, +} from '../../../shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component'; +import { LocationWarningComponent } from '../../../shared/components/location-warning/location-warning.component'; + +export interface SummaryScheduleDialogData { + alertType: string; // 'quest' +} + +/** Client-side cooldown for the "Send summary now" trigger — purely a duplicate-delivery guard. */ +const COOLDOWN_MS = 15_000; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + LocationWarningComponent, + MatButtonModule, + MatDialogModule, + MatIconModule, + MatProgressSpinnerModule, + MatTooltipModule, + TranslatePipe, + ], + selector: 'app-summary-schedule-dialog', + standalone: true, + styleUrl: './summary-schedule-dialog.component.scss', + templateUrl: './summary-schedule-dialog.component.html', +}) +export class SummaryScheduleDialogComponent { + /** Timestamp (ms) when the trigger cooldown expires; 0 = not cooling down. */ + private readonly cooldownUntil = signal(0); + private readonly dialog = inject(MatDialog); + + private readonly dialogRef = inject(MatDialogRef); + + private readonly i18n = inject(I18nService); + private readonly locationService = inject(LocationService); + private readonly snackBar = inject(MatSnackBar); + private readonly summaryService = inject(SummaryScheduleService); + + readonly coolingDown = computed(() => Date.now() < this.cooldownUntil()); + + readonly data: SummaryScheduleDialogData = inject(MAT_DIALOG_DATA); + readonly schedule = signal(null); + readonly entries = computed(() => this.schedule()?.activeHours ?? []); + + readonly hasSchedule = computed(() => this.entries().length > 0); + + readonly loading = signal(true); + /** Grouped amber pills mirroring the active-hours-chip idiom. */ + readonly pills = computed(() => + groupActiveHours(this.entries()).map(g => ({ label: `${compressDayRange(g.days)} ${formatTime12h(g.hours, g.mins)}` })), + ); + + readonly saving = signal(false); + + readonly triggering = signal(false); + readonly userLat = signal(0); + + readonly userLon = signal(0); + constructor() { + this.summaryService + .getSchedule(this.data.alertType) + .pipe(finalize(() => this.loading.set(false))) + .subscribe(schedule => this.schedule.set(schedule)); + + // 0,0 is already the default, and is what a disabled or unset location should leave in place -- + // without an error arm the same 403 threw instead. See #617. + this.locationService + .getLocation() + .pipe(catchError(() => of(null))) + .subscribe(location => { + this.userLat.set(location?.latitude ?? 0); + this.userLon.set(location?.longitude ?? 0); + }); + } + + /** Remove the schedule entirely (clear all rules). */ + clearSchedule(): void { + if (!this.hasSchedule() || this.saving()) return; + this.saving.set(true); + this.summaryService + .deleteSchedule(this.data.alertType) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + error: () => this.notify('QUESTS.SUMMARY_SCHEDULE_FAILED'), + next: () => { + this.schedule.set({ activeHours: [], alertType: this.data.alertType }); + this.notify('QUESTS.SUMMARY_SCHEDULE_CLEARED'); + }, + }); + } + + close(): void { + this.dialogRef.close(); + } + + /** Open the shared active-hours editor seeded with the current schedule, persist the returned array. */ + editSchedule(): void { + const ref = this.dialog.open(ActiveHoursEditorDialogComponent, { + maxWidth: '95vw', + width: '560px', + data: { + activeHours: this.entries(), + // Without this the editor said the profile would need a manual start, directly beneath this + // dialog's own line about quests being delivered individually. See #457. + emptyStateKey: 'QUESTS.SUMMARY_SCHEDULE_EMPTY', + profileName: this.i18n.instant('QUESTS.SUMMARY_SCHEDULE_ALERT_LABEL'), + } as ActiveHoursEditorData, + }); + + ref.afterClosed().subscribe((result: ActiveHourEntry[] | null | undefined) => { + if (result === null || result === undefined) return; + this.persist(result, result.length > 0 ? 'QUESTS.SUMMARY_SCHEDULE_SAVED' : 'QUESTS.SUMMARY_SCHEDULE_CLEARED'); + }); + } + + /** Flush-and-deliver the buffered quest summary now (cooldown + in-flight guarded). */ + sendNow(): void { + if (!this.hasSchedule() || this.triggering() || this.coolingDown()) return; + this.triggering.set(true); + this.summaryService + .trigger(this.data.alertType) + .pipe(finalize(() => this.triggering.set(false))) + .subscribe({ + error: err => this.notify(err?.status === 503 ? 'QUESTS.SUMMARY_SCHEDULE_UNAVAILABLE' : 'QUESTS.SUMMARY_SCHEDULE_FAILED'), + next: () => { + this.cooldownUntil.set(Date.now() + COOLDOWN_MS); + setTimeout(() => this.cooldownUntil.set(0), COOLDOWN_MS); + this.notify('QUESTS.SUMMARY_SCHEDULE_SENT'); + }, + }); + } + + private notify(key: string): void { + this.snackBar.open(this.i18n.instant(key), this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + private persist(hours: ActiveHourEntry[], successKey: string): void { + this.saving.set(true); + this.summaryService + .setSchedule(this.data.alertType, hours) + .pipe(finalize(() => this.saving.set(false))) + .subscribe({ + error: err => this.notify(err?.status === 503 ? 'QUESTS.SUMMARY_SCHEDULE_UNAVAILABLE' : 'QUESTS.SUMMARY_SCHEDULE_FAILED'), + next: () => { + this.schedule.set({ activeHours: hours, alertType: this.data.alertType }); + this.notify(successKey); + }, + }); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-admin-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-admin-dialog.component.spec.ts new file mode 100644 index 00000000..924ff3ec --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-admin-dialog.component.spec.ts @@ -0,0 +1,184 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { provideRouter } from '@angular/router'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { QuickPickAdminDialogComponent } from './quick-pick-admin-dialog.component'; +import { QuickPickDefinition } from '../../core/models'; +import { AuthService } from '../../core/services/auth.service'; +import { ConfigService } from '../../core/services/config.service'; +import { QuickPickService } from '../../core/services/quick-pick.service'; + +describe('QuickPickAdminDialogComponent', () => { + let component: QuickPickAdminDialogComponent; + let quickPickService: { saveAdmin: jest.Mock; saveUser: jest.Mock }; + + function setup(data: QuickPickDefinition | null, isAdmin = true): void { + quickPickService = { + saveAdmin: jest.fn(() => of({})), + saveUser: jest.fn(() => of({})), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: QuickPickService, useValue: quickPickService }, + { provide: AuthService, useValue: { isAdmin: () => isAdmin } }, + ], + imports: [QuickPickAdminDialogComponent], + }); + + const fixture = TestBed.createComponent(QuickPickAdminDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + function savedDefinition(): QuickPickDefinition { + const call = quickPickService.saveAdmin.mock.calls[0] ?? quickPickService.saveUser.mock.calls[0]; + return call[0] as QuickPickDefinition; + } + + describe('editing an existing definition', () => { + // The built-in "nundo" preset is { minIv: 0, maxIv: 0 } -- that is the entire point of it. Skipping + // falsy values on save turned "0% IV only" into "any IV" for everyone who applied it afterwards. + // See #654. + const nundo: QuickPickDefinition = { + id: 'nundo', + name: 'Nundo', + alarmType: 'monster', + category: 'PvP', + description: 'Zero IV', + enabled: true, + filters: { maxIv: 0, minIv: 0 }, + icon: 'pokeball', + scope: 'global', + sortOrder: 1, + }; + + it('keeps a stored filter whose value is zero', () => { + setup(nundo); + + component.save(); + + const saved = savedDefinition(); + expect(saved.filters['minIv']).toBe(0); + expect(saved.filters['maxIv']).toBe(0); + }); + + it('keeps a stored key the dialog has no control for', () => { + // quick-pick-apply reads filters['clean'] as its base bitmask, and no form exposes it. + setup({ ...nundo, filters: { clean: 5, maxIv: 0, minIv: 0 } }); + + component.save(); + + expect(savedDefinition().filters['clean']).toBe(5); + }); + }); + + describe('changing the alarm type', () => { + const monsterPick: QuickPickDefinition = { + id: 'p', + name: 'P', + alarmType: 'monster', + category: 'PvP', + description: '', + enabled: true, + filters: { clean: 5, minIv: 0, ping: '<@&123>', pvpRankingLeague: 1500, template: 'custom' }, + icon: 'pokeball', + scope: 'global', + sortOrder: 1, + }; + + it('drops the previous type’s filters', () => { + setup(monsterPick); + component.mainForm.patchValue({ alarmType: 'lure' }); + + component.save(); + + expect(savedDefinition().filters['pvpRankingLeague']).toBeUndefined(); + expect(savedDefinition().filters['minIv']).toBeUndefined(); + }); + + it('keeps the keys that belong to no type', () => { + // All four are properties of every alarm model and are exposed by no form. Clearing wholesale + // reset a pick's auto-delete bits (#671) and dropped its ping target and template (#674). + setup(monsterPick); + component.mainForm.patchValue({ alarmType: 'lure' }); + + component.save(); + + const filters = savedDefinition().filters; + expect(filters['clean']).toBe(5); + expect(filters['ping']).toBe('<@&123>'); + expect(filters['template']).toBe('custom'); + }); + }); + + describe('creating a new definition', () => { + it('does not write out the form defaults that mean "not set"', () => { + // Most numeric controls default to 0. Persisting them would put an explicit minIv, pokemonId and + // pvpRankingLeague of 0 on every new pick, which is the mirror image of the bug above. + setup(null); + component.mainForm.patchValue({ name: 'Fresh', alarmType: 'monster' }); + + component.save(); + + const filters = savedDefinition().filters; + expect(filters['minIv']).toBeUndefined(); + expect(filters['pokemonId']).toBeUndefined(); + expect(filters['pvpRankingLeague']).toBeUndefined(); + }); + + it('still writes a non-default value', () => { + setup(null); + component.mainForm.patchValue({ name: 'Hundo', alarmType: 'monster' }); + component.monsterForm.patchValue({ minIv: 100 }); + + component.save(); + + expect(savedDefinition().filters['minIv']).toBe(100); + }); + }); + + describe('scope', () => { + it('keeps a user-scoped pick user-scoped when an admin edits it', () => { + // Deciding from isAdmin alone republished an admin's own personal pick to every user. See #631. + setup({ + id: 'mine', + name: 'Mine', + alarmType: 'monster', + category: 'Common', + description: '', + enabled: true, + filters: {}, + icon: 'bolt', + scope: 'user', + sortOrder: 0, + }); + + component.save(); + + expect(quickPickService.saveUser).toHaveBeenCalled(); + expect(quickPickService.saveAdmin).not.toHaveBeenCalled(); + }); + + it('publishes a new pick globally when an admin creates it', () => { + setup(null); + component.mainForm.patchValue({ name: 'Global', alarmType: 'monster' }); + + component.save(); + + expect(quickPickService.saveAdmin).toHaveBeenCalled(); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-admin-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-admin-dialog.component.ts index 3e379575..b5090c83 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-admin-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-admin-dialog.component.ts @@ -9,13 +9,26 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { QuickPickDefinition } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; import { I18nService } from '../../core/services/i18n.service'; import { QuickPickService } from '../../core/services/quick-pick.service'; +/** + * Filter keys that belong to no particular alarm type, and so survive a type change. + * + * All four are properties of every one of the ten alarm models, and none is exposed by any of the + * per-type forms in `getFilterForm` -- the same test that justified preserving `clean` in #671. The + * backend's own `QuickPickService.SafeMonsterFilterKeys` lists them alongside it. `ping` in particular + * is never overridden at apply time, because the apply dialog has no ping control at all. + * + * This said `clean` was the only one, which was wrong -- and wrong in the direction that silently drops + * a user's ping target and template on a type change. See #671, #674. + */ +const TYPE_AGNOSTIC_FILTER_KEYS = new Set(['clean', 'distance', 'ping', 'template']); + @Component({ imports: [ ReactiveFormsModule, @@ -28,7 +41,7 @@ import { QuickPickService } from '../../core/services/quick-pick.service'; MatSlideToggleModule, MatSnackBarModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, ], selector: 'app-quick-pick-admin-dialog', standalone: true, @@ -134,6 +147,15 @@ export class QuickPickAdminDialogComponent implements OnInit { readonly saving = signal(false); + /** + * Where this pick belongs: an edit keeps its own scope, a new one follows who is creating it. + */ + /* Deciding purely from isAdmin meant an admin editing their *own* personal pick posted scope + * 'global', and SaveAdminPickAsync then republished it to every user and stripped its owner. The + * list's delete path already branched on scope; only save did not. See #631. */ + readonly targetScope = (): 'global' | 'user' => + this.isEdit ? (this.data?.scope === 'global' ? 'global' : 'user') : this.isAdmin ? 'global' : 'user'; + get currentAlarmType(): string { return this.mainForm.controls.alarmType.value ?? 'monster'; } @@ -185,13 +207,38 @@ export class QuickPickAdminDialogComponent implements OnInit { const main = this.mainForm.getRawValue(); const filterForm = this.getFilterForm(main.alarmType ?? 'monster'); - const filters: Record = {}; + // Start from what is stored rather than rebuilding: any key the dialog has no control for used to be + // dropped on save, and quick-pick-apply reads filters['clean'] as its base bitmask while no form + // exposes it. See #654. + // A type change clears the old type's filters -- carrying them across stored minIv and + // pvpRankingLeague on a lure pick (#669) -- but keeps the keys that belong to no type. Clearing + // wholesale threw away `clean`, which quick-pick-apply reads as its base bitmask, so changing a + // pick's type silently reset its auto-delete, edit and summary bits: the preservation #654 added, + // undone by the fix for #669. See #671. + const sameType = this.data?.alarmType === (main.alarmType ?? 'monster'); + const previous = this.data?.filters ?? {}; + const stored: Record = sameType + ? { ...previous } + : Object.fromEntries(Object.entries(previous).filter(([key]) => TYPE_AGNOSTIC_FILTER_KEYS.has(key))); + const filters: Record = stored; if (filterForm) { const raw = filterForm.getRawValue(); Object.entries(raw).forEach(([key, value]) => { - if (value !== 0 && value !== '' && value !== null) { - filters[key] = value; + if (value === null || value === '' || value === undefined) { + delete filters[key]; + return; } + + // A stored 0 is kept, because 0 is a real filter value: the built-in nundo preset is + // { minIv: 0, maxIv: 0 }, and dropping those turned "0% IV only" into "any IV" for everyone who + // applied it afterwards. A 0 that was never stored is still skipped, because most of these + // controls default to 0 meaning "not set" -- writing them out would put an explicit minIv, + // pokemonId and pvpRankingLeague of 0 on every newly created pick. + if (value === 0 && !(key in stored)) { + return; + } + + filters[key] = value; }); } @@ -204,11 +251,11 @@ export class QuickPickAdminDialogComponent implements OnInit { enabled: main.enabled ?? true, filters, icon: main.icon ?? 'bolt', - scope: this.isAdmin ? 'global' : 'user', + scope: this.targetScope(), sortOrder: main.sortOrder ?? 0, }; - const obs = this.isAdmin ? this.quickPickService.saveAdmin(definition) : this.quickPickService.saveUser(definition); + const obs = this.targetScope() === 'global' ? this.quickPickService.saveAdmin(definition) : this.quickPickService.saveUser(definition); obs.subscribe({ error: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.html index 89ba618f..ba2abf61 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.html @@ -54,40 +54,7 @@

{{ 'QUICK_PICKS.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (deliveryForm.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

{ TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ + // delivery-preview links to /areas with routerLink, so a router is required. + provideRouter([]), + provideTranslateService(), provideHttpClient(), provideHttpClientTesting(), { provide: ConfigService, useValue: { apiHost: API } }, { provide: MAT_DIALOG_DATA, useValue: data }, { provide: MatDialogRef, useValue: dialogRef }, ], - imports: [QuickPickApplyDialogComponent, TranslateModule.forRoot()], + imports: [QuickPickApplyDialogComponent], }); const fixture = TestBed.createComponent(QuickPickApplyDialogComponent); @@ -79,10 +83,19 @@ describe('QuickPickApplyDialogComponent', () => { it('should have a delivery form with default values', () => { const form = component.deliveryForm.getRawValue(); expect(form.clean).toBe(false); - expect(form.distanceKm).toBe(0); - expect(form.distanceMode).toBe('areas'); expect(form.template).toBe(''); }); + + it('seeds the scope from the saved alert defaults', () => { + // The radius and mode moved to the shared scope picker, but the Alert Defaults preference still + // has to reach a quick pick the same way it reaches an add dialog. Cleared and re-created here + // because this spec uses the real AlertDefaultsService, which reads localStorage — without the + // clear it inherits whatever another suite left behind. + localStorage.clear(); + setup(basePick); + + expect(component.scope()).toEqual({ mode: 'profile' }); + }); }); describe('reapply (with applied state)', () => { @@ -150,24 +163,4 @@ describe('QuickPickApplyDialogComponent', () => { expect(component.excludedPokemonIds()).toEqual([10, 20, 30]); }); }); - - describe('onDistanceModeChange', () => { - beforeEach(() => { - setup(basePick); - }); - - it('should reset distance to 0 when switching to areas mode', () => { - component.deliveryForm.controls.distanceKm.setValue(5); - component.deliveryForm.controls.distanceMode.setValue('areas'); - component.onDistanceModeChange(); - expect(component.deliveryForm.controls.distanceKm.value).toBe(0); - }); - - it('should set distance to 1 when switching to distance mode with 0', () => { - component.deliveryForm.controls.distanceKm.setValue(0); - component.deliveryForm.controls.distanceMode.setValue('distance'); - component.onDistanceModeChange(); - expect(component.deliveryForm.controls.distanceKm.value).toBe(1); - }); - }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.ts index ae155e2e..34978534 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.ts @@ -11,15 +11,18 @@ import { MatRadioModule } from '@angular/material/radio'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { QuickPickApplyRequest, QuickPickSummary } from '../../core/models'; +import { AlertDefaultsService } from '../../core/services/alert-defaults.service'; import { I18nService } from '../../core/services/i18n.service'; import { MasterDataService } from '../../core/services/masterdata.service'; import { QuickPickService } from '../../core/services/quick-pick.service'; -import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; +import { ScopePickerComponent } from '../../shared/components/scope-picker/scope-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AlarmScope, scopeToFields } from '../../shared/utils/alarm-scope'; +import { AUTO_DELETE, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -35,10 +38,10 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatSnackBarModule, MatTabsModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, PokemonSelectorComponent, TemplateSelectorComponent, - DeliveryPreviewComponent, + ScopePickerComponent, ], selector: 'app-quick-pick-apply-dialog', standalone: true, @@ -46,6 +49,8 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele templateUrl: './quick-pick-apply-dialog.component.html', }) export class QuickPickApplyDialogComponent { + private readonly alertDefaults = inject(AlertDefaultsService); + private readonly fb = inject(FormBuilder); private readonly i18n = inject(I18nService); private readonly masterData = inject(MasterDataService); @@ -58,15 +63,13 @@ export class QuickPickApplyDialogComponent { readonly data = inject(MAT_DIALOG_DATA); deliveryForm = this.fb.group({ clean: [false], - distanceKm: [0], - distanceMode: ['areas' as 'areas' | 'distance'], template: [''], }); readonly dialogRef = inject(MatDialogRef); + readonly excludedPokemonIds = signal(this.data.appliedState?.excludePokemonIds ?? []); readonly excludeEnabled = signal((this.data.appliedState?.excludePokemonIds?.length ?? 0) > 0); - readonly showExclusions = this.data.definition.alarmType === 'monster' && (this.data.definition.filters['pokemonId'] === 0 || @@ -81,6 +84,20 @@ export class QuickPickApplyDialogComponent { readonly isReapply = !!this.data.appliedState; + /** + * Seeded from the saved defaults, then owned by the picker. A quick pick creates real alarms, so it + * asks the same question in the same shape as the add dialogs do. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + /** Whether this apply will create individual rows */ readonly willTrackIndividually = computed(() => this.individualAlarmCount() > 0); @@ -95,11 +112,18 @@ export class QuickPickApplyDialogComponent { } const delivery = this.deliveryForm.getRawValue(); - const distanceMeters = delivery.distanceMode === 'areas' ? 0 : Math.round((delivery.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); + + // clean is a PoracleNG bitmask (bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary). + // The Delivery tab only toggles bit 1, so preserve any other bits the preset definition carries + // instead of clobbering them to 0 on (re-)apply. + const baseClean = typeof this.data.definition.filters['clean'] === 'number' ? (this.data.definition.filters['clean'] as number) : 0; const request: QuickPickApplyRequest = { - clean: delivery.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean: preserve(baseClean, AUTO_DELETE, delivery.clean ? 1 : 0), + distance: scope.distance, excludePokemonIds: this.showExclusions && this.excludeEnabled() ? this.excludedPokemonIds() : [], template: delivery.template || undefined, }; @@ -109,9 +133,11 @@ export class QuickPickApplyDialogComponent { : this.quickPickService.apply(this.data.definition.id, request); obs.subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('QUICK_PICKS.SNACK_FAILED_APPLY'), this.i18n.instant('TOAST.OK'), { - duration: 3000, + // The server says what to do -- remove the pick first, or fix the filter it will not accept. A fixed + // string left the user with a dead end. See #587. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('QUICK_PICKS.SNACK_FAILED_APPLY'), this.i18n.instant('TOAST.OK'), { + duration: 6000, }); this.applying.set(false); this.applyStatus.set(''); @@ -125,14 +151,6 @@ export class QuickPickApplyDialogComponent { }); } - onDistanceModeChange(): void { - if (this.deliveryForm.controls.distanceMode.value === 'areas') { - this.deliveryForm.controls.distanceKm.setValue(0); - } else if (!this.deliveryForm.controls.distanceKm.value) { - this.deliveryForm.controls.distanceKm.setValue(1); - } - } - onExcludedPokemonChange(ids: number[]): void { this.excludedPokemonIds.set(ids); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.html index b6d2340b..0a5c9ba1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.html @@ -95,7 +95,13 @@

{{ 'QUICK_PICKS.REMOVING' | translate }} } @else if (pick.appliedState) { - diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.spec.ts index c7a48950..e8a9c81e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.spec.ts @@ -1,13 +1,16 @@ import { provideHttpClient } from '@angular/common/http'; import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { TranslateModule } from '@ngx-translate/core'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; import { QuickPickListComponent } from './quick-pick-list.component'; import { QuickPickSummary } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; import { ConfigService } from '../../core/services/config.service'; import { QuickPickService } from '../../core/services/quick-pick.service'; +import { SettingsService } from '../../core/services/settings.service'; describe('QuickPickListComponent', () => { let component: QuickPickListComponent; @@ -17,6 +20,7 @@ describe('QuickPickListComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ providers: [ + provideTranslateService(), provideHttpClient(), provideHttpClientTesting(), { provide: ConfigService, useValue: { apiHost: API } }, @@ -29,7 +33,7 @@ describe('QuickPickListComponent', () => { }, QuickPickService, ], - imports: [QuickPickListComponent, TranslateModule.forRoot()], + imports: [QuickPickListComponent], }); const fixture = TestBed.createComponent(QuickPickListComponent); @@ -194,4 +198,57 @@ describe('QuickPickListComponent', () => { component.selectCategory('PvP'); expect(component.selectedCategory()).toBe('PvP'); }); + + describe('auto-seeding the built-in presets', () => { + // The guard exists so an admin who deletes the presets on purpose keeps an empty list (#634). It + // moved from a localStorage flag to a site setting (#662), and the local signal is only refreshed + // at app init -- so without updating it after a seed, deleting the last pick in the same session + // restored all thirty. See #666. + function setupAdmin(siteSettings: Record, picks: QuickPickSummary[]) { + const seed = jest.fn(() => of(undefined)); + const settings = { siteSettings: signal(siteSettings) }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: ConfigService, useValue: { apiHost: API } }, + { provide: AuthService, useValue: { currentUser: () => null, isAdmin: () => true } }, + { provide: SettingsService, useValue: settings }, + { provide: QuickPickService, useValue: { getAll: jest.fn(() => of(picks)), seed } }, + ], + imports: [QuickPickListComponent], + }); + + const fixture = TestBed.createComponent(QuickPickListComponent); + return { component: fixture.componentInstance, seed, settings }; + } + + it('seeds when the list is empty and the installation has never been seeded', () => { + const { component: sut, seed } = setupAdmin({}, []); + + sut.loadPicks(); + + expect(seed).toHaveBeenCalled(); + }); + + it('does not seed again after seeding once in the same session', () => { + const { component: sut, seed } = setupAdmin({}, []); + + sut.loadPicks(); + sut.loadPicks(); + + expect(seed).toHaveBeenCalledTimes(1); + }); + + it('does not seed an installation that has already been seeded', () => { + const { component: sut, seed } = setupAdmin({ quick_picks_seeded: 'true' }, []); + + sut.loadPicks(); + + expect(seed).not.toHaveBeenCalled(); + }); + }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.ts index 4e7f280c..4fb6af26 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-list.component.ts @@ -7,14 +7,18 @@ import { MatMenuModule } from '@angular/material/menu'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { QuickPickSummary } from '../../core/models'; import { AuthService } from '../../core/services/auth.service'; import { I18nService } from '../../core/services/i18n.service'; import { QuickPickService } from '../../core/services/quick-pick.service'; +import { SettingsService } from '../../core/services/settings.service'; import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component'; +/** Site setting marking that the built-in presets have been seeded once. See #634, #662. */ +const SEEDED_KEY = 'quick_picks_seeded'; + @Component({ imports: [ MatCardModule, @@ -25,7 +29,7 @@ import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/c MatProgressSpinnerModule, MatSnackBarModule, MatTooltipModule, - TranslateModule, + TranslatePipe, ], selector: 'app-quick-pick-list', standalone: true, @@ -37,6 +41,7 @@ export class QuickPickListComponent implements OnInit { private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); private readonly quickPickService = inject(QuickPickService); + private readonly settingsService = inject(SettingsService); private readonly snackBar = inject(MatSnackBar); readonly alarmTypeColors: Record = { @@ -94,11 +99,23 @@ export class QuickPickListComponent implements OnInit { this.loading.set(false); }, next: picks => { - if (picks.length === 0 && autoSeed && this.isAdmin()) { - // First visit with no picks — seed defaults and reload + if (picks.length === 0 && autoSeed && this.isAdmin() && this.settingsService.siteSettings()[SEEDED_KEY] !== 'true') { + // First visit with no picks — seed defaults and reload. Guarded because this ran on *every* + // empty read: an admin who deleted the presets on purpose got all thirty back on their next + // visit (#634). The marker is a site setting, not localStorage: whether an installation has + // been seeded is a property of the installation, and a per-browser flag meant a second admin + // reseeded anyway, while a failed seed latched the flag and never retried. See #662. this.quickPickService.seed().subscribe({ error: () => this.loading.set(false), - next: () => this.loadPicks(false), + next: () => { + // The marker is written server-side by the seed endpoint, atomically with the seed it + // describes (#662). The local signal is only refreshed at app init, so without this the + // key stayed absent for the rest of the session: deleting the last pick called loadPicks + // with autoSeed, saw an empty list and an absent marker, and restored all thirty. That is + // #634 again, minus the synchronous localStorage write that used to mask it. See #666. + this.settingsService.siteSettings.update(current => ({ ...current, [SEEDED_KEY]: 'true' })); + this.loadPicks(false); + }, }); return; } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html index a8bf2586..df79f71f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html @@ -34,27 +34,17 @@

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.GYM_PICKER_HINT' | translate }}

+ +

{{ 'RAIDS.RAID_LEVELS' | translate }}

-
- @for (level of levels; track level) { - - {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ level }} - - } -
+

{{ 'RAIDS.EGG_LEVELS' | translate }}

-
- @for (level of levels; track level) { - - {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ level }} - - } -
+
@@ -65,16 +55,6 @@

{{ 'RAIDS.EGG_LEVELS' | translate }}

@if (selectedPokemonIds().length > 0) {

{{ 'RAIDS.POKEMON_SELECTED' | translate: { count: selectedPokemonIds().length } }}

} - - - {{ 'RAIDS.RAID_LEVEL_LABEL' | translate }} - - {{ 'ALARM.ANY_LEVEL' | translate }} - @for (level of levels; track level) { - {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ level }} - } - -

@@ -88,48 +68,9 @@

{{ 'RAIDS.EGG_LEVELS' | translate }}

{{ 'RAIDS.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (commonForm.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } ); readonly isWebhook = inject(AuthService).isImpersonating(); - levels = [1, 2, 3, 4, 5, 6]; + saving = signal(false); + /** + * Seeded from the saved defaults so the Alert Defaults preference still reaches new alarms; the + * picker owns it from there. + */ + readonly scope = signal( + this.alertDefaults.defaultMode() === 'areas' + ? { mode: 'profile' } + : { + distanceKm: this.alertDefaults.defaultDistanceKm(), + mode: this.alertDefaults.defaultPlaceLabel() ? 'place' : 'profile', + placeLabel: this.alertDefaults.defaultPlaceLabel(), + }, + ); + selectedEggLevels = signal([]); selectedGymId = signal(null); - selectedPokemonIds = signal([]); + selectedPokemonIds = signal([]); selectedRaidLevels = signal([]); tabIndex = 0; @@ -90,15 +107,7 @@ export class RaidAddDialogComponent { return this.selectedPokemonIds().length > 0; } - onDistanceModeChange(): void { - if (this.commonForm.controls.distanceMode.value === 'areas') { - this.commonForm.controls.distanceKm.setValue(0); - } else { - if (!this.commonForm.controls.distanceKm.value) { - this.commonForm.controls.distanceKm.setValue(1); - } - } - } + /** Boss tab is single-select; the selector emits an array of length 0 or 1. */ onPokemonSelected(ids: number[]): void { this.selectedPokemonIds.set(ids); @@ -108,25 +117,32 @@ export class RaidAddDialogComponent { if (!this.canSave()) return; this.saving.set(true); const common = this.commonForm.getRawValue(); - const distanceMeters = common.distanceMode === 'areas' ? 0 : Math.round((common.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + // RSVP modes (1/2) need the edit bit so count changes edit the alert instead of re-sending. + // New alarms have no prior bits, so there is nothing to preserve here. + const clean = (common.clean ? AUTO_DELETE : 0) | ((common.rsvpChanges ?? 0) >= 1 ? EDIT : 0); - const creates: ReturnType[] = []; + // A union of the two return types confuses .pipe(); both are Observable of an alarm, and the batch + // only needs to know whether each one landed. See #577. + const creates: Observable[] = []; if (this.tabIndex === 0) { // By Level for (const level of this.selectedRaidLevels()) { const raid: RaidCreate = { - clean: common.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean, + distance: scope.distance, evolution: 9000, exclusive: 0, form: 0, - gymId: this.selectedGymId() || null, + gymId: this.selectedGymId() ?? '', level, move: 9000, - ping: common.ping || null, pokemonId: 9000, - rsvpChanges: 0, + rsvpChanges: common.rsvpChanges ?? 0, team: common.team ?? 4, template: common.template || null, }; @@ -134,34 +150,37 @@ export class RaidAddDialogComponent { } for (const level of this.selectedEggLevels()) { const egg: EggCreate = { - clean: common.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean, + distance: scope.distance, exclusive: 0, - gymId: this.selectedGymId() || null, + gymId: this.selectedGymId() ?? '', level, - ping: common.ping || null, - rsvpChanges: 0, + rsvpChanges: common.rsvpChanges ?? 0, team: common.team ?? 4, template: common.template || null, }; creates.push(this.eggService.create(egg)); } } else { - // By Boss - const bossLevel = this.bossForm.controls.level.value ?? 0; + // By Boss. The level is always the "any" sentinel, never a chosen one: trackingRaid.go rewrites + // level to 9000 for every alarm carrying a specific pokemon_id, so the tab used to show a level + // picker whose value could not survive the request. See #615. for (const pokemonId of this.selectedPokemonIds()) { const raid: RaidCreate = { - clean: common.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean, + distance: scope.distance, evolution: 9000, exclusive: 0, form: 0, - gymId: this.selectedGymId() || null, - level: bossLevel, + gymId: this.selectedGymId() ?? '', + level: ANY_LEVEL_VALUE, move: 9000, - ping: common.ping || null, pokemonId, - rsvpChanges: 0, + rsvpChanges: common.rsvpChanges ?? 0, team: common.team ?? 4, template: common.template || null, }; @@ -169,25 +188,40 @@ export class RaidAddDialogComponent { } } - forkJoin(creates).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('RAIDS.SNACK_FAILED_CREATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // forkJoin fails fast, so one refused alarm aborted the whole batch: the creates that had already + // succeeded were never reported, the dialog stayed open and the list never reloaded. Each request + // settles on its own now, and the toast says how many landed. See #577. + forkJoin(creates.map(c => c.pipe(catchError((err: { error?: { error?: string } }) => of({ failed: err }))))).subscribe({ + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + // Each create settles on its own, so a refused one no longer hides the ones that landed. + // The first refusal's message is shown, because it names what is in the way. See #577. + next: (results: ({ uid?: number } | { failed: { error?: { error?: string } } })[]) => { + const refused = results.filter((r): r is { failed: { error?: { error?: string } } } => 'failed' in r); + // Three outcomes, not two: refused (409), already tracked (200 with no uid), and created. The + // pokemon dialog has split these since #495; the rest reported duplicates as creations. See #605. + const landed = results.filter((r): r is { uid?: number } => !('failed' in r)); + const created = landed.filter(r => (r.uid ?? 0) > 0).length; + const duplicates = landed.length - created; this.saving.set(false); - }, - next: () => { - this.snackBar.open(this.i18n.instant('RAIDS.SNACK_CREATED_COUNT', { count: creates.length }), this.i18n.instant('TOAST.OK'), { - duration: 3000, - }); + + if (refused.length > 0) { + this.snackBar.open( + refused[0].failed?.error?.error ?? this.i18n.instant('RAIDS.SNACK_FAILED_CREATE'), + this.i18n.instant('COMMON.OK'), + { duration: 6000 }, + ); + } else { + const message = + duplicates > 0 + ? this.i18n.instant('ALARM.SNACK_CREATED_WITH_DUPLICATES', { count: created, duplicates }) + : this.i18n.instant('RAIDS.SNACK_CREATED_COUNT', { count: created }); + this.snackBar.open(message, this.i18n.instant('COMMON.OK'), { duration: 4000 }); + } + + // Close either way: whatever was created is real, and the list must reload to show it. this.dialogRef.close(true); }, }); } - - toggleEggLevel(level: number): void { - this.selectedEggLevels.update(levels => (levels.includes(level) ? levels.filter(l => l !== level) : [...levels, level])); - } - - toggleRaidLevel(level: number): void { - this.selectedRaidLevels.update(levels => (levels.includes(level) ? levels.filter(l => l !== level) : [...levels, level])); - } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html index 917794d9..410de9cd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html @@ -4,7 +4,7 @@

{{ (data.type === 'raid' ? 'RAIDS.EDIT_RAID_TITLE' : 'RAIDS

{{ getTitle() }}

- {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ data.item.level }} + {{ data.item.level | levelLabel }}

@@ -40,6 +40,8 @@

{{ getTitle() }}

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.GYM_PICKER_HINT' | translate }}

+ +
@@ -50,48 +52,9 @@

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.TAB_DELIVERY' | translate }}
-

{{ 'ALARM.LOCATION_MODE' | translate }}

- - -
- map -
- {{ 'ALARM.USE_AREAS' | translate }} -

{{ 'ALARM.USE_AREAS_HINT' | translate }}

-
-
-
- -
- straighten -
- {{ 'ALARM.SET_DISTANCE' | translate }} -

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

-
-
-
-
- - @if (form.controls.distanceMode.value === 'distance') { - - {{ 'ALARM.DISTANCE_LABEL' | translate }} - - {{ 'ALARM.DISTANCE_SUFFIX' | translate }} - - } - - - +

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

- @if (isWebhook) { - - {{ 'ALARM.PING_ROLE' | translate }} - - - } (MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); + form = this.fb.group({ - clean: [this.data.item.clean === 1], - distanceKm: [this.data.item.distance > 0 ? this.data.item.distance / 1000 : 1], - distanceMode: [this.data.item.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], - ping: [this.data.item.ping ?? ''], + clean: [isAutoDelete(this.data.item.clean)], + rsvpChanges: [this.data.item.rsvpChanges], team: [this.data.item.team], template: [this.data.item.template ?? ''], }); @@ -71,6 +78,9 @@ export class RaidEditDialogComponent { readonly isWebhook = inject(AuthService).isImpersonating(); saving = signal(false); + + /** The alarm's current scope, read back into the shared picker. */ + readonly scope = signal(scopeOf(this.data.item.overrideLocationLabel, this.data.item.overrideAreas, this.data.item.distance)); selectedGymId = signal(this.data.item.gymId); getImage(): string { @@ -86,23 +96,13 @@ export class RaidEditDialogComponent { getTitle(): string { if (this.data.type === 'egg') { - return this.i18n.instant('RAIDS.LEVEL_PREFIX') + ' ' + this.data.item.level + ' ' + this.i18n.instant('RAIDS.EGG_SUFFIX'); + return this.levelLabelPipe.transform(this.data.item.level) + ' ' + this.i18n.instant('RAIDS.EGG_SUFFIX'); } const raid = this.data.item as Raid; if (raid.pokemonId && raid.pokemonId !== 9000) { return this.i18n.instant('RAIDS.RAID_BOSS_NUM', { id: raid.pokemonId }); } - return this.i18n.instant('RAIDS.LEVEL_PREFIX') + ' ' + raid.level + ' ' + this.i18n.instant('RAIDS.RAID_SUFFIX'); - } - - onDistanceModeChange(): void { - if (this.form.controls.distanceMode.value === 'areas') { - this.form.controls.distanceKm.setValue(0); - } else { - if (!this.form.controls.distanceKm.value) { - this.form.controls.distanceKm.setValue(1); - } - } + return this.levelLabelPipe.transform(raid.level) + ' ' + this.i18n.instant('RAIDS.RAID_SUFFIX'); } onImageError(event: Event): void { @@ -112,28 +112,38 @@ export class RaidEditDialogComponent { save(): void { this.saving.set(true); const values = this.form.getRawValue(); - const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); + const scope = scopeToFields(this.scope()); + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + // RSVP modes (1/2) need the edit bit so count changes edit the alert instead of re-sending. + // Preserve any other bits (e.g. bot-set summary) the web UI does not surface. + const clean = + (values.clean ? AUTO_DELETE : 0) | ((values.rsvpChanges ?? 0) >= 1 ? EDIT : 0) | (this.data.item.clean & ~(AUTO_DELETE | EDIT)); if (this.data.type === 'raid') { const raid = this.data.item as Raid; const update: RaidUpdate = { - clean: values.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean, + distance: scope.distance, evolution: raid.evolution, exclusive: raid.exclusive, form: raid.form, - gymId: this.selectedGymId() || null, + gymId: this.selectedGymId() ?? '', level: raid.level, move: raid.move, - ping: values.ping || null, pokemonId: raid.pokemonId, - rsvpChanges: raid.rsvpChanges, + rsvpChanges: values.rsvpChanges ?? 0, team: values.team ?? 4, - template: values.template || null, + template: values.template || '', }; this.raidService.update(this.data.item.uid, update).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('RAIDS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('RAIDS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { @@ -144,19 +154,24 @@ export class RaidEditDialogComponent { } else { const egg = this.data.item as Egg; const update: EggUpdate = { - clean: values.clean ? 1 : 0, - distance: distanceMeters, + overrideAreas: scope.overrideAreas, + overrideLocationLabel: scope.overrideLocationLabel, + clean, + distance: scope.distance, exclusive: egg.exclusive, - gymId: this.selectedGymId() || null, + gymId: this.selectedGymId() ?? '', level: egg.level, - ping: values.ping || null, - rsvpChanges: egg.rsvpChanges, + rsvpChanges: values.rsvpChanges ?? 0, team: values.team ?? 4, - template: values.template || null, + template: values.template || '', }; this.eggService.update(this.data.item.uid, update).subscribe({ - error: () => { - this.snackBar.open(this.i18n.instant('RAIDS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + // The server names what is wrong -- which alarm already uses these settings, which + // field a file got wrong. A fixed string threw that away. See #567, #568. + error: (err: { error?: { error?: string } }) => { + this.snackBar.open(err?.error?.error ?? this.i18n.instant('RAIDS.SNACK_FAILED_UPDATE'), this.i18n.instant('TOAST.OK'), { + duration: 6000, + }); this.saving.set(false); }, next: () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html index 6975b692..9e0f44fc 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html @@ -39,6 +39,7 @@

{{ 'RAIDS.PAGE_TITLE' | translate }}

+ @@ -66,7 +67,7 @@

{{ 'RAIDS.PAGE_TITLE' | translate }}

}
} @else { - + @@ -75,11 +76,11 @@

{{ 'RAIDS.PAGE_TITLE' | translate }}

@for (raid of raids(); track raid.uid) { - + @if (selectMode()) { @@ -102,9 +103,10 @@

{{ getRaidTitle(raid) }}

}
- @if (raid.clean === 1) { + @if (isAutoDelete(raid.clean)) { {{ 'RAIDS.CLEAN_TAG' | translate }} } +
@@ -116,7 +118,15 @@

{{ getRaidTitle(raid) }}

>
- + + + @@ -176,11 +188,11 @@

{{ 'RAIDS.EMPTY_RAIDS_TITLE' | translate }}

@for (egg of eggs(); track egg.uid) { - + @if (selectMode()) { @@ -188,7 +200,7 @@

{{ 'RAIDS.EMPTY_RAIDS_TITLE' | translate }}

@@ -207,9 +219,10 @@

{{ getRaidLevelName(egg.level) }} {{ 'RAIDS.EGG_SUFFIX' | translate }}

}
- @if (egg.clean === 1) { + @if (isAutoDelete(egg.clean)) { {{ 'RAIDS.CLEAN_TAG' | translate }} } +
@@ -219,7 +232,15 @@

{{ getRaidLevelName(egg.level) }} {{ 'RAIDS.EGG_SUFFIX' | translate }}

{{ getTeamName(egg.team) }}
- + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts index 76ee2933..4f371000 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts @@ -8,22 +8,29 @@ import { MatIconModule } from '@angular/material/icon'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTabsModule } from '@angular/material/tabs'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom, forkJoin } from 'rxjs'; import { RaidAddDialogComponent } from './raid-add-dialog.component'; import { RaidEditDialogComponent, RaidEditDialogData } from './raid-edit-dialog.component'; import { Raid, Egg } from '../../core/models'; +import { resolveLevel } from '../../core/models/raid-level.models'; +import { AreaService } from '../../core/services/area.service'; import { EggService } from '../../core/services/egg.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; +import { RaidLevelService } from '../../core/services/raid-level.service'; import { RaidService } from '../../core/services/raid.service'; import { ScannerService } from '../../core/services/scanner.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { AlarmInfoComponent } from '../../shared/components/alarm-info/alarm-info.component'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { RsvpPillComponent } from '../../shared/components/rsvp-pill/rsvp-pill.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -36,8 +43,10 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTooltipModule, MatSnackBarModule, MatTabsModule, - TranslateModule, + TranslatePipe, AlarmInfoComponent, + RsvpPillComponent, + LevelLabelPipe, ], selector: 'app-raid-list', standalone: true, @@ -45,23 +54,37 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog templateUrl: './raid-list.component.html', }) export class RaidListComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly destroyRef = inject(DestroyRef); + private readonly dialog = inject(MatDialog); private readonly eggService = inject(EggService); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); private readonly masterData = inject(MasterDataService); + private readonly raidLevelService = inject(RaidLevelService); private readonly raidService = inject(RaidService); private readonly scannerService = inject(ScannerService); private readonly snackBar = inject(MatSnackBar); - readonly eggs = signal([]); + /** Which tab is in front: 0 raids, 1 eggs. Bulk actions are scoped to it. See #642. */ + readonly activeTab = signal(0); + readonly eggs = signal([]); readonly gymNames = signal>({}); readonly loading = signal(true); + /** Only used to word the inherited scope honestly; empty produces the more cautious wording. */ + readonly profileAreas = signal([]); + readonly raids = signal([]); - readonly selectedIds = signal(new Set()); + // Keyed "raid:12" / "egg:12", not by the bare uid. Raid and egg uids come from separate + // auto-increment sequences and do collide; with one set of integers covering both grids, ticking + // one card ticked the other, and the bulk actions sent both to the raid endpoint -- deleting or + // resizing the raid twice and leaving the egg alone. See #540. + readonly selectedIds = signal(new Set()); readonly selectMode = signal(false); readonly skeletonCards = Array.from({ length: 6 }); + readonly testAlertService = inject(TestAlertService); async bulkDelete(): Promise { @@ -75,19 +98,28 @@ export class RaidListComponent implements OnInit { }); const result = await firstValueFrom(ref.afterClosed()); if (result) { - const ids = [...this.selectedIds()]; - const raidUids = new Set(this.raids().map(r => r.uid)); - for (const uid of ids) { - if (raidUids.has(uid)) { + const keys = [...this.selectedIds()]; + let deleted = 0; + for (const uid of this.uidsOfKind(keys, 'raid')) { + try { await firstValueFrom(this.raidService.delete(uid)); - } else { + deleted++; + } catch { + // Already gone. See #603. + } + } + for (const uid of this.uidsOfKind(keys, 'egg')) { + try { await firstValueFrom(this.eggService.delete(uid)); + deleted++; + } catch { + // Already gone. See #603. } } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadData(); - this.snackBar.open(this.i18n.instant('RAIDS.SNACK_BULK_DELETED', { count: ids.length }), this.i18n.instant('TOAST.OK'), { + this.snackBar.open(this.i18n.instant('RAIDS.SNACK_BULK_DELETED', { count: deleted }), this.i18n.instant('TOAST.OK'), { duration: 3000, }); } @@ -97,16 +129,26 @@ export class RaidListComponent implements OnInit { const ref = this.dialog.open(DistanceDialogComponent, { width: '440px' }); const distance = await firstValueFrom(ref.afterClosed()); if (distance !== null && distance !== undefined) { - const ids = [...this.selectedIds()]; - const raidUids = this.raids().map(r => r.uid); - const selectedRaidUids = ids.filter(id => raidUids.includes(id)); - const selectedEggUids = ids.filter(id => !raidUids.includes(id)); - if (selectedRaidUids.length > 0) await firstValueFrom(this.raidService.updateBulkDistance(selectedRaidUids, distance)); - if (selectedEggUids.length > 0) await firstValueFrom(this.eggService.updateBulkDistance(selectedEggUids, distance)); + const keys = [...this.selectedIds()]; + const selectedRaidUids = this.uidsOfKind(keys, 'raid'); + const selectedEggUids = this.uidsOfKind(keys, 'egg'); + // The server refuses a radius that would take over an alarm the user did not select, and names + // the one in the way. Unguarded, that rejection cleared nothing, reloaded nothing and showed + // nothing -- indistinguishable from a successful no-op. See #641. + try { + if (selectedRaidUids.length > 0) await firstValueFrom(this.raidService.updateBulkDistance(selectedRaidUids, distance)); + if (selectedEggUids.length > 0) await firstValueFrom(this.eggService.updateBulkDistance(selectedEggUids, distance)); + } catch (err) { + const message = (err as { error?: { error?: string } })?.error?.error; + this.snackBar.open(message ?? this.i18n.instant('RAIDS.SNACK_FAILED_DISTANCE'), this.i18n.instant('TOAST.OK'), { + duration: 5000, + }); + return; + } this.selectedIds.set(new Set()); this.selectMode.set(false); this.loadData(); - this.snackBar.open(this.i18n.instant('RAIDS.SNACK_BULK_DISTANCE', { count: ids.length }), this.i18n.instant('TOAST.OK'), { + this.snackBar.open(this.i18n.instant('RAIDS.SNACK_BULK_DISTANCE', { count: keys.length }), this.i18n.instant('TOAST.OK'), { duration: 3000, }); } @@ -210,6 +252,33 @@ export class RaidListComponent implements OnInit { }); } + /** + * Change a raid's delivery scope from its card. Eggs share this list and the same sheet, so the + * tracking type is passed rather than duplicating the method. + */ + editScope(item: Egg | Raid, type: 'egg' | 'raid'): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(item.overrideLocationLabel, item.overrideAreas, item.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + + const service = type === 'egg' ? this.eggService : this.raidService; + service.update(item.uid, scopeToFields(scope)).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadData(); + }, + }); + }); + } + formatDistance(meters: number): string { if (meters >= 1000) { return `${(meters / 1000).toFixed(1)} km`; @@ -246,7 +315,12 @@ export class RaidListComponent implements OnInit { } getLevelStars(level: number): number[] { - if (level === 9000 || level > 100) return []; + // Stars are only meaningful for the literal "N Star Raid" tier (levels 1-5 + // per the WatWowMap masterfile). Levels 6+ (Mega, Mega Legendary, Ultra + // Beast, Elite, Primal, Shadow, Super Mega, Coordinated, customs) carry a + // semantic name that the title already conveys — a stars row would be + // misleading (e.g. "Elite Raid" is not a 9-star tier). + if (level < 1 || level > 5) return []; return Array.from({ length: level }, (_, i) => i); } @@ -258,14 +332,16 @@ export class RaidListComponent implements OnInit { } getRaidLevelName(level: number): string { - switch (level) { - case 6: - return this.i18n.instant('RAIDS.LEVEL_MEGA'); - case 9000: - return this.i18n.instant('ALARM.ANY_LEVEL'); - default: - return this.i18n.instant('RAIDS.LEVEL_PREFIX') + ' ' + level; + // Prefer the live raid-level list — when the API extends the canonical + // set (e.g. raid_20 ships in the masterfile), cards stay in sync with the + // selector dialog. Falls back to the baked-in resolveLevel + custom shape + // when the API hasn't loaded yet or the level is genuinely unknown. + const liveOpt = this.raidLevelService.byValue().get(level); + const opt = liveOpt ?? resolveLevel(level); + if (opt.category === 'custom') { + return this.i18n.instant(opt.labelKey) + ' ' + opt.value; } + return this.i18n.instant(opt.labelKey); } getRaidTitle(raid: Raid): string { @@ -303,6 +379,11 @@ export class RaidListComponent implements OnInit { } } + /** True when the auto-delete bit (clean bit 1) is set, ignoring the edit-in-place / summary bits. */ + isAutoDelete(clean: number): boolean { + return (clean & 1) !== 0; + } + loadData(): void { this.loading.set(true); forkJoin([this.raidService.getAll(), this.eggService.getAll()]) @@ -321,7 +402,9 @@ export class RaidListComponent implements OnInit { } ngOnInit(): void { + this.loadProfileAreas(); this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); + this.raidLevelService.load(); this.loadData(); } @@ -340,22 +423,32 @@ export class RaidListComponent implements OnInit { } selectAll(): void { - const ids = new Set(); - this.raids().forEach(r => ids.add(r.uid)); - this.eggs().forEach(e => ids.add(e.uid)); - this.selectedIds.set(ids); + // The visible tab only. Selecting both meant a user on the Raids tab pressed Select All, saw a + // count that included eggs they could not see, and Delete took those too -- reported as a plain + // "deleted N alarms". Every other list scopes this to what is rendered. See #642. + const keys = new Set(); + if (this.activeTab() === 0) { + this.raids().forEach(r => keys.add(this.selectionKey('raid', r.uid))); + } else { + this.eggs().forEach(e => keys.add(this.selectionKey('egg', e.uid))); + } + this.selectedIds.set(keys); + } + + selectionKey(kind: 'raid' | 'egg', uid: number): string { + return `${kind}:${uid}`; } sendTestAlert(type: string, alarm: { uid: number }): void { this.testAlertService.sendTestAlert(type, alarm.uid); } - toggleSelect(uid: number): void { + toggleSelect(key: string): void { const current = new Set(this.selectedIds()); - if (current.has(uid)) { - current.delete(uid); + if (current.has(key)) { + current.delete(key); } else { - current.add(uid); + current.add(key); } this.selectedIds.set(current); } @@ -384,6 +477,10 @@ export class RaidListComponent implements OnInit { }); } + private loadProfileAreas(): void { + this.areaService.getSelected().subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); + } + private resolveGymNames(items: (Raid | Egg)[]): void { const ids = [...new Set(items.filter(i => i.gymId).map(i => i.gymId!))]; if (ids.length === 0) return; @@ -396,4 +493,8 @@ export class RaidListComponent implements OnInit { this.gymNames.set(names); }); } + + private uidsOfKind(keys: string[], kind: 'raid' | 'egg'): number[] { + return keys.filter(k => k.startsWith(`${kind}:`)).map(k => Number(k.slice(kind.length + 1))); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts index f2d1a763..18d7c1ed 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts @@ -1,13 +1,13 @@ import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ActiveHourEntry, compressDayRange, formatTime12h, groupActiveHours } from '../../../core/models/active-hours.models'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatIconModule, MatTooltipModule, TranslateModule], + imports: [MatIconModule, MatTooltipModule, TranslatePipe], selector: 'app-active-hours-chip', standalone: true, styleUrl: './active-hours-chip.component.scss', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html index 228fee37..c57fe676 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html @@ -62,7 +62,7 @@

@if (groups().length === 0) { -
{{ 'PROFILES.ACTIVE_HOURS_NO_RULES' | translate }}
+
{{ data.emptyStateKey ?? 'PROFILES.ACTIVE_HOURS_NO_RULES' | translate }}
} @else {
@for (group of groups(); track formatGroupLabel(group)) { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.ts index 8711a18b..4ba13d65 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.ts @@ -6,7 +6,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { ActiveHourEntry, @@ -19,6 +19,12 @@ import { export interface ActiveHoursEditorData { activeHours: ActiveHourEntry[]; + /** + * Translation key for the line shown when there are no rules. The editor serves two contexts that mean + * different things by a schedule: profile rules drive PoracleNG's profile scheduler, quest rules drive + * summary delivery. The default keeps the profile wording, so profile callers need no change. See #457. + */ + emptyStateKey?: string; profileColor?: string; profileName: string; } @@ -33,7 +39,7 @@ export interface ActiveHoursEditorData { MatIconModule, MatSelectModule, MatTooltipModule, - TranslateModule, + TranslatePipe, ], selector: 'app-active-hours-editor-dialog', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.html index 480f7685..8bdda264 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.html @@ -1,12 +1,8 @@
- - {{ distance() === 0 ? 'map' : 'straighten' }} - {{ distance() | distanceDisplay }} - - @if (ping()) { - - notifications - {{ ping() }} - - } +
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.ts index 06b515aa..a0438e12 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alarm-info/alarm-info.component.ts @@ -2,12 +2,18 @@ import { Component, input } from '@angular/core'; import { MatChipsModule } from '@angular/material/chips'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; -import { DistanceDisplayPipe } from '../../pipes/distance-display.pipe'; +import { WhereChipComponent } from '../where-chip/where-chip.component'; +/** + * The one-line summary under an alarm card: where it reaches you. + * + * The distance display it used to hold could only say "areas" or a radius, which stopped being the + * whole truth when alarms gained a per-alarm scope. Delegating to the Where chip means raids, eggs, + * quests and max battles all describe themselves correctly without four copies of the logic. + */ @Component({ - imports: [MatChipsModule, MatIconModule, MatTooltipModule, DistanceDisplayPipe, TranslateModule], + imports: [MatChipsModule, MatIconModule, MatTooltipModule, WhereChipComponent], selector: 'app-alarm-info', standalone: true, styleUrl: './alarm-info.component.scss', @@ -16,6 +22,13 @@ import { DistanceDisplayPipe } from '../../pipes/distance-display.pipe'; export class AlarmInfoComponent { clean = input(0); distance = input(0); - ping = input(null); + + /** True where the host card wires a click that opens the scope sheet. */ + editable = input(false); + overrideAreas = input(null); + overrideLocationLabel = input(null); + + /** The profile's own areas, used only to word the inherited case honestly. */ + profileAreas = input([]); template = input(null); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.html new file mode 100644 index 00000000..d5d9c196 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.html @@ -0,0 +1,63 @@ +

+ tune + {{ 'ALERT_DEFAULTS.TITLE' | translate }} +

+ +

{{ 'ALERT_DEFAULTS.DESC' | translate }}

+ + + +
+ map +
+ {{ 'ALARM.USE_AREAS' | translate }} +

{{ 'ALARM.USE_AREAS_HINT' | translate }}

+
+
+
+ +
+ straighten +
+ {{ 'ALARM.SET_DISTANCE' | translate }} +

{{ 'ALARM.SET_DISTANCE_HINT' | translate }}

+
+
+
+
+ + @if (mode() === 'distance') { + + {{ 'ALERT_DEFAULTS.DEFAULT_DISTANCE' | translate }} + + {{ 'ALARM.DISTANCE_SUFFIX' | translate }} + @if (distanceError) { + {{ distanceError | translate }} + } @else { + {{ 'ALERT_DEFAULTS.DEFAULT_DISTANCE_HINT' | translate }} + } + + + + {{ 'WHERE.MEASURED_FROM' | translate }} + + {{ 'WHERE.MY_PIN' | translate }} + @for (place of places.named(); track place.label) { + {{ place.label }} + } + + + } + + + +

+ info + {{ 'ALERT_DEFAULTS.FOOTNOTE' | translate }} +

+
+ + + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.scss new file mode 100644 index 00000000..2aa9d6bc --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.scss @@ -0,0 +1,89 @@ +h2[mat-dialog-title] { + display: flex; + align-items: center; + gap: 8px; +} + +.title-icon { + color: var(--accent-primary, var(--mat-sys-primary, #1976d2)); +} + +mat-dialog-content { + min-width: 340px; + max-width: 460px; +} + +.dialog-desc { + margin: 0 0 16px; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + font-size: 14px; +} + +.mode-group { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 16px; +} + +/* Render each radio as a selectable card so the active default reads at a glance. */ +.mode-option { + padding: 10px 12px; + border: 1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, 0.12)); + border-radius: 12px; + transition: + border-color 0.15s ease, + background-color 0.15s ease; +} + +.mode-option.selected { + border-color: var(--accent-primary, var(--mat-sys-primary, #1976d2)); + background: var(--accent-light, rgba(25, 118, 210, 0.08)); +} + +.radio-label { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.radio-label mat-icon { + margin-top: 2px; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); +} + +.mode-option.selected .radio-label mat-icon { + color: var(--accent-primary, var(--mat-sys-primary, #1976d2)); +} + +.radio-hint { + margin: 2px 0 0; + font-size: 12px; + font-weight: normal; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); +} + +.full-width { + width: 100%; +} + +.dialog-footnote { + display: flex; + align-items: center; + gap: 6px; + margin: 12px 0 0; + color: var(--text-secondary, rgba(0, 0, 0, 0.6)); + font-size: 12px; +} + +.dialog-footnote mat-icon { + width: 16px; + height: 16px; + font-size: 16px; +} + +// Rides in the hint slot because only renders when the control reports an +// error state, which a bare ngModel never does. See #426 / #427. +.distance-error { + color: var(--mat-sys-error, #b3261e); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.spec.ts new file mode 100644 index 00000000..4389de43 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.spec.ts @@ -0,0 +1,99 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatDialogRef } from '@angular/material/dialog'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { AlertDefaultsDialogComponent } from './alert-defaults-dialog.component'; +import { ConfigService } from '../../../core/services/config.service'; + +describe('AlertDefaultsDialogComponent', () => { + let dialogRef: { close: jest.Mock }; + + function create(): AlertDefaultsDialogComponent { + dialogRef = { close: jest.fn() }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: MatDialogRef, useValue: dialogRef }, + { provide: ConfigService, useValue: { apiHost: 'http://test' } }, + provideHttpClient(), + provideHttpClientTesting(), + ], + imports: [AlertDefaultsDialogComponent], + }); + return TestBed.createComponent(AlertDefaultsDialogComponent).componentInstance; + } + + beforeEach(() => localStorage.clear()); + + it('initializes from the stored defaults (areas, 1 km) when nothing is saved', () => { + const component = create(); + expect(component.mode()).toBe('areas'); + expect(component.distanceKm).toBe(1); + }); + + it('initializes from a previously saved distance preference', () => { + localStorage.setItem('poracle-default-alert-mode', 'distance'); + localStorage.setItem('poracle-default-alert-distance-km', '3'); + const component = create(); + expect(component.mode()).toBe('distance'); + expect(component.distanceKm).toBe(3); + }); + + it('saves the chosen defaults to localStorage and closes', () => { + const component = create(); + component.mode.set('distance'); + component.distanceKm = 2.5; + component.save(); + + expect(localStorage.getItem('poracle-default-alert-mode')).toBe('distance'); + expect(localStorage.getItem('poracle-default-alert-distance-km')).toBe('2.5'); + expect(dialogRef.close).toHaveBeenCalledWith(true); + }); + + // This used to assert the silent clamp: the dialog accepted 200 km, echoed "200 km" in the live + // preview, then stored 100. That WAS the bug -- the user was shown one number and given another. + // Out-of-range input is now refused instead. See #426. + it('refuses to save a distance above the maximum', () => { + const component = create(); + component.mode.set('distance'); + component.distanceKm = 99999; + + expect(component.distanceError).toBe('ALERT_DEFAULTS.DISTANCE_TOO_LARGE'); + expect(component.canSave).toBe(false); + + component.save(); + + expect(localStorage.getItem('poracle-default-alert-distance-km')).toBeNull(); + }); + + it.each([0, -5, 0.05])('refuses %p, which used to be silently saved as 1', km => { + const component = create(); + component.mode.set('distance'); + component.distanceKm = km; + + expect(component.distanceError).toBe('ALERT_DEFAULTS.DISTANCE_TOO_SMALL'); + expect(component.canSave).toBe(false); + }); + + it('saves a valid distance', () => { + const component = create(); + component.mode.set('distance'); + component.distanceKm = 5; + + expect(component.canSave).toBe(true); + component.save(); + + expect(localStorage.getItem('poracle-default-alert-distance-km')).toBe('5'); + }); + + it('does not block saving in areas mode', () => { + const component = create(); + component.mode.set('areas'); + component.distanceKm = 99999; + + expect(component.canSave).toBe(true); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.ts new file mode 100644 index 00000000..6bb1df77 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.ts @@ -0,0 +1,89 @@ +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { SavedPlaces } from '../../../core/models'; +import { + AlertDefaultsService, + AlertLocationMode, + MAX_DEFAULT_DISTANCE_KM, + MIN_DEFAULT_DISTANCE_KM, +} from '../../../core/services/alert-defaults.service'; +import { PlacesService } from '../../../core/services/places.service'; +import { DeliveryPreviewComponent } from '../delivery-preview/delivery-preview.component'; + +@Component({ + imports: [ + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + MatSelectModule, + MatIconModule, + DeliveryPreviewComponent, + TranslatePipe, + ], + selector: 'app-alert-defaults-dialog', + standalone: true, + styleUrl: './alert-defaults-dialog.component.scss', + templateUrl: './alert-defaults-dialog.component.html', +}) +export class AlertDefaultsDialogComponent implements OnInit { + private readonly alertDefaults = inject(AlertDefaultsService); + readonly dialogRef = inject(MatDialogRef); + + distanceKm = this.alertDefaults.defaultDistanceKm(); + + readonly maxKm = MAX_DEFAULT_DISTANCE_KM; + + readonly minKm = MIN_DEFAULT_DISTANCE_KM; + mode = signal(this.alertDefaults.defaultMode()); + + /** Empty means the profile pin, which is what new alarms did before per-alarm scope. */ + placeLabel = this.alertDefaults.defaultPlaceLabel(); + readonly places = inject(PlacesService); + + get canSave(): boolean { + return this.distanceError === null; + } + + /** + * The service clamps to 0.1-100 on save. Without surfacing that, the dialog accepted 200, showed + * "200 km" in the live preview, then stored 100 - and 0 or -5 silently became 1. See #426. + */ + get distanceError(): string | null { + if (this.mode() !== 'distance') return null; + const km = this.distanceKm; + if (!Number.isFinite(km) || km < 0.1) return 'ALERT_DEFAULTS.DISTANCE_TOO_SMALL'; + if (km > 100) return 'ALERT_DEFAULTS.DISTANCE_TOO_LARGE'; + return null; + } + + ngOnInit(): void { + this.loadPlaces(); + } + + save(): void { + if (!this.canSave) return; + this.alertDefaults.save(this.mode(), this.distanceKm, this.placeLabel); + this.dialogRef.close(true); + } + + private loadPlaces(): void { + this.places.load().subscribe({ + error: () => undefined, + // A place the user has since deleted would otherwise keep seeding new alarms with a label + // PoracleNG rejects, and the select would show a blank row for it. + next: (places: SavedPlaces) => this.alertDefaults.reconcilePlace(places.named.map(p => p.label)), + }); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/area-map.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/area-map.component.spec.ts new file mode 100644 index 00000000..0284449e --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/area-map.component.spec.ts @@ -0,0 +1,154 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; +import * as L from 'leaflet'; + +import { AreaMapComponent } from './area-map.component'; +import { INITIAL_VIEW_MAX_ZOOM, LOCATION_ONLY_ZOOM } from './initial-view'; +import { GeofenceData } from '../../../core/models'; + +/** + * Covers the wiring between the map and planInitialView. The ladder itself is unit-tested in + * initial-view.spec.ts; what matters here is that the right bounds reach fitBounds, and that a + * selection change after the map has settled does not move it. See #693. + */ +describe('AreaMapComponent initial view', () => { + let component: AreaMapComponent; + let fixture: ComponentFixture; + let fitBounds: jest.SpyInstance; + let setView: jest.SpyInstance; + + // Richmond-ish and Sydney-ish, so "fitted the selection" and "fitted everything" are far apart. + const richmond = (name: string): GeofenceData => + ({ + name, + path: [ + [37.5, -77.5], + [37.5, -77.4], + [37.6, -77.4], + [37.6, -77.5], + ], + }) as GeofenceData; + + const sydney = (name: string): GeofenceData => + ({ + name, + path: [ + [-33.8, 151.2], + [-33.8, 151.3], + [-33.7, 151.3], + [-33.7, 151.2], + ], + }) as GeofenceData; + + const feed = [richmond('downtown'), richmond('fan'), sydney('summerland'), sydney('patonga')]; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService()], + imports: [AreaMapComponent], + }); + + // Leaflet needs a non-zero container to compute a zoom from bounds; jsdom reports 0x0. + jest.spyOn(L.Map.prototype, 'getSize').mockReturnValue(L.point(1200, 400)); + fitBounds = jest.spyOn(L.Map.prototype, 'fitBounds'); + setView = jest.spyOn(L.Map.prototype, 'setView'); + + fixture = TestBed.createComponent(AreaMapComponent); + component = fixture.componentInstance; + }); + + afterEach(() => jest.restoreAllMocks()); + + /** The bounds passed to the most recent fitBounds call. */ + const lastFitted = (): L.LatLngBounds => fitBounds.mock.calls[fitBounds.mock.calls.length - 1][0] as L.LatLngBounds; + + it('opens on the selected areas rather than the whole feed', () => { + component.geofence = feed; + component.selectedAreas = ['downtown', 'fan']; + fixture.detectChanges(); + + const bounds = lastFitted(); + expect(bounds.getSouth()).toBeCloseTo(37.5, 1); + expect(bounds.getNorth()).toBeCloseTo(37.6, 1); + // Sydney must not be in shot -- that is the bug. + expect(bounds.contains(L.latLng(-33.8, 151.2))).toBe(false); + }); + + it('caps the zoom so a single small area does not open at street level', () => { + component.geofence = feed; + component.selectedAreas = ['fan']; + fixture.detectChanges(); + + const options = fitBounds.mock.calls[fitBounds.mock.calls.length - 1][1] as L.FitBoundsOptions; + expect(options.maxZoom).toBe(INITIAL_VIEW_MAX_ZOOM); + }); + + it('falls back to the whole feed when nothing is selected and there is no location', () => { + component.geofence = feed; + component.selectedAreas = []; + fixture.detectChanges(); + + expect(lastFitted().contains(L.latLng(-33.8, 151.2))).toBe(true); + }); + + it('opens on the pinned location when nothing is selected', () => { + component.geofence = feed; + component.selectedAreas = []; + component.userLocation = { lat: 37.55, lng: -77.45 }; + fixture.detectChanges(); + + expect(setView).toHaveBeenCalledWith([37.55, -77.45], LOCATION_ONLY_ZOOM); + }); + + it('ignores a 0,0 location, which is how "not set" is stored', () => { + component.geofence = feed; + component.selectedAreas = []; + component.userLocation = { lat: 0, lng: 0 }; + fixture.detectChanges(); + + expect(setView).not.toHaveBeenCalledWith([0, 0], LOCATION_ONLY_ZOOM); + expect(lastFitted().contains(L.latLng(-33.8, 151.2))).toBe(true); + }); + + it('re-fits when the selection arrives after the feed', () => { + // The Areas page loads these from independent requests; the map is routinely on screen with an + // empty selection first. Without the upgrade the user is left looking at the whole world. + component.geofence = feed; + component.selectedAreas = []; + fixture.detectChanges(); + expect(lastFitted().contains(L.latLng(-33.8, 151.2))).toBe(true); + + component.selectedAreas = ['downtown', 'fan']; + component.ngOnChanges({ selectedAreas: { currentValue: ['downtown', 'fan'] } as never }); + + expect(lastFitted().contains(L.latLng(-33.8, 151.2))).toBe(false); + }); + + it('does not move the map when an area is toggled', () => { + component.geofence = feed; + component.selectedAreas = ['downtown']; + fixture.detectChanges(); + + const callsAfterInitialFit = fitBounds.mock.calls.length; + + component.selectedAreas = ['downtown', 'fan']; + component.ngOnChanges({ selectedAreas: { currentValue: ['downtown', 'fan'] } as never }); + + expect(fitBounds.mock.calls.length).toBe(callsAfterInitialFit); + }); + + it('does not move the map after the user has panned it', () => { + component.geofence = feed; + component.selectedAreas = []; + fixture.detectChanges(); + + component.mapElement.nativeElement.dispatchEvent(new Event('pointerdown')); + const callsBefore = fitBounds.mock.calls.length; + + component.selectedAreas = ['downtown']; + component.ngOnChanges({ selectedAreas: { currentValue: ['downtown'] } as never }); + + expect(fitBounds.mock.calls.length).toBe(callsBefore); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/area-map.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/area-map.component.ts index 73f5b530..b4caf0db 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/area-map.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/area-map.component.ts @@ -19,14 +19,26 @@ import { import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import * as L from 'leaflet'; import 'leaflet-draw'; +import { INITIAL_VIEW_MAX_ZOOM, LOCATION_ONLY_ZOOM, planInitialView } from './initial-view'; import { GeofenceData } from '../../../core/models'; import { I18nService } from '../../../core/services/i18n.service'; import { RegionOption, RegionSelectorComponent } from '../region-selector/region-selector.component'; +/** + * Padding for an automatic fit. Asymmetric on purpose: the "N area(s) selected" badge sits at + * bottom centre and the Leaflet attribution at bottom right, so a shape fitted flush to the bottom + * edge ends up underneath them. + */ +const FIT_OPTIONS: L.FitBoundsOptions = { + maxZoom: INITIAL_VIEW_MAX_ZOOM, + paddingBottomRight: [24, 56], + paddingTopLeft: [24, 24], +}; + const GROUP_COLORS = [ '#e53935', '#1e88e5', @@ -53,7 +65,7 @@ interface RegionEntry { } @Component({ - imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslateModule, RegionSelectorComponent], + imports: [MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe, RegionSelectorComponent], selector: 'app-area-map', standalone: true, styleUrl: './area-map.component.scss', @@ -61,9 +73,13 @@ interface RegionEntry { }) export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { private allBoundsRect: L.LatLngBounds | null = null; + private customBoundsRect: L.LatLngBounds | null = null; private customGeofenceLayer: L.LayerGroup = L.layerGroup(); private drawControl: L.Control.Draw | null = null; + /** Rank of the anchor the map is currently sitting on. See planInitialView. */ + private fittedViewPriority = 0; + private fullscreenHandler = () => { if (!document.fullscreenElement) { this.isFullscreen.set(false); @@ -73,9 +89,14 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { private groupColorMap = new Map(); - private hasFittedInitialBounds = false; private readonly i18n = inject(I18nService); + private initialized = false; + + private lockViewHandler = (): void => { + this.viewLockedByUser = true; + }; + private map: L.Map | null = null; private onDrawCreated = (event: L.LeafletEvent): void => { @@ -90,8 +111,12 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { private polygonByName = new Map(); private polygonLayers: L.Polygon[] = []; + private selectionBoundsRect: L.LatLngBounds | null = null; private userCircle: L.Circle | null = null; private userMarker: L.Marker | null = null; + + /** Set by any deliberate view choice -- drag, zoom, region jump, fit all. Stops auto-fitting. */ + private viewLockedByUser = false; @Output() areaClicked = new EventEmitter(); customGeofences = input([]); drawMode = input(false); @@ -146,8 +171,10 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { fitAll(): void { this.selectedRegion.set(''); + // An explicit "show me everything" is a view choice; nothing should silently override it. + this.viewLockedByUser = true; if (this.map && this.allBoundsRect) { - this.map.fitBounds(this.allBoundsRect, { padding: [20, 20] }); + this.map.fitBounds(this.allBoundsRect, FIT_OPTIONS); } } @@ -155,6 +182,9 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { this.initMap(); this.initialized = true; this.drawPolygons(); + // The customGeofences effect runs before the map exists and bails out, so the first value has + // to be drawn here or My Geofences opens with no shapes and no bounds to anchor on. + this.renderCustomGeofences(this.customGeofences()); document.addEventListener('fullscreenchange', this.fullscreenHandler); } @@ -164,21 +194,29 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { if (changes['geofence'] || changes['groupMapping']) { // Geofence data or group mapping changed -- full redraw needed, allow re-fit if (changes['geofence']) { - this.hasFittedInitialBounds = false; + this.fittedViewPriority = 0; } this.drawPolygons(); } else if (changes['selectedAreas']) { - // Only selection changed -- update polygon styles without resetting the map view + // Only selection changed -- restyle without resetting the view. The fit below cannot move the + // map once it has already anchored on a selection, so toggling an area never jumps the view; + // it only matters when the selection arrives after the map has fitted something worse. this.updatePolygonStyles(); + this.selectionBoundsRect = this.computeSelectionBounds(); + this.applyInitialView(); } if (changes['userLocation']) { this.updateUserMarker(); + this.applyInitialView(); } } ngOnDestroy(): void { document.removeEventListener('fullscreenchange', this.fullscreenHandler); + for (const event of ['pointerdown', 'wheel', 'keydown']) { + this.mapElement.nativeElement.removeEventListener(event, this.lockViewHandler); + } this.removeDrawControl(); if (this.map) { this.map.remove(); @@ -189,6 +227,8 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { onRegionSelected(option: RegionOption): void { const regionLabel = option.label; this.selectedRegion.set(regionLabel); + // Jumping to a region states where the user wants to be; later data must not pull them away. + this.viewLockedByUser = true; this.regionChanged.emit(option); if (!regionLabel || !this.map) { @@ -258,6 +298,46 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { this.map.on('draw:created', this.onDrawCreated); } + /** + * Positions the map on the best anchor available so far, upgrading if better data has since + * arrived. Safe to call as often as you like -- planInitialView decides whether anything happens. + */ + private applyInitialView(): void { + if (!this.map) return; + + const location = this.userLocation; + // 0,0 is how an unset location is stored, and the Gulf of Guinea is not a useful opening view. + const hasUserLocation = !!location && (location.lat !== 0 || location.lng !== 0); + + const plan = planInitialView({ + fittedPriority: this.fittedViewPriority, + hasAllBounds: !!this.allBoundsRect, + hasCustomBounds: !!this.customBoundsRect, + hasSelectionBounds: !!this.selectionBoundsRect, + hasUserLocation, + viewLockedByUser: this.viewLockedByUser || !!this.selectedRegion(), + }); + + if (!plan) return; + + switch (plan.source) { + case 'all': + this.map.fitBounds(this.allBoundsRect!, FIT_OPTIONS); + break; + case 'custom': + this.map.fitBounds(this.customBoundsRect!, FIT_OPTIONS); + break; + case 'location': + this.map.setView([location!.lat, location!.lng], LOCATION_ONLY_ZOOM); + break; + case 'selection': + this.map.fitBounds(this.selectionBoundsRect!, FIT_OPTIONS); + break; + } + + this.fittedViewPriority = plan.priority; + } + private buildRegions(): void { // Group names follow pattern "US - State - City" (3 parts) or "KOR - City" (2 parts) // Region = full group name (all 3 parts for US, all 2 parts for KOR/AUS) @@ -293,6 +373,24 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { this.regions.set(regions); } + /** Bounds of the fences the user is subscribed to, or null when none of them are in the feed. */ + private computeSelectionBounds(): L.LatLngBounds | null { + if (this.selectedAreas.length === 0 || this.geofence.length === 0) return null; + + const selectedSet = new Set(this.selectedAreas.map(a => a.toLowerCase())); + const points: L.LatLngExpression[] = []; + + for (const fence of this.geofence) { + if (!fence.path || fence.path.length < 3) continue; + if (!selectedSet.has(fence.name.toLowerCase())) continue; + points.push(...fence.path.map(coord => [coord[0], coord[1]] as L.LatLngExpression)); + } + + // A selection can name geofences the feed does not carry -- user-drawn fences are served with + // userSelectable=false and are not in the admin area list -- so an empty result is normal. + return points.length > 0 ? L.latLngBounds(points) : null; + } + private drawPolygons(): void { if (!this.map) return; @@ -399,13 +497,11 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { if (allBounds.length > 0) { this.allBoundsRect = L.latLngBounds(allBounds); - if (!this.hasFittedInitialBounds && !this.selectedRegion()) { - this.map.fitBounds(this.allBoundsRect, { padding: [20, 20] }); - this.hasFittedInitialBounds = true; - } } + this.selectionBoundsRect = this.computeSelectionBounds(); this.updateUserMarker(); + this.applyInitialView(); } private initMap(): void { @@ -420,6 +516,14 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { subdomains: 'abcd', }).addTo(this.map); + // Once the user has touched the map, stop repositioning it. Raw DOM input events are used + // rather than Leaflet's movestart/zoomstart because those fire for our own fitBounds calls too, + // which would lock the view against the very first fit. + const container = this.mapElement.nativeElement; + for (const event of ['pointerdown', 'wheel', 'keydown']) { + container.addEventListener(event, this.lockViewHandler, { passive: true }); + } + this.customGeofenceLayer.addTo(this.map); } @@ -438,10 +542,13 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { if (!this.map) return; this.customGeofenceLayer.clearLayers(); + const customBounds: L.LatLngExpression[] = []; + for (const fence of geofences) { if (!fence.path || fence.path.length < 3) continue; const latLngs: L.LatLngExpression[] = fence.path.map(coord => [coord[0], coord[1]] as L.LatLngExpression); + customBounds.push(...latLngs); const polygon = L.polygon(latLngs, { color: '#2196f3', @@ -458,6 +565,9 @@ export class AreaMapComponent implements AfterViewInit, OnChanges, OnDestroy { this.customGeofenceLayer.addLayer(polygon); } + + this.customBoundsRect = customBounds.length > 0 ? L.latLngBounds(customBounds) : null; + this.applyInitialView(); } private updatePolygonStyles(): void { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/initial-view.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/initial-view.spec.ts new file mode 100644 index 00000000..53e0d42b --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/initial-view.spec.ts @@ -0,0 +1,114 @@ +import { INITIAL_VIEW_MAX_ZOOM, INITIAL_VIEW_PRIORITY, LOCATION_ONLY_ZOOM, planInitialView, InitialViewState } from './initial-view'; + +const state = (overrides: Partial = {}): InitialViewState => ({ + fittedPriority: 0, + hasAllBounds: false, + hasCustomBounds: false, + hasSelectionBounds: false, + hasUserLocation: false, + viewLockedByUser: false, + ...overrides, +}); + +describe('planInitialView', () => { + describe('the ladder', () => { + it('opens on the user own geofences above everything else', () => { + // My Geofences is the only page that binds these, and the shapes you drew are what you came + // for there. A pin must not displace them. + const plan = planInitialView(state({ hasAllBounds: true, hasCustomBounds: true, hasSelectionBounds: true, hasUserLocation: true })); + + expect(plan?.source).toBe('custom'); + }); + + it('opens on the pin rather than the selected areas', () => { + // Areas binds the selection. A multi-area selection frames a whole region and opens too far + // out to act on, where the pin says where the person is. + const plan = planInitialView(state({ hasAllBounds: true, hasSelectionBounds: true, hasUserLocation: true })); + + expect(plan?.source).toBe('location'); + }); + + it('opens on the selected areas when there is no pin', () => { + const plan = planInitialView(state({ hasAllBounds: true, hasSelectionBounds: true })); + + expect(plan?.source).toBe('selection'); + }); + + it('opens on the pinned location when there are no shapes of the user own', () => { + const plan = planInitialView(state({ hasAllBounds: true, hasUserLocation: true })); + + expect(plan?.source).toBe('location'); + }); + + it('falls back to every area only when there is no other signal', () => { + const plan = planInitialView(state({ hasAllBounds: true })); + + expect(plan?.source).toBe('all'); + }); + + it('leaves the map alone when there is nothing to fit at all', () => { + expect(planInitialView(state())).toBeNull(); + }); + }); + + describe('upgrading a view that was fitted before the data arrived', () => { + // The Areas page loads the feed and the selection from independent requests, so the map is + // routinely on screen before the selection lands. This is the case that makes the whole + // difference between the fix working and working most of the time. + it('re-fits when the selection arrives after the map already fitted everything', () => { + const plan = planInitialView(state({ fittedPriority: INITIAL_VIEW_PRIORITY.all, hasAllBounds: true, hasSelectionBounds: true })); + + expect(plan).toEqual({ priority: INITIAL_VIEW_PRIORITY.selection, source: 'selection' }); + }); + + it('re-fits when the location arrives after the map already fitted everything', () => { + const plan = planInitialView(state({ fittedPriority: INITIAL_VIEW_PRIORITY.all, hasAllBounds: true, hasUserLocation: true })); + + expect(plan?.source).toBe('location'); + }); + }); + + describe('not moving a map the user is working with', () => { + // Toggling an area re-emits selectedAreas. If that re-fitted, the map would jump on every + // click -- worse than the bug this replaced. + it('does not move the map when the selection changes at the same rank', () => { + const plan = planInitialView( + state({ fittedPriority: INITIAL_VIEW_PRIORITY.selection, hasAllBounds: true, hasSelectionBounds: true }), + ); + + expect(plan).toBeNull(); + }); + + it('does not zoom back out when the user deselects everything', () => { + // With a pin present the map is already fitted to it, so losing the selection leaves nothing + // better to move to. Rule 2: equal rank is not an upgrade. + const plan = planInitialView(state({ fittedPriority: INITIAL_VIEW_PRIORITY.location, hasAllBounds: true, hasUserLocation: true })); + + expect(plan).toBeNull(); + }); + + it('does not fall back to the whole feed when a selection is cleared and there is no pin', () => { + const plan = planInitialView(state({ fittedPriority: INITIAL_VIEW_PRIORITY.selection, hasAllBounds: true })); + + expect(plan).toBeNull(); + }); + + it('never overrides a view the user chose, however much better the alternative', () => { + const plan = planInitialView(state({ hasAllBounds: true, hasSelectionBounds: true, hasUserLocation: true, viewLockedByUser: true })); + + expect(plan).toBeNull(); + }); + }); + + describe('zoom limits', () => { + it('caps an automatic fit below the tightest explicit region jump', () => { + // The region jump uses maxZoom 14; an inferred view should not be tighter than a stated one. + expect(INITIAL_VIEW_MAX_ZOOM).toBeLessThan(14); + }); + + it('opens a location-only view wide enough to show neighbouring areas', () => { + expect(LOCATION_ONLY_ZOOM).toBeGreaterThanOrEqual(10); + expect(LOCATION_ONLY_ZOOM).toBeLessThanOrEqual(12); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/initial-view.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/initial-view.ts new file mode 100644 index 00000000..0f2d78ba --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/area-map/initial-view.ts @@ -0,0 +1,89 @@ +/** + * Decides what the area map should open on. + * + * The map used to open on the bounds of every fence in the feed. On a single-city instance that + * looked like a sensible default; on a multi-region one it is the whole planet, where no polygon is + * more than a pixel and nothing can be clicked. The feed is not going to get smaller, so the fix is + * to open on the shapes the user actually came for and fall back only when there is no better + * signal. See #693. + * + * Two rules do the real work here, and both are easy to break by accident: + * + * 1. The anchors are ranked, and a fit only ever *upgrades*. The page loads the geofence feed and + * the user's selection from independent requests, so the map is often on screen before the + * selection arrives. Without an upgrade the map would fit everything, mark itself done, and + * never recover -- intermittently, depending on which response won. + * 2. Because a fit only upgrades, a selection that changes at the same rank cannot move the map. + * Toggling an area therefore restyles it and nothing else. Re-fitting on every toggle would + * yank the map out from under the cursor, which is worse than the bug being fixed. + */ + +/** Where the opening view is taken from, worst to best. */ +export type InitialViewSource = 'all' | 'custom' | 'location' | 'selection'; + +/** + * Higher wins. + * + * The pin now outranks the selection. #693 ranked the selection highest, on the reasoning that you + * open on the shapes you came for, but a multi-area selection frames a whole region and the map + * opens too far out to act on. A pin says where the person actually is, so it wins when one exists. + * + * It does NOT outrank `custom`. On My Geofences the shapes you drew are the thing you came for, and + * opening on your pin instead would be the same mistake in the other direction. The two never + * coexist in practice -- Areas binds `selectedAreas`, My Geofences binds `customGeofences` -- but + * the ordering has to be total, and this is the direction that is right on both pages. + */ +export const INITIAL_VIEW_PRIORITY: Record = { + all: 1, + custom: 4, + location: 3, + selection: 2, +}; + +/** + * Ceiling for an automatic fit. Admin areas rarely reach it -- a single Richmond area fits at 12-13 + * on its own -- but a user-drawn geofence a few hundred metres across would otherwise land at street + * level with no surrounding context to add neighbours from. An explicit region jump is allowed to go + * one step tighter, because it states an intent this has to infer. + */ +export const INITIAL_VIEW_MAX_ZOOM = 13; + +/** Roughly 30 km across, so several adjacent areas are visible and clickable around the pin. */ +export const LOCATION_ONLY_ZOOM = 11; + +export interface InitialViewPlan { + priority: number; + source: InitialViewSource; +} + +export interface InitialViewState { + /** Priority of the anchor already fitted. 0 when the map has not been positioned yet. */ + fittedPriority: number; + hasAllBounds: boolean; + hasCustomBounds: boolean; + hasSelectionBounds: boolean; + hasUserLocation: boolean; + /** A region jump, a "fit all", a drag or a zoom is a stated intent. Never override it. */ + viewLockedByUser: boolean; +} + +/** + * Returns the view to apply, or null to leave the map where it is. + */ +export function planInitialView(state: InitialViewState): InitialViewPlan | null { + if (state.viewLockedByUser) return null; + + const available: InitialViewSource[] = []; + if (state.hasSelectionBounds) available.push('selection'); + if (state.hasCustomBounds) available.push('custom'); + if (state.hasUserLocation) available.push('location'); + if (state.hasAllBounds) available.push('all'); + + if (available.length === 0) return null; + + const best = available.reduce((a, b) => (INITIAL_VIEW_PRIORITY[b] > INITIAL_VIEW_PRIORITY[a] ? b : a)); + const priority = INITIAL_VIEW_PRIORITY[best]; + + // Equal rank is not an upgrade: this is what stops a selection change from moving the map. + return priority > state.fittedPriority ? { priority, source: best } : null; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts index fd127778..987111b7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/confirm-dialog/confirm-dialog.component.ts @@ -6,7 +6,7 @@ import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/materia import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; export interface ConfirmDialogData { cancelText?: string; @@ -37,7 +37,7 @@ export interface ConfirmDialogResult { MatFormFieldModule, MatIconModule, MatInputModule, - TranslateModule, + TranslatePipe, ], selector: 'app-confirm-dialog', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.html index 6c8fa6ad..b5bd98b7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.html @@ -12,7 +12,7 @@ } @else {

- {{ 'DELIVERY_PREVIEW.NO_AREAS' | translate }} {{ 'DELIVERY_PREVIEW.SET_UP_AREAS' | translate }} + {{ 'DELIVERY_PREVIEW.NO_AREAS' | translate }} {{ 'DELIVERY_PREVIEW.SET_UP_AREAS' | translate }} {{ 'DELIVERY_PREVIEW.FIRST' | translate }}

} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.spec.ts new file mode 100644 index 00000000..a47223bb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.spec.ts @@ -0,0 +1,72 @@ +import { TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of, throwError } from 'rxjs'; + +import { DeliveryPreviewComponent } from './delivery-preview.component'; +import { AreaService } from '../../../core/services/area.service'; +import { LocationService } from '../../../core/services/location.service'; + +describe('DeliveryPreviewComponent', () => { + let areaService: { getSelected: jest.Mock }; + let locationService: { getDistanceMapUrl: jest.Mock; getLocation: jest.Mock }; + + function create(): DeliveryPreviewComponent { + const fixture = TestBed.createComponent(DeliveryPreviewComponent); + return fixture.componentInstance; + } + + beforeEach(() => { + TestBed.resetTestingModule(); + areaService = { getSelected: jest.fn().mockReturnValue(of([])) }; + locationService = { + getDistanceMapUrl: jest.fn().mockReturnValue(of({ url: 'map.png' })), + getLocation: jest.fn().mockReturnValue(of({ latitude: 1, longitude: 2 })), + }; + + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: AreaService, useValue: areaService }, + { provide: LocationService, useValue: locationService }, + ], + imports: [DeliveryPreviewComponent], + }); + }); + + it('stops loading when the location request fails', () => { + // disable_location answers 403 here. A next-only subscriber left the spinner running in every add + // and edit alarm dialog the moment distance mode was picked. See #617. + locationService.getLocation.mockReturnValue(throwError(() => ({ status: 403 }))); + + const component = create(); + component.mode = 'distance'; + component.distanceKm = 5; + component.ngOnChanges({}); + + expect(component.loading()).toBe(false); + expect(component.mapUrl()).toBe(''); + }); + + it('shows the map when the location resolves', () => { + const component = create(); + component.mode = 'distance'; + component.distanceKm = 5; + component.ngOnChanges({}); + + expect(component.loading()).toBe(false); + expect(component.mapUrl()).toBe('map.png'); + expect(locationService.getDistanceMapUrl).toHaveBeenCalledWith(1, 2, 5000); + }); + + it('does not fetch a map when the user has no location set', () => { + locationService.getLocation.mockReturnValue(of({ latitude: 0, longitude: 0 })); + + const component = create(); + component.mode = 'distance'; + component.distanceKm = 5; + component.ngOnChanges({}); + + expect(component.loading()).toBe(false); + expect(locationService.getDistanceMapUrl).not.toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.ts index 48c06d8a..a5870b4d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/delivery-preview/delivery-preview.component.ts @@ -2,13 +2,15 @@ import { Component, Input, OnChanges, SimpleChanges, inject, signal } from '@ang import { MatChipsModule } from '@angular/material/chips'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { TranslateModule } from '@ngx-translate/core'; +import { RouterLink } from '@angular/router'; +import { TranslatePipe } from '@ngx-translate/core'; +import { catchError, of } from 'rxjs'; import { AreaService } from '../../../core/services/area.service'; import { LocationService } from '../../../core/services/location.service'; @Component({ - imports: [MatChipsModule, MatIconModule, MatProgressSpinnerModule, TranslateModule], + imports: [RouterLink, MatChipsModule, MatIconModule, MatProgressSpinnerModule, TranslatePipe], selector: 'app-delivery-preview', standalone: true, styleUrl: './delivery-preview.component.scss', @@ -42,17 +44,23 @@ export class DeliveryPreviewComponent implements OnChanges { this.mapUrl.set(''); this.loading.set(true); - // Get user location first, then fetch distance map - this.locationService.getLocation().subscribe(loc => { - if (loc && (loc.latitude !== 0 || loc.longitude !== 0)) { - this.locationService.getDistanceMapUrl(loc.latitude, loc.longitude, distanceMeters).subscribe(result => { + // Get user location first, then fetch distance map. The error arm matters: with disable_location + // on, GET /api/location answers 403, and a next-only subscriber left `loading` set -- so the + // preview inside every add and edit alarm dialog span forever the moment distance mode was + // picked. No location means no map, which is what an empty mapUrl already renders. See #617. + this.locationService + .getLocation() + .pipe(catchError(() => of(null))) + .subscribe(loc => { + if (loc && (loc.latitude !== 0 || loc.longitude !== 0)) { + this.locationService.getDistanceMapUrl(loc.latitude, loc.longitude, distanceMeters).subscribe(result => { + this.loading.set(false); + if (result?.url) this.mapUrl.set(result.url); + }); + } else { this.loading.set(false); - if (result?.url) this.mapUrl.set(result.url); - }); - } else { - this.loading.set(false); - } - }); + } + }); } } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.html index c14e7676..cfd70d27 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.html @@ -27,6 +27,9 @@

{{ 'DIALOG.DISTANCE_TITLE' | translate }}

{{ 'ALARM.DISTANCE_LABEL' | translate }} + @if (!isValid) { + {{ 'DIALOG.DISTANCE_MUST_BE_POSITIVE' | translate }} + } {{ 'ALARM.DISTANCE_SUFFIX' | translate }} } @@ -36,5 +39,5 @@

{{ 'DIALOG.DISTANCE_TITLE' | translate }}

- + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.ts index e9abd312..ca234519 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/distance-dialog/distance-dialog.component.ts @@ -6,7 +6,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { DeliveryPreviewComponent } from '../delivery-preview/delivery-preview.component'; @@ -20,7 +20,7 @@ import { DeliveryPreviewComponent } from '../delivery-preview/delivery-preview.c MatRadioModule, MatIconModule, DeliveryPreviewComponent, - TranslateModule, + TranslatePipe, ], selector: 'app-distance-dialog', standalone: true, @@ -33,7 +33,18 @@ export class DistanceDialogComponent { distanceKm = 1; mode = signal<'areas' | 'distance'>('areas'); + /** + * The input carried `min="0.1"` but nothing enforced it -- no validator, no form, no disabled binding -- + * so typing -5 and pressing Update All sent -5000. PoracleNG clamps the upper bound but not the lower, + * and it treats distance > 0 as "use a radius", so a negative silently switched every selected alarm to + * area-based delivery while the cards went on showing a negative radius. See #417. + */ + get isValid(): boolean { + return this.mode() === 'areas' || (Number.isFinite(this.distanceKm) && this.distanceKm > 0); + } + apply(): void { + if (!this.isValid) return; const meters = this.mode() === 'areas' ? 0 : Math.round(this.distanceKm * 1000); this.dialogRef.close(meters); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.html index 407dc52b..f5a10254 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.html @@ -34,9 +34,26 @@

@if (mode === 'approve') { {{ 'ADMIN.APPROVAL_PROMOTED_NAME' | translate }} - - {{ 'ADMIN.APPROVAL_PROMOTED_NAME_HINT' | translate }} + + @if (promotedNameError) { + {{ promotedNameError | translate }} + } @else { + {{ 'ADMIN.APPROVAL_PROMOTED_NAME_HINT' | translate }} + } + + @if (hasRegions) { +
+ +

{{ 'ADMIN.APPROVAL_REGION_HINT' | translate }}

+ +
+ } } @if (mode === 'reject') { @@ -49,7 +66,7 @@

@if (mode === 'approve') { - diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.scss index b72ffd84..7ed6e0c1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.scss @@ -61,3 +61,20 @@ .full-width { width: 100%; } + +.region-section { + margin-top: 4px; +} + +.region-label { + display: block; + font-size: 12px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + margin-bottom: 4px; +} + +.region-hint { + font-size: 11px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + margin: 0 0 8px; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.spec.ts index 5d3ab6b4..5b08c69d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.spec.ts @@ -7,7 +7,7 @@ import { GeofenceApprovalDialogData, GeofenceApprovalDialogResult, } from './geofence-approval-dialog.component'; -import { UserGeofence } from '../../../core/models'; +import { GeofenceRegion, UserGeofence } from '../../../core/models'; describe('GeofenceApprovalDialogComponent', () => { let component: GeofenceApprovalDialogComponent; @@ -25,6 +25,11 @@ describe('GeofenceApprovalDialogComponent', () => { updatedAt: '2026-03-21T00:00:00Z', }; + const regions: GeofenceRegion[] = [ + { id: 5, name: 'city', displayName: 'City Center' }, + { id: 7, name: 'suburbs', displayName: 'Suburbs' }, + ]; + function setup(data?: Partial) { dialogRef = { close: jest.fn() }; @@ -32,7 +37,7 @@ describe('GeofenceApprovalDialogComponent', () => { TestBed.configureTestingModule({ providers: [ provideTranslateService(), - { provide: MAT_DIALOG_DATA, useValue: { geofence: mockGeofence, ...data } }, + { provide: MAT_DIALOG_DATA, useValue: { geofence: mockGeofence, regions: [], ...data } }, { provide: MatDialogRef, useValue: dialogRef }, ], imports: [GeofenceApprovalDialogComponent], @@ -143,4 +148,104 @@ describe('GeofenceApprovalDialogComponent', () => { it('should initialize reviewNotes as empty string', () => { expect(component.reviewNotes).toBe(''); }); + + describe('region selection (#314)', () => { + it('should report hasRegions=false and omit region overrides when no regions exist', () => { + setup({ regions: [] }); + component.promotedName = 'Downtown Official'; + component.onApprove(); + + expect(component.hasRegions).toBe(false); + expect(dialogRef.close).toHaveBeenCalledWith({ + action: 'approve', + promotedName: 'Downtown Official', + } as GeofenceApprovalDialogResult); + }); + + it('should default the selected region to the submission parentId', () => { + setup({ regions }); + expect(component.hasRegions).toBe(true); + expect(component.selectedRegionId).toBe(5); + }); + + it('should send the defaulted region on approve when untouched', () => { + setup({ regions }); + component.promotedName = 'Downtown Official'; + component.onApprove(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + action: 'approve', + groupName: 'City Center', + parentId: 5, + promotedName: 'Downtown Official', + } as GeofenceApprovalDialogResult); + }); + + it('should send the admin-chosen region override on approve', () => { + setup({ regions }); + component.onRegionPicked({ id: 7, label: 'Suburbs' }); + component.promotedName = 'Downtown'; + component.onApprove(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + action: 'approve', + groupName: 'Suburbs', + parentId: 7, + promotedName: 'Downtown', + } as GeofenceApprovalDialogResult); + }); + + it('should send parentId 0 / empty group when the region is cleared', () => { + setup({ regions }); + component.onRegionPicked({ label: '' }); + component.promotedName = 'Downtown'; + component.onApprove(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + action: 'approve', + groupName: '', + parentId: 0, + promotedName: 'Downtown', + } as GeofenceApprovalDialogResult); + }); + }); + + // The server rejects names outside ^[a-zA-Z0-9 \-'.()&]+$ or over 50 chars, and used to answer 404 + // for it — so the SPA toasted "Not found" for a submission that was plainly visible. Catching it here + // means the admin never makes that request. See #421. + describe('promoted name validation (#421)', () => { + it.each(['Downtown / Uptown', 'Park_West', 'North Side, East', 'bad<>name;drop'])('rejects %s, which the server would refuse', name => { + component.promotedName = name; + expect(component.promotedNameError).toBe('ADMIN.APPROVAL_PROMOTED_NAME_INVALID'); + expect(component.canApprove).toBe(false); + }); + + it.each(['Downtown Official', "O'Fallon Park", 'Midlo - Westchester', 'Parks (North) & South', 'Area 51'])('accepts %s', name => { + component.promotedName = name; + expect(component.promotedNameError).toBeNull(); + expect(component.canApprove).toBe(true); + }); + + it('rejects a name over 50 characters', () => { + component.promotedName = 'a'.repeat(51); + expect(component.promotedNameError).toBe('ADMIN.APPROVAL_PROMOTED_NAME_TOO_LONG'); + }); + + it('accepts exactly 50 characters', () => { + component.promotedName = 'a'.repeat(50); + expect(component.promotedNameError).toBeNull(); + }); + + it('allows an empty name, which means keep the current display name', () => { + component.promotedName = ' '; + expect(component.promotedNameError).toBeNull(); + expect(component.canApprove).toBe(true); + }); + + it('does not block rejecting just because the promoted name is invalid', () => { + component.promotedName = 'Downtown / Uptown'; + component.mode = 'reject'; + expect(component.canApprove).toBe(true); + }); + }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.ts index 48390dd4..8988401e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-approval-dialog/geofence-approval-dialog.component.ts @@ -7,16 +7,20 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; -import { UserGeofence } from '../../../core/models'; +import { GeofenceRegion, UserGeofence } from '../../../core/models'; +import { RegionOption, RegionSelectorComponent } from '../region-selector/region-selector.component'; export interface GeofenceApprovalDialogData { geofence: UserGeofence; + regions: GeofenceRegion[]; } export interface GeofenceApprovalDialogResult { action: 'approve' | 'reject'; + groupName?: string; + parentId?: number; promotedName?: string; reviewNotes?: string; } @@ -31,7 +35,8 @@ export interface GeofenceApprovalDialogResult { MatFormFieldModule, MatIconModule, MatInputModule, - TranslateModule, + RegionSelectorComponent, + TranslatePipe, ], selector: 'app-geofence-approval-dialog', standalone: true, @@ -39,28 +44,78 @@ export interface GeofenceApprovalDialogResult { templateUrl: './geofence-approval-dialog.component.html', }) export class GeofenceApprovalDialogComponent { + static readonly PROMOTED_NAME_MAX = 50; + /** + * Mirrors the server's promoted-name rules so the admin is told before the round-trip rather than + * after it. The server rejects anything outside this set, and used to answer 404 for it, so the + * SPA reported the submission as missing while it sat visible in the list. See #421. + */ + static readonly PROMOTED_NAME_PATTERN = /^[a-zA-Z0-9 \-'.()&]+$/; + readonly data = inject(MAT_DIALOG_DATA); + readonly dialogRef = inject(MatDialogRef); + // Region (Koji parent) the geofence will be filed under once public. Defaults to the submission's + // existing region (which may be none — see issue #314), and the admin can change it here. + readonly regionOptions: RegionOption[] = this.data.regions.map(r => ({ + id: r.id, + label: r.displayName, + shortLabel: r.displayName, + })); + + // Hide the region picker when Koji defines no regions (flat project) — there is nothing to choose + // from, so promotion just keeps whatever the submission had (issue #314). + readonly hasRegions = this.regionOptions.length > 0; mode: 'approve' | 'reject' = 'approve'; + promotedName = ''; reviewNotes = ''; + selectedRegionId: number | null = this.data.geofence.parentId > 0 ? this.data.geofence.parentId : null; + constructor() { this.promotedName = this.data.geofence.displayName; } + get canApprove(): boolean { + return this.mode !== 'approve' || this.promotedNameError === null; + } + + /** Empty is allowed — it means "keep the current display name". */ + get promotedNameError(): string | null { + const value = this.promotedName.trim(); + if (!value) return null; + if (value.length > GeofenceApprovalDialogComponent.PROMOTED_NAME_MAX) return 'ADMIN.APPROVAL_PROMOTED_NAME_TOO_LONG'; + if (!GeofenceApprovalDialogComponent.PROMOTED_NAME_PATTERN.test(value)) return 'ADMIN.APPROVAL_PROMOTED_NAME_INVALID'; + return null; + } + onApprove(): void { - this.dialogRef.close({ + const result: GeofenceApprovalDialogResult = { action: 'approve', promotedName: this.promotedName.trim() || undefined, - } as GeofenceApprovalDialogResult); + }; + + // Only send region overrides when there are regions to choose from. With no regions, leave + // parentId/groupName undefined so the backend keeps the submission's existing values (#314). + if (this.hasRegions) { + const region = this.selectedRegionId !== null ? this.data.regions.find(r => r.id === this.selectedRegionId) : undefined; + result.groupName = region?.displayName ?? ''; + result.parentId = region?.id ?? 0; + } + + this.dialogRef.close(result); } onCancel(): void { this.dialogRef.close(null); } + onRegionPicked(option: RegionOption): void { + this.selectedRegionId = option.id ?? null; + } + onReject(): void { this.dialogRef.close({ action: 'reject', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-detail-dialog/geofence-detail-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-detail-dialog/geofence-detail-dialog.component.ts index 2fc1d988..9a5acf3c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-detail-dialog/geofence-detail-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-detail-dialog/geofence-detail-dialog.component.ts @@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatChipsModule } from '@angular/material/chips'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import * as L from 'leaflet'; import { GeofenceData, UserGeofence } from '../../../core/models'; @@ -37,7 +37,7 @@ export interface GeofenceDetailDialogData { @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [DatePipe, MatButtonModule, MatChipsModule, MatDialogModule, MatIconModule, TranslateModule], + imports: [DatePipe, MatButtonModule, MatChipsModule, MatDialogModule, MatIconModule, TranslatePipe], selector: 'app-geofence-detail-dialog', standalone: true, styleUrl: './geofence-detail-dialog.component.scss', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.html index b4b7bc4c..a0ec06a3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.html @@ -12,27 +12,30 @@

{{ 'GEOFENCES.NAME_DIALOG_TITLE' | translate }}

} -
- - @if (!manualSelect()) { -
- - - map - {{ data.detectedRegion!.displayName }} - - - -
- } @else { - - } -
+ @if (hasRegions) { +
+ +

{{ 'GEOFENCES.REGION_OPTIONAL_HINT' | translate }}

+ @if (!manualSelect()) { +
+ + + map + {{ data.detectedRegion!.displayName }} + + + +
+ } @else { + + } +
+ } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.scss index 91031304..e2e571bc 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.scss @@ -11,9 +11,14 @@ mat-dialog-content { .region-label { font-size: 12px; color: var(--text-secondary, rgba(0, 0, 0, 0.54)); - margin-bottom: 8px; + margin-bottom: 4px; display: block; } +.region-hint { + font-size: 11px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + margin: 0 0 8px; +} .detected-region { display: flex; align-items: center; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.spec.ts index d309cc2c..5fcb24e4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.spec.ts @@ -105,9 +105,9 @@ describe('GeofenceNameDialogComponent', () => { expect(component.selectedRegionId).toBeNull(); }); - it('should be invalid when no region is selected even with a name', () => { + it('should be valid with a name and no region selected (region is optional, #314)', () => { component.displayName = 'My Fence'; - expect(component.isValid).toBe(false); + expect(component.isValid).toBe(true); }); it('should be valid when both name and region are set', () => { @@ -116,6 +116,17 @@ describe('GeofenceNameDialogComponent', () => { expect(component.isValid).toBe(true); }); + it('should save with empty group and parentId 0 when no region is selected (#314)', () => { + component.displayName = 'Region-less Fence'; + component.save(); + + expect(dialogRef.close).toHaveBeenCalledWith({ + displayName: 'Region-less Fence', + groupName: '', + parentId: 0, + } as GeofenceNameDialogResult); + }); + it('should return correct result when region is manually selected', () => { component.displayName = 'Suburb Fence'; component.selectedRegionId = 2; @@ -128,12 +139,16 @@ describe('GeofenceNameDialogComponent', () => { } as GeofenceNameDialogResult); }); - it('should not save when selected region is not found in regions list', () => { + it('should save with empty group when selected region id is not in the regions list', () => { component.displayName = 'Test'; component.selectedRegionId = 999; component.save(); - expect(dialogRef.close).not.toHaveBeenCalled(); + expect(dialogRef.close).toHaveBeenCalledWith({ + displayName: 'Test', + groupName: '', + parentId: 0, + } as GeofenceNameDialogResult); }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.ts index 9a55ae64..605b74cb 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geofence-name-dialog/geofence-name-dialog.component.ts @@ -6,7 +6,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/materia import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { GeofenceRegion } from '../../../core/models'; import { RegionOption, RegionSelectorComponent } from '../region-selector/region-selector.component'; @@ -32,7 +32,7 @@ export interface GeofenceNameDialogResult { MatIconModule, MatInputModule, RegionSelectorComponent, - TranslateModule, + TranslatePipe, ], selector: 'app-geofence-name-dialog', standalone: true, @@ -44,15 +44,20 @@ export class GeofenceNameDialogComponent { readonly dialogRef = inject(MatDialogRef); displayName = ''; - readonly manualSelect = signal(!this.data.detectedRegion); - readonly namePattern = /^[a-zA-Z0-9 \-'.()&]+$/; - readonly regionOptions: RegionOption[] = this.data.regions.map(r => ({ id: r.id, label: r.displayName, shortLabel: r.displayName, })); + // When Koji defines no regions (a flat project), there is nothing to pick — hide the region UI + // entirely rather than showing an empty dropdown (issue #314). + readonly hasRegions = this.regionOptions.length > 0; + + readonly manualSelect = signal(!this.data.detectedRegion); + + readonly namePattern = /^[a-zA-Z0-9 \-'.()&]+$/; + selectedRegionId: number | null = this.data.detectedRegion?.id ?? null; get hasInvalidChars(): boolean { @@ -61,7 +66,9 @@ export class GeofenceNameDialogComponent { get isValid(): boolean { const name = this.displayName.trim(); - return name.length > 0 && name.length <= 50 && !this.hasInvalidChars && this.selectedRegionId !== null; + // Region is optional: a private geofence does not need a Koji region. The region/parent is only + // used when an admin later promotes the geofence to a public Koji area. See issue #314. + return name.length > 0 && name.length <= 50 && !this.hasInvalidChars; } onChangeRegion(): void { @@ -75,13 +82,13 @@ export class GeofenceNameDialogComponent { save(): void { if (!this.isValid) return; - const region = this.data.regions.find(r => r.id === this.selectedRegionId); - if (!region) return; + // Region is optional — fall back to an empty group / parentId 0 when none is selected. + const region = this.selectedRegionId !== null ? this.data.regions.find(r => r.id === this.selectedRegionId) : undefined; this.dialogRef.close({ displayName: this.displayName.trim(), - groupName: region.displayName, - parentId: region.id, + groupName: region?.displayName ?? '', + parentId: region?.id ?? 0, } as GeofenceNameDialogResult); } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geojson-import-dialog/geojson-import-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geojson-import-dialog/geojson-import-dialog.component.ts index 54244970..cdd0e603 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geojson-import-dialog/geojson-import-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/geojson-import-dialog/geojson-import-dialog.component.ts @@ -10,7 +10,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { GeoJsonImportResult, GeofenceRegion } from '../../../core/models'; import { I18nService } from '../../../core/services/i18n.service'; @@ -51,7 +51,7 @@ type DialogStep = 'upload' | 'preview' | 'results'; MatProgressBarModule, MatSelectModule, MatTooltipModule, - TranslateModule, + TranslatePipe, ], selector: 'app-geojson-import-dialog', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.html index 911b7806..61135ca0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.html @@ -1,9 +1,9 @@ @if (selectedGym(); as gym) {
@if (gym.url) { - + } @else { - + }
{{ gym.name ?? gym.id }} @@ -31,9 +31,9 @@
@if (gym.url) { - + } @else { - + }
{{ gym.name ?? gym.id }} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.ts index a9806d20..25fb1a06 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/gym-picker/gym-picker.component.ts @@ -7,7 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { Subject } from 'rxjs'; import { debounceTime, distinctUntilChanged, filter, switchMap, tap } from 'rxjs/operators'; @@ -22,7 +22,7 @@ import { GymSearchResult, ScannerService } from '../../../core/services/scanner. MatIconModule, MatInputModule, MatProgressSpinnerModule, - TranslateModule, + TranslatePipe, ], selector: 'app-gym-picker', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.html new file mode 100644 index 00000000..07054a20 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.html @@ -0,0 +1,17 @@ +
+ + + + + @if (data.alt) { +

{{ data.alt }}

+ } +
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.scss new file mode 100644 index 00000000..6021b2a0 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.scss @@ -0,0 +1,34 @@ +.viewer { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.viewer-close { + position: absolute; + top: 4px; + right: 4px; + z-index: 1; + color: #fff; + background: rgba(0, 0, 0, 0.55); +} + +.viewer-image { + display: block; + max-width: 100%; + max-height: calc(96vh - 60px); + border-radius: 8px; + cursor: zoom-out; +} + +.viewer-caption { + margin: 0; + padding: 0 8px; + font-size: 13px; + text-align: center; + // Sits on the dialog backdrop, not on a surface -- the panel is transparent. + color: rgba(255, 255, 255, 0.9); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.spec.ts new file mode 100644 index 00000000..0c103c76 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.spec.ts @@ -0,0 +1,59 @@ +import { TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { ImageViewerDialogComponent, ImageViewerDialogData } from './image-viewer-dialog.component'; + +describe('ImageViewerDialogComponent', () => { + let dialogRef: { close: jest.Mock }; + + function setup(data: ImageViewerDialogData) { + dialogRef = { close: jest.fn() }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService(), { provide: MAT_DIALOG_DATA, useValue: data }, { provide: MatDialogRef, useValue: dialogRef }], + imports: [ImageViewerDialogComponent], + }); + + const fixture = TestBed.createComponent(ImageViewerDialogComponent); + fixture.detectChanges(); + return fixture; + } + + it('renders the image at the given source with its alt text', () => { + const fixture = setup({ alt: 'Dashboard overview', src: 'assets/help/dashboard-overview.png' }); + + const img = fixture.nativeElement.querySelector('img.viewer-image') as HTMLImageElement; + expect(img.getAttribute('src')).toBe('assets/help/dashboard-overview.png'); + expect(img.getAttribute('alt')).toBe('Dashboard overview'); + }); + + it('shows the alt text as a caption', () => { + const fixture = setup({ alt: 'Dashboard overview', src: 'assets/help/dashboard-overview.png' }); + + expect((fixture.nativeElement.querySelector('.viewer-caption') as HTMLElement).textContent).toContain('Dashboard overview'); + }); + + it('omits the caption when there is no alt text', () => { + const fixture = setup({ alt: '', src: 'assets/help/dashboard-overview.png' }); + + expect(fixture.nativeElement.querySelector('.viewer-caption')).toBeNull(); + }); + + it('closes when the image is clicked', () => { + const fixture = setup({ alt: 'Dashboard overview', src: 'assets/help/dashboard-overview.png' }); + + (fixture.nativeElement.querySelector('img.viewer-image') as HTMLImageElement).click(); + + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('closes when the close button is clicked', () => { + const fixture = setup({ alt: 'Dashboard overview', src: 'assets/help/dashboard-overview.png' }); + + (fixture.nativeElement.querySelector('.viewer-close') as HTMLButtonElement).click(); + + expect(dialogRef.close).toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.ts new file mode 100644 index 00000000..e1e495c3 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/image-viewer-dialog/image-viewer-dialog.component.ts @@ -0,0 +1,30 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe } from '@ngx-translate/core'; + +export interface ImageViewerDialogData { + /** Accessible description of the image; also shown as the caption. */ + alt: string; + src: string; +} + +/** Full-size viewer for the help screenshots, which are downscaled to the width of the help column. */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButtonModule, MatDialogModule, MatIconModule, MatTooltipModule, TranslatePipe], + selector: 'app-image-viewer-dialog', + standalone: true, + styleUrl: './image-viewer-dialog.component.scss', + templateUrl: './image-viewer-dialog.component.html', +}) +export class ImageViewerDialogComponent { + readonly data = inject(MAT_DIALOG_DATA); + readonly dialogRef = inject(MatDialogRef); + + close(): void { + this.dialogRef.close(); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.html deleted file mode 100644 index 948dec81..00000000 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.html +++ /dev/null @@ -1,8 +0,0 @@ - - language - - @for (lang of languages; track lang.code) { - {{ lang.label }} - } - - diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.scss deleted file mode 100644 index a66d9b59..00000000 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.scss +++ /dev/null @@ -1,22 +0,0 @@ -.language-field { - width: 110px; - margin: 0 4px; - - ::ng-deep .mat-mdc-form-field-subscript-wrapper { - display: none; - } - - ::ng-deep .mat-mdc-text-field-wrapper { - height: 36px; - padding: 0 8px; - } - - ::ng-deep .mat-mdc-form-field-infix { - padding: 4px 0; - min-height: unset; - } - - ::ng-deep .mat-mdc-select-trigger { - font-size: 13px; - } -} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.ts deleted file mode 100644 index 3a7d6313..00000000 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Component, inject, signal, OnInit } from '@angular/core'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatSelectModule } from '@angular/material/select'; - -import { LocationService } from '../../../core/services/location.service'; - -interface LanguageOption { - code: string; - label: string; -} - -@Component({ - imports: [MatSelectModule, MatFormFieldModule, MatIconModule], - selector: 'app-language-selector', - standalone: true, - styleUrl: './language-selector.component.scss', - templateUrl: './language-selector.component.html', -}) -export class LanguageSelectorComponent implements OnInit { - private readonly locationService = inject(LocationService); - - readonly languages: LanguageOption[] = [ - { code: 'en', label: 'English' }, - { code: 'de', label: 'Deutsch' }, - { code: 'fr', label: 'Francais' }, - { code: 'es', label: 'Espanol' }, - { code: 'it', label: 'Italiano' }, - { code: 'pt', label: 'Portugues' }, - { code: 'ja', label: 'Japanese' }, - { code: 'ko', label: 'Korean' }, - { code: 'zh', label: 'Chinese' }, - { code: 'ru', label: 'Russian' }, - { code: 'pl', label: 'Polski' }, - { code: 'nl', label: 'Nederlands' }, - { code: 'sv', label: 'Svenska' }, - { code: 'no', label: 'Norsk' }, - { code: 'da', label: 'Dansk' }, - { code: 'fi', label: 'Suomi' }, - { code: 'th', label: 'Thai' }, - { code: 'tr', label: 'Turkish' }, - ]; - - protected readonly selectedLanguage = signal('en'); - - ngOnInit(): void { - const stored = localStorage.getItem('poracle-language'); - if (stored) { - this.selectedLanguage.set(stored); - } - } - - onLanguageChange(locale: string): void { - this.selectedLanguage.set(locale); - localStorage.setItem('poracle-language', locale); - this.locationService.setLanguage(locale).subscribe(); - } -} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html new file mode 100644 index 00000000..00ebb613 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html @@ -0,0 +1,102 @@ +
+ + @for (opt of primaryLevels(); track opt.value) { + + {{ opt.labelKey | translate }} + + } + @if (showAny) { + + {{ anyLevel.labelKey | translate }} + + } + @for (opt of selectedOverflowChips(); track opt.value) { + + {{ opt.labelKey | translate }} + + } + @for (opt of palette(); track opt.value) { + + {{ opt.value }} + + + } + + + @if (overflowLevels().length > 0) { + + + @for (opt of overflowLevels(); track opt.value) { + + } + + } + + @if (isAddClosed()) { + + } @else { + + + + } +
+ +@if (isAddOpen() || addInputError()) { +

+ @if (addInputError()) { + + {{ addInputError()! | translate }} + } @else { + {{ 'RAIDS.LEVEL.ADD_HELP' | translate }} + } +

+} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss new file mode 100644 index 00000000..c6e0184d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss @@ -0,0 +1,189 @@ +:host { + display: block; + background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + border-radius: 8px; + padding: 10px 12px; +} + +.lv { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + row-gap: 8px; +} + +.lv-chips { + display: contents; + + ::ng-deep .mdc-evolution-chip-set__chips { + display: contents; + } +} + +.lv-sep { + margin: 0 4px; + opacity: 0.55; +} + +.lv-num { + font-variant-numeric: tabular-nums; +} + +mat-chip-option { + transition: + box-shadow 180ms ease, + transform 180ms ease; + + &.flash { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mat-sys-primary) 38%, transparent); + } + + // Bump the Material 3 selected-chip emphasis — defaults are too quiet + ::ng-deep &.mdc-evolution-chip--selected .mdc-evolution-chip__cell--primary { + font-weight: 600; + } +} + +.lv-custom .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.lv-add { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 32px; + padding: 0 10px; + border: 1px dashed color-mix(in srgb, var(--mat-sys-outline) 80%, transparent); + border-radius: 16px; + background: transparent; + color: var(--mat-sys-on-surface-variant); + cursor: pointer; + transition: + background 150ms ease, + border-color 150ms ease, + color 150ms ease; + + &:hover { + background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); + border-color: var(--mat-sys-primary); + color: var(--mat-sys-primary); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.lv-more { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 32px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 70%, transparent); + border-radius: 16px; + background: transparent; + color: var(--mat-sys-on-surface-variant); + cursor: pointer; + font-size: 0.82rem; + transition: + background 150ms ease, + border-color 150ms ease, + color 150ms ease; + + &:hover { + background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); + border-color: var(--mat-sys-primary); + color: var(--mat-sys-primary); + } + + &.has-active { + border-color: var(--mat-sys-primary); + color: var(--mat-sys-primary); + background: color-mix(in srgb, var(--mat-sys-primary) 6%, transparent); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.lv-add-open { + width: 88px; + padding: 0; + border-style: solid; + border-color: var(--mat-sys-primary); + background: var(--mat-sys-surface); + + input { + width: 100%; + height: 100%; + border: 0; + background: transparent; + padding: 0 10px; + font: inherit; + color: var(--mat-sys-on-surface); + outline: none; + + &::placeholder { + color: var(--mat-sys-on-surface-variant); + opacity: 0.7; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type='number'] { + -moz-appearance: textfield; + } + } +} + +.lv-add-invalid { + border-color: var(--mat-sys-error); + animation: lv-shake 200ms ease; +} + +@keyframes lv-shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-2px); + } + 75% { + transform: translateX(2px); + } +} + +.lv-help { + margin: 8px 0 0; + padding: 0; + font-size: 0.78rem; + color: var(--mat-sys-on-surface-variant); + display: flex; + align-items: center; + gap: 6px; + min-height: 18px; + + .lv-help-icon { + color: var(--mat-sys-error); + font-size: 16px; + width: 16px; + height: 16px; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts new file mode 100644 index 00000000..026f3526 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts @@ -0,0 +1,198 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { LevelSelectorComponent } from './level-selector.component'; +import { ANY_LEVEL_VALUE } from '../../../core/models/raid-level.models'; + +describe('LevelSelectorComponent', () => { + let fixture: ComponentFixture; + let component: LevelSelectorComponent; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), provideTranslateService()], + imports: [LevelSelectorComponent, NoopAnimationsModule], + }); + fixture = TestBed.createComponent(LevelSelectorComponent); + component = fixture.componentInstance; + component.pickerType = 'raid'; + }); + + // Type-narrowing helper for protected members exercised in tests. + function withInternals(c: LevelSelectorComponent) { + return c as unknown as { + toggle(v: number): void; + removeCustom(v: number, e: MouseEvent): void; + openAddInput(): void; + onAddKeydown(e: KeyboardEvent): void; + commitAddInput(): void; + addInputValue: { set(v: string): void; (): string }; + addInputError(): string | null; + isAddClosed(): boolean; + palette(): { value: number }[]; + primaryLevels(): { value: number }[]; + overflowLevels(): { value: number }[]; + }; + } + + it('renders without error', () => { + component.value = [1, 7]; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('seeds the local palette from a custom value on incoming `value`', () => { + component.value = [42, 1]; + expect( + withInternals(component) + .palette() + .map(o => o.value), + ).toEqual([42]); + }); + + it('does NOT persist the palette between component instances', () => { + component.value = [42]; + // Fresh component instance simulates dialog close+reopen + const fresh = TestBed.createComponent(LevelSelectorComponent).componentInstance; + fresh.pickerType = 'raid'; + expect(withInternals(fresh).palette()).toEqual([]); + }); + + it('toggle adds/removes in raid (multi-select) mode', () => { + const emitted: number[][] = []; + component.value = []; + component.valueChange.subscribe(v => emitted.push(v)); + + withInternals(component).toggle(3); + withInternals(component).toggle(5); + expect(emitted).toEqual([[3], [3, 5]]); + + withInternals(component).toggle(3); + expect(emitted[emitted.length - 1]).toEqual([5]); + }); + + it('boss picker is single-select', () => { + component.pickerType = 'boss'; + component.value = [3]; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + withInternals(component).toggle(5); + expect(emitted[0]).toEqual([5]); + }); + + it('boss picker clears when the active chip is toggled again', () => { + component.pickerType = 'boss'; + component.value = [3]; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + withInternals(component).toggle(3); + expect(emitted[0]).toEqual([]); + }); + + it('commitAddInput rejects 0 and negatives via inline error', () => { + const c = withInternals(component); + c.addInputValue.set('0'); + c.commitAddInput(); + expect(c.addInputError()).toBe('RAIDS.LEVEL.INVALID'); + + c.addInputValue.set('-1'); + c.commitAddInput(); + expect(c.addInputError()).toBe('RAIDS.LEVEL.INVALID'); + }); + + it('commitAddInput rejects non-integer input', () => { + const c = withInternals(component); + c.addInputValue.set('7.5'); + c.commitAddInput(); + expect(c.addInputError()).toBe('RAIDS.LEVEL.INVALID'); + }); + + it('commitAddInput snaps 9000 to the ANY chip on raid picker', () => { + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = withInternals(component); + c.addInputValue.set('9000'); + c.commitAddInput(); + + expect(emitted[emitted.length - 1]).toEqual([ANY_LEVEL_VALUE]); + expect(c.palette().map(o => o.value)).not.toContain(ANY_LEVEL_VALUE); + }); + + it('commitAddInput selects an existing known level instead of adding a duplicate', () => { + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = withInternals(component); + c.addInputValue.set('5'); + c.commitAddInput(); + + expect(emitted[emitted.length - 1]).toEqual([5]); + expect(c.palette().map(o => o.value)).not.toContain(5); + }); + + it('commitAddInput adds a new custom into the local palette and selects it', () => { + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = withInternals(component); + c.addInputValue.set('42'); + c.commitAddInput(); + + expect(c.palette().map(o => o.value)).toContain(42); + expect(emitted[emitted.length - 1]).toEqual([42]); + }); + + it('removeCustom removes from the local palette and the selection', () => { + component.value = [42]; + const c = withInternals(component); + expect(c.palette().map(o => o.value)).toContain(42); + + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + c.removeCustom(42, new MouseEvent('click')); + + expect(c.palette().map(o => o.value)).not.toContain(42); + expect(emitted[emitted.length - 1]).toEqual([]); + }); + + it('Escape cancels the add input', () => { + const c = withInternals(component); + c.openAddInput(); + c.addInputValue.set('99'); + c.onAddKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(c.isAddClosed()).toBe(true); + }); + + describe('pickerType-driven primary/overflow split', () => { + it('raid picker shows star + mega in primary, special/shadow/etc. in overflow', () => { + component.pickerType = 'raid'; + const c = withInternals(component); + expect(c.primaryLevels().map(l => l.value)).toEqual([1, 2, 3, 4, 5, 6, 7]); + expect(c.overflowLevels().map(l => l.value)).toEqual([8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + + it('egg picker shows star-only in primary and empty overflow', () => { + component.pickerType = 'egg'; + const c = withInternals(component); + expect(c.primaryLevels().map(l => l.value)).toEqual([1, 2, 3, 4, 5]); + expect(c.overflowLevels()).toEqual([]); + }); + + it('boss picker mirrors raid for chip composition', () => { + component.pickerType = 'boss'; + const c = withInternals(component); + expect(c.primaryLevels().map(l => l.value)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts new file mode 100644 index 00000000..0f4d805d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts @@ -0,0 +1,255 @@ +import { Component, computed, DestroyRef, ElementRef, EventEmitter, inject, Input, OnInit, Output, signal, ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { ANY_LEVEL, ANY_LEVEL_VALUE, isKnownLevel, LevelOption, makeCustomLevel } from '../../../core/models/raid-level.models'; +import { RaidLevelService } from '../../../core/services/raid-level.service'; + +/** + * Chip-based selector for raid/egg/boss levels. Standard star tiers + Mega + * always render as a primary row; the additional Pokémon GO raid types + * (Ultra Beast, Elite, Primal, Shadow, Super Mega, Coordinated) live in a + * "More raid types…" overflow menu so the dialog stays compact. + * + * Custom integers typed via the `+ Add` chip live in the component's local + * state for the dialog session and are seeded from whatever was passed in + * via `[value]`. They are NOT persisted across dialog opens — close the + * dialog and the typed-but-not-saved chips are gone. + * + * `pickerType` determines what the component shows and how it behaves: + * - `raid` : multi-select, primary + overflow, Any chip + * - `egg` : multi-select, primary only (no overflow), no Any + * - `boss` : single-select, primary + overflow, Any chip + */ +@Component({ + imports: [MatButtonModule, MatChipsModule, MatIconModule, MatMenuModule, MatSnackBarModule, MatTooltipModule, TranslatePipe], + selector: 'app-level-selector', + standalone: true, + styleUrl: './level-selector.component.scss', + templateUrl: './level-selector.component.html', +}) +export class LevelSelectorComponent implements OnInit { + /** Explicit two-state machine for the add affordance. */ + private readonly addMode = signal<'closed' | 'open'>('closed'); + /** + * Custom palette — chips for integers not in the canonical 1-19 list. + * Ephemeral: lives only for the lifetime of this component instance. + * Closing the dialog destroys the component and the palette with it. + */ + private readonly customPalette = signal([]); + private readonly destroyRef = inject(DestroyRef); + private readonly raidLevelService = inject(RaidLevelService); + + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + @ViewChild('addInput') addInput?: ElementRef; + protected readonly addInputError = signal(null); + protected readonly addInputValue = signal(''); + protected readonly anyLevel = ANY_LEVEL; + + protected readonly flashValue = signal(null); + + /** Which kind of picker this instance is. Drives layout + behavior. */ + @Input({ required: true }) pickerType!: 'raid' | 'egg' | 'boss'; + /** Levels relegated to the "More raid types…" overflow menu. Empty for eggs. */ + protected readonly overflowLevels = computed(() => { + if (this.pickerType === 'egg') return []; + return this.raidLevelService.levels().filter(l => l.category !== 'star' && l.category !== 'mega'); + }); + + /** Internal selection state, mirrored from `[value]` input. */ + protected readonly selected = signal([]); + + protected readonly hasOverflowSelected = computed(() => { + const sel = new Set(this.selected()); + return this.overflowLevels().some(l => sel.has(l.value)); + }); + + protected isAddClosed = () => this.addMode() === 'closed'; + protected isAddOpen = () => this.addMode() === 'open'; + + protected readonly palette = computed(() => this.customPalette().map(makeCustomLevel)); + /** Levels shown in the primary chip row. Driven by pickerType + live raid-level list. */ + protected readonly primaryLevels = computed(() => { + const all = this.raidLevelService.levels(); + if (this.pickerType === 'egg') { + return all.filter(l => l.category === 'star'); + } + return all.filter(l => l.category === 'star' || l.category === 'mega'); + }); + + protected readonly selectedOverflowChips = computed(() => { + const sel = new Set(this.selected()); + return this.overflowLevels().filter(l => sel.has(l.value)); + }); + + @Output() readonly valueChange = new EventEmitter(); + + protected get multiple(): boolean { + return this.pickerType !== 'boss'; + } + + protected get showAny(): boolean { + return this.pickerType !== 'egg'; + } + + @Input() + set value(next: number[] | null | undefined) { + const safe = (next ?? []).filter(v => Number.isInteger(v) && v >= 1); + this.selected.set(safe); + // Seed the local palette from any custom values on the incoming alarm so + // the chips show pre-selected. Built-in levels (1-19) already render as + // primary/overflow chips; only the truly unknown integers need a custom chip. + const customs = safe.filter(v => !isKnownLevel(v)); + if (customs.length > 0) { + this.customPalette.update(current => { + const seen = new Set(current); + const next2 = [...current]; + for (const v of customs) { + if (!seen.has(v)) { + seen.add(v); + next2.push(v); + } + } + return next2; + }); + } + } + + protected cancelAddInput(): void { + this.closeAdd(); + } + + protected commitAddInput(): void { + const raw = this.addInputValue().trim(); + if (raw === '') { + this.closeAdd(); + return; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed < 1 || String(parsed) !== raw.replace(/^0+(\d)/, '$1')) { + this.addInputError.set('RAIDS.LEVEL.INVALID'); + return; + } + + // Snap 9000 to the dedicated Any chip when surfaced. + if (parsed === ANY_LEVEL_VALUE && this.showAny) { + this.closeAdd(); + if (!this.isSelected(ANY_LEVEL_VALUE)) this.toggle(ANY_LEVEL_VALUE); + this.flash(ANY_LEVEL_VALUE); + return; + } + + // Duplicate of a known level — just select that chip. + if (isKnownLevel(parsed)) { + this.closeAdd(); + if (!this.isSelected(parsed)) this.toggle(parsed); + this.flash(parsed); + return; + } + + // Duplicate of an existing custom chip — flash + select. + if (this.customPalette().includes(parsed)) { + this.addInputError.set(this.translate.instant('RAIDS.LEVEL.DUPLICATE', { value: parsed })); + this.flash(parsed); + if (!this.isSelected(parsed)) this.toggle(parsed); + return; + } + + this.customPalette.update(current => [...current, parsed]); + this.closeAdd(); + if (!this.isSelected(parsed)) this.toggle(parsed); + } + + protected isSelected(value: number): boolean { + return this.selected().includes(value); + } + + ngOnInit(): void { + this.raidLevelService.load(); + } + + protected onAddInput(event: Event): void { + const v = (event.target as HTMLInputElement).value; + this.addInputValue.set(v); + if (this.addInputError()) this.addInputError.set(null); + } + + protected onAddKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter') { + event.preventDefault(); + this.commitAddInput(); + } else if (event.key === 'Escape') { + event.preventDefault(); + this.cancelAddInput(); + } + } + + protected openAddInput(): void { + this.addMode.set('open'); + this.addInputValue.set(''); + this.addInputError.set(null); + queueMicrotask(() => this.addInput?.nativeElement.focus()); + } + + protected removeCustom(value: number, event: MouseEvent): void { + event.stopPropagation(); + const wasSelected = this.selected().includes(value); + this.customPalette.update(current => current.filter(v => v !== value)); + if (wasSelected) { + const next = this.selected().filter(v => v !== value); + this.selected.set(next); + this.valueChange.emit(next); + } + const ref = this.snackBar.open(this.translate.instant('RAIDS.LEVEL.REMOVED', { value }), this.translate.instant('COMMON.UNDO'), { + duration: 3000, + }); + ref + .onAction() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.customPalette.update(current => (current.includes(value) ? current : [...current, value])); + if (wasSelected) { + const next = [...this.selected(), value]; + this.selected.set(next); + this.valueChange.emit(next); + } + }); + } + + protected toggle(value: number): void { + const current = this.selected(); + let next: number[]; + if (this.multiple) { + next = current.includes(value) ? current.filter(v => v !== value) : [...current, value]; + } else { + next = current.includes(value) && current.length === 1 ? [] : [value]; + } + this.selected.set(next); + this.valueChange.emit(next); + } + + protected toggleFromOverflow(value: number): void { + this.toggle(value); + this.flash(value); + } + + private closeAdd(): void { + this.addMode.set('closed'); + this.addInputValue.set(''); + this.addInputError.set(null); + } + + private flash(value: number): void { + this.flashValue.set(value); + setTimeout(() => { + if (this.flashValue() === value) this.flashValue.set(null); + }, 600); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.html index 49c28054..4e0e5695 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.html @@ -1,40 +1,42 @@ -

{{ 'DIALOG.LOCATION_TITLE' | translate }}

+

{{ (data?.pickOnly ? 'DIALOG.LOCATION_PICK_TITLE' : 'DIALOG.LOCATION_TITLE') | translate }}

@if (saving()) { } - - - {{ 'DIALOG.LOCATION_SEARCH' | translate }} - search - - @if (searching()) { - sync - } - - @for (result of searchResults(); track $index) { - -
- {{ getPlaceIcon(result) }} -
- {{ getAddressPrimary(result) }} - {{ getAddressSecondary(result) }} -
-
-
- } - @if (searchResults().length === 0 && searching()) { - - {{ 'DIALOG.LOCATION_SEARCHING' | translate }} - + @if (!geocodingDisabled) { + + + {{ 'DIALOG.LOCATION_SEARCH' | translate }} + search + + @if (searching()) { + sync } -
-
+ + @for (result of searchResults(); track $index) { + +
+ {{ getPlaceIcon(result) }} +
+ {{ getAddressPrimary(result) }} + {{ getAddressSecondary(result) }} +
+
+
+ } + @if (searchResults().length === 0 && searching()) { + + {{ 'DIALOG.LOCATION_SEARCHING' | translate }} + + } +
+ + }
@@ -68,6 +70,7 @@

{{ 'DIALOG.LOCATION_TITLE' | translate }}

diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.scss index beb9c6bc..54a5df93 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.scss @@ -1,3 +1,16 @@ +// One dialog, one size. Six callers each passed their own width and one said 400px, so the same +// dialog looked different depending on where you opened it from. The component decides now and no +// caller passes a width. +// +// Sized on the CONTENT, not on :host. A width on the host makes the component wider than the padded +// surface it sits in, which put a horizontal scrollbar under the whole dialog. This is the shape the +// other dialogs in the app already use. +mat-dialog-content { + box-sizing: border-box; + max-width: 100%; + min-width: min(560px, 84vw); +} + .full-width { width: 100%; } @@ -30,6 +43,9 @@ margin-top: 1px; } .mini-map { + // width:100% plus a 1px border made it 2px wider than every sibling, which is what was left of the + // dialog's horizontal scrollbar after the width fix. Measured at 514px against 512. + box-sizing: border-box; width: 100%; height: 200px; border-radius: 8px; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts index 7647488d..a9545e9c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts @@ -9,7 +9,7 @@ import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import * as L from 'leaflet'; import { Subject } from 'rxjs'; import { debounceTime, switchMap, takeUntil, filter, distinctUntilChanged } from 'rxjs/operators'; @@ -17,6 +17,13 @@ import { debounceTime, switchMap, takeUntil, filter, distinctUntilChanged } from import { Location, GeocodingResult } from '../../../core/models'; import { I18nService } from '../../../core/services/i18n.service'; import { LocationService } from '../../../core/services/location.service'; +import { SettingsService } from '../../../core/services/settings.service'; + +/** Extra options for {@link LocationDialogComponent}. */ +export interface LocationDialogData { + /** Close with the chosen coordinates instead of saving them as the profile pin. */ + pickOnly?: boolean; +} @Component({ imports: [ @@ -30,7 +37,7 @@ import { LocationService } from '../../../core/services/location.service'; MatProgressBarModule, MatAutocompleteModule, MatListModule, - TranslateModule, + TranslatePipe, ], selector: 'app-location-dialog', standalone: true, @@ -49,17 +56,30 @@ export class LocationDialogComponent implements OnInit, OnDestroy { }); private readonly locationService = inject(LocationService); - private map: L.Map | null = null; private readonly mapContainerRef = viewChild>('mapContainer'); + private marker: L.Marker | null = null; - private readonly search$ = new Subject(); + private readonly search$ = new Subject(); + private readonly settingsService = inject(SettingsService); private skipNextReverse = false; + private readonly snackBar = inject(MatSnackBar); - readonly data = inject(MAT_DIALOG_DATA); + /** + * `pickOnly` borrows this dialog as a coordinate picker without touching the profile pin. Saving + * unconditionally is what the dialog was for, so a caller that only wants a point (naming a place, + * say) would otherwise silently move the user's pin on the way past. + */ + readonly data = inject<(LocationDialogData & Location) | null>(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); + /** + * When the operator has switched off geocoding, hide the address search rather than let it 403. + * A feature-disabled 403 makes the error interceptor navigate to /dashboard, which would throw the + * user out of this dialog mid-edit. Coordinates and the map still work. See #420. + */ + readonly geocodingDisabled = this.settingsService.isDisabled('disable_nominatim'); latitude = this.data?.latitude ?? 0; readonly locating = signal(false); @@ -182,8 +202,14 @@ export class LocationDialogComponent implements OnInit, OnDestroy { save(): void { if (!this.isValid()) return; - this.saving.set(true); const loc: Location = { latitude: this.latitude, longitude: this.longitude }; + + if (this.data?.pickOnly) { + this.dialogRef.close(loc); + return; + } + + this.saving.set(true); this.locationService.setLocation(loc).subscribe({ error: () => { this.saving.set(false); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-warning/location-warning.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-warning/location-warning.component.ts index f339fb67..f2b23b33 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-warning/location-warning.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-warning/location-warning.component.ts @@ -1,11 +1,11 @@ import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatIconModule, MatTooltipModule, TranslateModule], + imports: [MatIconModule, MatTooltipModule, TranslatePipe], selector: 'app-location-warning', standalone: true, styleUrl: './location-warning.component.scss', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/onboarding/onboarding.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/onboarding/onboarding.component.ts index b09dde6b..2b8da28d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/onboarding/onboarding.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/onboarding/onboarding.component.ts @@ -5,7 +5,7 @@ import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatStepperModule } from '@angular/material/stepper'; import { RouterLink } from '@angular/router'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { firstValueFrom, forkJoin, catchError, of } from 'rxjs'; import { AreaService } from '../../../core/services/area.service'; @@ -15,7 +15,7 @@ import { SettingsService } from '../../../core/services/settings.service'; import { LocationDialogComponent } from '../location-dialog/location-dialog.component'; @Component({ - imports: [CommonModule, MatButtonModule, MatIconModule, MatStepperModule, RouterLink, TranslateModule], + imports: [CommonModule, MatButtonModule, MatIconModule, MatStepperModule, RouterLink, TranslatePipe], selector: 'app-onboarding', standalone: true, styles: [ @@ -318,7 +318,6 @@ export class OnboardingComponent implements OnInit { async openLocationDialog() { const dialogRef = this.dialog.open(LocationDialogComponent, { - width: '600px', data: null, }); const result = await firstValueFrom(dialogRef.afterClosed()); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.html new file mode 100644 index 00000000..e693a599 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.html @@ -0,0 +1,61 @@ +

+ place + {{ 'WHERE.PLACES_TITLE' | translate }} + +

+ +

{{ 'WHERE.PLACES_PAGE_DESC' | translate }}

+ +@if (loading()) { +
+ @for (i of skeletons; track i) { +
+
+
+
+
+
+
+
+
+ } +
+} @else if (places.named().length === 0) { +
+ add_location_alt +

{{ 'WHERE.PLACES_EMPTY_TITLE' | translate }}

+

{{ 'WHERE.PLACES_EMPTY' | translate }}

+ +
+} @else { +
+ @for (place of places.named(); track place.label) { +
+
+
+
+ place +
+ {{ place.label }} + {{ place.latitude | number: '1.4-4' }}, {{ place.longitude | number: '1.4-4' }} +
+
+ +
+
+ } +
+} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.scss new file mode 100644 index 00000000..92306514 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.scss @@ -0,0 +1,166 @@ +.section-action { + margin-left: auto; +} + +.section-description { + color: var(--text-secondary, rgb(0 0 0 / 54%)); + font-size: 13px; + margin: -8px 24px 12px; +} + +.place-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + padding: 0 24px; +} + +.place-card { + background: var(--card-bg, #fff); + border: 1px solid var(--card-border, rgb(0 0 0 / 12%)); + border-radius: 12px; + box-shadow: 0 1px 3px rgb(0 0 0 / 6%); + overflow: hidden; + position: relative; + transition: + transform 0.2s, + box-shadow 0.2s; + + &:hover { + box-shadow: 0 4px 12px rgb(0 0 0 / 10%); + transform: translateY(-2px); + } +} + +.place-card-accent { + background: #e91e63; + height: 4px; +} + +.place-card-body { + padding: 16px 16px 12px; +} + +.place-card-header { + align-items: flex-start; + display: flex; + gap: 10px; +} + +.place-card-icon { + color: #e91e63; + flex-shrink: 0; + font-size: 22px; + height: 22px; + margin-top: 1px; + width: 22px; +} + +.place-card-info { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.place-card-name { + font-size: 15px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.place-card-coords { + color: var(--text-secondary, rgb(0 0 0 / 54%)); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.place-card-footer { + border-top: 1px solid var(--divider, rgb(0 0 0 / 6%)); + display: flex; + justify-content: flex-end; + margin-top: 10px; + padding-top: 4px; +} + +.place-empty-state { + align-items: center; + background: var(--card-bg, rgb(0 0 0 / 1%)); + border: 2px dashed var(--card-border, rgb(0 0 0 / 12%)); + border-radius: 12px; + display: flex; + flex-direction: column; + margin: 16px 24px 0; + padding: 32px 24px; + text-align: center; +} + +.place-empty-icon { + color: var(--text-secondary, rgb(0 0 0 / 38%)); + font-size: 40px; + height: 40px; + margin-bottom: 8px; + width: 40px; +} + +.place-empty-title { + font-size: 15px; + font-weight: 500; + margin: 0 0 4px; +} + +.place-empty-subtitle { + color: var(--text-secondary, rgb(0 0 0 / 54%)); + font-size: 13px; + margin: 0 0 16px; + max-width: 40ch; +} + +.skeleton-place-card { + animation: pulse 1.5s ease-in-out infinite; + background: var(--card-bg, #fff); + border: 1px solid var(--card-border, rgb(0 0 0 / 12%)); + border-radius: 12px; + border-top: 4px solid var(--skeleton-bg, rgb(0 0 0 / 8%)); + padding: 14px 16px; +} + +.skeleton-place-header { + align-items: center; + display: flex; + gap: 10px; +} + +.skeleton-place-icon { + background: var(--skeleton-bg, rgb(0 0 0 / 8%)); + border-radius: 50%; + height: 22px; + width: 22px; +} + +.skeleton-place-lines { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; +} + +.skeleton-line { + background: var(--skeleton-bg, rgb(0 0 0 / 8%)); + border-radius: 6px; + height: 12px; +} + +@media (max-width: 599px) { + .place-grid { + grid-template-columns: 1fr; + padding: 0 16px; + } + + .place-empty-state { + margin: 16px; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.spec.ts new file mode 100644 index 00000000..7239d5df --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.spec.ts @@ -0,0 +1,140 @@ +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of, throwError } from 'rxjs'; + +import { PlacesSectionComponent } from './places-section.component'; +import { ConfigService } from '../../../core/services/config.service'; +import { PlacesService } from '../../../core/services/places.service'; + +describe('PlacesSectionComponent', () => { + let dialog: { open: jest.Mock }; + let places: { + add: jest.Mock; + load: jest.Mock; + named: jest.Mock; + pin: jest.Mock; + remove: jest.Mock; + }; + let snackBar: { open: jest.Mock }; + + /** Queues what each successive dialog.open() should resolve to. */ + function queueDialogResults(...results: unknown[]): void { + results.forEach(result => dialog.open.mockReturnValueOnce({ afterClosed: () => of(result) })); + } + + function create(): PlacesSectionComponent { + dialog = { open: jest.fn() }; + snackBar = { open: jest.fn() }; + places = { + named: jest.fn().mockReturnValue([{ label: 'work', latitude: 1, longitude: 2 }]), + add: jest.fn().mockReturnValue(of({ named: [], default: null })), + load: jest.fn().mockReturnValue(of({ named: [], default: null })), + pin: jest.fn().mockReturnValue({ label: '', latitude: 3, longitude: 4 }), + remove: jest.fn().mockReturnValue(of(void 0)), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: MatDialog, useValue: dialog }, + { provide: MatSnackBar, useValue: snackBar }, + { provide: PlacesService, useValue: places }, + { provide: ConfigService, useValue: { apiHost: 'http://test' } }, + provideHttpClient(), + provideHttpClientTesting(), + ], + imports: [PlacesSectionComponent], + }); + + // MatDialogModule is in the component's own imports, so its MatDialog wins over the TestBed + // provider. Overriding at the component injector is the only level that beats it. + TestBed.overrideComponent(PlacesSectionComponent, { + set: { + providers: [ + { provide: MatDialog, useValue: dialog }, + { provide: MatSnackBar, useValue: snackBar }, + ], + }, + }); + + const component = TestBed.createComponent(PlacesSectionComponent).componentInstance; + component.ngOnInit(); + return component; + } + + it('borrows the location dialog as a picker rather than moving the profile pin', () => { + // Without pickOnly the location dialog saves whatever point is chosen as the user's pin, so + // naming a place would quietly relocate every alarm that has no override. + const component = create(); + queueDialogResults(undefined); + + component.addPlace(); + + expect(dialog.open.mock.calls[0][1].data).toMatchObject({ pickOnly: true }); + }); + + it('saves the place once a point is picked and a name given', () => { + const component = create(); + queueDialogResults({ latitude: 10, longitude: 20 }, 'gym'); + + component.addPlace(); + + expect(places.add).toHaveBeenCalledWith({ label: 'gym', latitude: 10, longitude: 20 }); + }); + + it('saves nothing when the naming step is cancelled', () => { + // ConfirmDialog's prompt closes with false on cancel, which is falsy in the same way an empty + // name is: both mean no place. + const component = create(); + queueDialogResults({ latitude: 10, longitude: 20 }, false); + + component.addPlace(); + + expect(places.add).not.toHaveBeenCalled(); + }); + + it('offers the existing names so the prompt can refuse a duplicate', () => { + const component = create(); + queueDialogResults({ latitude: 10, longitude: 20 }, false); + + component.addPlace(); + + expect(dialog.open.mock.calls[1][1].data.promptField.existingNames).toEqual(['work']); + }); + + it('says how many alerts are in the way when a place cannot be deleted', () => { + // The 409 carries the alarms still pointing at it. "Could not delete" would leave the user with + // nothing to act on. + const component = create(); + places.remove.mockReturnValue( + throwError(() => new HttpErrorResponse({ error: { referencingRules: ['pokemon 7', 'raid 9'] }, status: 409 })), + ); + + component.removePlace({ label: 'work', latitude: 1, longitude: 2 }); + + expect(snackBar.open).toHaveBeenCalledWith('WHERE.PLACE_IN_USE', expect.anything(), expect.anything()); + }); + + it('reports a plain failure when the delete fails for any other reason', () => { + const component = create(); + places.remove.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.removePlace({ label: 'work', latitude: 1, longitude: 2 }); + + expect(snackBar.open).toHaveBeenCalledWith('WHERE.PLACE_DELETE_ERROR', expect.anything(), expect.anything()); + }); + + it('deletes only after the confirmation is accepted', () => { + const component = create(); + queueDialogResults(false); + + component.confirmRemove({ label: 'work', latitude: 1, longitude: 2 }); + + expect(places.remove).not.toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.ts new file mode 100644 index 00000000..59ca4c9d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.ts @@ -0,0 +1,150 @@ +import { DecimalPipe } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Location, SavedPlace } from '../../../core/models'; +import { PlacesService } from '../../../core/services/places.service'; +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'; +import { LocationDialogComponent } from '../location-dialog/location-dialog.component'; + +/** + * The places a user's alarms can be aimed at: the profile pin, plus whatever they have named. + * + * A section of the Areas page, directly under the Location card that holds the pin. The pin and the + * named places are the same kind of thing — points an alert can measure from — and three design + * reviews all landed on the same objection to giving them separate homes. The pin is not repeated in + * this grid because the card immediately above it is the pin. + * + * Adding a place borrows the location dialog as a coordinate picker rather than growing a second map, + * then asks for the name separately, because picking a point and naming it are two decisions and + * putting them on one screen makes both feel like a form. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DecimalPipe, MatButtonModule, MatIconModule, MatTooltipModule, TranslatePipe], + selector: 'app-places-section', + standalone: true, + styleUrl: './places-section.component.scss', + templateUrl: './places-section.component.html', +}) +export class PlacesSectionComponent implements OnInit { + private readonly dialog = inject(MatDialog); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + readonly busy = signal(false); + + readonly loading = signal(true); + readonly places = inject(PlacesService); + /** Placeholder count while loading: enough to fill a row without implying how many you have. */ + readonly skeletons = [0, 1, 2]; + + addPlace(): void { + const picker = this.dialog.open(LocationDialogComponent, { + data: { latitude: this.places.pin()?.latitude ?? 0, longitude: this.places.pin()?.longitude ?? 0, pickOnly: true }, + }); + + picker.afterClosed().subscribe((point?: Location) => { + if (!point) return; + this.nameAndSave(point); + }); + } + + confirmRemove(place: SavedPlace): void { + this.dialog + .open(ConfirmDialogComponent, { + data: { + confirmText: this.translate.instant('COMMON.DELETE'), + message: this.translate.instant('WHERE.PLACE_DELETE_CONFIRM', { place: place.label }), + title: this.translate.instant('WHERE.PLACE_DELETE_TITLE'), + }, + }) + .afterClosed() + .subscribe(confirmed => { + if (confirmed) this.removePlace(place); + }); + } + + ngOnInit(): void { + this.reload(); + } + + removePlace(place: SavedPlace): void { + this.busy.set(true); + this.places.remove(place.label).subscribe({ + error: (err: HttpErrorResponse) => { + this.busy.set(false); + + // 409 carries the alarms still pointing at the place. Naming them is the difference between + // "could not delete" and knowing what to repoint first. + const rules: string[] = err.status === 409 ? (err.error?.referencingRules ?? []) : []; + this.snackBar.open( + rules.length > 0 + ? this.translate.instant('WHERE.PLACE_IN_USE', { count: rules.length, place: place.label }) + : this.translate.instant('WHERE.PLACE_DELETE_ERROR'), + this.translate.instant('COMMON.OK'), + { duration: 6000 }, + ); + }, + next: () => { + this.busy.set(false); + this.snackBar.open(this.translate.instant('WHERE.PLACE_DELETED', { place: place.label }), this.translate.instant('COMMON.OK'), { + duration: 3000, + }); + }, + }); + } + + private nameAndSave(point: Location): void { + // ConfirmDialog's promptField already does the name-with-duplicate-check, so this reuses it rather + // than adding a third dialog that asks for a single string. + const naming = this.dialog.open(ConfirmDialogComponent, { + width: '420px', + data: { + confirmText: this.translate.instant('COMMON.SAVE'), + message: this.translate.instant('WHERE.NAME_PLACE_MESSAGE'), + promptField: { + existingNames: this.places.named().map(p => p.label), + label: this.translate.instant('WHERE.PLACE_NAME'), + value: '', + }, + title: this.translate.instant('WHERE.NAME_PLACE_TITLE'), + }, + }); + + naming.afterClosed().subscribe((label?: false | string) => { + if (!label) return; + + this.busy.set(true); + this.places.add({ label, latitude: point.latitude, longitude: point.longitude }).subscribe({ + error: (err: HttpErrorResponse) => { + this.busy.set(false); + // PoracleNG reports a rejected label inside its own response, so the API turns it into a 400 + // with the reason. Showing that beats a generic failure: it is usually "you already have one". + this.snackBar.open(err.error?.error ?? this.translate.instant('WHERE.PLACE_SAVE_ERROR'), this.translate.instant('COMMON.OK'), { + duration: 6000, + }); + }, + next: () => { + this.busy.set(false); + this.snackBar.open(this.translate.instant('WHERE.PLACE_SAVED', { place: label }), this.translate.instant('COMMON.OK'), { + duration: 3000, + }); + }, + }); + }); + } + + private reload(): void { + this.loading.set(true); + this.places.load().subscribe({ + error: () => this.loading.set(false), + next: () => this.loading.set(false), + }); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.html index 15923e3a..7be63d3f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.html @@ -10,7 +10,7 @@ {{ 'POKEMON_SELECTOR.TYPE' | translate }} @for (type of availableTypes(); track type) { }
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.ts index 8fffb1d4..8d9dcbf8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/pokemon-selector/pokemon-selector.component.ts @@ -6,7 +6,7 @@ import { MatChipsModule } from '@angular/material/chips'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; import { I18nService } from '../../../core/services/i18n.service'; import { IconService } from '../../../core/services/icon.service'; @@ -20,7 +20,7 @@ interface GenRange { } @Component({ - imports: [ReactiveFormsModule, MatAutocompleteModule, MatChipsModule, MatFormFieldModule, MatInputModule, MatIconModule, TranslateModule], + imports: [ReactiveFormsModule, MatAutocompleteModule, MatChipsModule, MatFormFieldModule, MatInputModule, MatIconModule, TranslatePipe], selector: 'app-pokemon-selector', standalone: true, styleUrl: './pokemon-selector.component.scss', @@ -202,4 +202,12 @@ export class PokemonSelectorComponent implements OnInit { this.searchControl.setValue(''); this.searchText.set(''); } + + /** + * Display text for a type chip. The chip's value stays the English name - the icon lookup and the + * filter comparison both key on it - so only the label is translated. + */ + typeLabel(type: string): string { + return this.masterData.getTypeLabel(type); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.spec.ts index 1fc2444e..1ac81e11 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.spec.ts @@ -133,6 +133,18 @@ describe('RegionSelectorComponent', () => { expect(emitSpy).toHaveBeenCalledWith(option); }); + it('should clear selection (not render a blank chip) when the "All Regions" sentinel is picked (#314)', () => { + const emitSpy = jest.spyOn(component.regionSelected, 'emit'); + component.onOptionSelected({ id: 1, label: 'Test', shortLabel: 'Test' }); + emitSpy.mockClear(); + + component.onOptionSelected({ label: '' }); + + expect(component.selectedOption()).toBeNull(); + expect(component.searchText()).toBe(''); + expect(emitSpy).toHaveBeenCalledWith({ label: '' }); + }); + it('should clear selection and emit empty label on clearSelection', () => { const emitSpy = jest.spyOn(component.regionSelected, 'emit'); component.onOptionSelected({ id: 1, label: 'Test', shortLabel: 'Test' }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.ts index ad907b35..c809edb0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/region-selector/region-selector.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, input, output, signal } from '@angular/core'; +import { Component, effect, computed, input, output, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatButtonModule } from '@angular/material/button'; @@ -6,7 +6,7 @@ import { MatChipsModule } from '@angular/material/chips'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslatePipe } from '@ngx-translate/core'; export interface RegionOption { count?: number; @@ -31,7 +31,7 @@ export interface RegionGroup { MatFormFieldModule, MatIconModule, MatInputModule, - TranslateModule, + TranslatePipe, ], selector: 'app-region-selector', standalone: true, @@ -40,7 +40,32 @@ export interface RegionGroup { }) export class RegionSelectorComponent { readonly regions = input([]); + readonly selectedOption = signal(null); + /** + * The region the caller says is already chosen, by id or label. + */ + /* Declared and then never read: the chip rendered purely off selectedOption, which starts null. So + * the approval dialog showed an empty picker for a submission that already carried a region, while + * still submitting the seeded id -- the admin approved a region they were never shown, and any touch + * of the picker replaced it with 0. See #650. */ + readonly selectedValue = input(null); + + /** Mirrors selectedValue into the visible selection whenever either side changes. See #650. */ + private readonly seedFromSelectedValue = effect(() => { + const wanted = this.selectedValue(); + const available = this.regions(); + if (wanted === null || wanted === undefined || wanted === '') { + return; + } + + const match = available.find(r => r.id === wanted || r.label === wanted || r.shortLabel === wanted); + if (match && this.selectedOption()?.id !== match.id) { + this.selectedOption.set(match); + } + }); + readonly searchText = signal(''); + readonly filteredGroups = computed((): RegionGroup[] => { const search = this.searchText().toLowerCase(); const all = this.regions(); @@ -61,13 +86,11 @@ export class RegionSelectorComponent { }); readonly label = input('Select Region'); + readonly placeholder = input('Search regions...'); readonly regionSelected = output(); - readonly selectedOption = signal(null); - readonly selectedValue = input(null); - readonly showCounts = input(false); clearSelection(): void { @@ -89,6 +112,12 @@ export class RegionSelectorComponent { } onOptionSelected(option: RegionOption): void { + // The "All Regions" sentinel has an empty label — treat it as a clear so the field returns to the + // search input rather than rendering a blank chip (issue #314). + if (!option.label) { + this.clearSelection(); + return; + } this.selectedOption.set(option); this.searchText.set(''); this.regionSelected.emit(option); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.html new file mode 100644 index 00000000..981a1ab0 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.html @@ -0,0 +1,3 @@ +@if (labelKey(); as key) { + {{ key | translate }} +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.scss new file mode 100644 index 00000000..6fe68c11 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.scss @@ -0,0 +1,19 @@ +:host { + display: contents; +} + +// Status badge — mirrors the .clean-tag micro-badge, themed with the M3 primary +// so it reads as a sibling status indicator next to the auto-delete tag. +.rsvp-tag { + display: inline-block; + padding: 1px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + line-height: 16px; + background: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); + flex-shrink: 0; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.spec.ts new file mode 100644 index 00000000..33be5267 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { RsvpPillComponent } from './rsvp-pill.component'; + +describe('RsvpPillComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService()], + imports: [RsvpPillComponent], + }); + fixture = TestBed.createComponent(RsvpPillComponent); + }); + + it('should render nothing when value is 0', () => { + fixture.componentRef.setInput('value', 0); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.rsvp-tag')).toBeNull(); + }); + + it('should render nothing when value is null', () => { + fixture.componentRef.setInput('value', null); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.rsvp-tag')).toBeNull(); + }); + + it('should render nothing when value is undefined', () => { + fixture.componentRef.setInput('value', undefined); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.rsvp-tag')).toBeNull(); + }); + + it('should render the include badge when value is 1', () => { + fixture.componentRef.setInput('value', 1); + fixture.detectChanges(); + const tag = fixture.nativeElement.querySelector('.rsvp-tag'); + expect(tag?.textContent).toContain('RAIDS.RSVP_PILL_INCLUDE'); + }); + + it('should render the only badge when value is 2', () => { + fixture.componentRef.setInput('value', 2); + fixture.detectChanges(); + const tag = fixture.nativeElement.querySelector('.rsvp-tag'); + expect(tag?.textContent).toContain('RAIDS.RSVP_PILL_ONLY'); + }); + + it('should render nothing for out-of-range values', () => { + fixture.componentRef.setInput('value', 3); + fixture.detectChanges(); + expect(fixture.componentInstance.labelKey()).toBeNull(); + expect(fixture.nativeElement.querySelector('.rsvp-tag')).toBeNull(); + + fixture.componentRef.setInput('value', -1); + fixture.detectChanges(); + expect(fixture.componentInstance.labelKey()).toBeNull(); + expect(fixture.nativeElement.querySelector('.rsvp-tag')).toBeNull(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.ts new file mode 100644 index 00000000..b345f4aa --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { TranslatePipe } from '@ngx-translate/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslatePipe], + selector: 'app-rsvp-pill', + standalone: true, + styleUrl: './rsvp-pill.component.scss', + templateUrl: './rsvp-pill.component.html', +}) +export class RsvpPillComponent { + readonly value = input(0); + + readonly labelKey = computed(() => { + switch (this.value()) { + case 1: + return 'RAIDS.RSVP_PILL_INCLUDE'; + case 2: + return 'RAIDS.RSVP_PILL_ONLY'; + default: + return null; + } + }); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.html new file mode 100644 index 00000000..fc284ece --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.html @@ -0,0 +1,13 @@ +
+
{{ 'RAIDS.RSVP_LABEL' | translate }}
+ + {{ 'RAIDS.RSVP_OFF' | translate }} + {{ 'RAIDS.RSVP_INCLUDE' | translate }} + {{ 'RAIDS.RSVP_ONLY' | translate }} + +

{{ descriptionKey() | translate }}

+
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.scss new file mode 100644 index 00000000..19362cda --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.scss @@ -0,0 +1,55 @@ +:host { + display: block; +} + +// Section block: uppercase header (legend) + control + hint, like the dialog's other sections. +.rsvp-field { + margin-top: 16px; +} + +.rsvp-legend { + margin: 0 0 6px; + padding: 0; + color: var(--text-muted, rgba(0, 0, 0, 0.64)); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +// Full-width segmented control with equal-width options. +.rsvp-toggle { + display: flex; + width: 100%; + max-width: 100%; + box-sizing: border-box; + // M3 defaults the segmented group to a pill radius; square it off for a crisper Material feel. + --mat-button-toggle-shape: 4px; + + ::ng-deep .mat-button-toggle { + flex: 1 1 0; + min-width: 0; + } + + // Stretch the clickable button to the tallest segment so wrapped and + // single-line options share a height... + ::ng-deep .mat-button-toggle-button { + height: 100%; + } + + // ...and center the (possibly wrapping) label vertically + horizontally. + ::ng-deep .mat-button-toggle-label-content { + display: flex; + align-items: center; + justify-content: center; + white-space: normal; + line-height: 1.25; + padding: 8px 12px; + } +} + +.rsvp-hint { + margin: 6px 0 0; + font-size: 12px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + line-height: 1.4; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.spec.ts new file mode 100644 index 00000000..0658e314 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.spec.ts @@ -0,0 +1,83 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormControl } from '@angular/forms'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { RsvpToggleComponent } from './rsvp-toggle.component'; + +describe('RsvpToggleComponent', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService()], + imports: [RsvpToggleComponent, NoopAnimationsModule], + }); + fixture = TestBed.createComponent(RsvpToggleComponent); + }); + + it('should render three toggle options under a labelled legend', () => { + fixture.componentRef.setInput('control', new FormControl(0)); + fixture.detectChanges(); + + const el: HTMLElement = fixture.nativeElement; + expect(el.querySelectorAll('mat-button-toggle').length).toBe(3); + expect(el.querySelector('.rsvp-legend')?.textContent).toContain('RAIDS.RSVP_LABEL'); + }); + + it('should show the description of the selected mode as a hint', () => { + const control = new FormControl(0); + fixture.componentRef.setInput('control', control); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.rsvp-hint')?.textContent).toContain('RAIDS.RSVP_OFF_DESC'); + + control.setValue(2); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.rsvp-hint')?.textContent).toContain('RAIDS.RSVP_ONLY_DESC'); + }); + + it('should reflect the bound control value on the toggle group', () => { + const control = new FormControl(2); + fixture.componentRef.setInput('control', control); + fixture.detectChanges(); + + const group = fixture.nativeElement.querySelector('mat-button-toggle-group'); + const checked = group?.querySelector('mat-button-toggle.mat-button-toggle-checked'); + expect(checked).toBeTruthy(); + expect(checked?.textContent).toContain('RAIDS.RSVP_ONLY'); + }); + + it('should propagate user selection back to the bound control', () => { + const control = new FormControl(0); + fixture.componentRef.setInput('control', control); + fixture.detectChanges(); + + const toggles = fixture.nativeElement.querySelectorAll('mat-button-toggle button'); + // Click the third toggle button (value = 2) + (toggles[2] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(control.value).toBe(2); + }); + + it('should not change value when the bound control is disabled', () => { + const control = new FormControl({ disabled: true, value: 1 }); + fixture.componentRef.setInput('control', control); + fixture.detectChanges(); + + const toggles = fixture.nativeElement.querySelectorAll('mat-button-toggle button'); + (toggles[2] as HTMLButtonElement).click(); + fixture.detectChanges(); + + expect(control.value).toBe(1); + }); + + it('should name the toggle group for assistive tech', () => { + fixture.componentRef.setInput('control', new FormControl(0)); + fixture.detectChanges(); + + const group = fixture.nativeElement.querySelector('mat-button-toggle-group'); + expect(group?.getAttribute('aria-label')).toContain('RAIDS.RSVP_LABEL'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.ts new file mode 100644 index 00000000..acd945eb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.ts @@ -0,0 +1,28 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { TranslatePipe } from '@ngx-translate/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, MatButtonToggleModule, TranslatePipe], + selector: 'app-rsvp-toggle', + standalone: true, + styleUrl: './rsvp-toggle.component.scss', + templateUrl: './rsvp-toggle.component.html', +}) +export class RsvpToggleComponent { + readonly control = input.required>(); + + /** i18n key describing the currently selected mode, shown as a hint below the toggle. */ + descriptionKey(): string { + switch (this.control().value) { + case 1: + return 'RAIDS.RSVP_INCLUDE_DESC'; + case 2: + return 'RAIDS.RSVP_ONLY_DESC'; + default: + return 'RAIDS.RSVP_OFF_DESC'; + } + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.html new file mode 100644 index 00000000..eae0b88c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.html @@ -0,0 +1,76 @@ +@if (!hideHeading()) { +

{{ 'WHERE.SHEET_TITLE' | translate }}

+} + + + + + + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.scss new file mode 100644 index 00000000..7ed2e6a4 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.scss @@ -0,0 +1,99 @@ +.scope-heading { + font-size: 14px; + font-weight: 500; + margin: 0 0 12px; +} + +.scope-options { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.scope-option { + display: block; +} + +.scope-option-detail { + color: var(--text-secondary, rgb(0 0 0 / 60%)); + font-size: 0.8rem; + margin: 0 0 0 2.25rem; +} + +.scope-option-body { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0.5rem 0 0 2.25rem; +} + +.scope-distance { + max-width: 8rem; +} + +.scope-areas { + min-width: 16rem; +} + +// The pin being unset is the difference between an alarm that works and one that silently never +// fires, so it is a warning rather than a hint. +// Wraps rather than squeezes. At phone width the icon was being crushed to a sliver and the action +// squashed into three lines against the right edge; measured at 390px. +.scope-warning { + align-items: flex-start; + color: var(--mat-sys-error, #d32f2f); + display: flex; + flex-wrap: wrap; + font-size: 0.78rem; + gap: 0.35rem; + margin: 0.5rem 0 0 2.25rem; +} + +.scope-warning span { + flex: 1 1 12rem; +} + +.scope-warning mat-icon { + flex: 0 0 auto; + margin-top: 1px; +} + +.scope-warning mat-icon { + font-size: 1rem; + height: 1rem; + width: 1rem; +} + +.scope-pin-unset { + color: var(--text-secondary, rgb(0 0 0 / 54%)); + font-size: 0.8em; +} + +.scope-add-icon, +.scope-own-icon { + font-size: 1rem; + height: 1rem; + opacity: 0.7; + vertical-align: middle; + width: 1rem; +} + +// The warning names a problem the user can fix without leaving the alarm they are editing. +.scope-warning-action { + --mdc-text-button-label-text-color: var(--mat-sys-error, #d32f2f); + + flex: 0 0 auto; + font-size: 0.78rem; + min-width: 0; + padding: 0 6px; +} + +// The 2.25rem indent lines options up with the radio labels, which is right on a wide dialog and +// wasteful on a phone, where it costs a fifth of the usable width. +@media (max-width: 599px) { + .scope-option-body, + .scope-option-detail, + .scope-warning { + margin-left: 1.25rem; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.spec.ts new file mode 100644 index 00000000..6cef0c47 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.spec.ts @@ -0,0 +1,71 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { ComponentRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { ScopePickerComponent } from './scope-picker.component'; +import { ConfigService } from '../../../core/services/config.service'; +import { AlarmScope } from '../../utils/alarm-scope'; + +describe('ScopePickerComponent', () => { + let fixture: ComponentFixture; + let ref: ComponentRef; + + function create(scope: AlarmScope): ScopePickerComponent { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: ConfigService, useValue: { apiHost: 'http://test' } }, + provideHttpClient(), + provideHttpClientTesting(), + ], + imports: [ScopePickerComponent], + }); + fixture = TestBed.createComponent(ScopePickerComponent); + ref = fixture.componentRef; + ref.setInput('scope', scope); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('opens on the scope the host passed, not its own default', () => { + // Seeding in the constructor read the model default instead of the input, then wrote it straight + // back — silently discarding an alarm's real scope when editing it, and the Alert Defaults + // preference when creating one. A signal input is not populated until after construction. + const picker = create({ distanceKm: 3, mode: 'place', placeLabel: 'work' }); + + expect(picker.mode()).toBe('near'); + expect(picker.placeLabel()).toBe('work'); + expect(picker.distanceKm()).toBe(3); + expect(picker.scope()).toEqual({ distanceKm: 3, mode: 'place', placeLabel: 'work' }); + }); + + it('opens on the inherited scope without inventing a radius', () => { + const picker = create({ mode: 'profile' }); + + expect(picker.mode()).toBe('inherit'); + expect(picker.scope()).toEqual({ mode: 'profile' }); + }); + + it('opens on the areas an alarm is confined to', () => { + const picker = create({ areas: ['terrigal'], mode: 'areas' }); + + expect(picker.mode()).toBe('areas'); + expect(picker.selectedAreas()).toEqual(['terrigal']); + }); + + it('reads a bare radius as measured from the pin', () => { + const picker = create({ distanceKm: 2, mode: 'profile' }); + + expect(picker.mode()).toBe('near'); + expect(picker.placeLabel()).toBe(''); + }); + + it('warns only when measuring from a pin that is not set', () => { + expect(create({ distanceKm: 2, mode: 'profile' }).pinMissing()).toBe(true); + expect(create({ mode: 'profile' }).pinMissing()).toBe(false); + expect(create({ areas: ['terrigal'], mode: 'areas' }).pinMissing()).toBe(false); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.ts new file mode 100644 index 00000000..cf885ffb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/scope-picker/scope-picker.component.ts @@ -0,0 +1,215 @@ +import { ChangeDetectionStrategy, Component, Injector, OnInit, computed, effect, inject, input, model, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { AreaService } from '../../../core/services/area.service'; +import { PlacesService } from '../../../core/services/places.service'; +import { UserGeofenceService } from '../../../core/services/user-geofence.service'; +import { AlarmScope, titleCaseArea } from '../../utils/alarm-scope'; +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'; +import { LocationDialogComponent } from '../location-dialog/location-dialog.component'; + +/** + * What the picker offers, which is not quite what PoracleNG stores. "Near a point" covers both a + * radius from the profile pin and a radius from a saved place, because to a person those are one + * choice with a target rather than two unrelated modes. + */ +type PickerMode = 'areas' | 'inherit' | 'near'; + +/** + * Where an alarm reaches you. The one control for that decision, wherever it is asked. + * + * It used to be asked twice in two different shapes: a two-option radio inside the alarm dialogs, and + * a three-option sheet from the card chip. That was not only inconsistent, it was lossy — the dialog + * version had no "only in specific areas", so a per-alarm area override could not be set until after + * the alarm existed. The two drifted apart within a day of being written, which is the argument for a + * shared component rather than two carefully-matched copies. + * + * The three options are a radio group because PoracleNG treats them as mutually exclusive: a place + * with areas, areas with a radius, or a place without one are all refused. Modelled as a choice, + * those states cannot be expressed, so there is nothing to validate and no error copy to write. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatRadioModule, + MatSelectModule, + TranslatePipe, + ], + selector: 'app-scope-picker', + standalone: true, + styleUrl: './scope-picker.component.scss', + templateUrl: './scope-picker.component.html', +}) +export class ScopePickerComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly dialog = inject(MatDialog); + private readonly geofenceService = inject(UserGeofenceService); + private readonly injector = inject(Injector); + private readonly translate = inject(TranslateService); + + /** + * Sentinel for the "add a place" row. Creating a place is only ever wanted at this exact moment, + * and sending someone to another screen to do it lost the alarm they were editing. + */ + protected readonly ADD_PLACE = '__add-place__'; + + /** + * Admin areas plus the user's own geofences. Their own are listed because PoracleWeb writes them + * past PoracleNG's user-selectable filter; without that they would be offered and then refused. + */ + readonly availableAreas = signal<{ name: string; own: boolean }[]>([]); + + readonly distanceKm = signal(1); + + /** The dialog puts the same question in its title bar, so it suppresses the inline one. */ + readonly hideHeading = input(false); + + readonly mode = signal('inherit'); + + /** Empty means the profile pin; anything else is a saved place's label. */ + readonly placeLabel = signal(''); + + readonly places = inject(PlacesService); + + /** + * True when the alarm would measure from a pin the user has never set. PoracleNG falls back to 0,0 + * and alerts on nothing useful, so this is worth saying at the moment the choice is made rather + * than leaving someone to wonder why an alarm is silent. + */ + readonly pinMissing = computed(() => this.mode() === 'near' && !this.placeLabel() && !this.places.pin()); + + /** Areas the profile subscribes to, so the inherited option can say what it means. */ + readonly profileAreas = input([]); + + readonly profileAreaSummary = computed(() => this.profileAreas().map(titleCaseArea).join(', ')); + + /** The alarm's scope. Two-way, so a host can seed it and read it back without an event dance. */ + readonly scope = model({ distanceKm: 1, mode: 'profile' }); + + readonly selectedAreas = signal([]); + + ngOnInit(): void { + // Seed here, not in the constructor: a signal input is not populated until after construction, so + // reading it there got the model's own default and wrote it straight back over whatever the host + // passed. That silently discarded the Alert Defaults preference on a new alarm and an existing + // alarm's own scope when editing one. + const initial = this.scope(); + this.mode.set(initialMode(initial)); + this.placeLabel.set(initial.placeLabel ?? ''); + this.distanceKm.set(initial.distanceKm || 1); + this.selectedAreas.set(initial.areas ?? []); + + // Only mirror outward after seeding, or the write-back races the seed. + effect(() => this.scope.set(this.currentScope()), { injector: this.injector }); + + this.places.load().subscribe({ error: () => undefined }); + + this.areaService.getAvailable().subscribe({ + error: () => undefined, + next: areas => this.availableAreas.update(current => [...areas.map(a => ({ name: a.name, own: false })), ...current]), + }); + + this.geofenceService.getCustomGeofences().subscribe({ + error: () => undefined, + next: own => this.availableAreas.update(current => [...current, ...own.map(g => ({ name: g.kojiName, own: true }))]), + }); + } + + /** Opens the place picker when the add row is chosen, and selects whatever comes back. */ + onTargetChange(value: string): void { + if (value !== this.ADD_PLACE) { + this.placeLabel.set(value); + return; + } + + // Seed with the pin so the map opens somewhere recognisable. 0,0 put it in the Atlantic. + const anchor = this.places.pin(); + + this.dialog + .open(LocationDialogComponent, { + data: { latitude: anchor?.latitude ?? 0, longitude: anchor?.longitude ?? 0, pickOnly: true }, + }) + .afterClosed() + .subscribe((point?: { latitude: number; longitude: number }) => { + if (point) this.namePlace(point); + }); + } + + /** + * Sets the profile pin from here. Sending someone to Areas & Places would lose the alarm they are in + * the middle of, and this is the moment they find out they need one. + */ + setPin(): void { + this.dialog + .open(LocationDialogComponent, { data: this.places.pin() }) + .afterClosed() + .subscribe((saved?: { latitude: number; longitude: number }) => { + // The dialog writes the pin itself; re-reading is what clears the warning. + if (saved) this.places.load().subscribe({ error: () => undefined }); + }); + } + + protected titleCase(area: string): string { + return titleCaseArea(area); + } + + private currentScope(): AlarmScope { + switch (this.mode()) { + case 'areas': + return { areas: this.selectedAreas(), mode: 'areas' }; + case 'near': + return this.placeLabel() + ? { distanceKm: this.distanceKm(), mode: 'place', placeLabel: this.placeLabel() } + : { distanceKm: this.distanceKm(), mode: 'profile' }; + default: + return { mode: 'profile' }; + } + } + + private namePlace(point: { latitude: number; longitude: number }): void { + this.dialog + .open(ConfirmDialogComponent, { + width: '420px', + data: { + confirmText: this.translate.instant('COMMON.SAVE'), + message: this.translate.instant('WHERE.NAME_PLACE_MESSAGE'), + promptField: { + existingNames: this.places.named().map(p => p.label), + label: this.translate.instant('WHERE.PLACE_NAME'), + value: '', + }, + title: this.translate.instant('WHERE.NAME_PLACE_TITLE'), + }, + }) + .afterClosed() + .subscribe((label?: false | string) => { + if (!label) return; + + this.places.add({ label, latitude: point.latitude, longitude: point.longitude }).subscribe({ + error: () => undefined, + // Select it straight away: the only reason to make a place here is to use it here. + next: () => this.placeLabel.set(label), + }); + }); + } +} + +/** A stored scope back into the picker's three options. A pin radius lands on "near", not "inherit". */ +function initialMode(scope: AlarmScope): PickerMode { + if (scope.mode === 'areas') return 'areas'; + if (scope.mode === 'place') return 'near'; + return (scope.distanceKm ?? 0) > 0 ? 'near' : 'inherit'; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.html new file mode 100644 index 00000000..d6417515 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.html @@ -0,0 +1,104 @@ + + +
+

{{ 'ADMIN.VERSIONS_TITLE' | translate }}

+ +
+ + @if (serverLoading()) { + + } @else if (serverProfile(); as p) { + +

{{ 'ADMIN.VERSIONS_WEB' | translate }}

+
+
{{ 'ADMIN.SERVER_VERSION' | translate }}
+
{{ p.web.version ?? ('ADMIN.SERVER_UNKNOWN' | translate) }}
+ + @if (p.web.revision) { +
{{ 'ADMIN.VERSIONS_BUILD' | translate }}
+
{{ p.web.revision.slice(0, 7) }}
+ } +
+ + @if (p.webUpdate.state === 'Behind') { +
+ system_update_alt + {{ + 'ADMIN.UPDATE_AVAILABLE' | translate: { name: 'PoracleWeb', running: p.webUpdate.running, latest: p.webUpdate.latest } + }} +
+ } @else if (p.webUpdate.state === 'PreRelease') { +
+ science + {{ 'ADMIN.UPDATE_PRERELEASE' | translate: { name: 'PoracleWeb', running: p.webUpdate.running } }} +
+ } @else if (p.webUpdate.state === 'UpToDate') { +

{{ 'ADMIN.UPDATE_CURRENT' | translate }}

+ } @else if (p.webUpdate.latest) { + +

{{ 'ADMIN.UPDATE_UNCOMPARABLE' | translate: { latest: p.webUpdate.latest } }}

+ } + + +

{{ 'ADMIN.SERVER_TITLE' | translate }}

+ + @if (p.belowMinimum) { +
+ error + {{ 'ADMIN.SERVER_TOO_OLD' | translate: { version: p.version, minimum: p.minimumSupported } }} +
+ } @else if (!p.reachable) { +
+ cloud_off + {{ 'ADMIN.SERVER_UNREACHABLE' | translate }} +
+ } + +
+
{{ 'ADMIN.SERVER_VERSION' | translate }}
+
{{ p.version ?? ('ADMIN.SERVER_UNKNOWN' | translate) }}
+ +
{{ 'ADMIN.SERVER_SCHEMA' | translate }}
+
{{ p.schemaVersion ?? ('ADMIN.SERVER_UNKNOWN' | translate) }}
+ +
{{ 'ADMIN.SERVER_CHECKED' | translate }}
+
{{ p.checkedAt | date: 'short' }}
+
+ + @if (p.poracleUpdate.state === 'Behind') { +
+ system_update_alt + {{ + 'ADMIN.UPDATE_AVAILABLE' | translate: { name: 'Poracle', running: p.poracleUpdate.running, latest: p.poracleUpdate.latest } + }} +
+ } @else if (p.poracleUpdate.state === 'PreRelease') { +
+ science + {{ 'ADMIN.UPDATE_PRERELEASE' | translate: { name: 'Poracle', running: p.poracleUpdate.running } }} +
+ } @else if (p.poracleUpdate.state === 'UpToDate') { +

{{ 'ADMIN.UPDATE_CURRENT' | translate }}

+ } + +

{{ 'ADMIN.SERVER_CAPABILITIES' | translate }}

+ @if (enabledCapabilities().length === 0) { +

{{ 'ADMIN.SERVER_NO_CAPABILITIES' | translate }}

+ } @else { +
+ @for (capability of enabledCapabilities(); track capability) { + {{ capability }} + } +
+ } + } @else { +

{{ 'ADMIN.SERVER_UNREACHABLE' | translate }}

+ } +
+
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.scss new file mode 100644 index 00000000..8ed4c8dc --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.scss @@ -0,0 +1,121 @@ +.server-card { + margin: 0 0 16px; +} + +.server-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + } +} + +.server-alert { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 12px 0; + padding: 10px 12px; + border-radius: 8px; + font-size: 13px; + line-height: 1.45; + + mat-icon { + flex-shrink: 0; + } +} + +// Red for "this will not work", amber for "nobody knows yet" — the two are different problems and the +// second one resolves itself when the server comes back. +.server-alert-error { + background: #ffebee; + color: #b71c1c; +} + +.server-alert-warn { + background: #fff8e1; + color: #e65100; +} + +.server-facts { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 16px; + margin: 12px 0 0; + font-size: 13px; + + dt { + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + } + + dd { + margin: 0; + font-variant-numeric: tabular-nums; + } +} + +.server-caps-title { + margin: 16px 0 8px; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.server-caps { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.server-cap { + padding: 2px 10px; + border-radius: 10px; + background: #e8f5e9; + color: #2e7d32; + font-size: 12px; + font-weight: 600; +} + +.server-caps-empty { + margin: 0; + font-size: 13px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); +} + +// Blue for "there is something newer", distinct from the red that means "this will not work" and the +// amber that means "nobody knows yet". An update is information, not a fault. +.server-alert-update { + background: #e3f2fd; + color: #0d47a1; +} + +.server-alert-info { + background: #ede7f6; + color: #4527a0; +} + +.server-section { + margin: 20px 0 8px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + + &:first-of-type { + margin-top: 12px; + } +} + +.server-current { + margin: 8px 0 0; + font-size: 12px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.spec.ts new file mode 100644 index 00000000..269bda23 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.spec.ts @@ -0,0 +1,158 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of, throwError } from 'rxjs'; + +import { ServerProfileCardComponent } from './server-profile-card.component'; +import { PoracleServerProfile } from '../../../core/models'; +import { AdminService } from '../../../core/services/admin.service'; + +/** + * The card exists so "this server is too old for the feature you just used" is visible somewhere other + * than the logs. These cover the states it has to tell apart: fine, old, unreachable, and unknown. + */ +describe('ServerProfileCardComponent', () => { + let adminService: { getServerProfile: jest.Mock }; + + const upToDate = { latest: null, running: null, state: 'UpToDate' as const }; + + const base: PoracleServerProfile = { + belowMinimum: false, + capabilities: { autocreate: true, buttons: true, snapshots: false }, + checkedAt: '2026-08-19T19:00:00Z', + minimumSupported: '5.1.0', + poracleUpdate: upToDate, + reachable: true, + schemaVersion: 5, + version: '5.1.0', + web: { buildDate: '2026-08-19T21:00:00Z', revision: 'fbfc16a17198bb6847914f7e4f0bedd57440ea61', version: '2.16.0' }, + webUpdate: upToDate, + }; + + function setup(profile: PoracleServerProfile | null) { + adminService = { + getServerProfile: jest.fn().mockReturnValue(profile ? of(profile) : throwError(() => new Error('down'))), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + provideHttpClient(), + provideHttpClientTesting(), + { provide: AdminService, useValue: adminService }, + ], + imports: [ServerProfileCardComponent], + }); + + const fixture = TestBed.createComponent(ServerProfileCardComponent); + fixture.detectChanges(); + return fixture; + } + + it('lists only the capabilities that are on, in order', () => { + // A key reported false means the binary knows the feature and has it switched off; listing it + // beside the live ones would read as support. + const component = setup(base).componentInstance; + + expect(component.enabledCapabilities()).toEqual(['autocreate', 'buttons']); + }); + + it('shows the version and schema once loaded', () => { + const fixture = setup(base); + + expect(fixture.componentInstance.serverLoading()).toBe(false); + expect(fixture.nativeElement.textContent).toContain('5.1.0'); + expect(fixture.nativeElement.textContent).toContain('5'); + }); + + it('warns when the server is older than this build needs', () => { + const fixture = setup({ ...base, belowMinimum: true, version: '5.0.4' }); + + expect(fixture.nativeElement.querySelector('.server-alert-error')).not.toBeNull(); + }); + + it('says so when the server did not answer', () => { + const fixture = setup({ ...base, capabilities: {}, reachable: false, version: null }); + + expect(fixture.nativeElement.querySelector('.server-alert-warn')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.server-alert-error')).toBeNull(); + }); + + it('does not warn about age on a healthy server', () => { + // The legitimate twin: a banner that shows on a good server is a banner people stop reading. + const fixture = setup(base); + + expect(fixture.nativeElement.querySelector('.server-alert')).toBeNull(); + }); + + it('stops loading rather than spinning forever when the call fails', () => { + const fixture = setup(null); + + expect(fixture.componentInstance.serverLoading()).toBe(false); + expect(fixture.componentInstance.serverProfile()).toBeNull(); + }); + + it('names this site as well as the Poracle it talks to', () => { + // The first version of this card described only Poracle, so the site's own build -- the half an + // admin is most likely to be behind on -- appeared nowhere. + const fixture = setup(base); + const text = fixture.nativeElement.textContent; + + expect(text).toContain('2.16.0'); + expect(text).toContain('fbfc16a'); + expect(text).toContain('5.1.0'); + }); + + it('names the latest release even on a channel it cannot compare', () => { + // beta is not a point on the release line, so no direction is claimed -- but which release is + // current is still worth saying. + const fixture = setup({ + ...base, + web: { ...base.web, version: 'beta' }, + webUpdate: { latest: 'v2.15.3', running: 'beta', state: 'Unknown' }, + }); + + // The test harness renders keys without interpolating, so the assertion is on which line was + // chosen: the "cannot compare, here is the latest" one rather than a behind/ahead claim. + expect(fixture.nativeElement.textContent).toContain('ADMIN.UPDATE_UNCOMPARABLE'); + expect(fixture.nativeElement.querySelector('.server-alert-update')).toBeNull(); + }); + + it('says when a component is behind its latest release', () => { + const fixture = setup({ + ...base, + poracleUpdate: { latest: '5.2.0', running: '5.1.0', state: 'Behind' }, + }); + + expect(fixture.nativeElement.querySelector('.server-alert-update')).not.toBeNull(); + }); + + it('names a development build rather than calling it out of date', () => { + // Running ahead of every release is how a develop build identifies itself; "update available" + // would be exactly backwards. + const fixture = setup({ + ...base, + poracleUpdate: { latest: '5.1.0', running: '5.2.0', state: 'PreRelease' }, + }); + + expect(fixture.nativeElement.querySelector('.server-alert-info')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('.server-alert-update')).toBeNull(); + }); + + it('shows no update line when both are current', () => { + const fixture = setup(base); + + expect(fixture.nativeElement.querySelector('.server-alert-update')).toBeNull(); + expect(fixture.nativeElement.querySelector('.server-alert-info')).toBeNull(); + }); + + it('re-probes when asked, instead of answering from the cache', () => { + const component = setup(base).componentInstance; + component.refreshServerProfile(); + + expect(adminService.getServerProfile).toHaveBeenNthCalledWith(1, false); + expect(adminService.getServerProfile).toHaveBeenNthCalledWith(2, true); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.ts new file mode 100644 index 00000000..53d8ed9f --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/server-profile-card/server-profile-card.component.ts @@ -0,0 +1,68 @@ +import { DatePipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { PoracleServerProfile } from '../../../core/models'; +import { AdminService } from '../../../core/services/admin.service'; + +/** + * Says which PoracleNG this deployment talks to, and warns when it is too old for this build. + * + * Nothing in the UI said this before. A server below the minimum fails the same quiet way for every + * feature that needs it — the control saves, the column does not exist, the filter does nothing — and + * the only clue was in the logs. + * + * Its own component rather than more markup inside the settings page: that page is already long, and + * this is testable on its own, which the first attempt at putting it on the unrouted admin landing page + * was not. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [DatePipe, MatCardModule, MatIconModule, MatButtonModule, MatProgressSpinnerModule, TranslatePipe], + selector: 'app-server-profile-card', + standalone: true, + styleUrl: './server-profile-card.component.scss', + templateUrl: './server-profile-card.component.html', +}) +export class ServerProfileCardComponent implements OnInit { + private readonly adminService = inject(AdminService); + + readonly serverProfile = signal(null); + + /** Only the capabilities that are on; a false key means the binary knows it and has it switched off. */ + readonly enabledCapabilities = computed(() => + Object.entries(this.serverProfile()?.capabilities ?? {}) + .filter(([, enabled]) => enabled) + .map(([name]) => name) + .sort((a, b) => a.localeCompare(b)), + ); + + readonly serverLoading = signal(true); + + ngOnInit(): void { + this.load(false); + } + + refreshServerProfile(): void { + this.load(true); + } + + private load(refresh: boolean): void { + this.serverLoading.set(true); + this.adminService.getServerProfile(refresh).subscribe({ + // A failed probe is itself an answer; the card says so rather than spinning forever. + error: () => { + this.serverProfile.set(null); + this.serverLoading.set(false); + }, + next: profile => { + this.serverProfile.set(profile); + this.serverLoading.set(false); + }, + }); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/template-selector/template-selector.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/template-selector/template-selector.component.ts index d3d5f612..6e2cd53f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/template-selector/template-selector.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/template-selector/template-selector.component.ts @@ -7,7 +7,7 @@ import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { AuthService } from '../../../core/services/auth.service'; import { ConfigService } from '../../../core/services/config.service'; @@ -67,7 +67,7 @@ const CONDITION_I18N_KEYS: Record = { MatSlideToggleModule, MatChipsModule, MatButtonModule, - TranslateModule, + TranslatePipe, ], selector: 'app-template-selector', standalone: true, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.html new file mode 100644 index 00000000..92308b28 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.html @@ -0,0 +1,4 @@ + + {{ icon() }} + {{ label() }} + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.scss new file mode 100644 index 00000000..8bc91857 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.scss @@ -0,0 +1,34 @@ +.where-chip { + align-items: center; + background: var(--chip-bg, rgb(0 0 0 / 6%)); + border-radius: 999px; + color: var(--chip-fg, inherit); + display: inline-flex; + font-size: 0.75rem; + gap: 0.25rem; + line-height: 1.4; + max-width: 100%; + padding: 0.15rem 0.55rem; +} + +// The inherited scope is on almost every card, so it recedes; an override is the exception and reads +// as one. +.where-chip-inherited { + opacity: 0.72; +} + +.where-chip-editable { + cursor: pointer; +} + +.where-chip-icon { + font-size: 1rem; + height: 1rem; + width: 1rem; +} + +.where-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.spec.ts new file mode 100644 index 00000000..762e7d0c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { WhereChipComponent } from './where-chip.component'; + +describe('WhereChipComponent', () => { + let fixture: ComponentFixture; + let ref: ComponentRef; + + function create(inputs: Record): WhereChipComponent { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService()], + imports: [WhereChipComponent], + }); + fixture = TestBed.createComponent(WhereChipComponent); + ref = fixture.componentRef; + Object.entries(inputs).forEach(([key, value]) => ref.setInput(key, value)); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('names the place and radius for a place-scoped alarm', () => { + const chip = create({ overrideLocationLabel: 'work', distance: 2000 }); + + expect(chip.label()).toBe('WHERE.NEAR_PLACE'); + expect(chip.icon()).toBe('place'); + expect(chip.isInherited()).toBe(false); + }); + + it('says the pin, not the areas, for a plain radius', () => { + // The pre-existing "within N km of me" alarm. Reading it as inherited areas would put the opposite + // words on the card, which is the whole reason the profile mode carries a radius. + const chip = create({ distance: 500 }); + + expect(chip.label()).toBe('WHERE.NEAR_PIN'); + expect(chip.isInherited()).toBe(true); + }); + + it('recedes for the inherited scope, since nearly every card has it', () => { + const chip = create({ distance: 0, profileAreas: ['terrigal'] }); + + expect(chip.label()).toBe('WHERE.PROFILE_AREAS'); + expect(chip.icon()).toBe('public'); + expect(chip.isInherited()).toBe(true); + }); + + it('does not claim areas the user has not got', () => { + const chip = create({ distance: 0, profileAreas: [] }); + + expect(chip.label()).toBe('WHERE.PROFILE_ANYWHERE'); + }); + + it('shows the map icon when the alarm is confined to areas', () => { + const chip = create({ overrideAreas: ['terrigal'], distance: 0 }); + + expect(chip.label()).toBe('WHERE.ONLY_IN'); + expect(chip.icon()).toBe('map'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.ts new file mode 100644 index 00000000..067f3a4e --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateService } from '@ngx-translate/core'; + +import { AlarmScope, describeScope, scopeOf } from '../../utils/alarm-scope'; + +/** + * Where an alarm reaches you, as a sentence fragment: "Anywhere in my areas", "Within 2 km of Home", + * "Only in Terrigal, Erina". + * + * Every alarm has always had an answer to this; before per-alarm scope it was an invisible inherited + * one. The chip states it on the card and is the way into the Where sheet, so the same control reads + * and edits the same idea wherever it appears. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIconModule, MatTooltipModule], + selector: 'app-where-chip', + standalone: true, + styleUrl: './where-chip.component.scss', + templateUrl: './where-chip.component.html', +}) +export class WhereChipComponent { + private readonly translate = inject(TranslateService); + + /** The alarm's radius in metres, as PoracleNG stores it. */ + readonly distance = input(0); + + /** False on a read-only surface, where the chip states the scope without offering to change it. */ + readonly editable = input(true); + + /** Areas the alarm is confined to, when it has any. */ + readonly overrideAreas = input(null); + + /** Saved place the alarm measures its radius from, when it has one. */ + readonly overrideLocationLabel = input(null); + + readonly scope = computed(() => scopeOf(this.overrideLocationLabel(), this.overrideAreas(), this.distance())); + + readonly icon = computed(() => { + switch (this.scope().mode) { + case 'areas': + return 'map'; + case 'place': + return 'place'; + default: + return 'public'; + } + }); + + /** The inherited case is the quiet one: it is the default, and most cards will show it. */ + readonly isInherited = computed(() => this.scope().mode === 'profile'); + + /** Areas the profile subscribes to, used only to describe the inherited case. */ + readonly profileAreas = input([]); + + readonly label = computed(() => describeScope(this.scope(), this.profileAreas(), this.translate)); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html new file mode 100644 index 00000000..03593798 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html @@ -0,0 +1,10 @@ +

{{ 'WHERE.SHEET_TITLE' | translate }}

+ + + + + + + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.scss new file mode 100644 index 00000000..4390a240 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.scss @@ -0,0 +1,4 @@ +.where-sheet { + display: block; + min-width: min(28rem, 80vw); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts new file mode 100644 index 00000000..666f5762 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { AlarmScope } from '../../utils/alarm-scope'; +import { ScopePickerComponent } from '../scope-picker/scope-picker.component'; + +export interface WhereSheetData { + /** Areas the profile subscribes to, so the inherited option can say what it means. */ + profileAreas: string[]; + scope: AlarmScope; +} + +/** + * Changing an alarm's scope from its card, where there is no form to put the control in. + * + * A shell around ScopePickerComponent and nothing else. The alarm dialogs render the same component + * inline; when this held its own copy of the control the two drifted apart within a day, and the + * dialog copy was missing an option entirely. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButtonModule, MatDialogModule, ScopePickerComponent, TranslatePipe], + selector: 'app-where-sheet', + standalone: true, + styleUrl: './where-sheet.component.scss', + templateUrl: './where-sheet.component.html', +}) +export class WhereSheetComponent { + readonly data = inject(MAT_DIALOG_DATA); + readonly dialogRef = inject>(MatDialogRef); + + readonly scope = signal(this.data.scope); + + save(): void { + this.dialogRef.close(this.scope()); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts new file mode 100644 index 00000000..c7d2d858 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; + +import { LevelLabelPipe } from './level-label.pipe'; +import { I18nService } from '../../core/services/i18n.service'; + +/** + * Mirror of ngx-translate's "key not found" behavior: if a key has no entry, + * the translated string equals the key. Tests configure `knownKeys` to control + * the boundary between "translated" and "missing". + */ +class FakeI18n { + knownKeys = new Set(); + instant(key: string): string { + return this.knownKeys.has(key) ? `TR(${key})` : key; + } +} + +describe('LevelLabelPipe', () => { + let pipe: LevelLabelPipe; + let i18n: FakeI18n; + + beforeEach(() => { + TestBed.resetTestingModule(); + i18n = new FakeI18n(); + // Seed with every key the canonical 19 levels rely on, plus ANY + CUSTOM. + for (let v = 1; v <= 19; v++) i18n.knownKeys.add(`RAIDS.LEVEL.RAID_${v}`); + i18n.knownKeys.add('RAIDS.LEVEL.ANY'); + i18n.knownKeys.add('RAIDS.LEVEL.CUSTOM'); + TestBed.configureTestingModule({ + providers: [{ provide: I18nService, useValue: i18n }, LevelLabelPipe], + }); + pipe = TestBed.inject(LevelLabelPipe); + }); + + it('translates every known level via its RAID_N key', () => { + for (let v = 1; v <= 19; v++) { + expect(pipe.transform(v)).toBe(`TR(RAIDS.LEVEL.RAID_${v})`); + } + }); + + it('translates 9000 as ANY', () => { + expect(pipe.transform(9000)).toBe('TR(RAIDS.LEVEL.ANY)'); + }); + + it('formats custom levels with the CUSTOM key prefix + integer', () => { + expect(pipe.transform(42)).toBe('TR(RAIDS.LEVEL.CUSTOM) 42'); + expect(pipe.transform(20)).toBe('TR(RAIDS.LEVEL.CUSTOM) 20'); + }); + + it('falls back to the CUSTOM label when a known-level key is missing from i18n', () => { + // Simulate the future case where the canonical list grows to 20 but the + // locale file hasn't been updated yet — the model would surface RAID_20 + // as a labelKey but the i18n returns the bare key. + i18n.knownKeys.delete('RAIDS.LEVEL.RAID_7'); + expect(pipe.transform(7)).toBe('TR(RAIDS.LEVEL.CUSTOM) 7'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts new file mode 100644 index 00000000..12f46325 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts @@ -0,0 +1,40 @@ +import { inject, Pipe, PipeTransform } from '@angular/core'; + +import { resolveLevel } from '../../core/models/raid-level.models'; +import { I18nService } from '../../core/services/i18n.service'; + +/** + * Resolve a stored raid/egg level integer to its display label. + * + * - Levels 1-19 → masterfile names ("1 Star", "Mega Legendary", "Elite", …) + * - 9000 (wildcard sentinel) → "Any" + * - Anything else → "Level {n}" (custom) + * + * Graceful degradation: if a translation key is missing for the level (e.g. a + * future raid_20 ships before the i18n files are updated), ngx-translate + * returns the literal key string. We detect that case and fall back to the + * generic "Level {n}" custom format so users see a number rather than + * "RAIDS.LEVEL.RAID_20". + */ +@Pipe({ + name: 'levelLabel', + standalone: true, +}) +export class LevelLabelPipe implements PipeTransform { + private readonly i18n = inject(I18nService); + + transform(value: number): string { + const opt = resolveLevel(value); + if (opt.category === 'custom') { + return this.i18n.instant(opt.labelKey) + ' ' + opt.value; + } + const translated = this.i18n.instant(opt.labelKey); + // ngx-translate returns the key unchanged when the key isn't found — + // detect that and fall back to a useful generic label rather than leaking + // the raw translation key into the UI. + if (translated === opt.labelKey) { + return this.i18n.instant('RAIDS.LEVEL.CUSTOM') + ' ' + value; + } + return translated; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts new file mode 100644 index 00000000..6bc7c8a5 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts @@ -0,0 +1,101 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { describeScope, formatAreaList, kmToMetres, metresToKm, scopeOf, scopeToFields, titleCaseArea } from './alarm-scope'; + +/** Echoes the key and interpolation so assertions read against the shape, not a translation. */ +const translate = { + instant: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key), +} as unknown as TranslateService; + +describe('alarm-scope', () => { + describe('scopeOf', () => { + it('reads areas as the areas mode', () => { + expect(scopeOf(null, ['terrigal'], 0)).toEqual({ areas: ['terrigal'], mode: 'areas' }); + }); + + it('reads a label and a radius as the place mode, in km', () => { + expect(scopeOf('home', null, 2500)).toEqual({ distanceKm: 2.5, mode: 'place', placeLabel: 'home' }); + }); + + it('reads a radius with no place as measured from the pin, not as inherited areas', () => { + // This is the pre-existing "within N km of me" alarm. Collapsing it into the areas reading would + // put the opposite words on the card. + expect(scopeOf(null, null, 500)).toEqual({ distanceKm: 0.5, mode: 'profile' }); + }); + + it('reads no overrides and no radius as inherited', () => { + expect(scopeOf(null, null, 0)).toEqual({ distanceKm: 0, mode: 'profile' }); + }); + + it('treats an empty area list as inherited rather than as an empty restriction', () => { + // An alarm restricted to no areas would match nothing. PoracleNG stores the cleared state as an + // empty column, so this has to read back as "no override". + expect(scopeOf(null, [], 0)).toEqual({ distanceKm: 0, mode: 'profile' }); + }); + + it('prefers areas when a row somehow carries both', () => { + // PoracleNG refuses to store both, but a row written by an older client might. Areas win because + // they are the more restrictive of the two, so the alarm cannot silently widen. + expect(scopeOf('home', ['terrigal'], 500).mode).toBe('areas'); + }); + }); + + describe('scopeToFields', () => { + it('clears with empty values rather than null, so an override can be taken off', () => { + // null means "not stated, keep what is stored" on the write path. Sending null here would make + // the override impossible to remove. + expect(scopeToFields({ mode: 'profile' })).toEqual({ overrideAreas: [], overrideLocationLabel: '', distance: 0 }); + }); + + it('zeroes the radius when areas are chosen', () => { + // Areas and a radius are mutually exclusive upstream; leaving a stale radius on the form would + // be refused with a message about a field the user did not touch. + expect(scopeToFields({ areas: ['terrigal'], mode: 'areas' }).distance).toBe(0); + }); + + it('sends the place radius in metres', () => { + expect(scopeToFields({ distanceKm: 2.5, mode: 'place', placeLabel: 'home' })).toEqual({ + overrideAreas: [], + overrideLocationLabel: 'home', + distance: 2500, + }); + }); + }); + + describe('describeScope', () => { + it('names the place and the radius', () => { + expect(describeScope({ distanceKm: 2, mode: 'place', placeLabel: 'Home' }, [], translate)).toBe( + 'WHERE.NEAR_PLACE:{"distance":"2","place":"Home"}', + ); + }); + + it('distinguishes an inherited scope with areas from one without', () => { + expect(describeScope({ mode: 'profile' }, ['terrigal'], translate)).toBe('WHERE.PROFILE_AREAS'); + expect(describeScope({ mode: 'profile' }, [], translate)).toBe('WHERE.PROFILE_ANYWHERE'); + }); + + it('says the pin, not the areas, when the inherited scope carries a radius', () => { + expect(describeScope({ distanceKm: 2, mode: 'profile' }, ['terrigal'], translate)).toBe('WHERE.NEAR_PIN:{"distance":"2"}'); + }); + }); + + describe('formatAreaList', () => { + it('title-cases the stored lowercase names', () => { + // Geofence names are lowercase because Poracle matches case-sensitively. People are not. + expect(formatAreaList(['avoca beach'], translate)).toBe('Avoca Beach'); + }); + + it('counts the tail past three', () => { + expect(formatAreaList(['a', 'b', 'c', 'd', 'e'], translate)).toBe('WHERE.AREA_LIST_MORE:{"areas":"A, B, C","count":2}'); + }); + }); + + it('rounds metres to a tenth of a km and back', () => { + expect(metresToKm(2450)).toBe(2.5); + expect(kmToMetres(2.5)).toBe(2500); + }); + + it('leaves an already-capitalised name alone', () => { + expect(titleCaseArea('Avoca Beach')).toBe('Avoca Beach'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts new file mode 100644 index 00000000..8b92fbbb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts @@ -0,0 +1,117 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { AlarmScope, AlarmScopeMode } from '../../core/models'; + +export type { AlarmScope, AlarmScopeMode }; + +/** + * The three answers an alarm can give to "where should this reach me", read off the two override + * fields and the radius. + * + * PoracleNG stores them as independent columns and enforces their mutual exclusion with three + * validation rules. Reading them back into one discriminated value is what lets the UI offer a radio + * group instead of three fields plus error messages, so the invalid combinations cannot be expressed. + */ +export function scopeOf( + overrideLocationLabel: null | string | undefined, + overrideAreas: null | string[] | undefined, + distanceMetres: number, +): AlarmScope { + if (overrideAreas && overrideAreas.length > 0) { + return { areas: overrideAreas, mode: 'areas' }; + } + + if (overrideLocationLabel) { + return { + distanceKm: metresToKm(distanceMetres), + mode: 'place', + placeLabel: overrideLocationLabel, + }; + } + + // A radius with no place is the behaviour that predates per-alarm scope: measured from the profile + // pin. It has to survive as its own reading, or "within 500 m of me" renders as "anywhere in my + // areas" — the same words for the opposite of what the alarm does. + return { distanceKm: metresToKm(distanceMetres), mode: 'profile' }; +} + +/** + * The scope as the fields PoracleNG stores. The unused half is sent as an explicit empty rather than + * null, because null means "not stated, keep what is stored" on the write path — an override could + * otherwise be set but never taken off. + */ +export function scopeToFields(scope: AlarmScope): { + distance: number; + overrideAreas: string[]; + overrideLocationLabel: string; +} { + switch (scope.mode) { + case 'areas': + // Areas and a radius are mutually exclusive upstream, so the radius goes to zero here rather + // than being left at whatever the form last held. + return { overrideAreas: scope.areas ?? [], overrideLocationLabel: '', distance: 0 }; + case 'place': + return { + overrideAreas: [], + overrideLocationLabel: scope.placeLabel ?? '', + distance: kmToMetres(scope.distanceKm ?? 0), + }; + default: + // Inherited scope, with or without a radius from the pin. Both overrides are cleared explicitly. + return { overrideAreas: [], overrideLocationLabel: '', distance: kmToMetres(scope.distanceKm ?? 0) }; + } +} + +/** One line describing the scope, in the second person, for a chip or a summary row. */ +export function describeScope(scope: AlarmScope, profileAreas: string[], translate: TranslateService): string { + switch (scope.mode) { + case 'areas': { + const areas = scope.areas ?? []; + return translate.instant('WHERE.ONLY_IN', { areas: formatAreaList(areas, translate) }); + } + case 'place': + return translate.instant('WHERE.NEAR_PLACE', { + distance: formatDistance(scope.distanceKm ?? 0), + place: scope.placeLabel ?? '', + }); + default: + if ((scope.distanceKm ?? 0) > 0) { + return translate.instant('WHERE.NEAR_PIN', { distance: formatDistance(scope.distanceKm ?? 0) }); + } + + return profileAreas.length > 0 ? translate.instant('WHERE.PROFILE_AREAS') : translate.instant('WHERE.PROFILE_ANYWHERE'); + } +} + +/** + * Area names as a reader would say them. Past three the list stops being informative and starts being + * a wall, so the tail is counted instead. + */ +export function formatAreaList(areas: string[], translate: TranslateService): string { + const shown = areas.slice(0, 3).map(titleCaseArea); + + return areas.length > 3 + ? translate.instant('WHERE.AREA_LIST_MORE', { areas: shown.join(', '), count: areas.length - 3 }) + : shown.join(', '); +} + +/** Geofence names are stored lowercase because Poracle matches case-sensitively; people are not. */ +export function titleCaseArea(area: string): string { + return area + .split(' ') + .map(word => (word.length > 0 ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +/** Trailing zeroes read as false precision on a radius someone typed as "2". */ +export function formatDistance(km: number): string { + return Number.isInteger(km) ? `${km}` : `${km.toFixed(1)}`; +} + +export function metresToKm(metres: number): number { + return Math.round((metres / 1000) * 10) / 10; +} + +export function kmToMetres(km: number): number { + return Math.round(km * 1000); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.spec.ts new file mode 100644 index 00000000..f6deaefc --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.spec.ts @@ -0,0 +1,80 @@ +import { ALL, AUTO_DELETE, compose, EDIT, isAutoDelete, isEdit, isSummary, preserve, SUMMARY } from './clean-flags'; + +describe('clean-flags', () => { + it('constants match the PoracleNG bitmask', () => { + expect(AUTO_DELETE).toBe(1); + expect(EDIT).toBe(2); + expect(SUMMARY).toBe(4); + expect(ALL).toBe(7); + }); + + describe('isAutoDelete', () => { + it.each([ + [0, false], + [1, true], + [2, false], + [3, true], + [5, true], + [7, true], + ])('isAutoDelete(%i) === %s', (clean, expected) => { + expect(isAutoDelete(clean)).toBe(expected); + }); + }); + + describe('isEdit', () => { + it.each([ + [0, false], + [1, false], + [2, true], + [3, true], + [6, true], + [7, true], + ])('isEdit(%i) === %s', (clean, expected) => { + expect(isEdit(clean)).toBe(expected); + }); + }); + + describe('isSummary', () => { + it.each([ + [0, false], + [1, false], + [4, true], + [5, true], + [6, true], + [7, true], + ])('isSummary(%i) === %s', (clean, expected) => { + expect(isSummary(clean)).toBe(expected); + }); + }); + + describe('compose', () => { + it.each([ + [false, false, false, 0], + [true, false, false, 1], + [false, true, false, 2], + [true, true, false, 3], + [false, false, true, 4], + [true, false, true, 5], + [true, true, true, 7], + ])('compose(%s, %s, %s) === %i', (autoDelete, edit, summary, expected) => { + expect(compose(autoDelete, edit, summary)).toBe(expected); + }); + }); + + describe('preserve', () => { + it.each([ + // Clearing auto-delete on clean=5 (auto-delete + summary) leaves summary intact. + [5, 1, 0, 4], + // Setting auto-delete on clean=4 (summary only) yields 5. + [4, 1, 1, 5], + // Replacing only the edit bit leaves auto-delete alone. + [1, 2, 2, 3], + // Changes outside the mask are ignored. + [0, 1, 4, 0], + // No-op when the mask is empty. + [5, 0, 7, 5], + ])('preserve(%i, %i, %i) === %i', (existing, mask, changes, expected) => { + expect(preserve(existing, mask, changes)).toBe(expected); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.ts new file mode 100644 index 00000000..615727b9 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.ts @@ -0,0 +1,47 @@ +/** + * Helpers for the PoracleNG alarm `clean` column, which is a 3-bit bitmask: + * bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. Mirrors PoracleNG's + * db.IsClean / db.IsEdit / db.IsSummary (processor/internal/db/clean.go) and the + * backend CleanFlags helper, so reads and writes preserve bits the web UI does not surface. + */ + +/** Auto-delete bit (bit 1). PoracleNG db.IsClean. */ +export const AUTO_DELETE = 1; + +/** Edit-in-place bit (bit 2). PoracleNG db.IsEdit. */ +export const EDIT = 2; + +/** Summary bit (bit 4). PoracleNG db.IsSummary. */ +export const SUMMARY = 4; + +/** All known bits combined (7). */ +export const ALL = AUTO_DELETE | EDIT | SUMMARY; + +/** True when the auto-delete bit (bit 1) is set. */ +export function isAutoDelete(clean: number): boolean { + return (clean & AUTO_DELETE) !== 0; +} + +/** True when the edit-in-place bit (bit 2) is set. */ +export function isEdit(clean: number): boolean { + return (clean & EDIT) !== 0; +} + +/** True when the summary bit (bit 4) is set. */ +export function isSummary(clean: number): boolean { + return (clean & SUMMARY) !== 0; +} + +/** Composes a clean bitmask from the three known flags. */ +export function compose(autoDelete: boolean, edit: boolean, summary: boolean): number { + return (autoDelete ? AUTO_DELETE : 0) | (edit ? EDIT : 0) | (summary ? SUMMARY : 0); +} + +/** + * Returns `existing` with only the bits in `mask` replaced by the corresponding bits from + * `changes`. Bits outside the mask are left untouched, so bot-set bits the web UI does not + * edit survive a save. + */ +export function preserve(existing: number, mask: number, changes: number): number { + return (existing & ~mask) | (changes & mask); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/location.utils.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/location.utils.ts new file mode 100644 index 00000000..12cad799 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/location.utils.ts @@ -0,0 +1,22 @@ +/** The shape every location-ish value in the app shares. */ +export interface Coordinates { + latitude: number; + longitude: number; +} + +/** + * Poracle stores "no pin" as 0,0 rather than null, so a cleared pin comes back from the API as a + * real pair of coordinates in the Gulf of Guinea. + * + * Clearing the pin looked right until you navigated away and back, because the page set its own + * state to null while the reload took 0,0 at face value and rendered it as coordinates. The same + * literal lives in half a dozen components, each rewriting it; this is the one place it belongs. + */ +export function hasPin(location: Coordinates | null | undefined): boolean { + return !!location && (location.latitude !== 0 || location.longitude !== 0); +} + +/** The location, or null when it is the 0,0 that means unset. */ +export function pinOrNull(location: null | T | undefined): null | T { + return hasPin(location) ? (location as T) : null; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/min-time.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/min-time.spec.ts new file mode 100644 index 00000000..052dedb7 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/min-time.spec.ts @@ -0,0 +1,40 @@ +import { MIN_TIME_PRESETS_SECONDS, minTimeLabel, minTimeOptions, minTimePillLabel } from './min-time'; + +describe('minTimeOptions', () => { + it('offers the presets when the rule has no filter', () => { + expect(minTimeOptions(0)).toEqual([...MIN_TIME_PRESETS_SECONDS]); + }); + + it('offers the presets unchanged when the rule already holds one of them', () => { + expect(minTimeOptions(300)).toEqual([...MIN_TIME_PRESETS_SECONDS]); + }); + + it('keeps a value the bot set that is not a preset, in order', () => { + // Without this the select renders blank and the next save silently drops the filter. + expect(minTimeOptions(137)).toEqual([0, 60, 120, 137, 300, 600, 900, 1200]); + }); +}); + +describe('minTimeLabel', () => { + it('calls no filter what it is', () => { + expect(minTimeLabel(0)).toEqual({ key: 'POKEMON.MIN_TIME_ANY' }); + }); + + it('reads whole minutes as minutes', () => { + expect(minTimeLabel(300)).toEqual({ key: 'POKEMON.MIN_TIME_MINUTES', params: { count: 5 } }); + }); + + it('falls back to seconds for a value that is not whole minutes', () => { + expect(minTimeLabel(137)).toEqual({ key: 'POKEMON.MIN_TIME_SECONDS', params: { count: 137 } }); + }); +}); + +describe('minTimePillLabel', () => { + it('says the pill is a floor, not a duration', () => { + expect(minTimePillLabel(300)).toEqual({ key: 'POKEMON.PILL_TIME_LEFT_MINUTES', params: { count: 5 } }); + }); + + it('falls back to seconds', () => { + expect(minTimePillLabel(137)).toEqual({ key: 'POKEMON.PILL_TIME_LEFT_SECONDS', params: { count: 137 } }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/min-time.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/min-time.ts new file mode 100644 index 00000000..460b2a41 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/min-time.ts @@ -0,0 +1,38 @@ +/** + * The "minimum time left" filter, in the shape a person picks it. + * + * PoracleNG stores seconds and compares them against a spawn's remaining time, so the question the + * filter answers is "can I still get there?". People answer that in minutes, and in round ones: every + * rule in production that uses this field is set to exactly five. A closed list of presets rules out + * both ways a free number field goes wrong here — typing 5 and meaning minutes, which silently asks for + * five seconds, and typing something longer than a spawn lives, which mutes the rule with no error. + */ +export const MIN_TIME_PRESETS_SECONDS = [0, 60, 120, 300, 600, 900, 1200] as const; + +/** + * The presets, plus whatever the rule already holds. + * + * A rule set with the bot can carry any value at all, and a select that does not offer it would show + * blank and quietly rewrite it on the next save. + */ +export function minTimeOptions(current: number): number[] { + const options = [...MIN_TIME_PRESETS_SECONDS] as number[]; + return current > 0 && !options.includes(current) ? [...options, current].sort((a, b) => a - b) : options; +} + +/** Translation key and params for one option. Whole minutes read as minutes; anything else as seconds. */ +export function minTimeLabel(seconds: number): { key: string; params?: Record } { + if (seconds <= 0) return { key: 'POKEMON.MIN_TIME_ANY' }; + if (seconds % 60 === 0) return { key: 'POKEMON.MIN_TIME_MINUTES', params: { count: seconds / 60 } }; + return { key: 'POKEMON.MIN_TIME_SECONDS', params: { count: seconds } }; +} + +/** + * Key and params for the card pill, which has to carry the comparison in the words: a bare "5 min" on a + * card of thresholds reads as a duration rather than a floor. + */ +export function minTimePillLabel(seconds: number): { key: string; params: Record } { + return seconds % 60 === 0 + ? { key: 'POKEMON.PILL_TIME_LEFT_MINUTES', params: { count: seconds / 60 } } + : { key: 'POKEMON.PILL_TIME_LEFT_SECONDS', params: { count: seconds } }; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/pokemon-types.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/pokemon-types.ts new file mode 100644 index 00000000..da7dac4c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/pokemon-types.ts @@ -0,0 +1,33 @@ +/** + * The 18 Pokemon types, keyed by the type ids the game (and PoracleNG's masterdata) use. + * + * These English names are identity, not display text: uicons files are named after the id, alarm + * filters compare by name, and the type filter chips track by name. PoracleNG returns type names + * already translated into the requested locale, so the localized string is kept apart from this and + * used only for rendering — see `MasterDataService.getTypeLabel`. + */ +export const POKEMON_TYPE_NAMES_BY_ID: Record = { + 1: 'Normal', + 2: 'Fighting', + 3: 'Flying', + 4: 'Poison', + 5: 'Ground', + 6: 'Rock', + 7: 'Bug', + 8: 'Ghost', + 9: 'Steel', + 10: 'Fire', + 11: 'Water', + 12: 'Grass', + 13: 'Electric', + 14: 'Psychic', + 15: 'Ice', + 16: 'Dragon', + 17: 'Dark', + 18: 'Fairy', +}; + +/** The inverse of {@link POKEMON_TYPE_NAMES_BY_ID}: English type name to type id. */ +export const POKEMON_TYPE_IDS: Record = Object.fromEntries( + Object.entries(POKEMON_TYPE_NAMES_BY_ID).map(([id, name]) => [name, Number(id)]), +); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index a9a70822..a1dc02fe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -16,7 +16,7 @@ "GYMS": "Gyms", "FORT_CHANGES": "Fort-ændringer", "PROFILES": "Profiler", - "AREAS": "Områder", + "AREAS": "Områder og steder", "MY_GEOFENCES": "Mine geofences", "CLEANING": "Oprydning", "HELP": "Hjælp", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Viser som", - "BACK_TO_ADMIN": "Tilbage til Admin", + "EXIT_IMPERSONATION": "Tilbage til din konto", "DISABLED_ACCOUNT": "Din konto er blevet deaktiveret. Det kan skyldes hastighedsbegrænsning eller en administrativ handling.", + "DISABLED_ACCOUNT_INSPECTED": "Denne konto er blevet deaktiveret af en administrator og modtager ikke notifikationer.", "DISABLED_SUPPORT": "For at få hjælp, spørg i", "PAUSED_ALERTS": "Dine alarmer er sat på pause. Du vil ikke modtage notifikationer.", "RESUME": "Genoptag" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Ændrer kun teksten på dette site.", "PROFILE_PREFIX": "Profil #", "PAUSE_ALERTS": "Pause alarmer", "RESUME_ALERTS": "Genoptag alarmer", "SWITCH_PROFILE": "Skift profil", - "AREAS_LOCATION": "Områder og placering", "CLEANING": "Oprydning", "ACCENT_THEME": "Accenttema", - "LANGUAGE": "Sprog", + "DISPLAY_LANGUAGE": "Visningssprog", + "ALERT_LANGUAGE": "Beskedsprog", + "ALERT_LANGUAGE_HINT": "Bruges til beskedtekst og Pokemon-navne.", "LOGOUT": "Log ud", + "LOGOUT_EVERYWHERE": "Log ud overalt", "ACCENT_DEFAULT": "Standard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Standardindstillinger for advarsler" }, "SHORTCUTS": { "TITLE": "Tastaturgenveje", @@ -77,6 +82,7 @@ "NETWORK": "Kan ikke nå serveren. Tjek din forbindelse.", "BAD_REQUEST": "Ugyldig forespørgsel. Tjek dine input.", "UNAUTHORIZED": "Din session er udløbet. Log ind igen.", + "INSPECTION_ENDED": "Inspektionen er afsluttet – du er tilbage i din egen session.", "FORBIDDEN": "Du har ikke tilladelse til at udføre denne handling.", "NOT_FOUND": "Den anmodede ressource blev ikke fundet.", "CONFLICT": "Der opstod en konflikt. Elementet kan være blevet ændret.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Velkomst-onboarding" }, "POKEMON": { + "PVP_EVOLUTION": "Mega-udvikling", + "PVP_EVOLUTION_HINT": "Ranger grundformerne eller en mega. Megaer rangeres separat, så en mega-regel matcher ikke en grundform.", + "PVP_EVO_BASE": "Grund", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Pokemon-alarmer", "PAGE_DESC": "Spor vilde Pokemon-spawns med brugerdefinerede IV-, CP-, niveau- og PVP-filtre.", "SEARCH_PLACEHOLDER": "Søg efter navn eller #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Form og køn", "LABEL_FORM": "Form", "ALL_FORMS": "Alle former", + "FORM_MULTI_HINT": "Lad stå tomt for at matche alle former", "LABEL_GENDER": "Køn", "GENDER_ALL": "Alle", "GENDER_MALE": "Han", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Giv kun besked, hvis udviklet CP opfylder dette minimum", "PVP_DISABLED_HINT": "Vælg en liga for at filtrere efter PVP-rang.", "SNACK_CREATED": "{{count}} Pokemon-alarm(er) oprettet", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} Pokemon-alarm(er) oprettet, {{duplicates}} spores allerede", "SNACK_UPDATED": "Pokemon-alarm opdateret", "SNACK_DELETED": "Pokemon-alarm slettet", "SNACK_DELETED_ALL": "Alle Pokemon-alarmer slettet", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Niveaugrænse", + "PVP_CAP_ALL": "Alle", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Standard — fra Poracle-konfigurationen", + "FILTER_TIME_LEFT": "Resterende Tid", + "LABEL_MIN_TIME": "Mindste resterende tid", + "MIN_TIME_HINT": "Springer spawns over, der er væk, før du når frem.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "{{count}} min tilbage", + "PILL_TIME_LEFT_SECONDS": "{{count}} s tilbage", + "MIN_TIME_ANY": "Alle" }, "ALARM": { "LOCATION_MODE": "Placeringstilstand", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Sletter automatisk notifikationen fra Discord, når lokkemodulet udløber", "CLEAN_HINT_NEST": "Sletter automatisk notifikationen fra Discord, når reder migrerer", "CLEAN_HINT_GYM": "Sletter automatisk notifikationen fra Discord, når gym-aktiviteten ændres", - "CLEAN_HINT_FORT": "Sletter automatisk notifikationen fra Discord, når den udløber", "CLEAN_HINT_MAX_BATTLE": "Sletter automatisk notifikationen fra Discord, når max-kampen slutter", "SAVING": "Gemmer...", "SAVE": "Gem", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Ventetid aktiv", "TEST_SEND": "Send testnotifikation", "TAB_DELIVERY": "Levering", - "COMMON_SETTINGS": "Fælles indstillinger" + "COMMON_SETTINGS": "Fælles indstillinger", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} oprettet, {{duplicates}} spores allerede" }, "RAIDS": { + "RSVP_LABEL": "RSVP-notifikationer", + "RSVP_OFF": "Kun matches", + "RSVP_INCLUDE": "Matches + RSVP-opdateringer", + "RSVP_ONLY": "Kun RSVP-opdateringer", + "RSVP_OFF_DESC": "Kun standard raid-/æg-notifikationer.", + "RSVP_INCLUDE_DESC": "Giv også besked, når RSVP-antal ændres.", + "RSVP_ONLY_DESC": "Spring indledende matches over; giv kun besked om RSVP-ændringer. Uden en scanner der sender RSVP er alarmen stille.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Kun RSVP", "PAGE_TITLE": "Raid- og æg-alarmer", "PAGE_DESC": "Bliv notificeret om raid-bosser og æg-hatches på nærliggende gyms.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Er du sikker på, at du vil slette ALLE raid- og æg-alarmer? Denne handling kan ikke fortrydes.", "CONFIRM_BULK_DELETE_TITLE": "Slet valgte alarmer", "CONFIRM_BULK_DELETE_MSG": "Er du sikker på, at du vil slette {{count}} alarmer?", - "CONFIRM_DELETE_SELECTED": "Slet valgte" + "CONFIRM_DELETE_SELECTED": "Slet valgte", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Niveau", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Særlige", + "SECTION_CUSTOM": "Egne", + "ADD": "Tilføj niveau", + "ADD_PLACEHOLDER": "f.eks. 42", + "ADD_HELP": "Ethvert positivt heltal, din server bruger. 9000 betyder \"alle niveauer\".", + "INVALID": "Niveauet skal være mindst 1.", + "DUPLICATE": "Niveau {{value}} står allerede på listen.", + "SR_REMOVE": "Fjern eget niveau {{value}}", + "REMOVED": "Niveau {{value}} fjernet", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest-alarmer", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Mega-energi", "TAB_CANDY": "Slik", "ITEM_REWARD": "Genstandsbelønning", - "ANY_ITEM": "Alle genstande", + "ANY_ITEM": "Enhver genstand", "QUEST_TYPE_LABEL": "Quest-type:", "SNACK_CREATED": "Quest-alarm oprettet", "SNACK_UPDATED": "Quest-alarm opdateret", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Alle quest-alarmer slettet", "SNACK_FAILED_DELETE_ALL": "Kunne ikke slette alarmer", "SNACK_FAILED_DISTANCE": "Kunne ikke opdatere afstande", - "CONFIRM_DELETE_SELECTED": "Slet valgte" + "CONFIRM_DELETE_SELECTED": "Slet valgte", + "SUMMARY_MODE": "Daglig oversigt", + "SUMMARY_HINT": "Samler matchende opgaver i én oversigtsbesked i stedet for én notifikation pr. opgave. Kræver en konfigureret oversigtsplan på botten.", + "SUMMARY_BADGE": "Oversigt", + "SUMMARY_SCHEDULE": "Levering af opgaveoversigt", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Opgaveoversigt", + "SUMMARY_SCHEDULE_EMPTY": "Ingen oversigtsplan angivet. Opgaver leveres enkeltvis.", + "SUMMARY_SCHEDULE_EDIT": "Rediger plan", + "SUMMARY_SCHEDULE_CLEAR": "Fjern plan", + "SUMMARY_SCHEDULE_SEND_NOW": "Send oversigt nu", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Leverer questmatch indsamlet siden din seneste oversigt. Hvis intet er bufret endnu, sendes der ingenting.", + "SUMMARY_SCHEDULE_SAVED": "Oversigtsplan gemt", + "SUMMARY_SCHEDULE_CLEARED": "Oversigtsplan fjernet", + "SUMMARY_SCHEDULE_SENT": "Oversigt sendt", + "SUMMARY_SCHEDULE_FAILED": "Kunne ikke opdatere oversigtsplanen", + "SUMMARY_SCHEDULE_UNAVAILABLE": "Oversigtslevering er midlertidigt utilgængelig. Prøv igen senere.", + "SUMMARY_DISABLED_HINT": "Planlægning af oversigter er ikke tilgængelig på denne server.", + "TAB_STARDUST": "Stjernestøv", + "MIN_AMOUNT": "Mindste antal", + "MIN_AMOUNT_HINT": "0 = et hvilket som helst antal", + "MIN_STARDUST": "Mindste stjernestøv", + "MIN_STARDUST_HINT": "0 = enhver stjernestøvsopgave", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Invasionsalarmer", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnetisk", "TYPE_RAINY": "Regnfuld", "TYPE_GOLDEN": "Gylden", - "TYPE_UNKNOWN": "Lokke #{{id}}" + "TYPE_UNKNOWN": "Lokke #{{id}}", + "EDIT_MODE": "Rediger beskeden på stedet", + "EDIT_HINT": "Opdaterer den eksisterende Discord-besked, når lokkemodulet ændres, i stedet for at sende en ny.", + "EDIT_BADGE": "Rediger", + "CONFIRM_DELETE_TITLE": "Slet lokkemodul-alarm?", + "SNACK_FAILED_DISTANCE": "Afstanden kunne ikke opdateres." }, "NESTS": { "PAGE_TITLE": "Rede-alarmer", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Rede-alarm slettet", "SNACK_FAILED_CREATE": "Kunne ikke oprette alarm", "SNACK_FAILED_UPDATE": "Kunne ikke opdatere alarm", - "SNACK_FAILED_DELETE": "Kunne ikke slette alarm" + "SNACK_FAILED_DELETE": "Kunne ikke slette alarm", + "CONFIRM_DELETE_TITLE": "Slet rede-alarm?", + "SNACK_FAILED_DISTANCE": "Afstanden kunne ikke opdateres." }, "GYMS": { "PAGE_TITLE": "Gym-alarmer", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Mystic", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinct", - "TEAM_UNKNOWN": "Hold {{id}}" + "TEAM_UNKNOWN": "Hold {{id}}", + "CONFIRM_DELETE_TITLE": "Slet gym-alarm?", + "SNACK_FAILED_DISTANCE": "Afstanden kunne ikke opdateres." }, "FORT_CHANGES": { "PAGE_TITLE": "Fort-ændringsalarmer", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Fjernet", "CHANGE_NEW": "Ny fort", "INCLUDE_EMPTY": "Inkluder forts uden navn", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Beskeden kunne ikke oprettes", + "CREATE_SUCCESS": "Besked om gym-ændringer oprettet", + "UPDATE_FAILED": "Beskeden kunne ikke opdateres", + "UPDATE_SUCCESS": "Besked om gym-ændringer opdateret", "ALL_CHANGES": "Alle ændringer", "LABEL_NAME": "Navn", "LABEL_LOCATION": "Placering", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Slet {{type}}-ændringsalarmen?", "SNACK_DELETED": "Fort-ændringsalarm slettet", "SNACK_FAILED_DISTANCE": "Kunne ikke opdatere afstande", - "SNACK_ALL_DISTANCE": "Alle afstande opdateret" + "SNACK_ALL_DISTANCE": "Alle afstande opdateret", + "FORT_TYPE_LABEL": "Fort-type", + "CHANGE_TYPES_LABEL": "Ændringstyper", + "TRACKING_SUBTITLE": "Sporing af fort-ændringer", + "CHANGE_DESCRIPTION": "Beskrivelse ændret" }, "MAX_BATTLES": { "PAGE_TITLE": "Max-kampalarmer", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Beskederne kunne ikke oprettes", + "CREATE_SUCCESS": "{{count}} besked(er) oprettet", "ANY_POKEMON": "Ethvert Pokémon", "ANY_LEVEL": "Ethvert niveau", "STAR_LABEL": "{{stars}} stjerner", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Kunne ikke opdatere afstande", "SNACK_ALL_DISTANCE": "Alle afstande opdateret", "SNACK_FAILED_UPDATE": "Kunne ikke opdatere alarm", - "SNACK_UPDATED": "Max Battle-alarm opdateret" + "SNACK_UPDATED": "Max Battle-alarm opdateret", + "HINT_BY_LEVEL": "Følger enhver Pokemon på disse kampniveauer. Hvert valgt niveau bliver sin egen alarm.", + "HINT_BY_POKEMON": "Følger bestemte Pokemon i Max-kampe, uanset niveau.", + "HINT_GMAX_ONLY_ADD": "Giver kun besked om Gigantamax-kampe for de valgte Pokemon.", + "HINT_GMAX_ONLY_EDIT": "Giver kun besked om Gigantamax-kampe for denne Pokemon.", + "HINT_ALL_LEVELS": "Denne alarm følger én Pokemon på alle Max-kampniveauer.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Områder og placering", + "MANAGE_PLACES": "Administrer steder", + "PAGE_TITLE": "Områder og steder", "PAGE_DESC": "Kontrollér, hvor du modtager notifikationer.", "METHOD_AREAS": "Områder", "METHOD_AREAS_ACTIVE": "{{count}} område(r) aktive", "METHOD_NOT_CONFIGURED": "Ikke konfigureret", "METHOD_AREAS_DESC": "Bliv notificeret om alt, der sker inden for dine valgte geofence-zoner.", "METHOD_AREAS_TIP": "Bedst til: dækning af hele byer, kvarterer eller parker", - "METHOD_LOCATION": "Placering", - "METHOD_LOCATION_NOT_SET": "Ikke angivet", - "METHOD_LOCATION_DESC": "Bliv notificeret om alt inden for en bestemt afstand fra din fastgjorte placering.", + "METHOD_LOCATION": "Min position", + "METHOD_LOCATION_NOT_SET": "Ingen position angivet", + "METHOD_LOCATION_DESC": "Få beskeder om alt inden for en fastsat afstand fra din position.", "METHOD_LOCATION_TIP": "Bedst til: alarmer tæt på dit hjem, arbejde eller et bestemt sted", "CLEAR_LOCATION": "Ryd", "CHANGE_LOCATION": "Ændr", "SET_LOCATION": "Angiv", "METHOD_NOTE": "Hver alarm vælger én metode i sin Levering-fane.", + "NOTIFICATION_LANGUAGE": "Notifikationssprog", + "NOTIFICATION_LANGUAGE_DESC": "Det sprog Poracle bruger til dine alarmbeskeder og Pokémon-navne. Det er adskilt fra visningssproget i topmenuen.", + "SNACK_LANGUAGE_UPDATED": "Notifikationssprog opdateret", + "SNACK_LANGUAGE_FAILED": "Kunne ikke opdatere notifikationssprog", "SELECT_AREAS": "Vælg områder", "MAP_VIEW": "Kort", "LIST_VIEW": "Liste", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Kunne ikke opdatere placering", "SEARCH_AREAS": "Søg områder", "MANUAL_ADD_PLACEHOLDER": "Indtast et områdenavn og tryk Enter", - "FILTER_PLACEHOLDER": "Filtrer efter navn..." + "FILTER_PLACEHOLDER": "Filtrer efter navn...", + "SNACK_LOAD_SELECTED_FAILED": "Dine nuværende områder kunne ikke indlæses. Genindlæs, før du ændrer dem.", + "SELECTION_UNKNOWN": "Dine nuværende områder kunne ikke indlæses — genindlæs siden før du gemmer." }, "PROFILES": { "PAGE_TITLE": "Profiler", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Vælg region", "SEARCH_REGIONS": "Søg regioner...", "TOGGLE_TOOLTIP": "Slå notifikationer til/fra for dette geofence i nuværende profil", - "CREATED_PREFIX": "Oprettet" + "CREATED_PREFIX": "Oprettet", + "REGION_OPTIONAL_HINT": "Valgfrit. Vælg en region, hvis dit geofence hører til en." }, "CLEANING": { "PAGE_TITLE": "Oprydningstilstand", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Noget hjælpeindhold er muligvis endnu ikke tilgængeligt på dit sprog.", "TRANSLATION_CTA_LINK": "Hjælp med at oversætte", "FALLBACK_CHIP": "Engelsk", + "IMAGE_ENLARGE": "Klik for at forstørre", "SECTION_GETTING_STARTED": "Kom i gang", "SECTION_GETTING_STARTED_SUB": "Login, onboarding-guide og første opsætning", "SECTION_DASHBOARD": "Oversigt", "SECTION_DASHBOARD_SUB": "Din oversigt over alarmer, områder og status", - "SECTION_LOCATION": "Angiv din placering", + "SECTION_LOCATION": "Angiv din position", "SECTION_LOCATION_SUB": "GPS, adressesøgning og koordinater", - "SECTION_AREAS": "Vælg dine områder", - "SECTION_AREAS_SUB": "Kortvisning, listevisning og regionfiltrering", + "SECTION_AREAS": "Områder og steder", + "SECTION_AREAS_SUB": "Kortvisning, listevisning, regionfiltrering og steder", "SECTION_GEOFENCES": "Brugerdefinerede geofences", "SECTION_GEOFENCES_SUB": "Tegn grænser, indsend til offentlig godkendelse", "SECTION_POKEMON": "Pokemon-alarmer", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Andre alarmtyper", "SECTION_OTHER_ALARMS_SUB": "Raids, æg, quests, rockets, lokkemoduler, reder, gyms, fort-ændringer", "SECTION_DELIVERY": "Leveringsindstillinger", - "SECTION_DELIVERY_SUB": "Områder vs afstand, skabeloner og oprydningstilstand", + "SECTION_DELIVERY_SUB": "Leveringsområde, skabeloner og oprydningstilstand", + "SECTION_QUEST_SUMMARY": "Levering af opgaveoversigt", + "SECTION_QUEST_SUMMARY_SUB": "Saml støjende opgaver i én planlagt oversigt", "SECTION_TEST_ALERTS": "Test-alarmer", "SECTION_TEST_ALERTS_SUB": "Send prøvenotifikationer for at forhåndsvise dine alarmer", "SECTION_POKEMON_AVAILABILITY": "Pokemon-tilgængelighed", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Almindelige problemer og løsninger", "CONTENT_GETTING_STARTED": "

DM Alerts-siden lader dig tilpasse præcis hvilke Pokemon GO-notifikationer du modtager som direkte beskeder. I stedet for at få hver eneste alert, vælger du hvad der er vigtigt for dig — specifikke Pokemon, raids, quests og mere — og får kun besked om dem.

ℹ️
Før du kan bruge siden, skal du først registrere dig hos Poracle-botten på Discord eller Telegram. Når du er registreret, kan du komme tilbage hertil og logge ind.

Log ind

  • Discord — Klik på \"Sign in with Discord\" på login-siden. Du bliver sendt til Discord for at godkende appen og derefter automatisk sendt tilbage.
  • Telegram — Hvis det er aktiveret, brug Telegram login-widgeten på login-siden. Bekræft login i din Telegram-app.
\"Login-side

Førstegangskonfiguration

Når du logger ind første gang, guider en velkomstguide dig gennem tre trin:

  1. Indstil din placering — Bruges til at beregne afstande for notifikationer i nærheden.
  2. Vælg dine områder — Vælg de geografiske zoner du vil have alarmer fra.
  3. Tilføj din første alarm — Opret en Pokemon-, Raid- eller Quest-alarm for at begynde at få notifikationer.
\"Velkomstguide

Du kan springe ethvert trin over og vende tilbage senere. Guiden vises ikke igen, når du lukker den eller gennemfører alle trin.

", "CONTENT_DASHBOARD": "\"Dashboard

Dashboardet er din hjemmebase. Det viser et overblik over din nuværende opsætning.

Statuskort

  • Placering — Viser dine gemte koordinater eller adresse. Klik for at indstille eller opdatere din placering.
  • Aktive områder — Viser hvor mange områder du følger. Klik for at administrere dine områder.
  • Profil — Viser din aktive profil. Hvis du har flere profiler, klik for at skifte mellem dem.

Aktive filtre

Et gitter af kort viser hvor mange alarmer du har for hver type (Pokemon, Raids, Quests osv.). Klik på et kort for at gå til den alarmliste.

Vejr

Hvis du har en placering indstillet, viser dashboardet det aktuelle in-game vejr ved dine koordinater sammen med tidspunktet for seneste opdatering. Områdevejr vises også for hvert af dine valgte områder, så du kan se vejrforholdene på tværs af alle zoner du følger.

Hurtige handlinger

Genvejsknapper til at tilføje Pokemon-, Raid- eller Quest-alarmer, administrere områder eller konfigurere oprydning — alt sammen uden at navigere gennem sidepanelet.

Tips

Hjælpsomme påmindelser vises når din opsætning er ufuldstændig — som manglende placering, ingen valgte områder eller ingen konfigurerede alarmer. Hvert tip har en handlingsknap til at løse det. Du kan afvise tips du ikke har brug for.

Navigation

Brug sidepanelet til at navigere mellem sektioner. Alarmtyper er listet øverst, efterfulgt af indstillinger som Områder, Geofences, Profiler og Oprydning. Hjælp er altid i bunden.

\"Sidepanel", - "CONTENT_LOCATION": "\"Dashboard

Din placering bruges til afstandsbaserede notifikationer. Når en alarm bruger tilstanden \"Indstil afstand\", får du besked om begivenheder inden for en radius af denne placering.

Indstil din placering

Åbn placeringsdialogen fra Dashboardet eller Område-siden. Du har fire måder at indstille den på:

  • Søg efter adresse — Skriv en adresse, by eller et stednavn. Vælg fra de forslag der vises.
  • Indtast koordinater — Skriv bredde- og længdegrad direkte hvis du kender dem.
  • Brug din GPS — Klik på \"Use My Location\" for at bruge din enheds aktuelle placering. Din browser vil bede om tilladelse.
  • Klik på kortet — Klik hvor som helst på minikortet for at sætte det punkt som din placering.

Efter du har valgt en placering, vises adressen automatisk. Klik på Gem for at bekræfte.

💡
Du kan rydde din placering fra Område-siden, hvis du kun vil have områdebaserede alarmer.
", - "CONTENT_AREAS": "\"Områder

Områder er forhåndsdefinerede geografiske zoner sat op af dit community. Når en alarm bruger tilstanden \"Brug områder\", får du besked om begivenheder der sker inden for dine valgte områder.

Vælg områder

Gå til Områder og Placering i sidepanelet. Du kan vælge områder på to måder:

  • Kortvisning — Klik på farvede polygoner på kortet for at vælge eller fravælge områder. Valgte områder bliver grønne. Hold musen over et område for at se dets navn.
  • Listevisning — Brug afkrydsningsfelter til at vælge områder fra en søgbar liste.

Regionfiltrering

Hvis dit community har mange områder på tværs af forskellige regioner, brug regionens rullemenu til at zoome ind på en bestemt region. Det gør det nemmere at finde områder tæt på dig.

Indlejrede områder

Nogle områder overlapper — en mindre zone inden i en større. Begge er klikbare. Zoom ind for at gøre det nemmere at klikke på det mindre område.

Gem

En gemmebjælke vises i bunden når du har foretaget ændringer. Klik på Gem for at bekræfte dine valg, eller Annuller for at fortryde.

ℹ️
Områder er per profil. Hver profil har sit eget sæt af valgte områder. Skift af profil viser andre områdevalg. Brugerdefinerede geofences kan også slås til eller fra per profil fra Geofence-siden.
", - "CONTENT_GEOFENCES": "\"Mine

Hvis de forhåndsdefinerede områder ikke dækker det sted du vil have alarmer fra, kan du tegne dine egne brugerdefinerede geofence-grænser på kortet.

Tegn en geofence

  1. Gå til Mine Geofences i sidepanelet.
  2. Klik på Tegn Geofence.
  3. Klik på kortet for at placere punkter af din polygongrænse. Klik på det første punkt igen for at lukke formen (minimum 3 punkter).
  4. Giv din geofence et navn og vælg hvilken region den tilhører. Regionen detekteres normalt automatisk.
  5. Klik på Gem.

Administrer geofences

  • Rediger — Omdøb din geofence eller skift dens region.
  • Slet — Fjern en geofence du ikke længere har brug for. Geofencen fjernes fra alle profiler automatisk.

Profilskift

Hvert geofence-kort har en skydekontakt til at aktivere eller deaktivere den for din aktive profil. Når du opretter en geofence, aktiveres den automatisk på den profil du bruger. Skift til en anden profil og kontakten viser \"Inaktiv\" — slå den til for også at modtage alarmer for den geofence på den profil. Det lader dig styre hvilke profiler der får notifikationer for hver geofence uden at genskabe den.

ℹ️
Godkendte geofences (forfremmet til offentlige områder) viser ikke kontakten — administrer dem fra Områder-siden i stedet.

GeoJSON Import & Export

Du kan importere og eksportere geofences i standard GeoJSON-format, hvilket gør det nemt at dele grænser eller oprette dem i eksterne værktøjer som geojson.io.

  • Import — Klik på upload-ikonet og indsæt eller upload en GeoJSON-fil. Hver polygon i filen bliver en ny geofence. Du kan gennemgå og omdøbe hver enkelt før du gemmer.
  • Eksport — Klik på download-ikonet og vælg hvilke geofences der skal inkluderes. Den eksporterede GeoJSON-fil indeholder alle valgte polygoner og kan åbnes i ethvert GIS-værktøj eller korteditor.
💡
GeoJSON-import er nyttig til at migrere geofences fra andre systemer eller tegne komplekse grænser i et desktop GIS-værktøj og derefter importere dem her.

Indsend til offentlig godkendelse

Hvis du mener din geofence ville være nyttig for hele communityet, kan du indsende den til admin-gennemgang. Hvis den godkendes, bliver den et offentligt område alle kan vælge. Din private geofence fortsætter med at virke mens gennemgangen er i gang.

Statusmærkater

  • Aktiv — Din private geofence, virker kun for dig.
  • Afventer gennemgang — Indsendt og venter på admin-gennemgang.
  • Godkendt — Forfremmet til et offentligt område.
  • Afvist — Ikke godkendt. Du kan se adminens feedback, og geofencen forbliver aktiv som en privat zone.
ℹ️
Du kan have op til 10 brugerdefinerede geofences, hver med op til 500 grænsepunkter.
", - "CONTENT_POKEMON": "\"Pokemon-alarmside

Pokemon-alarmer giver dig besked når en vild Pokemon spawner der matcher dine filtre.

Tilføj en Pokemon-alarm

\"Tilføj
  1. Gå til Pokemon i sidepanelet og klik på +-knappen.
  2. Vælg Pokemon — Søg efter navn eller Pokedex-nummer, eller brug generations- og typefilterknapperne til at gennemse. Du kan vælge flere Pokemon på én gang.
  3. Indstil filtre — Vælg hvad der gør en spawn værd at få besked om:
  • IV-interval — Minimum og maksimum IV-procent (0-100%)
  • CP-interval — Filtrer efter kampstyrke
  • Niveau-interval — Filtrer efter Pokemon-niveau (0-55)
  • Individuelle stats — Filtrer efter ATK, DEF og STA værdier (0-15 hver)
  • Form — Følg specifikke former (f.eks. Alolan, Galarian) eller alle former
  • Køn — Han, hun, kønsløs eller alle
  • Vægt — Filtrer efter vægtinterval
  • Størrelse — Filtrer efter størrelseskategori: vælg ALL (intet filter) for at matche enhver størrelse, eller vælg specifikke størrelser fra XXS til XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Standard filterværdier er sat så alle Pokemon matcher når ingen filtre er eksplicit konfigureret. For eksempel er IV standard 0-100%, niveau 0-55 og størrelse ALL. Du behøver kun at justere de filtre du er interesseret i.

PVP-filtre

Få besked når en Pokemon har gode PVP IV'er. Vælg en liga (Great, Ultra eller Little Cup) og indstil det ranginterval du er interesseret i (f.eks. rang 1-50).

\"Alle Pokemon\"-alarm

💡
Vælg \"All Pokemon\" (ID 0) for at oprette én alarm der dækker alle arter. Nyttigt med et højt IV-filter som 96-100% for at fange enhver værdifuld spawn.

Læs alarmkort

Hvert alarmkort viser farvede mærkater der opsummerer dine filtre:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Raids-side

Raid- og Æg-alarmer

Få besked når en raid boss eller et æg dukker op som du er interesseret i.

  • Efter niveau — Vælg raid-niveauer (1-6) eller æg-niveauer for at følge alle raids på det niveau.
  • Efter boss — Vælg specifikke Pokemon raid-bosser du vil jage.
  • Holdfilter — Få kun besked om raids ved gyms kontrolleret af et bestemt hold (Mystic, Valor, Instinct).
  • Gym-følgning — Følg raids ved specifikke gyms efter navn, så du kun får besked om dine favoritgyms.
  • Angrebsfilter — Filtrer raid-bosser efter deres hurtige eller ladede angreb.
  • RSVP-notifikationer — Få besked når andre trænere tilmelder sig et raid eller æg du følger.

Raid- og Æg-alarmer administreres på separate faner på Raids-siden. Æg understøtter også gym-specifik følgning og RSVP-notifikationer.

Max Battle (Dynamax)-alarmer

Få besked om Dynamax- og Gigantamax-kampe ved Power Spots.

  • Efter niveau — Vælg kampniveauer for at følge alle Pokemon på de niveauer. Niveauer går fra 1 stjerne til 5 stjerner (Legendary) for Dynamax, plus Gigantamax og Legendary Gigantamax for de største kampe. Én alarm oprettes per valgt niveau.
  • Efter Pokemon — Vælg specifikke Pokemon du vil kæmpe mod på alle Max Battle-niveauer. Hvis scannerdatabasen er konfigureret, filtreres vælgeren til kun at vise Pokemon der har optrådt i Max Battles.
  • Kun Gigantamax — Når du følger efter Pokemon, slå dette til for kun at få notifikationer når den Pokemon optræder i Gigantamax-kampe (de højeste kampe med unikke G-Max-angreb). For niveaubaseret følgning håndteres Gigantamax ved at vælge Gigantamax- eller Legendary Gigantamax-niveauerne direkte.
  • Vælg alle — Vælg hurtigt alle tilgængelige niveauer på én gang (svarer til bottens !maxbattle everything kommando).

Quest-alarmer

Få besked om feltforskningsopgaver med specifikke belønninger.

  • Pokemon-møder — Vælg Pokemon du vil have som quest-belønninger.
  • Genstande — Følg quests der giver specifikke genstande.
  • Mega Energi — Følg quests der giver mega-energi til specifikke Pokemon.
  • Slik — Følg quests der giver slik til specifikke Pokemon.

Invasionsalarmer

Få besked om Team Rocket-invasioner.

  • Følg alle — Én alarm for hver grunt-type og leder.
  • Efter type — Vælg specifikke grunt-typer (Bug, Dragon, Fire osv.), Rocket Leaders eller Giovanni. Grunt-typenavne normaliseres automatisk (uden forskel på store/små bogstaver), så du behøver ikke bekymre dig om præcis stavning.
  • Køn — Filtrer efter grunt-køn.

Lure-alarmer

Få besked når en bestemt lure-type placeres. Vælg mellem Normal, Glacial, Mossy, Magnetic, Rainy og Golden.

Rede-alarmer

Følg Pokemon-arter der har reder. Indstil en minimum spawns per time-tærskel, så du kun får besked om reder med nok aktivitet.

Gym-alarmer

Følg gym-holdskift. Vælg hvilke hold (Neutral, Mystic, Valor, Instinct) der skal overvåges. Aktiver Ændringer i pladser for at få besked når gym-pladser åbner sig, eller aktiver Ændringer i kampe for at få besked når et gym er under angreb.

Fortændringsalarmer

Følg ændringer i PokéStops og gyms selv — ikke aktiviteterne ved dem, men ændringer i selve interessepunkterne.

  • Fort-type — Vælg at følge PokéStops, Gyms eller Alt.
  • Ændringstyper — Vælg hvilke ændringer der skal overvåges: Navn ændret, Placering ændret, Billede ændret, Fjernelse eller Nyt fort tilføjet.
  • Inkluder tomme — Inkluder forts uden navn.
💡
Fortændringsalarmer er nyttige til at følge kortdatabaseopdateringer — nye PokéStops der dukker op, gyms der flyttes, eller POI'er der fjernes fra spillet.

Målret et bestemt gym

Når du opretter eller redigerer en Raid-, Æg- eller Gym-alarm, kan du valgfrit søge efter og vælge et bestemt gym. Det er nyttigt når du kun er interesseret i aktivitet ved dit favoritgym — som det på din frokosttur eller tæt på dit hjem.

  • Sådan bruger du det — I tilføj- eller redigeringsdialogen, skriv et gym-navn i gym-søgefeltet. Resultaterne viser gymmets billede, navn og område så du kan identificere det rigtige.
  • Når et gym er valgt — Alarmen udløses kun for begivenheder ved det specifikke gym. Gym-navnet vises på alarmkortet i din liste så du kan se hvilket gym den retter sig mod.
  • Når intet gym er valgt — Det er standard. Alarmen virker normalt for alle gyms i dine valgte områder eller inden for din afstandsradius.
💡
Du kan kombinere en gym-specifik alarm med en bredere alarm. Opret for eksempel én raid-alarm rettet mod dit lokale gym for alle niveauer, og en anden alarm for niveau 5-raids på tværs af alle dine områder.
", - "CONTENT_DELIVERY": "\"Pokemon-alarmkort

Hver alarm har leveringsindstillinger der styrer hvor du får notifikationer.

Områder vs Afstand

Hver alarm bruger en af to leveringstilstande:

🗺
Brug områderFå besked når begivenheder sker i dine valgte områder. Godt til at følge bestemte kvarterer.
📏
Indstil afstandFå besked inden for en radius (km) fra din gemte placering. Godt til at følge alt i nærheden.

Du kan bruge forskellige tilstande til forskellige alarmer — for eksempel områder til Pokemon og afstand til raids.

Notifikationsskabeloner

Hvis skabeloner er aktiveret, kan du vælge hvordan dine notifikationsbeskeder ser ud. Skabelonvælgeren viser en live forhåndsvisning af hvordan din Discord DM vil se ud, inklusive embed-format, felter og billeder.

Oprydningstilstand

Når den er aktiveret, sletter botten automatisk notifikationen fra Discord efter begivenheden udløber (f.eks. en Pokemon despawner eller en raid slutter). Det holder dine DM'er ryddelige. Du kan aktivere oprydningstilstand per alarm eller samlet fra Oprydning-siden.

Ping / Rolleomtaler

Hvis du bruger webhooks, kan du indstille en Discord-rolle til at nævne i notifikationen (f.eks. @Pokemon). Det er kun relevant for webhook-opsætninger.

", + "CONTENT_LOCATION": "\"Dashboard

Din position er det punkt, dine beskeder måles fra. En alarm, der når dig inden for en radius, bruger positionen, medmindre du retter netop den alarm mod et gemt sted i stedet.

Angiv din position

Åbn positionsdialogen fra Dashboardet eller siden Områder og steder. Du har fire måder at angive den på:

  • Søg efter adresse — Skriv en adresse, by eller et stednavn. Vælg fra de forslag der vises.
  • Indtast koordinater — Skriv bredde- og længdegrad direkte hvis du kender dem.
  • Brug din GPS — Klik på \"Use My Location\" for at bruge din enheds aktuelle placering. Din browser vil bede om tilladelse.
  • Klik på kortet — Klik hvor som helst på minikortet for at sætte det punkt som din position.

Efter du har valgt et punkt, vises adressen automatisk. Klik på Gem for at bekræfte.

Den samme dialog bruges, når du tilføjer et sted eller vælger et punkt til en enkelt alarm. Den hedder da Vælg et punkt og bekræftes med Brug dette punkt, og din egen position røres ikke.

💡
Du kan rydde din position fra siden Områder og steder, hvis du kun vil have områdebaserede alarmer.
", + "CONTENT_AREAS": "\"Siden

Områder er forhåndsdefinerede geografiske zoner sat op af dit community. De områder, du vælger her, følger alle alarmer som standard: en alarm sat til Overalt i mine områder udløses ved begivenheder inde i dem.

Vælg områder

Gå til Områder og steder i sidepanelet. Du kan vælge områder på to måder:

  • Kortvisning — Klik på farvede polygoner på kortet for at vælge eller fravælge områder. Valgte områder bliver grønne. Hold musen over et område for at se dets navn.
  • Listevisning — Brug afkrydsningsfelter til at vælge områder fra en søgbar liste.

Steder

Et sted er et navngivet punkt — arbejdet, træningscenteret, dine forældres hus — som en alarm kan måle sin radius fra i stedet for din position. Tilføj et i afsnittet Steder på samme side, og vælg det derefter under Målt fra, når du angiver, hvor en alarm skal nå dig. Et sted kan ikke slettes, så længe alarmer peger på det, og beskeden fortæller hvor mange.

Regionfiltrering

Hvis dit community har mange områder på tværs af forskellige regioner, brug regionens rullemenu til at zoome ind på en bestemt region. Det gør det nemmere at finde områder tæt på dig.

Indlejrede områder

Nogle områder overlapper — en mindre zone inden i en større. Begge er klikbare. Zoom ind for at gøre det nemmere at klikke på det mindre område.

Gem

En gemmebjælke vises i bunden når du har foretaget ændringer. Klik på Gem for at bekræfte dine valg, eller Annuller for at fortryde.

ℹ️
Områder er per profil. Hver profil har sit eget sæt af valgte områder. Skift af profil viser andre områdevalg. Brugerdefinerede geofences kan også slås til eller fra per profil fra Geofence-siden.
", + "CONTENT_GEOFENCES": "\"Mine

Hvis de forhåndsdefinerede områder ikke dækker det sted du vil have alarmer fra, kan du tegne dine egne brugerdefinerede geofence-grænser på kortet.

Tegn en geofence

  1. Gå til Mine Geofences i sidepanelet.
  2. Klik på Tegn Geofence.
  3. Klik på kortet for at placere punkter af din polygongrænse. Klik på det første punkt igen for at lukke formen (minimum 3 punkter).
  4. Giv din geofence et navn og vælg hvilken region den tilhører. Regionen detekteres normalt automatisk.
  5. Klik på Gem.

Administrer geofences

  • Rediger — Omdøb din geofence eller skift dens region.
  • Slet — Fjern en geofence du ikke længere har brug for. Geofencen fjernes fra alle profiler automatisk.

Profilskift

Hvert geofence-kort har en skydekontakt til at aktivere eller deaktivere den for din aktive profil. Når du opretter en geofence, aktiveres den automatisk på den profil du bruger. Skift til en anden profil og kontakten viser \"Inaktiv\" — slå den til for også at modtage alarmer for den geofence på den profil. Det lader dig styre hvilke profiler der får notifikationer for hver geofence uden at genskabe den.

ℹ️
Godkendte geofences (forfremmet til offentlige områder) viser ikke kontakten — administrer dem fra Områder-siden i stedet.

Brug en geofence til én alarm

En geofence, du selv har tegnet, står også på listen Kun i bestemte områder, når du vælger, hvor en enkelt alarm skal nå dig; den er mærket med et tegneikon. Det begrænser én alarm til den uden at aktivere geofencen for hele profilen.

GeoJSON Import & Export

Du kan importere og eksportere geofences i standard GeoJSON-format, hvilket gør det nemt at dele grænser eller oprette dem i eksterne værktøjer som geojson.io.

  • Import — Klik på upload-ikonet og indsæt eller upload en GeoJSON-fil. Hver polygon i filen bliver en ny geofence. Du kan gennemgå og omdøbe hver enkelt før du gemmer.
  • Eksport — Klik på download-ikonet og vælg hvilke geofences der skal inkluderes. Den eksporterede GeoJSON-fil indeholder alle valgte polygoner og kan åbnes i ethvert GIS-værktøj eller korteditor.
💡
GeoJSON-import er nyttig til at migrere geofences fra andre systemer eller tegne komplekse grænser i et desktop GIS-værktøj og derefter importere dem her.

Indsend til offentlig godkendelse

Hvis du mener din geofence ville være nyttig for hele communityet, kan du indsende den til admin-gennemgang. Hvis den godkendes, bliver den et offentligt område alle kan vælge. Din private geofence fortsætter med at virke mens gennemgangen er i gang.

Statusmærkater

  • Aktiv — Din private geofence, virker kun for dig.
  • Afventer gennemgang — Indsendt og venter på admin-gennemgang.
  • Godkendt — Forfremmet til et offentligt område.
  • Afvist — Ikke godkendt. Du kan se adminens feedback, og geofencen forbliver aktiv som en privat zone.
ℹ️
Du kan have op til 10 brugerdefinerede geofences, hver med op til 500 grænsepunkter.
", + "CONTENT_POKEMON": "\"Pokemon-alarmside

Pokemon-alarmer giver dig besked når en vild Pokemon spawner der matcher dine filtre.

Tilføj en Pokemon-alarm

\"Tilføj
  1. Gå til Pokemon i sidepanelet og klik på +-knappen.
  2. Vælg Pokemon — Søg efter navn eller Pokedex-nummer, eller brug generations- og typefilterknapperne til at gennemse. Du kan vælge flere Pokemon på én gang.
  3. Indstil filtre — Vælg hvad der gør en spawn værd at få besked om:
  • IV-interval — Minimum og maksimum IV-procent (0-100%)
  • CP-interval — Filtrer efter kampstyrke
  • Niveau-interval — Filtrer efter Pokemon-niveau (0-55)
  • Individuelle stats — Filtrer efter ATK, DEF og STA værdier (0-15 hver)
  • Form — Følg specifikke former (f.eks. Alolan, Galarian) eller alle former
  • Køn — Han, hun, kønsløs eller alle
  • Vægt — Filtrer efter vægtinterval
  • Størrelse — Filtrer efter størrelseskategori: vælg ALL (intet filter) for at matche enhver størrelse, eller vælg specifikke størrelser fra XXS til XXL (XXS, XS, Normal, XL, XXL)
  • Mindste resttid — Spring spawns over, der er væk, før du når frem. Sæt den under Flere filtre; kortet viser derefter en pille som "10 min tilbage"
ℹ️
Standard filterværdier er sat så alle Pokemon matcher når ingen filtre er eksplicit konfigureret. For eksempel er IV standard 0-100%, niveau 0-55 og størrelse ALL. Du behøver kun at justere de filtre du er interesseret i.

PVP-filtre

Få besked når en Pokemon har gode PVP IV'er. Vælg en liga (Great, Ultra eller Little Cup) og indstil det ranginterval du er interesseret i (f.eks. rang 1-50).

Knapperne Level Cap vælger, hvilket cap rangene læses ved. Lad den stå på Alle for at bruge det, dit communitys Poracle-konfiguration sætter.

Mega-udvikling vælger, om reglen rangerer grundformen eller en mega: Base, Mega, Mega X eller Mega Y. Megaer rangeres for sig, så en mega-regel matcher ikke et spawn i grundform.

\"Alle Pokemon\"-alarm

💡
Vælg \"All Pokemon\" (ID 0) for at oprette én alarm der dækker alle arter. Nyttigt med et højt IV-filter som 96-100% for at fange enhver værdifuld spawn.

Læs alarmkort

Hvert alarmkort viser farvede mærkater der opsummerer dine filtre:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Raids-side

Raid- og Æg-alarmer

Få besked når en raid boss eller et æg dukker op som du er interesseret i.

  • Efter niveau — Vælg raid-niveauer (1-6) eller æg-niveauer for at følge alle raids på det niveau.
  • Efter boss — Vælg specifikke Pokemon raid-bosser du vil jage.
  • Holdfilter — Få kun besked om raids ved gyms kontrolleret af et bestemt hold (Mystic, Valor, Instinct).
  • Gym-følgning — Følg raids ved specifikke gyms efter navn, så du kun får besked om dine favoritgyms.
  • Angrebsfilter — Filtrer raid-bosser efter deres hurtige eller ladede angreb.
  • RSVP-notifikationer — Få besked når andre trænere tilmelder sig et raid eller æg du følger.

Raid- og Æg-alarmer administreres på separate faner på Raids-siden. Æg understøtter også gym-specifik følgning og RSVP-notifikationer.

Max Battle (Dynamax)-alarmer

Få besked om Dynamax- og Gigantamax-kampe ved Power Spots.

  • Efter niveau — Vælg kampniveauer for at følge alle Pokemon på de niveauer. Niveauer går fra 1 stjerne til 5 stjerner (Legendary) for Dynamax, plus Gigantamax og Legendary Gigantamax for de største kampe. Én alarm oprettes per valgt niveau.
  • Efter Pokemon — Vælg specifikke Pokemon du vil kæmpe mod på alle Max Battle-niveauer. Hvis scannerdatabasen er konfigureret, filtreres vælgeren til kun at vise Pokemon der har optrådt i Max Battles.
  • Kun Gigantamax — Når du følger efter Pokemon, slå dette til for kun at få notifikationer når den Pokemon optræder i Gigantamax-kampe (de højeste kampe med unikke G-Max-angreb). For niveaubaseret følgning håndteres Gigantamax ved at vælge Gigantamax- eller Legendary Gigantamax-niveauerne direkte.
  • Vælg alle — Vælg hurtigt alle tilgængelige niveauer på én gang (svarer til bottens !maxbattle everything kommando).

Quest-alarmer

Få besked om feltforskningsopgaver med specifikke belønninger.

  • Pokemon-møder — Vælg Pokemon du vil have som quest-belønninger.
  • Genstande — Følg quests der giver specifikke genstande.
  • Mega Energi — Følg quests der giver mega-energi til specifikke Pokemon.
  • Slik — Følg quests der giver slik til specifikke Pokemon.
  • Stardust — Følg quests der giver stardust.

Fanerne for genstande, mega-energi og slik har hver et felt Mindste antal, og stardust-fanen Mindste stardust. Lad det stå på 0 for at matche ethvert antal. Kortene viser antallet ved siden af belønningen, f.eks. "3× Rare Candy".

Invasionsalarmer

Få besked om Team Rocket-invasioner.

  • Følg alle — Én alarm for hver grunt-type og leder.
  • Efter type — Vælg specifikke grunt-typer (Bug, Dragon, Fire osv.), Rocket Leaders eller Giovanni. Grunt-typenavne normaliseres automatisk (uden forskel på store/små bogstaver), så du behøver ikke bekymre dig om præcis stavning.
  • Køn — Filtrer efter grunt-køn.

Lure-alarmer

Få besked når en bestemt lure-type placeres. Vælg mellem Normal, Glacial, Mossy, Magnetic, Rainy og Golden.

Rede-alarmer

Følg Pokemon-arter der har reder. Indstil en minimum spawns per time-tærskel, så du kun får besked om reder med nok aktivitet.

Gym-alarmer

Følg gym-holdskift. Vælg hvilke hold (Neutral, Mystic, Valor, Instinct) der skal overvåges. Aktiver Ændringer i pladser for at få besked når gym-pladser åbner sig, eller aktiver Ændringer i kampe for at få besked når et gym er under angreb.

Fortændringsalarmer

Følg ændringer i PokéStops og gyms selv — ikke aktiviteterne ved dem, men ændringer i selve interessepunkterne.

  • Fort-type — Vælg at følge PokéStops, Gyms eller Alt.
  • Ændringstyper — Vælg hvilke ændringer der skal overvåges: Navn ændret, Beskrivelse ændret, Placering ændret, Billede ændret, Fjernet eller Nyt fort.
  • Inkluder tomme — Inkluder forts uden navn.
💡
Fortændringsalarmer er nyttige til at følge kortdatabaseopdateringer — nye PokéStops der dukker op, gyms der flyttes, eller POI'er der fjernes fra spillet.

Målret et bestemt gym

Når du opretter eller redigerer en Raid-, Æg- eller Gym-alarm, kan du valgfrit søge efter og vælge et bestemt gym. Det er nyttigt når du kun er interesseret i aktivitet ved dit favoritgym — som det på din frokosttur eller tæt på dit hjem.

  • Sådan bruger du det — I tilføj- eller redigeringsdialogen, skriv et gym-navn i gym-søgefeltet. Resultaterne viser gymmets billede, navn og område så du kan identificere det rigtige.
  • Når et gym er valgt — Alarmen udløses kun for begivenheder ved det specifikke gym. Gym-navnet vises på alarmkortet i din liste så du kan se hvilket gym den retter sig mod.
  • Når intet gym er valgt — Det er standard. Alarmen virker normalt for alle gyms i dine valgte områder eller inden for din afstandsradius.
💡
Du kan kombinere en gym-specifik alarm med en bredere alarm. Opret for eksempel én raid-alarm rettet mod dit lokale gym for alle niveauer, og en anden alarm for niveau 5-raids på tværs af alle dine områder.
", + "CONTENT_DELIVERY": "\"Pokemon-alarmkort

Hver alarm har leveringsindstillinger der styrer hvor du får notifikationer.

Hvor beskeden når dig

Leverings-fanen i hver tilføj- og redigér-dialog spørger Hvor skal beskeden nå dig? og giver tre svar:

  • Overalt i mine områder — Standarden. Alarmen følger de områder, din profil har valgt, så ændrer du dine områder, ændrer du også denne alarm.
  • Nær et punkt — En radius i kilometer, målt fra din position eller fra et gemt sted, du vælger under Målt fra. Har du ingen position endnu, siger vælgeren det og tilbyder at angive en.
  • Kun i bestemte områder — Et udvalg af områder til netop denne alarm, valgt blandt de offentlige områder og de geofences, du selv har tegnet.

Alarmer kan svare forskelligt: områder til Pokemon, en radius fra din position til raids, ét navngivet sted til quests.

Chippen på alarmkortet

De fleste alarmkort har en chip, der viser svaret — "Overalt i mine områder", "Overalt hvor jeg får beskeder", "Inden for 5 km fra min position", "Inden for 2 km fra Hjem", "Kun i Terrigal, Erina". Klik på chippen for at ændre netop den alarm uden at åbne hele redigér-dialogen.

Standard for nye alarmer

Nye alarmer åbner i tilstanden Områder som standard. Vil du ændre det, så åbn brugermenuen (din avatar øverst til højre) og vælg Standardindstillinger for advarsler — vælg om nye alarmer starter i Områder eller Afstand, sæt en standardradius, og vælg om radius måles fra din position eller fra et gemt sted. Valget gemmes i din browser og bruges også af Quick Pick-dialogen. Det gælder kun nyoprettede alarmer; eksisterende ændres ikke, og du kan stadig ændre, hvor den enkelte alarm når dig.

Notifikationsskabeloner

Hvis skabeloner er aktiveret, kan du vælge hvordan dine notifikationsbeskeder ser ud. Skabelonvælgeren viser en live forhåndsvisning af hvordan din Discord DM vil se ud, inklusive embed-format, felter og billeder.

Oprydningstilstand

Når den er aktiveret, sletter botten automatisk notifikationen fra Discord efter begivenheden udløber (f.eks. en Pokemon despawner eller en raid slutter). Det holder dine DM'er ryddelige. Du kan aktivere oprydningstilstand per alarm eller samlet fra Oprydning-siden.

Rediger på stedet & oversigter

Nogle alarmer understøtter ekstra leveringstilstande. Slå Rediger besked på stedet til for et lokkemiddel for at opdatere den eksisterende Discord-besked, når lokkemidlet ændres, i stedet for at sende en ny, eller Daglig oversigt for en quest for at samle matchende quests i én oversigtsbesked (kræver en konfigureret oversigtsplan på botten). Raids og æg redigeres automatisk på stedet, når du vælger en RSVP-tilstand. Disse indstillinger bevares, selv hvis du angiver dem fra botten.

RSVP-opdateringer (raids & æg)

Raid- og ægalarmer tilføjer en RSVP-notifikationer-indstilling i tilføj-/redigeringsdialogen med tre valg: Kun matches sender standard raid-/ægnotifikationer; Matches + RSVP-opdateringer giver dig også besked, når RSVP-antal ændres (trænere der tilmelder sig); og Kun RSVP-opdateringer springer den indledende match over og giver dig kun besked om RSVP-ændringer. At vælge en af RSVP-tilstandene får botten til at redigere den eksisterende Discord-besked på stedet, når antallet ændres, i stedet for at sende nye, og kortet viser en "RSVP"- eller "Kun RSVP"-etiket. Bemærk at Kun RSVP-opdateringer bliver stille, medmindre dit fællesskabs scanner sender RSVP-begivenheder — vælg det kun, hvis du ved, at RSVP rapporteres.

", + "CONTENT_QUEST_SUMMARY": "

Field Research-opgaver skifter dagligt og kan matche i store mængder, så et travlt opgavefilter kan oversvømme dine DM’er. Levering af opgaveoversigt samler matchende opgaver i én planlagt oversigt i stedet for mange separate notifikationer.

To dele, der arbejder sammen

  • Daglig oversigt-knap — slå denne til for en opgavealarm (i dens opret/rediger-dialog) for at markere dens match til oversigten i stedet for øjeblikkelig levering.
  • Leveringsplan — vælg hvornår de indsamlede opgaver sendes.

Begge dele er nødvendige: knappen bestemmer hvilke opgaver der skal samles, planen bestemmer hvornår de skal leveres.

Sådan opsætter du din plan

Åbn Opgaver-siden, derefter -menuen i værktøjslinjen, og vælg Levering af opgaveoversigt. Brug Rediger plan til at vælge dage og tidspunkter — samme editor som bruges til profilers aktive timer. Gemte tidspunkter vises som ravgule piller.

Planen er pr. bruger og deles på tværs af alle dine profiler — i modsætning til profilers aktive timer, som indstilles pr. profil.

Send oversigt nu

Send oversigt nu leverer med det samme alt, hvad der er indsamlet siden din seneste oversigt. Hvis der endnu ikke er indsamlet noget, sendes der intet — opgaver bufres efterhånden som de matcher, så giv det tid eller vent på, at planen udløses.

Godt at vide

  • Menuen vises kun, når din servers bot har opgaveoversigter aktiveret.
  • Leveringstidspunktet bruger din gemte placering til tidszonen — angiv en placering, ellers kan oversigter ankomme på det forkerte lokale tidspunkt (dialogen advarer dig, når der ikke er angivet nogen placering).
  • Fjernelse af planen bevarer pr.-alarm-knappen; opgaver samles stadig, men falder tilbage til botens standardtidspunkt.
", "CONTENT_TEST_ALERTS": "

Hvert alarmkort har en Test-knap (papirflyikon) der sender en prøvenotifikation til din Discord eller Telegram, med alarmens præcise filtre og din aktuelle leveringsskabelon.

Sådan virker det

  1. Find et alarmkort på din liste (Pokemon, Raid, Quest osv.).
  2. Klik på send-ikonet i kortets handlingsrække.
  3. En simuleret begivenhed der matcher dine alarmfiltre genereres og sendes gennem notifikationspipelinen. Du modtager en DM ligesom en rigtig alert.

Hvad bliver testet

Testen bruger din alarms filterværdier (Pokemon ID, raid-niveau, quest-belønning osv.) og din gemte placering som de simulerede begivenhedskoordinater. Notifikationen formateres med din valgte skabelon, så du ser præcis hvordan en rigtig alert ville se ud.

Nedkøling

For at forhindre spam har hver alarm en 15-sekunders nedkølingsperiode mellem testforsendelser. Knappen er deaktiveret under nedkølingen, og en infobar viser feedback (succes, fejl eller resterende nedkøling).

💡
Testalarmer er gode til at verificere at din skabelon ser rigtig ud, eller bekræfte at din webhook-levering virker, før du venter på en rigtig begivenhed.
", "CONTENT_POKEMON_AVAILABILITY": "

Når du tilføjer eller redigerer Pokemon-alarmer, kan Pokemon-vælgeren vise tilgængelighedsindikatorer — små mærkater der fortæller dig hvilke Pokemon der aktuelt spawner i naturen.

Sådan virker det

Hvis dit community har en Golbat-scanner konfigureret, viser vælgeren farvede prikker ved siden af Pokemon-navne:

  • Grøn prik — Denne Pokemon er set spawne for nylig.
  • Ingen prik — Ikke aktuelt rapporteret i scannerdataene.

Det hjælper dig med at undgå at oprette alarmer for Pokemon der ikke spawner i dit område lige nu (f.eks. sæsonbestemte eller event-eksklusive arter).

Opdatering af tilgængelighed

Dataene opdateres automatisk i baggrunden. Du behøver ikke gøre noget — kig bare efter prikkerne når du gennemser Pokemon-vælgeren.

ℹ️
Denne funktion er kun synlig hvis din admin har konfigureret Golbat-scannerintegrationen. Hvis du ikke ser tilgængelighedsprikker, er funktionen ikke aktiveret for dit community.
", "CONTENT_BULK": "\"Pokemon-alarmliste

Alle alarmsider understøtter masseoperationer så du kan administrere mange alarmer på én gang.

Vælgetilstand

Klik på tjeklisteikonet i værktøjslinjen for at gå i vælgetilstand. Klik derefter på individuelle alarmkort for at vælge dem, eller brug Vælg alle til at tage alt synligt.

Massehandlinger

  • Opdater afstand — Skift leveringstilstand (områder eller afstand) for alle valgte alarmer på én gang.
  • Slet — Fjern alle valgte alarmer med én bekræftelse.
💡
I bunden af hver alarmliste finder du også knapperne Opdater alle afstande og Slet alle der gælder for hver alarm af den type.
", - "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks er færdige alarmskabeloner oprettet af dit communitys administratorer. De lader dig konfigurere almindelige alarmopsætninger med ét klik i stedet for at oprette hver alarm individuelt.

Anvend et Quick Pick

  1. Gå til Quick Picks i sidepanelet.
  2. Gennemse de tilgængelige valg, eventuelt filtreret efter kategori.
  3. Klik på Anvend på det Quick Pick du ønsker.
  4. Tilpas før du anvender: vælg leveringstilstand (områder eller afstand), aktiver oprydningstilstand og udeluk eventuelt specifikke Pokemon.
  5. Bekræft for at oprette alle alarmer på én gang.

Fjern Quick Pick-alarmer

Hvis du ikke længere vil have alarmer fra et Quick Pick, klik på Fjern for at slette alle alarmer det oprettede.

", - "CONTENT_PROFILES": "

Profilsiden er dit samlede center til at administrere profiler og se alle alarmer på tværs af alle profiler ét sted.

Hvorfor bruge profiler?

Profiler lader dig vedligeholde helt separate alarmkonfigurationer. Hver profil har sit eget sæt alarmer, valgte områder, placering og brugerdefinerede geofence-aktiveringer. Nyttigt til forskellige situationer — for eksempel en \"Hjem\"-profil til dit kvarter og en \"Arbejde\"-profil til omkring dit kontor.

Overblik

Siden viser en statistiklinje med samlede alarmtal per type, en søgelinje til at filtrere på tværs af alle profiler, og typefilterchips til kun at vise bestemte alarmtyper (Pokemon, Raids, Quests osv.).

Hver profil vises som et udvidbart panel. Klik for at udvide og se alle alarmer grupperet efter type, med spilgrafik (Pokemon-sprites, raid-æg, lure-ikoner) og filtermærkater der viser IV, CP, Niveau, PVP og andre indstillinger.

Administrer profiler

  • Opret — Klik på +-knappen øverst til højre. Profilnavne skal være unikke (op til 32 tegn).
  • Skift — Klik på Skift inde i et profilpanel for at gøre det til din aktive profil. Din aktive profil er markeret med et grønt mærkat og venstre kant.
  • Rediger — Klik på blyantikonet for at omdøbe en profil.
  • Slet — Klik på papirkurvsikonet for at fjerne en profil og alle dens alarmer. Du kan ikke slette din aktive profil.

Duplér

Klik på kopiikonet på en profil for at oprette en nøjagtig kopi med alle dens alarmer. Du bliver bedt om at navngive den nye profil — et standardnavn som \"Profil (Kopi)\" foreslås. Duplikatet inkluderer alle alarmfiltre men får et nyt sæt områdevalg.

Eksport & Import

  • Eksport — Klik på download-ikonet på en profil for at gemme en backupfil (JSON). Filen indeholder alle alarmfiltre, renset for interne ID'er så den er bærbar.
  • Import — Klik på Import-knappen øverst til højre, vælg en backupfil og vælg et navn til den nye profil. Alle alarmer fra backuppen gendannes. Hvis en profil med samme navn findes, tilføjes et nummersuffiks automatisk.

Duplikatdetektion

Hvis den samme alarm findes på flere profiler (f.eks. følger Pikachu på både \"Hjem\" og \"Arbejde\"), fremhæves disse alarmer med en orange kant og et kopiikon. Når der er duplikater, vises et Duplikater-filterchip i filterlinjen — klik på det for kun at vise duplikerede alarmer på tværs af profiler.

⚠️
Advarsel: Sletning af en profil fjerner permanent alle alarmer i den profil. Du kan ikke slette din aktuelt aktive profil. Overvæj at eksportere en backup først.
", - "CONTENT_CLEANING": "\"Oprydningsside

Oprydningssiden lader dig styre oprydningstilstand på tværs af alle dine alarmtyper på én gang.

Når oprydningstilstand er slået til for en alarmtype, sletter botten automatisk notifikationer fra Discord efter begivenheden udløber:

  • Pokemon — Slettet når spawnet despawner
  • Raids — Slettet når raidet slutter
  • Eggs — Slettet når ægget klækkes
  • Quests — Slettet når quests nulstilles ved midnat
  • Invasions — Slettet når grunten forlader
  • Lures — Slettet når luren udløber
  • Nests — Slettet når reder migrerer
  • Gyms — Slettet efter gym-ændringer
  • Fort Changes — Slettet efter fortændringsnotifikation udløber
  • Max Battles — Slettet når kampen slutter

Brug Aktiver alle eller Deaktiver alle til at skifte alt på én gang.

💡
Anbefalet: Hold oprydningstilstand aktiveret for at forhindre forældede alarmer i at hobe sig op i dine DM'er.
", - "CONTENT_APPEARANCE": "

Mørk / Lys tilstand

Klik på sol/måne-ikonet i den øverste værktøjslinje for at skifte mellem mørkt og lyst tema. Dit valg gemmes automatisk.

\"Værktøjslinje

Accentfarver

Åbn brugermenuen (dit avatar øverst til højre) og vælg Accenttema. Vælg mellem:

  • Standard — Blå
  • Pokemon — Grøn
  • Raids — Rød
  • Mystic — Blå
  • Valor — Rød
  • Instinct — Gul

Accentfarven ændrer værktøjslinjens gradient, aktiv navigationsfremhævning og andre UI-accenter på tværs af siden.

\"Dashboard

Sprog

Hvis tilgængeligt, brug sprogvælgeren i værktøjslinjen til at skifte interfacesprog. 18 sprog understøttes.

Tastaturgenveje

?Vis tastaturgenveje
EscLuk menuer eller dialoger
[Fold sidepanel sammen
]Udvid sidepanel
", - "CONTENT_ALERTS_LOGOUT": "\"Brugermenu

Pause alarmer

Åbn brugermenuen (dit avatar) og klik på Pause alarmer. Et rødt banner vises øverst på siden der bekræfter at dine alarmer er sat på pause. Du modtager ingen notifikationer mens de er på pause.

For at genoptage, klik på Genoptag alarmer fra brugermenuen eller banneret.

Log ud

Åbn brugermenuen og klik på Log ud. Du sendes tilbage til login-siden.

", - "CONTENT_FAQ": "

\"Jeg kan ikke logge ind\"

Du skal registrere dig hos Poracle-botten på Discord eller Telegram før du kan logge ind på denne side. Hvis du ser \"Din konto er ikke registreret\", kontakt din community-admin for registreringsinstruktioner.

\"Jeg får ingen notifikationer\"

Tjek disse almindelige årsager:

  1. Alarmer sat på pause — Se efter et rødt banner øverst på siden. Genoptag alarmer fra brugermenuen.
  2. Ingen placering indstillet — Hvis dine alarmer bruger afstandstilstand, har du brug for en gemt placering.
  3. Ingen områder valgt — Hvis dine alarmer bruger områdetilstand, sørg for at du har valgt områder på Område-siden.
  4. Forkert profil — Du har måske alarmer på en anden profil. Tjek hvilken profil der er aktiv på Dashboardet.
  5. For strenge filtre — Prøv at løsne dine IV-, CP- eller niveaufiltre for at se om notifikationer begynder at komme.

\"Mine alarmer er forsvundet\"

Alarmer er profilspecifikke. Hvis du skiftede profil, er dine alarmer fra den anden profil stadig der — skift bare tilbage fra Dashboardet eller Profilsiden.

\"Jeg kan ikke klikke på et lille område på kortet\"

Når områder overlapper, zoom ind for at gøre det mindre område nemmere at klikke på. Mindre områder er altid oven på større.

\"Hvad gør oprydningstilstand?\"

Oprydningstilstand fortæller botten automatisk at slette en notifikation fra Discord efter begivenheden udløber (f.eks. en Pokemon despawner). Uden den forbliver gamle alarmer i dine DM'er for evigt. Aktiver den på Oprydningssiden eller per alarm i Leveringsfanen.

\"Hvad er forskellen mellem Områder og Afstand?\"

Hver alarm bruger én leveringstilstand. Områder giver dig besked om begivenheder inden for bestemte geografiske zoner. Afstand giver dig besked om begivenheder inden for en radius af din gemte placering. Du kan blande begge på tværs af forskellige alarmer.

" + "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks er færdige alarmskabeloner oprettet af dit communitys administratorer. De lader dig konfigurere almindelige alarmopsætninger med ét klik i stedet for at oprette hver alarm individuelt.

Anvend et Quick Pick

  1. Gå til Quick Picks i sidepanelet.
  2. Gennemse de tilgængelige valg, eventuelt filtreret efter kategori.
  3. Klik på Anvend på det Quick Pick du ønsker.
  4. Tilpas før du anvender: vælg hvor beskederne skal nå dig — Levering-fanen er den samme vælger med tre muligheder som en enkelt alarm, så du kan rette dem mod et gemt sted eller et udvalg af områder — aktiver oprydningstilstand og udeluk eventuelt specifikke Pokemon.
  5. Bekræft for at oprette alle alarmer på én gang.

Fjern Quick Pick-alarmer

Hvis du ikke længere vil have alarmer fra et Quick Pick, klik på Fjern for at slette alle alarmer det oprettede.

", + "CONTENT_PROFILES": "

Profilsiden er dit samlede center til at administrere profiler og se alle alarmer på tværs af alle profiler ét sted.

Hvorfor bruge profiler?

Profiler lader dig vedligeholde helt separate alarmkonfigurationer. Hver profil har sit eget sæt alarmer, valgte områder, placering og brugerdefinerede geofence-aktiveringer. Nyttigt til forskellige situationer — for eksempel en \"Hjem\"-profil til dit kvarter og en \"Arbejde\"-profil til omkring dit kontor.

Overblik

Siden viser en statistiklinje med samlede alarmtal per type, en søgelinje til at filtrere på tværs af alle profiler, og typefilterchips til kun at vise bestemte alarmtyper (Pokemon, Raids, Quests osv.).

Hver profil vises som et udvidbart panel. Klik for at udvide og se alle alarmer grupperet efter type, med spilgrafik (Pokemon-sprites, raid-æg, lure-ikoner) og filtermærkater der viser IV, CP, Niveau, PVP og andre indstillinger.

Administrer profiler

  • Opret — Klik på +-knappen øverst til højre. Profilnavne skal være unikke (op til 32 tegn).
  • Skift — Klik på Skift inde i et profilpanel for at gøre det til din aktive profil. Din aktive profil er markeret med et grønt mærkat og venstre kant.
  • Rediger — Klik på blyantikonet for at omdøbe en profil.
  • Slet — Klik på papirkurvsikonet for at fjerne en profil og alle dens alarmer. Du kan ikke slette din aktive profil.

Duplér

Klik på kopiikonet på en profil for at oprette en nøjagtig kopi med alle dens alarmer. Du bliver bedt om at navngive den nye profil — et standardnavn som \"Profil (Kopi)\" foreslås. Duplikatet indeholder alle alarmfiltre, og dets områder, placering og aktive timer kopieres også fra kildeprofilen.

Eksport & Import

  • Eksport — Klik på download-ikonet på en profil for at gemme en backupfil (JSON). Filen indeholder alle alarmfiltre, renset for interne ID'er så den er bærbar.
  • Import — Klik på Import-knappen øverst til højre, vælg en backupfil og vælg et navn til den nye profil. Alle alarmer fra backuppen gendannes. Hvis en profil med samme navn findes, tilføjes et nummersuffiks automatisk.

Duplikatdetektion

Hvis den samme alarm findes på flere profiler (f.eks. følger Pikachu på både \"Hjem\" og \"Arbejde\"), fremhæves disse alarmer med en orange kant og et kopiikon. Når der er duplikater, vises et Duplikater-filterchip i filterlinjen — klik på det for kun at vise duplikerede alarmer på tværs af profiler.

⚠️
Advarsel: Sletning af en profil fjerner permanent alle alarmer i den profil. Du kan ikke slette din aktuelt aktive profil. Overvæj at eksportere en backup først.
", + "CONTENT_CLEANING": "\"Oprydningsside

Oprydningssiden lader dig styre oprydningstilstand på tværs af alle dine alarmtyper på én gang.

Når oprydningstilstand er slået til for en alarmtype, sletter botten automatisk notifikationer fra Discord efter begivenheden udløber:

  • Pokemon — Slettet når spawnet despawner
  • Raids — Slettet når raidet slutter
  • Eggs — Slettet når ægget klækkes
  • Quests — Slettet når quests nulstilles ved midnat
  • Invasions — Slettet når grunten forlader
  • Lures — Slettet når luren udløber
  • Nests — Slettet når reder migrerer
  • Gyms — Slettet efter gym-ændringer
  • Max Battles — Slettet når kampen slutter

Brug Aktiver alle eller Deaktiver alle til at skifte alt på én gang.

💡
Anbefalet: Hold oprydningstilstand aktiveret for at forhindre forældede alarmer i at hobe sig op i dine DM'er.
", + "CONTENT_APPEARANCE": "

Mørk / Lys tilstand

Klik på sol/måne-ikonet i den øverste værktøjslinje for at skifte mellem mørkt og lyst tema. Dit valg gemmes automatisk.

\"Værktøjslinje

Accentfarver

Åbn brugermenuen (dit avatar øverst til højre) og vælg Accenttema. Vælg mellem:

  • Standard — Blå
  • Pokemon — Grøn
  • Raids — Rød
  • Mystic — Blå
  • Valor — Rød
  • Instinct — Gul

Accentfarven ændrer værktøjslinjens gradient, aktiv navigationsfremhævning og andre UI-accenter på tværs af siden.

\"Dashboard

Visningssprog

Åbn brugermenuen (din avatar øverst til højre) og vælg Visningssprog. Der er 11 sprog. Det ændrer tekst på selve siden og også de Pokemon-navne, -typer og -former, der vises i vælgerne og på dine alarmkort. Har du aldrig valgt et, får du din browsers sprog eller det, din Poracle-server er sat til.

Beskedsprog

Lige under ligger Beskedsprog, en separat indstilling. Den bestemmer, hvilket sprog Poracle skriver dine DM'er på. De to er uafhængige: en dansk side med engelske DM'er, eller omvendt, er helt normalt. Den lå tidligere på Områder-siden.

Tastaturgenveje

?Vis tastaturgenveje
EscLuk menuer eller dialoger
[Fold sidepanel sammen
]Udvid sidepanel
", + "CONTENT_ALERTS_LOGOUT": "\"Brugermenu

Pause alarmer

Åbn brugermenuen (dit avatar) og klik på Pause alarmer. Et rødt banner vises øverst på siden der bekræfter at dine alarmer er sat på pause. Du modtager ingen notifikationer mens de er på pause.

For at genoptage, klik på Genoptag alarmer fra brugermenuen eller banneret.

Log ud

Åbn brugermenuen og klik på Log ud. Du sendes tilbage til login-siden.

Hvis du er logget ind via en SSO-udbyder, der understøtter single logout, tilbyder menuen også Log ud overalt — det afslutter også din session hos udbyderen, ikke kun her.

", + "CONTENT_FAQ": "

\"Jeg kan ikke logge ind\"

Du skal registrere dig hos Poracle-botten på Discord eller Telegram før du kan logge ind på denne side. Hvis du ser \"Din konto er ikke registreret\", kontakt din community-admin for registreringsinstruktioner.

\"Jeg får ingen notifikationer\"

Tjek disse almindelige årsager:

  1. Alarmer sat på pause — Se efter et rødt banner øverst på siden. Genoptag alarmer fra brugermenuen.
  2. Ingen position angivet — En alarm, der når dig inden for en radius, måler fra din position eller fra et gemt sted. Angiv en på siden Områder og steder.
  3. Intet i rækkevidde — Se på chippen på alarmkortet. Den viser, hvor alarmen når dig, og den kan pege på områder, din profil ikke længere dækker.
  4. Forkert profil — Du har måske alarmer på en anden profil. Tjek hvilken profil der er aktiv på Dashboardet.
  5. For strenge filtre — Prøv at løsne dine IV-, CP- eller niveaufiltre for at se om notifikationer begynder at komme.

\"Mine alarmer er forsvundet\"

Alarmer er profilspecifikke. Hvis du skiftede profil, er dine alarmer fra den anden profil stadig der — skift bare tilbage fra Dashboardet eller Profilsiden.

\"Jeg kan ikke klikke på et lille område på kortet\"

Når områder overlapper, zoom ind for at gøre det mindre område nemmere at klikke på. Mindre områder er altid oven på større.

\"Hvad gør oprydningstilstand?\"

Oprydningstilstand fortæller botten automatisk at slette en notifikation fra Discord efter begivenheden udløber (f.eks. en Pokemon despawner). Uden den forbliver gamle alarmer i dine DM'er for evigt. Aktiver den på Oprydningssiden eller per alarm i Leveringsfanen.

\"Hvor når en besked mig?\"

Hver alarm svarer selv, i sin Levering-fane. Overalt i mine områder følger de områder, din profil har valgt. Nær et punkt er en radius fra din position eller fra et gemt sted. Kun i bestemte områder begrænser netop den alarm til et udvalg af områder. Chippen på kortet viser altid det aktuelle svar, og et klik ændrer det.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "DM-alarmer", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Log ind", "SIGN_IN_DESC": "Log ind for at administrere dine Pokemon GO-notifikationsalarmer.", "SIGN_IN_DISCORD": "Log ind med Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Log ind med Telegram", + "SIGN_IN_OIDC": "Log ind med {{provider}}", + "SIGNED_OUT_TITLE": "Logget ud", + "SIGNED_OUT_DESC": "Du er blevet logget ud af DM Alerts.", + "PROVIDER_DISABLED_BY_ADMIN": "Denne loginmetode er slået fra af en administrator.", + "PROVIDER_DISABLED_HINT": "Denne loginmetode er slået fra for ikke-administratorer.", + "ERR_TELEGRAM_DISABLED": "Login med Telegram er slået fra lige nu.", "OR": "eller", "NO_METHODS": "Ingen loginmetoder er aktuelt aktiverede. Kontakt venligst en administrator.", "AUTHENTICATING": "Godkender...", "FOOTER": "Administrer alarmer for Pokemon, Raids, Quests og mere", "AUTH_FAILED": "Godkendelse mislykkedes", "BACK_TO_LOGIN": "Tilbage til login", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "Login med Discord er slået fra lige nu.", + "ERR_DISCORD_FETCH": "Din Discord-profil kunne ikke hentes. Prøv igen.", + "ERR_MISSING_CODE": "Login med Discord blev annulleret eller mislykkedes.", + "ERR_MISSING_ROLE": "Du har ikke den Discord-rolle, der kræves for denne side.", + "ERR_NOT_IN_GUILD": "Du skal være medlem af Discord-serveren for at bruge siden.", + "ERR_NOT_REGISTERED": "Din konto er ikke registreret. Tilmeld dig for at komme i gang.", + "ERR_OIDC_DISABLED": "Ekstern login er i øjeblikket deaktiveret.", + "ERR_OIDC_NO_IDENTITY": "Din eksterne login-udbyder returnerede ikke en konto, vi kan matche. Sørg for, at din Discord-konto er tilknyttet.", + "ERR_OIDC_TOKEN_EXCHANGE": "Ekstern login mislykkedes. Prøv venligst igen.", + "ERR_OIDC_USERINFO": "Kunne ikke hente din profil fra den eksterne login-udbyder. Prøv venligst igen.", + "ERR_ROLE_CHECK_FAILED": "Dine Discord-roller kunne ikke bekræftes. Prøv igen senere.", + "ERR_TELEGRAM_FAILED": "Login med Telegram mislykkedes. Prøv igen.", + "ERR_TOKEN_EXCHANGE": "Login med Discord mislykkedes. Prøv igen.", "ERR_GENERIC": "Godkendelsesfejl: {{error}}", "ERR_NO_TOKEN": "Intet godkendelsestoken modtaget.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "Tilmeld dig", + "SIGN_UP_DESC": "Har du ikke en konto? Tilmeld dig for at komme i gang.", + "SIGN_IN_AGAIN": "Log ind igen" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Denne funktion er deaktiveret af administratoren." }, "ADMIN": { "USERS_TITLE": "Brugeradministration", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Forfremmet navn", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Navn til den forfremmede geofence", "APPROVAL_PROMOTED_NAME_HINT": "Valgfrit. Bruger det nuværende visningsnavn som standard.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Årsag til afvisning", "APPROVAL_REJECT_PLACEHOLDER": "Forklar hvorfor denne geofence afvises...", "USERS_DESC_FULL": "Administrer registrerede Discord-brugere. Stoppet = brugeren pausede alarmer eller ramte hastighedsbegrænsninger. Blokeret = hårdt blokeret af admin.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Kunne ikke godkende indsendelse", "SNACK_APPROVED": "\"{{name}}\" godkendt", "SNACK_FAILED_REJECT": "Kunne ikke afvise indsendelse", - "SNACK_REJECTED": "\"{{name}}\" afvist" + "SNACK_REJECTED": "\"{{name}}\" afvist", + "APPROVAL_REGION_HINT": "Vælg den region, dette geofence skal vises under.", + "SERVER_TITLE": "Poracle-server", + "SERVER_REFRESH": "Tjek igen", + "SERVER_VERSION": "Version", + "SERVER_SCHEMA": "Databaseskema", + "SERVER_CHECKED": "Sidst tjekket", + "SERVER_CAPABILITIES": "Funktioner", + "SERVER_NO_CAPABILITIES": "Denne server melder ingen.", + "SERVER_UNKNOWN": "Ukendt", + "SERVER_UNREACHABLE": "Poracle svarede ikke. Alarmer, profiler og steder går gennem den og fejler, indtil den svarer igen.", + "SERVER_TOO_OLD": "Poracle {{version}} er ældre end {{minimum}}, som denne version af siden kræver. Levering pr. alarm, PVP-mega-filteret og filteret for resterende tid ser ud til at gemme uden at ændre noget.", + "UPDATE_AVAILABLE": "{{name}} {{running}} kører, og {{latest}} er udkommet.", + "UPDATE_PRERELEASE": "{{name}} {{running}} er nyere end nogen udgivelse — det er en udviklingsbuild.", + "VERSIONS_TITLE": "Versioner", + "VERSIONS_WEB": "Dette websted", + "VERSIONS_BUILD": "Build", + "UPDATE_CURRENT": "Opdateret.", + "UPDATE_UNCOMPARABLE": "Udviklingskanal. Nyeste udgivelse er {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Vælg et punkt", "CANCEL": "Annuller", "CONFIRM": "Bekræft", "DONT_ASK_AGAIN": "Spørg ikke igen i denne session", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Opdater alle afstande", "DISTANCE_DESC": "Angiv placeringstilstand for alle alarmer af denne type.", "DISTANCE_UPDATE_ALL": "Opdater alle", + "DISTANCE_MUST_BE_POSITIVE": "Afstanden skal være større end nul.", "LOCATION_SAVE_ERROR": "Kunne ikke opdatere placering", "LOCATION_SAVE_SUCCESS": "Placering opdateret", "LOCATION_GEO_UNSUPPORTED": "Geolokation understøttes ikke af din browser", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "For mange testalarmer. Vent venligst et øjeblik.", "ERROR_NOT_FOUND": "Alarm ikke fundet — den kan være blevet slettet.", "ERROR_GENERIC": "Kunne ikke sende testalarm. Prøv igen senere.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "For mange testbeskeder. Vent et øjeblik.", + "NOT_FOUND": "Beskeden blev ikke fundet — den er måske slettet.", + "UNSUPPORTED": "Testbeskeder understøttes ikke for denne type.", + "FAILED": "Testbeskeden kunne ikke sendes. Prøv igen senere." }, "COMMON": { "CANCEL": "Annuller", @@ -1296,6 +1448,7 @@ "EDIT": "Rediger", "ADD": "Tilføj", "OK": "OK", + "UNDO": "Fortryd", "CONFIRM": "Bekræft", "DELETE_ALL": "Slet alle", "CLOSE": "Luk", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Søg efter et gym (valgfrit)", "SEARCH_HINT": "Skriv gym-navn...", - "CLEAR_ARIA": "Ryd gym-valg" + "CLEAR_ARIA": "Ryd gym-valg", + "RATE_LIMITED": "For mange scanneranmodninger — sæt farten lidt ned." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Notifikationer sendes for disse områder:", @@ -1388,18 +1542,24 @@ "SAVE_SUCCESS": "{{count}} setting(s) saved", "SAVE_PARTIAL": "{{done}} saved, {{errors}} failed", "ICONS_SELECTED": "Selected {{repo}} icons — click Save to apply", + "SEARCH_PLACEHOLDER": "Søg i indstillinger…", + "SEARCH_CLEAR": "Ryd søgning", + "UNSAVED_CHANGES": "{{count}} ikke gemt", + "SAVE_CHANGES": "Gem ændringer", + "DISCARD_CHANGES": "Kassér", + "COLLAPSE_SECTION": "Skjul sektion", + "EXPAND_SECTION": "Udvid sektion", + "SUMMARY_ENABLED": "{{count}} af {{total}} aktiveret", "GROUP_BRANDING": "Branding", "GROUP_ALARM_TYPES": "Alarmtyper", "GROUP_FEATURES": "Funktioner", "GROUP_ADMINISTRATION": "Administration", - "GROUP_COMMANDS": "Kommandoer", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Kort & aktiver", "GROUP_ANALYTICS_LINKS": "Analyse & links", - "GROUP_DEBUG": "Fejlfinding", "GROUP_ICON_REPO": "Ikon-repository", "GROUP_OTHER": "Andet", + "GROUP_OIDC": "Ekstern SSO", "CUSTOM_TITLE_LABEL": "Sidetitel", "CUSTOM_TITLE_DESC": "Navn vist i browser-fanen og sidens overskrift.", "HEADER_LOGO_URL_LABEL": "Header-logo-URL", @@ -1411,52 +1571,51 @@ "FAVICON_URL_PREVIEW": "Favicon-forhåndsvisning (32×32)", "FAVICON_URL_CACHE_WARNING": "Browsere cacher favicons aggressivt. Efter du gemmer, skal brugerne rydde browserens cache eller foretage en hård genindlæsning (Ctrl+F5 / Cmd+Shift+R) for at se det nye ikon.", "FAVICON_URL_CSP_NOTE": "Hvis dit websted bruger en Content Security Policy, skal favicon-URL'ens oprindelse være tilladt af dit img-src-direktiv; ellers blokerer browseren hentningen og falder tilbage til standardikonet.", + "FORCED_BY_PORACLE": "Deaktiveret i Poracles egen konfiguration. Poracle kasserer disse webhooks, og botten afviser kommandoen, så dette kan ikke aktiveres her.", + "FORCED_BY_PORACLE_TOOLTIP": "Styres af Poracles konfiguration, ikke af denne side.", "CUSTOM_PAGE_NAME_LABEL": "Navigationslink-etiket", "CUSTOM_PAGE_NAME_DESC": "Etiket til det brugerdefinerede navigationslink (f.eks. \"Tilbage til kort\").", "CUSTOM_PAGE_URL_LABEL": "Navigationslink-URL", "CUSTOM_PAGE_URL_DESC": "URL, som det brugerdefinerede navigationslink peger på.", "CUSTOM_PAGE_ICON_LABEL": "Navigationslink-ikon", "CUSTOM_PAGE_ICON_DESC": "FontAwesome-klasse for navigationslink-ikonet (f.eks. \"fas fa-map\").", - "DISABLE_MONS_LABEL": "Deaktiver Pokémon", - "DISABLE_MONS_DESC": "Skjul administration af Pokémon-alarmer for alle brugere.", - "DISABLE_RAIDS_LABEL": "Deaktiver Raids", - "DISABLE_RAIDS_DESC": "Skjul administration af Raid-alarmer for alle brugere.", - "DISABLE_QUESTS_LABEL": "Deaktiver Opgaver", - "DISABLE_QUESTS_DESC": "Skjul administration af opgave-alarmer for alle brugere.", - "DISABLE_INVASIONS_LABEL": "Deaktiver Invasioner", - "DISABLE_INVASIONS_DESC": "Skjul administration af invasions-alarmer for alle brugere.", - "DISABLE_LURES_LABEL": "Deaktiver Lokkemoduler", - "DISABLE_LURES_DESC": "Skjul administration af lokke-alarmer for alle brugere.", - "DISABLE_NESTS_LABEL": "Deaktiver Reder", - "DISABLE_NESTS_DESC": "Skjul administration af rede-alarmer for alle brugere.", - "DISABLE_GYMS_LABEL": "Deaktiver Gyms", - "DISABLE_GYMS_DESC": "Skjul administration af gym-alarmer for alle brugere.", - "DISABLE_FORT_CHANGES_LABEL": "Deaktiver fort-ændringer", - "DISABLE_FORT_CHANGES_DESC": "Skjul administration af fort-ændringsalarmer for alle brugere.", - "DISABLE_MAXBATTLES_LABEL": "Deaktiver Max Battles", - "DISABLE_MAXBATTLES_DESC": "Skjul administration af Max Battle-alarmer for alle brugere.", - "DISABLE_AREAS_LABEL": "Deaktiver områder", - "DISABLE_AREAS_DESC": "Forhindr brugere i at administrere deres områdeabonnementer.", - "DISABLE_PROFILES_LABEL": "Deaktiver profiler", - "DISABLE_PROFILES_DESC": "Forhindr brugere i at oprette og skifte alarmprofiler.", - "DISABLE_LOCATION_LABEL": "Deaktiver placering", - "DISABLE_LOCATION_DESC": "Forhindr brugere i at angive en hjemmeplacering.", - "DISABLE_NOMINATIM_LABEL": "Deaktiver geokodning", - "DISABLE_NOMINATIM_DESC": "Deaktiver Nominatim-adressesøgning ved valg af placering.", - "DISABLE_GEOMAP_LABEL": "Deaktiver kortvisning", - "DISABLE_GEOMAP_DESC": "Skjul det interaktive geofence-kort helt.", - "DISABLE_GEOMAP_SELECT_LABEL": "Deaktiver områdevalg på kort", - "DISABLE_GEOMAP_SELECT_DESC": "Forhindr brugere i at vælge områder ved at klikke på kortet.", - "ENABLE_TEMPLATES_LABEL": "Aktiver skabeloner", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Lad brugere administrere Pokémon-alarmer.", + "DISABLE_RAIDS_LABEL": "Raids", + "DISABLE_RAIDS_DESC": "Lad brugere administrere Raid-alarmer.", + "DISABLE_QUESTS_LABEL": "Opgaver", + "DISABLE_QUESTS_DESC": "Lad brugere administrere opgave-alarmer.", + "DISABLE_INVASIONS_LABEL": "Invasioner", + "DISABLE_INVASIONS_DESC": "Lad brugere administrere invasions-alarmer.", + "DISABLE_LURES_LABEL": "Lokkemoduler", + "DISABLE_LURES_DESC": "Lad brugere administrere lokke-alarmer.", + "DISABLE_NESTS_LABEL": "Reder", + "DISABLE_NESTS_DESC": "Lad brugere administrere rede-alarmer.", + "DISABLE_GYMS_LABEL": "Gyms", + "DISABLE_GYMS_DESC": "Lad brugere administrere gym-alarmer.", + "DISABLE_FORT_CHANGES_LABEL": "Fort-ændringer", + "DISABLE_FORT_CHANGES_DESC": "Lad brugere administrere fort-ændringsalarmer.", + "DISABLE_MAXBATTLES_LABEL": "Max Battles", + "DISABLE_MAXBATTLES_DESC": "Lad brugere administrere Max Battle-alarmer.", + "DISABLE_AREAS_LABEL": "Områder", + "DISABLE_AREAS_DESC": "Lad brugere administrere deres områdeabonnementer.", + "DISABLE_PROFILES_LABEL": "Profiler", + "DISABLE_PROFILES_DESC": "Lad brugere oprette og skifte alarmprofiler.", + "DISABLE_LOCATION_LABEL": "Placering", + "DISABLE_LOCATION_DESC": "Lad brugere angive en hjemmeplacering.", + "DISABLE_NOMINATIM_LABEL": "Geokodning", + "DISABLE_NOMINATIM_DESC": "Tillad Nominatim-adressesøgning ved valg af placering.", + "DISABLE_USER_GEOFENCES_LABEL": "Brugerdefinerede geofences", + "DISABLE_USER_GEOFENCES_DESC": "Lad brugere tegne, importere og indsende deres egne geofences. Eksisterende geofences fungerer fortsat.", + "ENABLE_TEMPLATES_LABEL": "Skabeloner", "ENABLE_TEMPLATES_DESC": "Tillad brugere at vælge skabeloner for notifikationsbeskeder.", "ALLOWED_LANGUAGES_LABEL": "Tilladte UI-sprog", "ALLOWED_LANGUAGES_DESC": "Kommaseparerede sprogkoder, der skal vises i sprogvælgeren (f.eks. \"en,de,fr,es\"). Lad stå tomt for at vise alle 11 sprog.", + "PORACLE_LOCALE_HINT": "Standardsprog for nye brugere: {{locale}}, hentet fra Poracles egen konfiguration. Den, der vælger et sprog, eller hvis browser beder om et, som dette site har, får det i stedet.", "ENABLE_ROLES_LABEL": "Aktiver rollebaseret adgang", "ENABLE_ROLES_DESC": "Tillad kun brugere med specifikke Discord-roller at logge ind. Kræver Bot-token og Guild-ID.", "ALLOWED_ROLE_IDS_LABEL": "Tilladte rolle-ID'er", - "ALLOWED_ROLE_IDS_DESC": "Kommaseparerede Discord-rolle-ID'er, der giver adgang (f.eks. \"123456789,987654321\"). Lad stå tomt for at tillade alle.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Tilladte sprog", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Kommasepareret liste over sprogkoder, som brugere kan vælge (f.eks. \"en,de,fr\").", + "ALLOWED_ROLE_IDS_DESC": "Kommaseparerede Discord-rolle-ID'er, f.eks. 123456789,987654321. En bruger skal have mindst én af disse roller for at kunne logge ind. Lad stå tomt for at tillade alle.", "REGISTER_COMMAND_LABEL": "Registreringskommando", "REGISTER_COMMAND_DESC": "Poracle-bot-kommando, som brugere kører for at registrere sig (f.eks. \"$!register\").", "LOCATION_COMMAND_LABEL": "Placeringskommando", @@ -1464,11 +1623,32 @@ "ENABLE_TELEGRAM_LABEL": "Aktiver Telegram-login", "ENABLE_TELEGRAM_DESC": "Tillad Telegram-login på dette site. Kræver TELEGRAM_ENABLED=true, bot-token og bot-brugernavn i .env (servergenstart kræves efter .env-ændringer).", "TELEGRAM_BOT_LABEL": "Bot-brugernavn", - "TELEGRAM_BOT_DESC": "Telegram-bot-brugernavn (uden @).", + "TELEGRAM_BOT_DESC": "Telegram-bot-brugernavn (uden @). Bruges, når TELEGRAM_BOT_USERNAME ikke er konfigureret.", "ENABLE_DISCORD_LABEL": "Aktiver Discord-login", "ENABLE_DISCORD_DESC": "Tillad Discord-login på dette site. Kræver Discord Client ID og Client Secret i .env (servergenstart kræves efter .env-ændringer). Påvirker ikke PoracleNG-bot-levering.", "PROVIDER_URL_LABEL": "Kortflise-URL", "PROVIDER_URL_DESC": "URL-skabelon til kortflise-udbyderen (bruges til statiske kort).", + "ENABLE_OIDC_LABEL": "Aktiver ekstern SSO-login", + "ENABLE_OIDC_DESC": "Tillad login via den konfigurerede eksterne OIDC/OAuth2-udbyder. Kræver OIDC_*-indstillinger (udbyder-URL'er, client ID og secret) i .env (servergenstart kræves efter .env-ændringer).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Alle brugere omdirigeres til den eksterne SSO-udbyder. Lokal login forbigås.", + "AUTH_MODE_SWITCH_CONFIRM": "Skift til SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Skift til SSO-login?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Når du gemmer, omdirigeres alle brugere (inklusive administratorer) til {{provider}} for at logge ind — den lokale Discord/Telegram-loginside forbigås. Hvis udbyderen er utilgængelig, kan du blive låst ude; gendan ved at angive AUTH_FORCE_LOCAL=true i servermiljøet.", + "AUTH_OIDC_NOT_CONFIGURED": "SSO er utilgængeligt, indtil OIDC-udbyderen er konfigureret i servermiljøet (OIDC_*-miljøvariabler).", + "AUTH_OIDC_HIDES_LOCAL": "Discord og Telegram skjules, mens SSO er den aktive login-tilstand.", + "AUTH_SLO_LABEL": "Enkelt-logud", + "AUTH_SLO_DESC": "Når dette er aktiveret, afslutter \"Log ud overalt\" også udbyderens session (ikke kun dette site). Kræver udbyderens end-session-endpoint (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Enkelt-logud er utilgængeligt, indtil udbyderens end-session-endpoint er konfigureret (OIDC_END_SESSION_URL-miljøvariabel).", + "OIDC_SERVER_CONFIG": "OIDC-udbyderkonfiguration", + "OIDC_PROVIDER_LABEL": "Udbydernavn", + "OIDC_AUTHORIZATION_URL_LABEL": "Authorization-URL", + "OIDC_TOKEN_URL_LABEL": "Token-URL", + "OIDC_USERINFO_URL_LABEL": "UserInfo-URL", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Identitets-claim", + "OIDC_USE_PKCE_LABEL": "Brug PKCE", "GANALYTICSID_LABEL": "Google Analytics-id", "GANALYTICSID_DESC": "GA4-måle-id (lad stå tomt for at deaktivere).", "PATREONURL_LABEL": "Patreon-URL", @@ -1498,7 +1678,14 @@ "DISCORD_ADMIN_IDS_LABEL": "Admin-ID'er", "DISCORD_ADMIN_IDS_DESC": "Discord-bruger-ID'er med admin-adgang (maskeret).", "DISCORD_GEOFENCE_FORUM_LABEL": "Geofence-forumkanal", - "DISCORD_GEOFENCE_FORUM_DESC": "Discord-forumkanal til geofence-indsendelsestråde." + "DISCORD_GEOFENCE_FORUM_DESC": "Discord-forumkanal til geofence-indsendelsestråde.", + "GROUP_AUTH": "Godkendelse", + "AUTH_MODE_LABEL": "Loginmetode", + "AUTH_MODE_LOCAL": "Lokal", + "AUTH_MODE_LOCAL_DESC": "Log ind direkte med Discord eller Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "Lokalt login er gennemtvunget af serverkonfigurationen.", + "DISABLE_UPDATE_CHECK_LABEL": "Søg ikke efter opdateringer", + "DISABLE_UPDATE_CHECK_DESC": "Stopper siden i at spørge GitHub, om der er udgivet en nyere PoracleWeb eller Poracle. Det er den eneste forespørgsel uden for dit eget netværk, og der sendes ingen data med." }, "GEOFENCE_DETAIL": { "NAME": "Navn", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Din placering", "SELECTED_COUNT": "{{count}} valgt:", "AREAS_SELECTED": "{{count}} område(r) valgt" + }, + "ALERT_DEFAULTS": { + "TITLE": "Standardindstillinger for advarsler", + "DESC": "Vælg, hvordan nye advarsler leveres som standard. Du kan stadig ændre dette for hver advarsel, når du opretter den.", + "DEFAULT_DISTANCE": "Standardafstand", + "DEFAULT_DISTANCE_HINT": "Bruges til at udfylde radius for nye afstandsbaserede advarsler på forhånd.", + "FOOTNOTE": "Gælder kun for nyoprettede advarsler — eksisterende ændres ikke.", + "DISTANCE_TOO_SMALL": "Skal være mindst 0,1 km.", + "DISTANCE_TOO_LARGE": "Må højst være 100 km." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Elementer pr. side:", + "RANGE": "{{start}} - {{end}} af {{total}}", + "RANGE_EMPTY": "0 af {{total}}", + "NEXT_PAGE": "Næste side", + "PREVIOUS_PAGE": "Forrige side", + "FIRST_PAGE": "Første side", + "LAST_PAGE": "Sidste side" + }, + "WHERE": { + "SET_PIN": "Angiv din position", + "PIN_MISSING_WARNING": "Du har ikke angivet en position endnu, så beskeden har intet at måle fra.", + "PLACES_EMPTY_TITLE": "Ingen steder endnu", + "PIN_UNSET": "Ikke angivet", + "PLACES_PAGE_DESC": "Navngivne punkter, dine beskeder kan rettes mod i stedet for din position.", + "ADD_PLACE": "Tilføj et sted", + "AREAS_LABEL": "Områder", + "AREA_LIST_MORE": "{{areas}} og {{count}} mere", + "MEASURED_FROM": "Målt fra", + "MY_PIN": "Min position", + "NAME_PLACE_MESSAGE": "Hvad skal stedet hedde?", + "NAME_PLACE_TITLE": "Navngiv stedet", + "NEAR_PIN": "Inden for {{distance}} km fra min position", + "NEAR_PLACE": "Inden for {{distance}} km fra {{place}}", + "NO_PLACES": "Ingen steder endnu. Tilføj et nedenfor for at rette beskeden et andet sted hen end din position.", + "ONLY_IN": "Kun i {{areas}}", + "OPTION_AREAS": "Kun i bestemte områder", + "OPTION_NEAR": "Nær et punkt", + "OPTION_PLACE": "Nær et sted", + "OPTION_PROFILE": "Overalt i mine områder", + "PIN_NOTE": "Standarden for enhver besked uden eget mål.", + "PIN_TITLE": "Min position", + "PLACES_EMPTY": "Tilføj et for at få beskeder et andet sted end ved din position: arbejdet, fitnesscentret, hos dine forældre.", + "PLACES_TITLE": "Steder", + "PLACE_DELETED": "{{place}} slettet.", + "PLACE_DELETE_CONFIRM": "Beskeder rettet mod {{place}} falder tilbage til din position.", + "PLACE_DELETE_ERROR": "Stedet kunne ikke slettes.", + "PLACE_DELETE_TITLE": "Slet dette sted?", + "PLACE_IN_USE": "{{place}} bruges af {{count}} besked(er). Peg dem et andet sted hen først.", + "PLACE_LABEL": "Sted", + "PLACE_NAME": "Navn", + "PLACE_SAVED": "{{place}} gemt.", + "PLACE_SAVE_ERROR": "Stedet kunne ikke gemmes.", + "PROFILE_ANYWHERE": "Overalt hvor jeg får beskeder", + "PROFILE_AREAS": "Overalt i mine områder", + "RADIUS_KM": "Radius (km)", + "SAVE": "Angiv hvor", + "SCOPE_SAVED": "Opdateret.", + "SCOPE_SAVE_ERROR": "Kunne ikke opdatere, hvor beskeden når dig.", + "SHEET_TITLE": "Hvor skal beskeden nå dig?", + "USE_THIS_POINT": "Brug dette punkt" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json index 2d4fb0cf..c67075e7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -16,7 +16,7 @@ "GYMS": "Arenen", "FORT_CHANGES": "Fort-Änderungen", "PROFILES": "Profile", - "AREAS": "Gebiete", + "AREAS": "Gebiete & Orte", "MY_GEOFENCES": "Meine Geofences", "CLEANING": "Aufräumen", "HELP": "Hilfe", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Angezeigt als", - "BACK_TO_ADMIN": "Zurück zum Admin", + "EXIT_IMPERSONATION": "Zurück zu deinem Konto", "DISABLED_ACCOUNT": "Dein Konto wurde deaktiviert. Dies kann an einer Ratenbegrenzung oder einer administrativen Maßnahme liegen.", + "DISABLED_ACCOUNT_INSPECTED": "Dieses Konto wurde von einem Administrator deaktiviert und erhält keine Benachrichtigungen.", "DISABLED_SUPPORT": "Für Hilfe frag in", "PAUSED_ALERTS": "Deine Benachrichtigungen sind pausiert. Du wirst keine Benachrichtigungen erhalten.", "RESUME": "Fortsetzen" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Ändert nur den Text dieser Seite.", "PROFILE_PREFIX": "Profil #", "PAUSE_ALERTS": "Alarme pausieren", "RESUME_ALERTS": "Alarme fortsetzen", "SWITCH_PROFILE": "Profil wechseln", - "AREAS_LOCATION": "Gebiete & Standort", "CLEANING": "Aufräumen", "ACCENT_THEME": "Akzentfarbe", - "LANGUAGE": "Sprache", + "DISPLAY_LANGUAGE": "Anzeigesprache", + "ALERT_LANGUAGE": "Meldungssprache", + "ALERT_LANGUAGE_HINT": "Für Meldungstext und Pokemon-Namen.", "LOGOUT": "Abmelden", + "LOGOUT_EVERYWHERE": "Überall abmelden", "ACCENT_DEFAULT": "Standard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Benachrichtigungs-Standards" }, "SHORTCUTS": { "TITLE": "Tastenkürzel", @@ -77,6 +82,7 @@ "NETWORK": "Server nicht erreichbar. Bitte überprüfe deine Verbindung.", "BAD_REQUEST": "Ungültige Anfrage. Bitte überprüfe deine Eingabe.", "UNAUTHORIZED": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", + "INSPECTION_ENDED": "Die Ansicht wurde beendet – du bist zurück in deiner eigenen Sitzung.", "FORBIDDEN": "Du hast keine Berechtigung für diese Aktion.", "NOT_FOUND": "Die angeforderte Ressource wurde nicht gefunden.", "CONFLICT": "Ein Konflikt ist aufgetreten. Das Element wurde möglicherweise geändert.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Willkommens-Einführung" }, "POKEMON": { + "PVP_EVOLUTION": "Mega-Entwicklung", + "PVP_EVOLUTION_HINT": "Werte die Grundformen oder eine Mega-Form. Megas werden getrennt gewertet, eine Mega-Regel trifft also keine Grundform.", + "PVP_EVO_BASE": "Basis", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Pokemon-Alarme", "PAGE_DESC": "Verfolge wilde Pokemon-Spawns mit individuellen IV-, CP-, Level- und PVP-Filtern.", "SEARCH_PLACEHOLDER": "Suche nach Name oder #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Form & Geschlecht", "LABEL_FORM": "Form", "ALL_FORMS": "Alle Formen", + "FORM_MULTI_HINT": "Leer lassen, um alle Formen einzuschließen", "LABEL_GENDER": "Geschlecht", "GENDER_ALL": "Alle", "GENDER_MALE": "Männlich", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Nur benachrichtigen, wenn entwickelter CP dieses Minimum erreicht", "PVP_DISABLED_HINT": "Wähle eine Liga, um nach PVP-Rang zu filtern.", "SNACK_CREATED": "{{count}} Pokemon-Alarm(e) erstellt", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} Pokemon-Alarm(e) erstellt, {{duplicates}} bereits verfolgt", "SNACK_UPDATED": "Pokemon-Alarm aktualisiert", "SNACK_DELETED": "Pokemon-Alarm gelöscht", "SNACK_DELETED_ALL": "Alle Pokemon-Alarme gelöscht", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Level-Cap", + "PVP_CAP_ALL": "Alle", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Standard — aus der Poracle-Konfiguration", + "FILTER_TIME_LEFT": "Restzeit", + "LABEL_MIN_TIME": "Mindestens verbleibende Zeit", + "MIN_TIME_HINT": "Überspringt Spawns, die verschwunden sind, bevor du ankommst.", + "MIN_TIME_MINUTES": "{{count}} Min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "noch {{count}} Min", + "PILL_TIME_LEFT_SECONDS": "noch {{count}} s", + "MIN_TIME_ANY": "Beliebig" }, "ALARM": { "LOCATION_MODE": "Standortmodus", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Löscht die Benachrichtigung automatisch aus Discord, wenn das Lockmodul abläuft", "CLEAN_HINT_NEST": "Löscht die Benachrichtigung automatisch aus Discord, wenn Nester wechseln", "CLEAN_HINT_GYM": "Löscht die Benachrichtigung automatisch aus Discord, wenn sich die Arena-Aktivität ändert", - "CLEAN_HINT_FORT": "Löscht die Benachrichtigung automatisch aus Discord, wenn sie abläuft", "CLEAN_HINT_MAX_BATTLE": "Löscht die Benachrichtigung automatisch aus Discord, wenn der Max-Kampf endet", "SAVING": "Wird gespeichert...", "SAVE": "Speichern", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Abklingzeit aktiv", "TEST_SEND": "Testbenachrichtigung senden", "TAB_DELIVERY": "Zustellung", - "COMMON_SETTINGS": "Allgemeine Einstellungen" + "COMMON_SETTINGS": "Allgemeine Einstellungen", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} erstellt, {{duplicates}} bereits verfolgt" }, "RAIDS": { + "RSVP_LABEL": "RSVP-Benachrichtigungen", + "RSVP_OFF": "Nur Treffer", + "RSVP_INCLUDE": "Treffer + RSVP-Updates", + "RSVP_ONLY": "Nur RSVP-Updates", + "RSVP_OFF_DESC": "Nur normale Raid-/Ei-Benachrichtigungen.", + "RSVP_INCLUDE_DESC": "Zusätzlich erneut benachrichtigen, wenn sich RSVP-Zahlen ändern.", + "RSVP_ONLY_DESC": "Erste Treffer überspringen; nur bei RSVP-Änderungen benachrichtigen. Ohne einen RSVP-fähigen Scanner bleibt dieser Alarm stumm.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Nur RSVP", "PAGE_TITLE": "Raid- & Ei-Alarme", "PAGE_DESC": "Werde über Raid-Bosse und schlüpfende Eier in nahen Arenen benachrichtigt.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Möchtest du wirklich ALLE Raid- und Ei-Alarme löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "CONFIRM_BULK_DELETE_TITLE": "Ausgewählte Alarme löschen", "CONFIRM_BULK_DELETE_MSG": "Möchtest du wirklich {{count}} Alarme löschen?", - "CONFIRM_DELETE_SELECTED": "Ausgewählte löschen" + "CONFIRM_DELETE_SELECTED": "Ausgewählte löschen", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Besondere", + "SECTION_CUSTOM": "Eigene", + "ADD": "Level hinzufügen", + "ADD_PLACEHOLDER": "z. B. 42", + "ADD_HELP": "Jede positive Ganzzahl, die dein Server nutzt. 9000 bedeutet „jedes Level“.", + "INVALID": "Level muss mindestens 1 sein.", + "DUPLICATE": "Level {{value}} steht bereits in der Liste.", + "SR_REMOVE": "Eigenes Level {{value}} entfernen", + "REMOVED": "Level {{value}} entfernt", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest-Alarme", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Mega-Energie", "TAB_CANDY": "Bonbons", "ITEM_REWARD": "Item-Belohnung", - "ANY_ITEM": "Beliebiges Item", + "ANY_ITEM": "Beliebiger Gegenstand", "QUEST_TYPE_LABEL": "Quest-Typ:", "SNACK_CREATED": "Quest-Alarm erstellt", "SNACK_UPDATED": "Quest-Alarm aktualisiert", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Alle Quest-Alarme gelöscht", "SNACK_FAILED_DELETE_ALL": "Alarme konnten nicht gelöscht werden", "SNACK_FAILED_DISTANCE": "Entfernungen konnten nicht aktualisiert werden", - "CONFIRM_DELETE_SELECTED": "Ausgewählte löschen" + "CONFIRM_DELETE_SELECTED": "Ausgewählte löschen", + "SUMMARY_MODE": "Tägliche Zusammenfassung", + "SUMMARY_HINT": "Fasst passende Quests in einer einzigen Zusammenfassung zusammen, statt jede einzeln zu melden. Erfordert einen konfigurierten Zusammenfassungszeitplan im Bot.", + "SUMMARY_BADGE": "Zusammenfassung", + "SUMMARY_SCHEDULE": "Zustellung der Quest-Zusammenfassung", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Quest-Zusammenfassung", + "SUMMARY_SCHEDULE_EMPTY": "Kein Zusammenfassungsplan festgelegt. Quests werden einzeln zugestellt.", + "SUMMARY_SCHEDULE_EDIT": "Plan bearbeiten", + "SUMMARY_SCHEDULE_CLEAR": "Plan entfernen", + "SUMMARY_SCHEDULE_SEND_NOW": "Zusammenfassung jetzt senden", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Liefert die seit deiner letzten Zusammenfassung gesammelten Quest-Treffer. Ist noch nichts gepuffert, wird nichts gesendet.", + "SUMMARY_SCHEDULE_SAVED": "Zusammenfassungsplan gespeichert", + "SUMMARY_SCHEDULE_CLEARED": "Zusammenfassungsplan entfernt", + "SUMMARY_SCHEDULE_SENT": "Zusammenfassung gesendet", + "SUMMARY_SCHEDULE_FAILED": "Der Zusammenfassungsplan konnte nicht aktualisiert werden", + "SUMMARY_SCHEDULE_UNAVAILABLE": "Die Zusammenfassungszustellung ist vorübergehend nicht verfügbar. Bitte versuche es später erneut.", + "SUMMARY_DISABLED_HINT": "Die Planung von Zusammenfassungen ist auf diesem Server nicht verfügbar.", + "TAB_STARDUST": "Sternenstaub", + "MIN_AMOUNT": "Mindestmenge", + "MIN_AMOUNT_HINT": "0 = beliebige Menge", + "MIN_STARDUST": "Mindestens Sternenstaub", + "MIN_STARDUST_HINT": "0 = jede Sternenstaub-Aufgabe", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Invasions-Alarme", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnetisch", "TYPE_RAINY": "Regnerisch", "TYPE_GOLDEN": "Golden", - "TYPE_UNKNOWN": "Modul #{{id}}" + "TYPE_UNKNOWN": "Modul #{{id}}", + "EDIT_MODE": "Nachricht direkt bearbeiten", + "EDIT_HINT": "Aktualisiert die vorhandene Discord-Nachricht bei Änderungen am Lockmodul, statt eine neue zu senden.", + "EDIT_BADGE": "Bearbeiten", + "CONFIRM_DELETE_TITLE": "Lockmodul-Alarm löschen?", + "SNACK_FAILED_DISTANCE": "Die Entfernung konnte nicht aktualisiert werden." }, "NESTS": { "PAGE_TITLE": "Nest-Alarme", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Nest-Alarm gelöscht", "SNACK_FAILED_CREATE": "Alarm konnte nicht erstellt werden", "SNACK_FAILED_UPDATE": "Alarm konnte nicht aktualisiert werden", - "SNACK_FAILED_DELETE": "Alarm konnte nicht gelöscht werden" + "SNACK_FAILED_DELETE": "Alarm konnte nicht gelöscht werden", + "CONFIRM_DELETE_TITLE": "Nest-Alarm löschen?", + "SNACK_FAILED_DISTANCE": "Die Entfernung konnte nicht aktualisiert werden." }, "GYMS": { "PAGE_TITLE": "Arena-Alarme", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Weisheit", "TEAM_VALOR": "Wagemut", "TEAM_INSTINCT": "Intuition", - "TEAM_UNKNOWN": "Team {{id}}" + "TEAM_UNKNOWN": "Team {{id}}", + "CONFIRM_DELETE_TITLE": "Arena-Alarm löschen?", + "SNACK_FAILED_DISTANCE": "Die Entfernung konnte nicht aktualisiert werden." }, "FORT_CHANGES": { "PAGE_TITLE": "Fort-Änderungs-Alarme", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Entfernt", "CHANGE_NEW": "Neues Fort", "INCLUDE_EMPTY": "Forts ohne Namen einschließen", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Meldung konnte nicht erstellt werden", + "CREATE_SUCCESS": "Meldung für Arena-Änderungen erstellt", + "UPDATE_FAILED": "Meldung konnte nicht aktualisiert werden", + "UPDATE_SUCCESS": "Meldung für Arena-Änderungen aktualisiert", "ALL_CHANGES": "Alle Änderungen", "LABEL_NAME": "Name", "LABEL_LOCATION": "Standort", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Alarm für {{type}}-Änderung löschen?", "SNACK_DELETED": "Fort-Änderungsalarm gelöscht", "SNACK_FAILED_DISTANCE": "Distanzen konnten nicht aktualisiert werden", - "SNACK_ALL_DISTANCE": "Alle Distanzen aktualisiert" + "SNACK_ALL_DISTANCE": "Alle Distanzen aktualisiert", + "FORT_TYPE_LABEL": "Fort-Typ", + "CHANGE_TYPES_LABEL": "Änderungsarten", + "TRACKING_SUBTITLE": "Fort-Änderungs-Tracking", + "CHANGE_DESCRIPTION": "Beschreibung geändert" }, "MAX_BATTLES": { "PAGE_TITLE": "Max-Kampf-Alarme", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Meldung(en) konnten nicht erstellt werden", + "CREATE_SUCCESS": "{{count}} Meldung(en) erstellt", "ANY_POKEMON": "Beliebiges Pokémon", "ANY_LEVEL": "Beliebige Stufe", "STAR_LABEL": "{{stars}}-Sterne", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Distanzen konnten nicht aktualisiert werden", "SNACK_ALL_DISTANCE": "Alle Distanzen aktualisiert", "SNACK_FAILED_UPDATE": "Alarm konnte nicht aktualisiert werden", - "SNACK_UPDATED": "Dynamax-Kampfalarm aktualisiert" + "SNACK_UPDATED": "Dynamax-Kampfalarm aktualisiert", + "HINT_BY_LEVEL": "Verfolgt jedes Pokemon auf diesen Kampfstufen. Jede gewählte Stufe wird ein eigener Alarm.", + "HINT_BY_POKEMON": "Verfolgt bestimmte Pokemon in Max-Kämpfen, unabhängig von der Stufe.", + "HINT_GMAX_ONLY_ADD": "Meldet nur Gigantamax-Kämpfe für die gewählten Pokemon.", + "HINT_GMAX_ONLY_EDIT": "Meldet nur Gigantamax-Kämpfe für dieses Pokemon.", + "HINT_ALL_LEVELS": "Dieser Alarm verfolgt ein Pokemon über alle Max-Kampfstufen hinweg.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Gebiete & Standort", + "MANAGE_PLACES": "Orte verwalten", + "PAGE_TITLE": "Gebiete & Orte", "PAGE_DESC": "Bestimme, wo du Benachrichtigungen erhältst.", "METHOD_AREAS": "Gebiete", "METHOD_AREAS_ACTIVE": "{{count}} Gebiet(e) aktiv", "METHOD_NOT_CONFIGURED": "Nicht konfiguriert", "METHOD_AREAS_DESC": "Werde über alles benachrichtigt, was in deinen ausgewählten Geofence-Zonen passiert.", "METHOD_AREAS_TIP": "Ideal für: ganze Städte, Viertel oder Parks abdecken", - "METHOD_LOCATION": "Standort", - "METHOD_LOCATION_NOT_SET": "Nicht gesetzt", - "METHOD_LOCATION_DESC": "Werde über alles innerhalb einer bestimmten Entfernung von deinem Standort benachrichtigt.", + "METHOD_LOCATION": "Mein Standort", + "METHOD_LOCATION_NOT_SET": "Kein Standort gesetzt", + "METHOD_LOCATION_DESC": "Werde über alles im festgelegten Umkreis um deinen Standort benachrichtigt.", "METHOD_LOCATION_TIP": "Ideal für: Benachrichtigungen nahe Zuhause, Arbeit oder einem bestimmten Ort", "CLEAR_LOCATION": "Löschen", "CHANGE_LOCATION": "Ändern", "SET_LOCATION": "Setzen", "METHOD_NOTE": "Jeder Alarm wählt eine Methode im Zustellungs-Tab.", + "NOTIFICATION_LANGUAGE": "Benachrichtigungssprache", + "NOTIFICATION_LANGUAGE_DESC": "Die Sprache, die Poracle für deine Alarmtexte und Pokémon-Namen verwendet. Sie ist unabhängig von der Anzeigesprache im oberen Menü.", + "SNACK_LANGUAGE_UPDATED": "Benachrichtigungssprache aktualisiert", + "SNACK_LANGUAGE_FAILED": "Benachrichtigungssprache konnte nicht aktualisiert werden", "SELECT_AREAS": "Gebiete auswählen", "MAP_VIEW": "Karte", "LIST_VIEW": "Liste", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Standort konnte nicht aktualisiert werden", "SEARCH_AREAS": "Gebiete suchen", "MANUAL_ADD_PLACEHOLDER": "Gebietsnamen eingeben und Eingabetaste drücken", - "FILTER_PLACEHOLDER": "Nach Name filtern..." + "FILTER_PLACEHOLDER": "Nach Name filtern...", + "SNACK_LOAD_SELECTED_FAILED": "Deine aktuellen Gebiete konnten nicht geladen werden. Lade neu, bevor du sie änderst.", + "SELECTION_UNKNOWN": "Deine aktuellen Gebiete konnten nicht geladen werden — lade die Seite neu, bevor du speicherst." }, "PROFILES": { "PAGE_TITLE": "Profile", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Region auswählen", "SEARCH_REGIONS": "Regionen suchen...", "TOGGLE_TOOLTIP": "Benachrichtigungen für dieses Geofence im aktuellen Profil umschalten", - "CREATED_PREFIX": "Erstellt" + "CREATED_PREFIX": "Erstellt", + "REGION_OPTIONAL_HINT": "Optional. Wähle eine Region, falls dein Geofence zu einer gehört." }, "CLEANING": { "PAGE_TITLE": "Aufräummodus", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Einige Hilfeinhalte sind möglicherweise noch nicht in deiner Sprache verfügbar.", "TRANSLATION_CTA_LINK": "Beim Übersetzen helfen", "FALLBACK_CHIP": "Englisch", + "IMAGE_ENLARGE": "Zum Vergrößern klicken", "SECTION_GETTING_STARTED": "Erste Schritte", "SECTION_GETTING_STARTED_SUB": "Anmeldung, Einrichtungsassistent und erste Konfiguration", "SECTION_DASHBOARD": "Dashboard", "SECTION_DASHBOARD_SUB": "Deine Übersicht über Alarme, Gebiete und Status", "SECTION_LOCATION": "Standort festlegen", "SECTION_LOCATION_SUB": "GPS, Adresssuche und Koordinaten", - "SECTION_AREAS": "Gebiete auswählen", - "SECTION_AREAS_SUB": "Kartenansicht, Listenansicht und Regionenfilter", + "SECTION_AREAS": "Gebiete & Orte", + "SECTION_AREAS_SUB": "Kartenansicht, Listenansicht, Regionenfilter und Orte", "SECTION_GEOFENCES": "Eigene Geofences", "SECTION_GEOFENCES_SUB": "Grenzen zeichnen, zur öffentlichen Genehmigung einreichen", "SECTION_POKEMON": "Pokemon-Alarme", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Weitere Alarmtypen", "SECTION_OTHER_ALARMS_SUB": "Raids, Eier, Quests, Rocket, Lockmodule, Nester, Arenen, Fort-Änderungen", "SECTION_DELIVERY": "Zustellungseinstellungen", - "SECTION_DELIVERY_SUB": "Gebiete vs. Entfernung, Vorlagen und Aufräummodus", + "SECTION_DELIVERY_SUB": "Zustellungsbereich, Vorlagen und Aufräummodus", + "SECTION_QUEST_SUMMARY": "Zustellung der Quest-Zusammenfassung", + "SECTION_QUEST_SUMMARY_SUB": "Fasse laute Quests zu einer geplanten Zusammenfassung zusammen", "SECTION_TEST_ALERTS": "Testbenachrichtigungen", "SECTION_TEST_ALERTS_SUB": "Beispielbenachrichtigungen senden, um deine Alarme zu testen", "SECTION_POKEMON_AVAILABILITY": "Pokemon-Verfügbarkeit", @@ -1050,23 +1177,24 @@ "SECTION_ALERTS_LOGOUT_SUB": "Benachrichtigungen pausieren und abmelden", "SECTION_FAQ": "Häufig gestellte Fragen", "SECTION_FAQ_SUB": "Häufige Probleme und deren Lösungen", - "CONTENT_GETTING_STARTED": "

Die DM-Alarme-Seite ermöglicht dir, genau festzulegen, welche Pokemon GO-Benachrichtigungen du als Direktnachrichten erhältst. Anstatt jede Benachrichtigung zu bekommen, wählst du aus, was dir wichtig ist — bestimmte Pokemon, Raids, Quests und mehr — und wirst nur darüber informiert.

ℹ️
Bevor du die Seite nutzen kannst, musst du dich zuerst beim Poracle-Bot auf Discord oder Telegram registrieren. Danach komm zurück und melde dich an.

Anmeldung

  • Discord — Klicke auf der Anmeldeseite auf \\\"Mit Discord anmelden\\\". Du wirst zu Discord weitergeleitet, um die App zu autorisieren, und dann automatisch zurückgeleitet.
  • Telegram — Falls aktiviert, nutze das Telegram-Login-Widget auf der Anmeldeseite. Bestätige die Anmeldung in deiner Telegram-App.
\"Anmeldeseite

Ersteinrichtung

Bei der ersten Anmeldung führt dich ein Willkommens-Assistent durch drei Schritte:

  1. Standort festlegen — Wird für die Entfernungsberechnung bei Benachrichtigungen in der Nähe verwendet.
  2. Gebiete auswählen — Wähle die geografischen Zonen, aus denen du Benachrichtigungen erhalten möchtest.
  3. Ersten Alarm hinzufügen — Erstelle einen Pokemon-, Raid- oder Quest-Alarm, um Benachrichtigungen zu erhalten.
\"Einrichtungsassistent

Du kannst jeden Schritt überspringen und später zurückkommen. Der Assistent erscheint nicht mehr, sobald du ihn schließt oder alle Schritte abgeschlossen hast.

", + "CONTENT_GETTING_STARTED": "

Die DM-Alarme-Seite ermöglicht dir, genau festzulegen, welche Pokemon GO-Benachrichtigungen du als Direktnachrichten erhältst. Anstatt jede Benachrichtigung zu bekommen, wählst du aus, was dir wichtig ist — bestimmte Pokemon, Raids, Quests und mehr — und wirst nur darüber informiert.

ℹ️
Bevor du die Seite nutzen kannst, musst du dich zuerst beim Poracle-Bot auf Discord oder Telegram registrieren. Danach komm zurück und melde dich an.

Anmeldung

  • Discord — Klicke auf der Anmeldeseite auf \"Mit Discord anmelden\". Du wirst zu Discord weitergeleitet, um die App zu autorisieren, und dann automatisch zurückgeleitet.
  • Telegram — Falls aktiviert, nutze das Telegram-Login-Widget auf der Anmeldeseite. Bestätige die Anmeldung in deiner Telegram-App.
\"Anmeldeseite

Ersteinrichtung

Bei der ersten Anmeldung führt dich ein Willkommens-Assistent durch drei Schritte:

  1. Standort festlegen — Wird für die Entfernungsberechnung bei Benachrichtigungen in der Nähe verwendet.
  2. Gebiete auswählen — Wähle die geografischen Zonen, aus denen du Benachrichtigungen erhalten möchtest.
  3. Ersten Alarm hinzufügen — Erstelle einen Pokemon-, Raid- oder Quest-Alarm, um Benachrichtigungen zu erhalten.
\"Einrichtungsassistent

Du kannst jeden Schritt überspringen und später zurückkommen. Der Assistent erscheint nicht mehr, sobald du ihn schließt oder alle Schritte abgeschlossen hast.

", "CONTENT_DASHBOARD": "\"Dashboard

Das Dashboard ist deine Startseite. Es zeigt dir auf einen Blick eine Übersicht deiner aktuellen Einrichtung.

Statuskarten

  • Standort — Zeigt deine gespeicherten Koordinaten oder Adresse. Klicke, um deinen Standort zu setzen oder zu aktualisieren.
  • Aktive Gebiete — Zeigt, wie viele Gebiete du verfolgst. Klicke, um deine Gebiete zu verwalten.
  • Profil — Zeigt dein aktives Profil. Bei mehreren Profilen klicke, um zwischen ihnen zu wechseln.

Aktive Filter

Ein Kartenraster zeigt die Anzahl deiner Alarme pro Typ (Pokemon, Raids, Quests usw.). Klicke auf eine Karte, um zur jeweiligen Alarmliste zu springen.

Wetter

Wenn du einen Standort gesetzt hast, zeigt das Dashboard das aktuelle Spielwetter an deinen Koordinaten sowie die letzte Aktualisierungszeit. Das Gebietswetter wird auch für jedes deiner ausgewählten Gebiete angezeigt, sodass du die Wetterbedingungen in allen verfolgten Zonen sehen kannst.

Schnellaktionen

Verknüpfungsschaltflächen zum Hinzufügen von Pokemon-, Raid- oder Quest-Alarmen, zum Verwalten von Gebieten oder zum Konfigurieren des Aufräumens — alles ohne durch die Seitenleiste zu navigieren.

Tipps

Hilfreiche Hinweise erscheinen, wenn deine Einrichtung unvollständig ist — z.B. fehlender Standort, keine Gebiete ausgewählt oder keine Alarme konfiguriert. Jeder Tipp hat eine Aktionsschaltfläche zur Behebung. Du kannst Tipps schließen, die du nicht brauchst.

Navigation

Nutze die Seitenleiste zum Navigieren zwischen den Bereichen. Alarmtypen stehen oben, gefolgt von Einstellungen wie Gebiete, Geofences, Profile und Aufräumen. Hilfe ist immer ganz unten.

\"Seitenleiste", - "CONTENT_LOCATION": "\"Dashboard

Dein Standort wird für entfernungsbasierte Benachrichtigungen verwendet. Wenn ein Alarm den Modus \\\"Entfernung festlegen\\\" nutzt, wirst du über Ereignisse innerhalb eines Radius um diesen Standort benachrichtigt.

Standort festlegen

Öffne den Standort-Dialog vom Dashboard oder der Gebiete-Seite. Du hast vier Möglichkeiten:

  • Adresse suchen — Gib eine Adresse, Stadt oder einen Ortsnamen ein. Wähle aus den angezeigten Vorschlägen.
  • Koordinaten eingeben — Gib Breiten- und Längengrad direkt ein, wenn du sie kennst.
  • GPS verwenden — Klicke auf \\\"Meinen Standort verwenden\\\", um den aktuellen Gerätestandort zu nutzen. Dein Browser wird um Erlaubnis fragen.
  • Karte anklicken — Klicke irgendwo auf der Mini-Karte, um diesen Punkt als Standort zu setzen.

Nach der Auswahl wird die Adresse automatisch angezeigt. Klicke Speichern zum Bestätigen.

💡
Du kannst deinen Standort auf der Gebiete-Seite löschen, wenn du nur gebietsbasierte Alarme möchtest.
", - "CONTENT_AREAS": "\"Gebiete

Gebiete sind vordefinierte geografische Zonen, die von deiner Community eingerichtet wurden. Wenn ein Alarm den Modus \\\"Gebiete verwenden\\\" nutzt, wirst du über Ereignisse innerhalb deiner ausgewählten Gebiete benachrichtigt.

Gebiete auswählen

Gehe über die Seitenleiste zu Gebiete & Standort. Du kannst Gebiete auf zwei Arten auswählen:

  • Kartenansicht — Klicke auf farbige Polygone auf der Karte, um Gebiete zu wählen oder abzuwählen. Ausgewählte Gebiete werden grün. Fahre mit der Maus über ein Gebiet, um seinen Namen zu sehen.
  • Listenansicht — Verwende Kontrollkästchen, um Gebiete aus einer durchsuchbaren Liste auszuwählen.

Regionenfilter

Wenn deine Community viele Gebiete in verschiedenen Regionen hat, nutze das Regionen-Dropdown, um auf eine bestimmte Region zu zoomen. Das erleichtert es, Gebiete in deiner Nähe zu finden.

Verschachtelte Gebiete

Manche Gebiete überlappen sich — eine kleinere Zone innerhalb einer größeren. Beide sind anklickbar. Zoome herein, um die kleinere Zone leichter anzuklicken.

Speichern

Eine Speicherleiste erscheint am unteren Rand, wenn du Änderungen vorgenommen hast. Klicke Speichern zum Bestätigen oder Abbrechen zum Zurücksetzen.

ℹ️
Gebiete sind profilspezifisch. Jedes Profil hat seine eigenen ausgewählten Gebiete. Ein Profilwechsel zeigt andere Gebietsauswahlen. Eigene Geofences können auch pro Profil auf der Geofences-Seite ein- oder ausgeschaltet werden.
", - "CONTENT_GEOFENCES": "\"Meine

Wenn die vordefinierten Gebiete nicht abdecken, wo du Benachrichtigungen möchtest, kannst du eigene Geofence-Grenzen auf der Karte zeichnen.

Geofence zeichnen

  1. Gehe über die Seitenleiste zu Meine Geofences.
  2. Klicke auf Geofence zeichnen.
  3. Klicke auf die Karte, um Punkte deines Polygons zu setzen. Klicke auf den ersten Punkt, um die Form zu schließen (mindestens 3 Punkte).
  4. Gib deinem Geofence einen Namen und wähle die zugehörige Region. Die Region wird normalerweise automatisch erkannt.
  5. Klicke Speichern.

Geofences verwalten

  • Bearbeiten — Geofence umbenennen oder Region ändern.
  • Löschen — Einen nicht mehr benötigten Geofence entfernen. Er wird automatisch aus allen Profilen entfernt.

Profil-Schalter

Jede Geofence-Karte hat einen Schieberegler zum Aktivieren oder Deaktivieren für dein aktuelles Profil. Wenn du einen Geofence erstellst, wird er automatisch im aktuellen Profil aktiviert. Wechsle zu einem anderen Profil und der Schalter zeigt \\\"Inaktiv\\\" — schalte ihn ein, um auch dort Benachrichtigungen für diesen Geofence zu erhalten. So kannst du steuern, welche Profile Benachrichtigungen für jeden Geofence erhalten, ohne ihn neu erstellen zu müssen.

ℹ️
Genehmigte Geofences (zu öffentlichen Gebieten befördert) zeigen keinen Schalter — verwalte sie stattdessen auf der Gebiete-Seite.

GeoJSON Import & Export

Du kannst Geofences im Standard-GeoJSON-Format importieren und exportieren, um Grenzen einfach zu teilen oder in externen Tools wie geojson.io zu erstellen.

  • Import — Klicke auf das Upload-Symbol und füge eine GeoJSON-Datei ein oder lade sie hoch. Jedes Polygon in der Datei wird ein neuer Geofence. Du kannst jeden einzelnen vor dem Speichern überprüfen und umbenennen.
  • Export — Klicke auf das Download-Symbol und wähle die zu exportierenden Geofences. Die exportierte GeoJSON-Datei enthält alle ausgewählten Polygone und kann in jedem GIS-Tool oder Karteneditor geöffnet werden.
💡
GeoJSON-Import ist nützlich zum Migrieren von Geofences aus anderen Systemen oder zum Zeichnen komplexer Grenzen in einem Desktop-GIS-Tool und anschließendem Import hier.

Zur öffentlichen Genehmigung einreichen

Wenn du denkst, dass dein Geofence für die ganze Community nützlich wäre, kannst du ihn zur Admin-Überprüfung einreichen. Bei Genehmigung wird er zu einem öffentlichen Gebiet, das jeder auswählen kann. Dein privater Geofence funktioniert weiterhin, während die Überprüfung aussteht.

Status-Badges

  • Aktiv — Dein privater Geofence, nur für dich.
  • Überprüfung ausstehend — Eingereicht und wartet auf Admin-Überprüfung.
  • Genehmigt — Zu einem öffentlichen Gebiet befördert.
  • Abgelehnt — Nicht genehmigt. Du kannst das Admin-Feedback sehen und der Geofence bleibt als private Zone aktiv.
ℹ️
Du kannst bis zu 10 eigene Geofences haben, jeweils mit bis zu 500 Grenzpunkten.
", - "CONTENT_POKEMON": "\"Pokemon-Alarmseite

Pokemon-Alarme benachrichtigen dich, wenn ein wildes Pokemon spawnt, das deinen Filtern entspricht.

Pokemon-Alarm hinzufügen

\"Pokemon-Alarm-hinzufügen-Dialog
  1. Gehe über die Seitenleiste zu Pokemon und klicke auf die +-Schaltfläche.
  2. Pokemon auswählen — Suche nach Name oder Pokedex-Nummer oder nutze die Generations- und Typ-Filterbuttons zum Durchsuchen. Du kannst mehrere Pokemon auf einmal auswählen.
  3. Filter setzen — Wähle, was einen Spawn meldungswürdig macht:
  • IV-Bereich — Mindest- und Höchst-IV-Prozentsatz (0-100%)
  • CP-Bereich — Nach Kampfstärke filtern
  • Level-Bereich — Nach Pokemon-Level filtern (0-55)
  • Einzelwerte — Nach ATK-, DEF- und STA-Werten filtern (je 0-15)
  • Form — Bestimmte Formen verfolgen (z.B. Alolan, Galarian) oder alle Formen
  • Geschlecht — Männlich, weiblich, geschlechtslos oder alle
  • Gewicht — Nach Gewichtsbereich filtern
  • Größe — Nach Größenkategorie filtern: ALLE (kein Filter) für beliebige Größe, oder bestimmte Größen von XXS bis XXL wählen (XXS, XS, Normal, XL, XXL)
ℹ️
Standard-Filterwerte sind so gesetzt, dass alle Pokemon passen, wenn keine Filter explizit konfiguriert sind. IV ist z.B. standardmäßig 0-100%, Level 0-55 und Größe ALLE. Du musst nur die Filter anpassen, die dir wichtig sind.

PVP-Filter

Werde benachrichtigt, wenn ein Pokemon gute PVP-IVs hat. Wähle eine Liga (Super, Hyper oder Little Cup) und setze den gewünschten Rangbereich (z.B. Rang 1-50).

\\\"Alle Pokemon\\\"-Alarm

💡
Wähle \\\"Alle Pokemon\\\" (ID 0), um einen Alarm für jede Art zu erstellen. Nützlich mit einem hohen IV-Filter wie 96-100%, um jeden wertvollen Spawn zu erwischen.

Alarmkarten lesen

Jede Alarmkarte zeigt farbige Kapseln, die deine Filter auf einen Blick zusammenfassen:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Raids-Seite

Raid- & Ei-Alarme

Werde benachrichtigt, wenn ein Raid-Boss oder Ei erscheint, der/das dich interessiert.

  • Nach Level — Wähle Raid-Level (1-6) oder Ei-Level, um alle Raids dieser Stufe zu verfolgen.
  • Nach Boss — Wähle bestimmte Pokemon-Raid-Bosse, die du jagen möchtest.
  • Teamfilter — Nur bei Raids an Arenen eines bestimmten Teams benachrichtigen (Mystic, Valor, Instinct).
  • Arena-Verfolgung — Raids an bestimmten Arenen nach Name verfolgen, sodass du nur über deine Lieblingsarenen benachrichtigt wirst.
  • Attacken-Filter — Raid-Bosse nach ihren Sofort- oder Lade-Attacken filtern.
  • RSVP-Benachrichtigungen — Werde benachrichtigt, wenn andere Trainer sich für einen Raid oder ein Ei anmelden, das du verfolgst.

Raid- und Ei-Alarme werden auf getrennten Tabs innerhalb der Raids-Seite verwaltet. Eier unterstützen ebenfalls arenenspezifische Verfolgung und RSVP-Benachrichtigungen.

Max-Kampf-Alarme (Dynamax)

Werde über Dynamax- und Gigantamax-Kämpfe an Power Spots benachrichtigt.

  • Nach Level — Wähle Kampfstufen, um beliebige Pokemon auf diesen Stufen zu verfolgen. Stufen reichen von 1 Stern bis 5 Sterne (Legendär) für Dynamax, plus Gigantamax und Legendäres Gigantamax für die größten Kämpfe. Pro ausgewähltem Level wird ein Alarm erstellt.
  • Nach Pokemon — Wähle bestimmte Pokemon, die du über alle Max-Kampf-Level bekämpfen möchtest. Wenn die Scanner-Datenbank konfiguriert ist, zeigt die Auswahl nur Pokemon, die bereits in Max-Kämpfen erschienen sind.
  • Nur Gigantamax — Beim Verfolgen nach Pokemon aktiviere dies, um nur Benachrichtigungen zu erhalten, wenn dieses Pokemon in Gigantamax-Kämpfen erscheint (die höchststufigen Kämpfe mit einzigartigen G-Max-Attacken). Bei level-basierter Verfolgung wird Gigantamax durch direkte Auswahl der Gigantamax- oder Legendäres-Gigantamax-Level abgedeckt.
  • Alle auswählen — Alle verfügbaren Level auf einmal auswählen (entspricht dem Bot-Befehl !maxbattle everything).

Quest-Alarme

Werde über Feldforschungsaufgaben mit bestimmten Belohnungen benachrichtigt.

  • Pokemon-Begegnungen — Wähle Pokemon, die du als Quest-Belohnungen möchtest.
  • Items — Verfolge Quests, die bestimmte Items belohnen.
  • Mega-Energie — Verfolge Quests, die Mega-Energie für bestimmte Pokemon geben.
  • Bonbons — Verfolge Quests, die Bonbons für bestimmte Pokemon belohnen.

Invasions-Alarme

Werde über Team Rocket-Invasionen benachrichtigt.

  • Alle verfolgen — Ein Alarm für jeden Rüpel-Typ und Anführer.
  • Nach Typ — Wähle bestimmte Rüpel-Typen (Käfer, Drache, Feuer usw.), Rocket-Anführer oder Giovanni. Rüpel-Typnamen werden automatisch normalisiert (Groß-/Kleinschreibung egal), du musst dir also keine Sorgen um die exakte Schreibweise machen.
  • Geschlecht — Nach Rüpel-Geschlecht filtern.

Lockmodul-Alarme

Werde benachrichtigt, wenn ein bestimmtes Lockmodul platziert wird. Wähle aus Normal, Gletscher, Moos, Magnet, Regen und Gold.

Nest-Alarme

Verfolge nistende Pokemon-Arten. Setze einen Mindest-Spawns pro Stunde-Schwellenwert, damit du nur über Nester mit ausreichend Aktivität benachrichtigt wirst.

Arena-Alarme

Verfolge Arena-Teamwechsel. Wähle die zu überwachenden Teams (Neutral, Mystic, Valor, Instinct). Aktiviere Platzänderungen, um benachrichtigt zu werden, wenn Arena-Plätze frei werden, oder aktiviere Kampfänderungen, um benachrichtigt zu werden, wenn eine Arena angegriffen wird.

Fort-Änderungs-Alarme

Verfolge Änderungen an PokéStops und Arenen selbst — nicht die Aktivitäten dort, sondern Änderungen an den eigentlichen Points of Interest.

  • Fort-Typ — Wähle PokéStops, Arenen oder alles.
  • Änderungstypen — Wähle zu überwachende Änderungen: Name geändert, Standort geändert, Bild geändert, Entfernung oder neues Fort hinzugefügt.
  • Leere einschließen — Forts ohne gesetzten Namen einschließen.
💡
Fort-Änderungs-Alarme sind nützlich, um Kartendatenbank-Aktualisierungen zu verfolgen — neue PokéStops, verlegte Arenen oder aus dem Spiel entfernte POIs.

Bestimmte Arena auswählen

Beim Erstellen oder Bearbeiten eines Raid-, Ei- oder Arena-Alarms kannst du optional nach einer bestimmten Arena suchen und sie auswählen. Das ist nützlich, wenn du dich nur für Aktivitäten an deiner Lieblingsarena interessierst — z.B. die auf deinem Weg zur Mittagspause oder in der Nähe deines Zuhauses.

  • Verwendung — Gib im Hinzufügen- oder Bearbeiten-Dialog einen Arena-Namen in das Arena-Suchfeld ein. Ergebnisse zeigen Foto, Name und Gebiet der Arena, damit du die richtige identifizieren kannst.
  • Wenn eine Arena ausgewählt ist — Der Alarm feuert nur bei Ereignissen an dieser bestimmten Arena. Der Arena-Name erscheint auf der Alarmkarte in deiner Liste, damit du auf einen Blick siehst, welche Arena er verfolgt.
  • Wenn keine Arena ausgewählt ist — Das ist der Standard. Der Alarm funktioniert normal für alle Arenen in deinen ausgewählten Gebieten oder innerhalb deines Entfernungsradius.
💡
Du kannst einen arenenspezifischen Alarm mit einem breiteren Alarm kombinieren. Erstelle z.B. einen Raid-Alarm für deine lokale Arena für alle Level und einen zweiten Alarm für Level-5-Raids in allen deinen Gebieten.
", - "CONTENT_DELIVERY": "\"Pokemon-Alarmkarten

Jeder Alarm hat Zustellungseinstellungen, die steuern, wo du benachrichtigt wirst.

Gebiete vs. Entfernung

Jeder Alarm nutzt einen von zwei Zustellungsmodi:

🗺
Gebiete verwendenBenachrichtigung bei Ereignissen in deinen ausgewählten Gebieten. Gut für bestimmte Viertel.
📏
Entfernung festlegenBenachrichtigung innerhalb eines Radius (km) um deinen gespeicherten Standort. Gut für alles in deiner Nähe.

Du kannst verschiedene Modi für verschiedene Alarme nutzen — z.B. Gebiete für Pokemon und Entfernung für Raids.

Benachrichtigungsvorlagen

Wenn Vorlagen aktiviert sind, kannst du das Aussehen deiner Benachrichtigungen wählen. Die Vorlagenauswahl zeigt eine Live-Vorschau, wie deine Discord-DM aussehen wird, einschließlich Embed-Format, Feldern und Bildern.

Aufräummodus

Wenn aktiviert, löscht der Bot die Benachrichtigung automatisch aus Discord, wenn das Ereignis abläuft (z.B. ein Pokemon despawnt oder ein Raid endet). Das hält deine DMs aufgeräumt. Du kannst den Aufräummodus pro Alarm oder in Masse auf der Aufräumen-Seite aktivieren.

Ping / Rollenerwähnungen

Wenn du Webhooks nutzt, kannst du eine Discord-Rolle festlegen, die in der Benachrichtigung erwähnt wird (z.B. @Pokemon). Das ist nur für Webhook-Setups relevant.

", + "CONTENT_LOCATION": "\"Dashboard

Dein Standort ist der Punkt, von dem deine Meldungen gemessen werden. Ein Alarm, der dich innerhalb eines Radius erreicht, nutzt den Standort, sofern du genau diesen Alarm nicht auf einen gespeicherten Ort ausrichtest.

Standort festlegen

Öffne den Standort-Dialog vom Dashboard oder der Seite Gebiete & Orte. Du hast vier Möglichkeiten:

  • Adresse suchen — Gib eine Adresse, Stadt oder einen Ortsnamen ein. Wähle aus den angezeigten Vorschlägen.
  • Koordinaten eingeben — Gib Breiten- und Längengrad direkt ein, wenn du sie kennst.
  • GPS verwenden — Klicke auf \"Meinen Standort verwenden\", um den aktuellen Gerätestandort zu nutzen. Dein Browser wird um Erlaubnis fragen.
  • Karte anklicken — Klicke irgendwo auf der Mini-Karte, um diesen Punkt als Standort zu setzen.

Nach der Auswahl wird die Adresse automatisch angezeigt. Klicke Speichern zum Bestätigen.

Derselbe Dialog wird wiederverwendet, wenn du einen Ort anlegst oder einen einzelnen Punkt für einen Alarm auswählst. Er heißt dann Punkt auswählen und wird mit Diesen Punkt verwenden bestätigt; dein eigener Standort bleibt unverändert.

💡
Du kannst deinen Standort auf der Seite Gebiete & Orte löschen, wenn du nur gebietsbasierte Alarme möchtest.
", + "CONTENT_AREAS": "\"Seite

Gebiete sind vordefinierte geografische Zonen, die von deiner Community eingerichtet wurden. Die hier ausgewählten Gebiete gelten für jeden Alarm als Standard: ein Alarm auf Überall in meinen Gebieten löst bei Ereignissen darin aus.

Gebiete auswählen

Gehe über die Seitenleiste zu Gebiete & Orte. Du kannst Gebiete auf zwei Arten auswählen:

  • Kartenansicht — Klicke auf farbige Polygone auf der Karte, um Gebiete zu wählen oder abzuwählen. Ausgewählte Gebiete werden grün. Fahre mit der Maus über ein Gebiet, um seinen Namen zu sehen.
  • Listenansicht — Verwende Kontrollkästchen, um Gebiete aus einer durchsuchbaren Liste auszuwählen.

Orte

Ein Ort ist ein benannter Punkt — die Arbeit, das Fitnessstudio, das Haus deiner Eltern — von dem ein Alarm seinen Radius messen kann statt von deinem Standort. Lege einen im Abschnitt Orte derselben Seite an und wähle ihn dann unter Gemessen ab, wenn du festlegst, wo ein Alarm dich erreichen soll. Solange Alarme auf einen Ort zeigen, lässt er sich nicht löschen; die Meldung nennt die Anzahl.

Regionenfilter

Wenn deine Community viele Gebiete in verschiedenen Regionen hat, nutze das Regionen-Dropdown, um auf eine bestimmte Region zu zoomen. Das erleichtert es, Gebiete in deiner Nähe zu finden.

Verschachtelte Gebiete

Manche Gebiete überlappen sich — eine kleinere Zone innerhalb einer größeren. Beide sind anklickbar. Zoome herein, um die kleinere Zone leichter anzuklicken.

Speichern

Eine Speicherleiste erscheint am unteren Rand, wenn du Änderungen vorgenommen hast. Klicke Speichern zum Bestätigen oder Abbrechen zum Zurücksetzen.

ℹ️
Gebiete sind profilspezifisch. Jedes Profil hat seine eigenen ausgewählten Gebiete. Ein Profilwechsel zeigt andere Gebietsauswahlen. Eigene Geofences können auch pro Profil auf der Geofences-Seite ein- oder ausgeschaltet werden.
", + "CONTENT_GEOFENCES": "\"Meine

Wenn die vordefinierten Gebiete nicht abdecken, wo du Benachrichtigungen möchtest, kannst du eigene Geofence-Grenzen auf der Karte zeichnen.

Geofence zeichnen

  1. Gehe über die Seitenleiste zu Meine Geofences.
  2. Klicke auf Geofence zeichnen.
  3. Klicke auf die Karte, um Punkte deines Polygons zu setzen. Klicke auf den ersten Punkt, um die Form zu schließen (mindestens 3 Punkte).
  4. Gib deinem Geofence einen Namen und wähle die zugehörige Region. Die Region wird normalerweise automatisch erkannt.
  5. Klicke Speichern.

Geofences verwalten

  • Bearbeiten — Geofence umbenennen oder Region ändern.
  • Löschen — Einen nicht mehr benötigten Geofence entfernen. Er wird automatisch aus allen Profilen entfernt.

Profil-Schalter

Jede Geofence-Karte hat einen Schieberegler zum Aktivieren oder Deaktivieren für dein aktuelles Profil. Wenn du einen Geofence erstellst, wird er automatisch im aktuellen Profil aktiviert. Wechsle zu einem anderen Profil und der Schalter zeigt \"Inaktiv\" — schalte ihn ein, um auch dort Benachrichtigungen für diesen Geofence zu erhalten. So kannst du steuern, welche Profile Benachrichtigungen für jeden Geofence erhalten, ohne ihn neu erstellen zu müssen.

ℹ️
Genehmigte Geofences (zu öffentlichen Gebieten befördert) zeigen keinen Schalter — verwalte sie stattdessen auf der Gebiete-Seite.

Ein Geofence für einen einzelnen Alarm

Ein selbst gezeichnetes Geofence erscheint auch in der Liste Nur in bestimmten Gebieten, wenn du festlegst, wo ein einzelner Alarm dich erreichen soll; es ist mit einem Zeichnen-Symbol markiert. Damit beschränkst du einen Alarm darauf, ohne das Geofence für das ganze Profil zu aktivieren.

GeoJSON Import & Export

Du kannst Geofences im Standard-GeoJSON-Format importieren und exportieren, um Grenzen einfach zu teilen oder in externen Tools wie geojson.io zu erstellen.

  • Import — Klicke auf das Upload-Symbol und füge eine GeoJSON-Datei ein oder lade sie hoch. Jedes Polygon in der Datei wird ein neuer Geofence. Du kannst jeden einzelnen vor dem Speichern überprüfen und umbenennen.
  • Export — Klicke auf das Download-Symbol und wähle die zu exportierenden Geofences. Die exportierte GeoJSON-Datei enthält alle ausgewählten Polygone und kann in jedem GIS-Tool oder Karteneditor geöffnet werden.
💡
GeoJSON-Import ist nützlich zum Migrieren von Geofences aus anderen Systemen oder zum Zeichnen komplexer Grenzen in einem Desktop-GIS-Tool und anschließendem Import hier.

Zur öffentlichen Genehmigung einreichen

Wenn du denkst, dass dein Geofence für die ganze Community nützlich wäre, kannst du ihn zur Admin-Überprüfung einreichen. Bei Genehmigung wird er zu einem öffentlichen Gebiet, das jeder auswählen kann. Dein privater Geofence funktioniert weiterhin, während die Überprüfung aussteht.

Status-Badges

  • Aktiv — Dein privater Geofence, nur für dich.
  • Überprüfung ausstehend — Eingereicht und wartet auf Admin-Überprüfung.
  • Genehmigt — Zu einem öffentlichen Gebiet befördert.
  • Abgelehnt — Nicht genehmigt. Du kannst das Admin-Feedback sehen und der Geofence bleibt als private Zone aktiv.
ℹ️
Du kannst bis zu 10 eigene Geofences haben, jeweils mit bis zu 500 Grenzpunkten.
", + "CONTENT_POKEMON": "\"Pokemon-Alarmseite

Pokemon-Alarme benachrichtigen dich, wenn ein wildes Pokemon spawnt, das deinen Filtern entspricht.

Pokemon-Alarm hinzufügen

\"Pokemon-Alarm-hinzufügen-Dialog
  1. Gehe über die Seitenleiste zu Pokemon und klicke auf die +-Schaltfläche.
  2. Pokemon auswählen — Suche nach Name oder Pokedex-Nummer oder nutze die Generations- und Typ-Filterbuttons zum Durchsuchen. Du kannst mehrere Pokemon auf einmal auswählen.
  3. Filter setzen — Wähle, was einen Spawn meldungswürdig macht:
  • IV-Bereich — Mindest- und Höchst-IV-Prozentsatz (0-100%)
  • CP-Bereich — Nach Kampfstärke filtern
  • Level-Bereich — Nach Pokemon-Level filtern (0-55)
  • Einzelwerte — Nach ATK-, DEF- und STA-Werten filtern (je 0-15)
  • Form — Bestimmte Formen verfolgen (z.B. Alolan, Galarian) oder alle Formen
  • Geschlecht — Männlich, weiblich, geschlechtslos oder alle
  • Gewicht — Nach Gewichtsbereich filtern
  • Größe — Nach Größenkategorie filtern: ALLE (kein Filter) für beliebige Größe, oder bestimmte Größen von XXS bis XXL wählen (XXS, XS, Normal, XL, XXL)
  • Mindestens verbleibende Zeit — Überspringt Spawns, die weg sind, bevor du ankommst. Unter Weitere Filter einstellbar; die Karte zeigt dann eine Pille wie "10 Min. übrig"
ℹ️
Standard-Filterwerte sind so gesetzt, dass alle Pokemon passen, wenn keine Filter explizit konfiguriert sind. IV ist z.B. standardmäßig 0-100%, Level 0-55 und Größe ALLE. Du musst nur die Filter anpassen, die dir wichtig sind.

PVP-Filter

Werde benachrichtigt, wenn ein Pokemon gute PVP-IVs hat. Wähle eine Liga (Super, Hyper oder Little Cup) und setze den gewünschten Rangbereich (z.B. Rang 1-50).

Die Schaltflächen Level Cap bestimmen, bei welchem Cap die Ränge gelesen werden. Belasse es auf Alle, um den Wert aus der Poracle-Konfiguration deiner Community zu nutzen.

Mega-Entwicklung legt fest, ob die Regel die Grundform oder eine Mega bewertet: Base, Mega, Mega X oder Mega Y. Megas werden getrennt bewertet, eine Mega-Regel trifft also nie einen Spawn in Grundform.

\"Alle Pokemon\"-Alarm

💡
Wähle \"Alle Pokemon\" (ID 0), um einen Alarm für jede Art zu erstellen. Nützlich mit einem hohen IV-Filter wie 96-100%, um jeden wertvollen Spawn zu erwischen.

Alarmkarten lesen

Jede Alarmkarte zeigt farbige Kapseln, die deine Filter auf einen Blick zusammenfassen:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Raids-Seite

Raid- & Ei-Alarme

Werde benachrichtigt, wenn ein Raid-Boss oder Ei erscheint, der/das dich interessiert.

  • Nach Level — Wähle Raid-Level (1-6) oder Ei-Level, um alle Raids dieser Stufe zu verfolgen.
  • Nach Boss — Wähle bestimmte Pokemon-Raid-Bosse, die du jagen möchtest.
  • Teamfilter — Nur bei Raids an Arenen eines bestimmten Teams benachrichtigen (Mystic, Valor, Instinct).
  • Arena-Verfolgung — Raids an bestimmten Arenen nach Name verfolgen, sodass du nur über deine Lieblingsarenen benachrichtigt wirst.
  • Attacken-Filter — Raid-Bosse nach ihren Sofort- oder Lade-Attacken filtern.
  • RSVP-Benachrichtigungen — Werde benachrichtigt, wenn andere Trainer sich für einen Raid oder ein Ei anmelden, das du verfolgst.

Raid- und Ei-Alarme werden auf getrennten Tabs innerhalb der Raids-Seite verwaltet. Eier unterstützen ebenfalls arenenspezifische Verfolgung und RSVP-Benachrichtigungen.

Max-Kampf-Alarme (Dynamax)

Werde über Dynamax- und Gigantamax-Kämpfe an Power Spots benachrichtigt.

  • Nach Level — Wähle Kampfstufen, um beliebige Pokemon auf diesen Stufen zu verfolgen. Stufen reichen von 1 Stern bis 5 Sterne (Legendär) für Dynamax, plus Gigantamax und Legendäres Gigantamax für die größten Kämpfe. Pro ausgewähltem Level wird ein Alarm erstellt.
  • Nach Pokemon — Wähle bestimmte Pokemon, die du über alle Max-Kampf-Level bekämpfen möchtest. Wenn die Scanner-Datenbank konfiguriert ist, zeigt die Auswahl nur Pokemon, die bereits in Max-Kämpfen erschienen sind.
  • Nur Gigantamax — Beim Verfolgen nach Pokemon aktiviere dies, um nur Benachrichtigungen zu erhalten, wenn dieses Pokemon in Gigantamax-Kämpfen erscheint (die höchststufigen Kämpfe mit einzigartigen G-Max-Attacken). Bei level-basierter Verfolgung wird Gigantamax durch direkte Auswahl der Gigantamax- oder Legendäres-Gigantamax-Level abgedeckt.
  • Alle auswählen — Alle verfügbaren Level auf einmal auswählen (entspricht dem Bot-Befehl !maxbattle everything).

Quest-Alarme

Werde über Feldforschungsaufgaben mit bestimmten Belohnungen benachrichtigt.

  • Pokemon-Begegnungen — Wähle Pokemon, die du als Quest-Belohnungen möchtest.
  • Items — Verfolge Quests, die bestimmte Items belohnen.
  • Mega-Energie — Verfolge Quests, die Mega-Energie für bestimmte Pokemon geben.
  • Bonbons — Verfolge Quests, die Bonbons für bestimmte Pokemon belohnen.
  • Sternenstaub — Verfolge Quests, die Sternenstaub geben.

Die Tabs für Items, Mega-Energie und Bonbons haben je ein Feld Mindestmenge, der Sternenstaub-Tab ein Mindest-Sternenstaub. Bei 0 passt jede Menge. Die Karten zeigen die Menge neben der Belohnung, etwa "3× Rare Candy".

Invasions-Alarme

Werde über Team Rocket-Invasionen benachrichtigt.

  • Alle verfolgen — Ein Alarm für jeden Rüpel-Typ und Anführer.
  • Nach Typ — Wähle bestimmte Rüpel-Typen (Käfer, Drache, Feuer usw.), Rocket-Anführer oder Giovanni. Rüpel-Typnamen werden automatisch normalisiert (Groß-/Kleinschreibung egal), du musst dir also keine Sorgen um die exakte Schreibweise machen.
  • Geschlecht — Nach Rüpel-Geschlecht filtern.

Lockmodul-Alarme

Werde benachrichtigt, wenn ein bestimmtes Lockmodul platziert wird. Wähle aus Normal, Gletscher, Moos, Magnet, Regen und Gold.

Nest-Alarme

Verfolge nistende Pokemon-Arten. Setze einen Mindest-Spawns pro Stunde-Schwellenwert, damit du nur über Nester mit ausreichend Aktivität benachrichtigt wirst.

Arena-Alarme

Verfolge Arena-Teamwechsel. Wähle die zu überwachenden Teams (Neutral, Mystic, Valor, Instinct). Aktiviere Platzänderungen, um benachrichtigt zu werden, wenn Arena-Plätze frei werden, oder aktiviere Kampfänderungen, um benachrichtigt zu werden, wenn eine Arena angegriffen wird.

Fort-Änderungs-Alarme

Verfolge Änderungen an PokéStops und Arenen selbst — nicht die Aktivitäten dort, sondern Änderungen an den eigentlichen Points of Interest.

  • Fort-Typ — Wähle PokéStops, Arenen oder alles.
  • Änderungstypen — Wähle zu überwachende Änderungen: Name geändert, Beschreibung geändert, Standort geändert, Bild geändert, Entfernt oder Neues Fort.
  • Leere einschließen — Forts ohne gesetzten Namen einschließen.
💡
Fort-Änderungs-Alarme sind nützlich, um Kartendatenbank-Aktualisierungen zu verfolgen — neue PokéStops, verlegte Arenen oder aus dem Spiel entfernte POIs.

Bestimmte Arena auswählen

Beim Erstellen oder Bearbeiten eines Raid-, Ei- oder Arena-Alarms kannst du optional nach einer bestimmten Arena suchen und sie auswählen. Das ist nützlich, wenn du dich nur für Aktivitäten an deiner Lieblingsarena interessierst — z.B. die auf deinem Weg zur Mittagspause oder in der Nähe deines Zuhauses.

  • Verwendung — Gib im Hinzufügen- oder Bearbeiten-Dialog einen Arena-Namen in das Arena-Suchfeld ein. Ergebnisse zeigen Foto, Name und Gebiet der Arena, damit du die richtige identifizieren kannst.
  • Wenn eine Arena ausgewählt ist — Der Alarm feuert nur bei Ereignissen an dieser bestimmten Arena. Der Arena-Name erscheint auf der Alarmkarte in deiner Liste, damit du auf einen Blick siehst, welche Arena er verfolgt.
  • Wenn keine Arena ausgewählt ist — Das ist der Standard. Der Alarm funktioniert normal für alle Arenen in deinen ausgewählten Gebieten oder innerhalb deines Entfernungsradius.
💡
Du kannst einen arenenspezifischen Alarm mit einem breiteren Alarm kombinieren. Erstelle z.B. einen Raid-Alarm für deine lokale Arena für alle Level und einen zweiten Alarm für Level-5-Raids in allen deinen Gebieten.
", + "CONTENT_DELIVERY": "\"Pokemon-Alarmkarten,

Jeder Alarm hat Zustellungseinstellungen, die steuern, wo du benachrichtigt wirst.

Wo dich eine Meldung erreicht

Der Zustellungs-Tab jedes Dialogs zum Anlegen und Bearbeiten fragt Wo soll dich diese Meldung erreichen? und bietet drei Antworten:

  • Überall in meinen Gebieten — Der Standard. Der Alarm folgt den Gebieten deines Profils, eine Änderung dort ändert also auch diesen Alarm.
  • In der Nähe eines Punktes — Ein Radius in Kilometern, gemessen ab deinem Standort oder ab einem gespeicherten Ort, den du unter Gemessen ab wählst. Ohne gesetzten Standort sagt die Auswahl das und bietet an, einen zu setzen.
  • Nur in bestimmten Gebieten — Eine Auswahl an Gebieten für genau diesen Alarm, aus den öffentlichen Gebieten und deinen selbst gezeichneten Geofences.

Alarme dürfen unterschiedlich antworten: Gebiete für Pokemon, ein Radius um deinen Standort für Raids, ein benannter Ort für Quests.

Der Chip auf der Alarmkarte

Die meisten Alarmkarten tragen einen Chip mit der Antwort — "Überall in meinen Gebieten", "Überall, wo ich Meldungen erhalte", "Im Umkreis von 5 km um meinen Standort", "Im Umkreis von 2 km um Zuhause", "Nur in Terrigal, Erina". Ein Klick darauf ändert genau diesen Alarm, ohne den vollen Bearbeiten-Dialog zu öffnen.

Standard für neue Alarme

Neue Alarme starten standardmäßig im Modus Gebiete. Zum Ändern öffne das Benutzermenü (dein Avatar oben rechts) und wähle Benachrichtigungs-Standards — lege fest, ob neue Alarme mit Gebiete oder Entfernung starten, setze einen Standardradius und wähle, ob dieser Radius ab deinem Standort oder ab einem gespeicherten Ort gemessen wird. Die Einstellung liegt in deinem Browser und füllt auch den Schnellauswahl-Dialog vor. Sie gilt nur für neu erstellte Alarme; bestehende bleiben unverändert, und du kannst weiterhin für jeden einzelnen Alarm ändern, wo er dich erreicht.

Benachrichtigungsvorlagen

Wenn Vorlagen aktiviert sind, kannst du das Aussehen deiner Benachrichtigungen wählen. Die Vorlagenauswahl zeigt eine Live-Vorschau, wie deine Discord-DM aussehen wird, einschließlich Embed-Format, Feldern und Bildern.

Aufräummodus

Wenn aktiviert, löscht der Bot die Benachrichtigung automatisch aus Discord, wenn das Ereignis abläuft (z.B. ein Pokemon despawnt oder ein Raid endet). Das hält deine DMs aufgeräumt. Du kannst den Aufräummodus pro Alarm oder in Masse auf der Aufräumen-Seite aktivieren.

Direkt bearbeiten & Zusammenfassungen

Einige Alarme unterstützen zusätzliche Zustellmodi. Aktiviere Nachricht direkt bearbeiten für einen Lockmodul-Alarm, damit die bestehende Discord-Nachricht aktualisiert wird, wenn sich das Lockmodul ändert, statt eine neue zu senden, oder Tägliche Zusammenfassung für eine Quest, um passende Quests in einer Sammelnachricht zu bündeln (erfordert einen Zusammenfassungsplan im Bot). Raids und Eier werden automatisch direkt bearbeitet, wenn du einen RSVP-Modus wählst. Diese Einstellungen bleiben erhalten, auch wenn du sie über den Bot setzt.

RSVP-Updates (Raids & Eier)

Raid- und Ei-Alarme ergänzen im Hinzufügen-/Bearbeiten-Dialog eine Einstellung RSVP-Benachrichtigungen mit drei Optionen: Nur Treffer sendet normale Raid-/Ei-Benachrichtigungen; Treffer + RSVP-Updates benachrichtigt zusätzlich erneut, wenn sich die RSVP-Zahlen ändern (Trainer melden sich an); und Nur RSVP-Updates überspringt den ersten Treffer und benachrichtigt dich nur bei RSVP-Änderungen. Wenn du einen der RSVP-Modi wählst, bearbeitet der Bot die bestehende Discord-Nachricht direkt, während sich die Zahlen ändern, statt neue zu senden, und auf der Karte erscheint eine "RSVP"- oder "Nur RSVP"-Plakette. Beachte, dass Nur RSVP-Updates stumm bleibt, sofern der Scanner deiner Community keine RSVP-Ereignisse aussendet — wähle diesen Modus nur, wenn du weißt, dass RSVPs gemeldet werden.

", + "CONTENT_QUEST_SUMMARY": "

Feldforschungs-Quests wechseln täglich und können in großer Zahl zutreffen, sodass ein voller Quest-Filter deine DMs überfluten kann. Zustellung der Quest-Zusammenfassung sammelt passende Quests in einer geplanten Zusammenfassung statt vieler einzelner Benachrichtigungen.

Zwei Teile, die zusammenarbeiten

  • Schalter „Tägliche Zusammenfassung“ — aktiviere ihn für einen Quest-Alarm (in dessen Hinzufügen/Bearbeiten-Dialog), um dessen Treffer für die Zusammenfassung zu markieren statt sie sofort zuzustellen.
  • Zustellungsplan — lege fest, wann die gesammelten Quests gesendet werden.

Beides ist nötig: Der Schalter bestimmt, welche Quests gesammelt werden, der Plan bestimmt, wann sie zugestellt werden.

Plan einrichten

Öffne die Seite Quests, dann das Menü in der Symbolleiste und wähle Zustellung der Quest-Zusammenfassung. Mit Plan bearbeiten wählst du Tage und Uhrzeiten — derselbe Editor wie für die aktiven Zeiten von Profilen. Gespeicherte Zeiten erscheinen als bernsteinfarbene Chips.

Der Plan gilt pro Benutzer und wird über alle deine Profile hinweg geteilt — anders als die aktiven Zeiten von Profilen, die pro Profil eingestellt werden.

Zusammenfassung jetzt senden

Zusammenfassung jetzt senden liefert sofort alles, was seit deiner letzten Zusammenfassung gesammelt wurde. Wurde noch nichts gesammelt, wird nichts gesendet — Quests werden gepuffert, sobald sie zutreffen, gib ihm also Zeit oder warte, bis der Plan ausgelöst wird.

Gut zu wissen

  • Das Menü erscheint nur, wenn der Bot deines Servers Quest-Zusammenfassungen aktiviert hat.
  • Der Zustellzeitpunkt nutzt deinen gespeicherten Standort für die Zeitzone — lege einen Standort fest, sonst können Zusammenfassungen zur falschen Ortszeit ankommen (der Dialog warnt dich, wenn kein Standort gesetzt ist).
  • Das Entfernen des Plans behält den Schalter pro Alarm bei; Quests werden weiterhin gesammelt, fallen aber auf die Standardzeit des Bots zurück.
", "CONTENT_TEST_ALERTS": "

Jede Alarmkarte hat einen Test-Button (Papierflieger-Symbol), der eine Beispielbenachrichtigung an dein Discord oder Telegram sendet, basierend auf den genauen Filtern des Alarms und deiner aktuellen Zustellungsvorlage.

Funktionsweise

  1. Finde eine Alarmkarte in deiner Liste (Pokemon, Raid, Quest usw.).
  2. Klicke auf das Senden-Symbol in der Aktionszeile der Karte.
  3. Ein simuliertes Ereignis, das deinen Alarmfiltern entspricht, wird generiert und durch die Benachrichtigungspipeline gesendet. Du erhältst eine DM wie bei einem echten Alarm.

Was getestet wird

Der Test verwendet die Filterwerte deines Alarms (Pokemon-ID, Raid-Level, Quest-Belohnung usw.) und deinen gespeicherten Standort als Ereigniskoordinaten. Die Benachrichtigung wird mit deiner gewählten Vorlage formatiert, sodass du genau siehst, wie ein echter Alarm aussehen würde.

Abklingzeit

Um Spam zu vermeiden, hat jeder Alarm eine 15-Sekunden-Abklingzeit zwischen Testsendungen. Der Button ist während der Abklingzeit deaktiviert und eine Snackbar zeigt Feedback (Erfolg, Fehler oder verbleibende Abklingzeit).

💡
Testalarme sind ideal, um zu überprüfen, ob deine Vorlage richtig aussieht oder ob deine Webhook-Zustellung funktioniert, bevor du auf ein echtes Ereignis wartest.
", "CONTENT_POKEMON_AVAILABILITY": "

Beim Hinzufügen oder Bearbeiten von Pokemon-Alarmen kann die Pokemon-Auswahl Verfügbarkeitsindikatoren anzeigen — kleine Badges, die zeigen, welche Pokemon gerade in der Wildnis spawnen.

Funktionsweise

Wenn deine Community einen Golbat-Scanner konfiguriert hat, zeigt die Auswahl farbige Punkte neben Pokemon-Namen:

  • Grüner Punkt — Dieses Pokemon wurde kürzlich beim Spawnen gesehen.
  • Kein Punkt — Derzeit nicht in den Scanner-Daten gemeldet.

Das hilft dir, Alarme für Pokemon zu vermeiden, die gerade nicht in deinem Gebiet spawnen (z.B. saisonale oder eventexklusive Arten).

Aktualisierung der Verfügbarkeit

Die Daten werden automatisch im Hintergrund aktualisiert. Du musst nichts tun — achte einfach auf die Punkte beim Durchsuchen der Pokemon-Auswahl.

ℹ️
Diese Funktion ist nur sichtbar, wenn dein Admin die Golbat-Scanner-Integration konfiguriert hat. Wenn du keine Verfügbarkeitspunkte siehst, ist die Funktion für deine Community nicht aktiviert.
", "CONTENT_BULK": "\"Pokemon-Alarmliste

Alle Alarmseiten unterstützen Massenoperationen, um viele Alarme gleichzeitig zu verwalten.

Auswahlmodus

Klicke auf das Checklisten-Symbol in der Symbolleiste, um den Auswahlmodus zu aktivieren. Klicke dann auf einzelne Alarmkarten, um sie auszuwählen, oder nutze Alle auswählen, um alles Sichtbare zu erfassen.

Massenaktionen

  • Entfernung aktualisieren — Zustellungsmodus (Gebiete oder Entfernung) für alle ausgewählten Alarme gleichzeitig ändern.
  • Löschen — Alle ausgewählten Alarme mit einer Bestätigung entfernen.
💡
Am Ende jeder Alarmliste findest du auch Alle Entfernungen aktualisieren und Alle löschen-Buttons, die für jeden Alarm dieses Typs gelten.
", - "CONTENT_QUICK_PICKS": "\"Schnellauswahl-Seite

Schnellauswahlen sind vorgefertigte Alarm-Vorlagen, die von den Admins deiner Community erstellt wurden. Sie ermöglichen dir, häufige Alarmkonfigurationen mit einem Klick einzurichten, anstatt jeden Alarm einzeln zu erstellen.

Schnellauswahl anwenden

  1. Gehe über die Seitenleiste zu Schnellauswahl.
  2. Durchsuche die verfügbaren Vorlagen, optional nach Kategorie gefiltert.
  3. Klicke bei einer gewünschten Schnellauswahl auf Anwenden.
  4. Vor dem Anwenden anpassen: Zustellungsmodus (Gebiete oder Entfernung) wählen, Aufräummodus aktivieren und optional bestimmte Pokemon ausschließen.
  5. Bestätige, um alle Alarme auf einmal zu erstellen.

Schnellauswahl-Alarme entfernen

Wenn du die Alarme einer Schnellauswahl nicht mehr möchtest, klicke Entfernen, um alle damit erstellten Alarme zu löschen.

", - "CONTENT_PROFILES": "

Die Profilseite ist dein zentraler Ort zum Verwalten von Profilen und zum Anzeigen aller Alarme über alle Profile hinweg.

Warum Profile nutzen?

Profile ermöglichen komplett getrennte Alarmkonfigurationen. Jedes Profil hat seine eigenen Alarme, ausgewählten Gebiete, Standort und eigene Geofence-Aktivierungen. Nützlich für verschiedene Situationen — z.B. ein \\\"Zuhause\\\"-Profil für dein Viertel und ein \\\"Arbeit\\\"-Profil für die Umgebung deines Büros.

Übersicht

Die Seite zeigt eine Statistikleiste mit Alarmzahlen pro Typ, eine Suchleiste zum Filtern über alle Profile und Typ-Filterchips, um nur bestimmte Alarmtypen anzuzeigen (Pokemon, Raids, Quests usw.).

Jedes Profil erscheint als aufklappbarer Bereich. Klicke zum Aufklappen und sieh alle Alarme nach Typ gruppiert, mit Spiel-Asset-Bildern (Pokemon-Sprites, Raid-Eier, Lockmodul-Symbole) und Filterkapseln, die IV, CP, Level, PVP und weitere Einstellungen auf einen Blick zeigen.

Profile verwalten

  • Erstellen — Klicke oben rechts auf die +-Schaltfläche. Profilnamen müssen eindeutig sein (bis zu 32 Zeichen).
  • Wechseln — Klicke Wechseln in einem Profilbereich, um es zu deinem aktiven Profil zu machen. Dein aktives Profil ist mit einem grünen Badge und linkem Rand markiert.
  • Bearbeiten — Klicke das Stift-Symbol zum Umbenennen eines Profils.
  • Löschen — Klicke das Papierkorb-Symbol, um ein Profil und alle seine Alarme zu entfernen. Du kannst dein aktives Profil nicht löschen.

Duplizieren

Klicke das Kopier-Symbol bei einem Profil, um eine exakte Kopie mit allen Alarmen zu erstellen. Du wirst aufgefordert, das neue Profil zu benennen — ein Standardname wie \\\"Profil (Kopie)\\\" wird vorgeschlagen. Das Duplikat enthält alle Alarmfilter, bekommt aber eine neue Gebietsauswahl.

Export & Import

  • Export — Klicke das Download-Symbol bei einem Profil, um eine Backup-Datei (JSON) zu speichern. Die Datei enthält alle Alarmfilter, ohne interne IDs, sodass sie portabel ist.
  • Import — Klicke den Importieren-Button oben rechts, wähle eine Backup-Datei und vergib einen Namen für das neue Profil. Alle Alarme aus dem Backup werden wiederhergestellt. Wenn ein Profil mit demselben Namen existiert, wird automatisch ein Zahlen-Suffix hinzugefügt.

Duplikat-Erkennung

Wenn derselbe Alarm auf mehreren Profilen existiert (z.B. Pikachu wird auf \\\"Zuhause\\\" und \\\"Arbeit\\\" verfolgt), werden diese Alarme mit einem orangen Rand und Kopier-Symbol hervorgehoben. Wenn Duplikate existieren, erscheint ein Duplikate-Filterchip in der Filterleiste — klicke darauf, um nur duplizierte Alarme über Profile hinweg anzuzeigen.

⚠️
Warnung: Das Löschen eines Profils entfernt dauerhaft alle Alarme darin. Du kannst dein aktuell aktives Profil nicht löschen. Erwäge vorher, ein Backup zu exportieren.
", - "CONTENT_CLEANING": "\"Aufräumen-Seite

Die Aufräumen-Seite steuert den Aufräummodus für alle deine Alarmtypen gleichzeitig.

Wenn der Aufräummodus für einen Alarmtyp aktiv ist, löscht der Bot Benachrichtigungen automatisch aus Discord, wenn das Ereignis abläuft:

  • Pokemon — Gelöscht wenn der Spawn despawnt
  • Raids — Gelöscht wenn der Raid endet
  • Eier — Gelöscht wenn das Ei schlüpft
  • Quests — Gelöscht wenn Quests um Mitternacht zurückgesetzt werden
  • Invasionen — Gelöscht wenn der Rüpel verschwindet
  • Lockmodule — Gelöscht wenn das Lockmodul abläuft
  • Nester — Gelöscht wenn Nester wechseln
  • Arenen — Gelöscht nach Arena-Änderungen
  • Fort-Änderungen — Gelöscht nach Ablauf der Fort-Änderungs-Benachrichtigung
  • Max-Kämpfe — Gelöscht wenn der Kampf endet

Nutze Alle aktivieren oder Alle deaktivieren, um alles auf einmal umzuschalten.

💡
Empfohlen: Lass den Aufräummodus aktiviert, damit veraltete Alarme sich nicht in deinen DMs ansammeln.
", - "CONTENT_APPEARANCE": "

Dunkel- / Hell-Modus

Klicke auf das Sonnen-/Mond-Symbol in der oberen Symbolleiste, um zwischen dunklem und hellem Theme zu wechseln. Deine Wahl wird automatisch gespeichert.

\"Symbolleiste

Akzentfarben

Öffne das Benutzermenü (dein Avatar oben rechts) und wähle Akzentfarbe. Zur Auswahl:

  • Standard — Blau
  • Pokemon — Grün
  • Raids — Rot
  • Mystic — Blau
  • Valor — Rot
  • Instinct — Gelb

Die Akzentfarbe ändert den Symbolleisten-Gradienten, die aktive Navigationshervorhebung und andere UI-Akzente auf der gesamten Seite.

\"Dashboard

Sprache

Falls verfügbar, nutze die Sprachauswahl in der Symbolleiste, um die Sprache der Oberfläche zu wechseln. 18 Sprachen werden unterstützt.

Tastenkürzel

?Tastenkürzel anzeigen
EscMenüs oder Dialoge schließen
[Seitenleiste einklappen
]Seitenleiste ausklappen
", - "CONTENT_ALERTS_LOGOUT": "\"Benutzermenü

Alarme pausieren

Öffne das Benutzermenü (dein Avatar) und klicke Alarme pausieren. Ein roter Banner erscheint oben auf der Seite und bestätigt, dass deine Alarme pausiert sind. Du erhältst keine Benachrichtigungen, solange pausiert ist.

Zum Fortsetzen klicke Alarme fortsetzen im Benutzermenü oder im Banner.

Abmelden

Öffne das Benutzermenü und klicke Abmelden. Du wirst zur Anmeldeseite zurückgeleitet.

", - "CONTENT_FAQ": "

\\\"Ich kann mich nicht anmelden\\\"

Du musst dich zuerst beim Poracle-Bot auf Discord oder Telegram registrieren, bevor du dich auf dieser Seite anmelden kannst. Wenn du \\\"Dein Konto ist nicht registriert\\\" siehst, kontaktiere deinen Community-Admin für Registrierungsanweisungen.

\\\"Ich erhalte keine Benachrichtigungen\\\"

Überprüfe diese häufigen Ursachen:

  1. Alarme pausiert — Achte auf einen roten Banner oben auf der Seite. Setze Alarme über das Benutzermenü fort.
  2. Kein Standort gesetzt — Wenn deine Alarme den Entfernungsmodus nutzen, brauchst du einen gespeicherten Standort.
  3. Keine Gebiete ausgewählt — Wenn deine Alarme den Gebietsmodus nutzen, stelle sicher, dass du Gebiete auf der Gebiete-Seite ausgewählt hast.
  4. Falsches Profil — Du hast möglicherweise Alarme auf einem anderen Profil. Prüfe auf dem Dashboard, welches Profil aktiv ist.
  5. Filter zu streng — Versuche, deine IV-, CP- oder Level-Filter zu lockern, um zu sehen, ob Benachrichtigungen durchkommen.

\\\"Meine Alarme sind verschwunden\\\"

Alarme sind profilspezifisch. Wenn du Profile gewechselt hast, sind deine Alarme vom anderen Profil noch da — wechsle einfach über das Dashboard oder die Profilseite zurück.

\\\"Ich kann ein kleines Gebiet auf der Karte nicht anklicken\\\"

Wenn sich Gebiete überlappen, zoome herein, um das kleinere Gebiet leichter anzuklicken. Kleinere Gebiete sind immer über den größeren.

\\\"Was macht der Aufräummodus?\\\"

Der Aufräummodus weist den Bot an, eine Benachrichtigung automatisch aus Discord zu löschen, wenn das Ereignis abläuft (z.B. ein Pokemon despawnt). Ohne ihn bleiben alte Alarme für immer in deinen DMs. Aktiviere ihn auf der Aufräumen-Seite oder pro Alarm im Zustellung-Tab.

\\\"Was ist der Unterschied zwischen Gebieten und Entfernung?\\\"

Jeder Alarm nutzt einen Zustellungsmodus. Gebiete benachrichtigt dich über Ereignisse in bestimmten geografischen Zonen. Entfernung benachrichtigt dich über Ereignisse innerhalb eines Radius um deinen gespeicherten Standort. Du kannst beide über verschiedene Alarme mischen.

" + "CONTENT_QUICK_PICKS": "\"Schnellauswahl-Seite

Schnellauswahlen sind vorgefertigte Alarm-Vorlagen, die von den Admins deiner Community erstellt wurden. Sie ermöglichen dir, häufige Alarmkonfigurationen mit einem Klick einzurichten, anstatt jeden Alarm einzeln zu erstellen.

Schnellauswahl anwenden

  1. Gehe über die Seitenleiste zu Schnellauswahl.
  2. Durchsuche die verfügbaren Vorlagen, optional nach Kategorie gefiltert.
  3. Klicke bei einer gewünschten Schnellauswahl auf Anwenden.
  4. Vor dem Anwenden anpassen: festlegen, wo dich die Meldungen erreichen sollen — der Zustellungs-Tab ist dieselbe Auswahl mit drei Optionen wie bei einem einzelnen Alarm, du kannst sie also auf einen gespeicherten Ort oder eine Gebietsauswahl richten —, den Aufräummodus aktivieren und optional bestimmte Pokemon ausschließen.
  5. Bestätige, um alle Alarme auf einmal zu erstellen.

Schnellauswahl-Alarme entfernen

Wenn du die Alarme einer Schnellauswahl nicht mehr möchtest, klicke Entfernen, um alle damit erstellten Alarme zu löschen.

", + "CONTENT_PROFILES": "

Die Profilseite ist dein zentraler Ort zum Verwalten von Profilen und zum Anzeigen aller Alarme über alle Profile hinweg.

Warum Profile nutzen?

Profile ermöglichen komplett getrennte Alarmkonfigurationen. Jedes Profil hat seine eigenen Alarme, ausgewählten Gebiete, Standort und eigene Geofence-Aktivierungen. Nützlich für verschiedene Situationen — z.B. ein \"Zuhause\"-Profil für dein Viertel und ein \"Arbeit\"-Profil für die Umgebung deines Büros.

Übersicht

Die Seite zeigt eine Statistikleiste mit Alarmzahlen pro Typ, eine Suchleiste zum Filtern über alle Profile und Typ-Filterchips, um nur bestimmte Alarmtypen anzuzeigen (Pokemon, Raids, Quests usw.).

Jedes Profil erscheint als aufklappbarer Bereich. Klicke zum Aufklappen und sieh alle Alarme nach Typ gruppiert, mit Spiel-Asset-Bildern (Pokemon-Sprites, Raid-Eier, Lockmodul-Symbole) und Filterkapseln, die IV, CP, Level, PVP und weitere Einstellungen auf einen Blick zeigen.

Profile verwalten

  • Erstellen — Klicke oben rechts auf die +-Schaltfläche. Profilnamen müssen eindeutig sein (bis zu 32 Zeichen).
  • Wechseln — Klicke Wechseln in einem Profilbereich, um es zu deinem aktiven Profil zu machen. Dein aktives Profil ist mit einem grünen Badge und linkem Rand markiert.
  • Bearbeiten — Klicke das Stift-Symbol zum Umbenennen eines Profils.
  • Löschen — Klicke das Papierkorb-Symbol, um ein Profil und alle seine Alarme zu entfernen. Du kannst dein aktives Profil nicht löschen.

Duplizieren

Klicke das Kopier-Symbol bei einem Profil, um eine exakte Kopie mit allen Alarmen zu erstellen. Du wirst aufgefordert, das neue Profil zu benennen — ein Standardname wie \"Profil (Kopie)\" wird vorgeschlagen. Das Duplikat enthält alle Alarmfilter; Gebiete, Standort und aktive Zeiten werden ebenfalls vom Quellprofil übernommen.

Export & Import

  • Export — Klicke das Download-Symbol bei einem Profil, um eine Backup-Datei (JSON) zu speichern. Die Datei enthält alle Alarmfilter, ohne interne IDs, sodass sie portabel ist.
  • Import — Klicke den Importieren-Button oben rechts, wähle eine Backup-Datei und vergib einen Namen für das neue Profil. Alle Alarme aus dem Backup werden wiederhergestellt. Wenn ein Profil mit demselben Namen existiert, wird automatisch ein Zahlen-Suffix hinzugefügt.

Duplikat-Erkennung

Wenn derselbe Alarm auf mehreren Profilen existiert (z.B. Pikachu wird auf \"Zuhause\" und \"Arbeit\" verfolgt), werden diese Alarme mit einem orangen Rand und Kopier-Symbol hervorgehoben. Wenn Duplikate existieren, erscheint ein Duplikate-Filterchip in der Filterleiste — klicke darauf, um nur duplizierte Alarme über Profile hinweg anzuzeigen.

⚠️
Warnung: Das Löschen eines Profils entfernt dauerhaft alle Alarme darin. Du kannst dein aktuell aktives Profil nicht löschen. Erwäge vorher, ein Backup zu exportieren.
", + "CONTENT_CLEANING": "\"Aufräumen-Seite

Die Aufräumen-Seite steuert den Aufräummodus für alle deine Alarmtypen gleichzeitig.

Wenn der Aufräummodus für einen Alarmtyp aktiv ist, löscht der Bot Benachrichtigungen automatisch aus Discord, wenn das Ereignis abläuft:

  • Pokemon — Gelöscht wenn der Spawn despawnt
  • Raids — Gelöscht wenn der Raid endet
  • Eier — Gelöscht wenn das Ei schlüpft
  • Quests — Gelöscht wenn Quests um Mitternacht zurückgesetzt werden
  • Invasionen — Gelöscht wenn der Rüpel verschwindet
  • Lockmodule — Gelöscht wenn das Lockmodul abläuft
  • Nester — Gelöscht wenn Nester wechseln
  • Arenen — Gelöscht nach Arena-Änderungen
  • Max-Kämpfe — Gelöscht wenn der Kampf endet

Nutze Alle aktivieren oder Alle deaktivieren, um alles auf einmal umzuschalten.

💡
Empfohlen: Lass den Aufräummodus aktiviert, damit veraltete Alarme sich nicht in deinen DMs ansammeln.
", + "CONTENT_APPEARANCE": "

Dunkel- / Hell-Modus

Klicke auf das Sonnen-/Mond-Symbol in der oberen Symbolleiste, um zwischen dunklem und hellem Theme zu wechseln. Deine Wahl wird automatisch gespeichert.

\"Symbolleiste

Akzentfarben

Öffne das Benutzermenü (dein Avatar oben rechts) und wähle Akzentfarbe. Zur Auswahl:

  • Standard — Blau
  • Pokemon — Grün
  • Raids — Rot
  • Mystic — Blau
  • Valor — Rot
  • Instinct — Gelb

Die Akzentfarbe ändert den Symbolleisten-Gradienten, die aktive Navigationshervorhebung und andere UI-Akzente auf der gesamten Seite.

\"Dashboard

Anzeigesprache

Öffne das Benutzermenü (dein Avatar oben rechts) und wähle Anzeigesprache. Es gibt 11 Sprachen. Sie ändert den Text dieser Seite und auch die Pokemon-Namen, -Typen und -Formen in den Auswahllisten und auf deinen Alarm-Karten. Wenn du nie eine gewählt hast, bekommst du die Sprache deines Browsers oder die deines Poracle-Servers.

Meldungssprache

Direkt darunter steht Meldungssprache, eine eigene Einstellung. Sie bestimmt, in welcher Sprache Poracle deine DMs schreibt. Beide sind unabhängig: eine deutsche Seite mit englischen DMs, oder umgekehrt, ist völlig normal. Früher lag sie auf der Gebiete-Seite.

Tastenkürzel

?Tastenkürzel anzeigen
EscMenüs oder Dialoge schließen
[Seitenleiste einklappen
]Seitenleiste ausklappen
", + "CONTENT_ALERTS_LOGOUT": "\"Benutzermenü

Alarme pausieren

Öffne das Benutzermenü (dein Avatar) und klicke Alarme pausieren. Ein roter Banner erscheint oben auf der Seite und bestätigt, dass deine Alarme pausiert sind. Du erhältst keine Benachrichtigungen, solange pausiert ist.

Zum Fortsetzen klicke Alarme fortsetzen im Benutzermenü oder im Banner.

Abmelden

Öffne das Benutzermenü und klicke Abmelden. Du wirst zur Anmeldeseite zurückgeleitet.

Wenn du dich über einen SSO-Anbieter mit Single Logout angemeldet hast, bietet das Menü zusätzlich Überall abmelden — damit wird auch deine Sitzung beim Anbieter beendet, nicht nur hier.

", + "CONTENT_FAQ": "

\"Ich kann mich nicht anmelden\"

Du musst dich zuerst beim Poracle-Bot auf Discord oder Telegram registrieren, bevor du dich auf dieser Seite anmelden kannst. Wenn du \"Dein Konto ist nicht registriert\" siehst, kontaktiere deinen Community-Admin für Registrierungsanweisungen.

\"Ich erhalte keine Benachrichtigungen\"

Überprüfe diese häufigen Ursachen:

  1. Alarme pausiert — Achte auf einen roten Banner oben auf der Seite. Setze Alarme über das Benutzermenü fort.
  2. Kein Standort gesetzt — Ein Alarm, der dich innerhalb eines Radius erreicht, misst ab deinem Standort oder ab einem gespeicherten Ort. Setze einen auf der Seite Gebiete & Orte.
  3. Nichts in Reichweite — Sieh dir den Chip auf der Alarmkarte an. Er nennt, wo dich der Alarm erreicht, und er kann auf Gebiete zeigen, die dein Profil nicht mehr abdeckt.
  4. Falsches Profil — Du hast möglicherweise Alarme auf einem anderen Profil. Prüfe auf dem Dashboard, welches Profil aktiv ist.
  5. Filter zu streng — Versuche, deine IV-, CP- oder Level-Filter zu lockern, um zu sehen, ob Benachrichtigungen durchkommen.

\"Meine Alarme sind verschwunden\"

Alarme sind profilspezifisch. Wenn du Profile gewechselt hast, sind deine Alarme vom anderen Profil noch da — wechsle einfach über das Dashboard oder die Profilseite zurück.

\"Ich kann ein kleines Gebiet auf der Karte nicht anklicken\"

Wenn sich Gebiete überlappen, zoome herein, um das kleinere Gebiet leichter anzuklicken. Kleinere Gebiete sind immer über den größeren.

\"Was macht der Aufräummodus?\"

Der Aufräummodus weist den Bot an, eine Benachrichtigung automatisch aus Discord zu löschen, wenn das Ereignis abläuft (z.B. ein Pokemon despawnt). Ohne ihn bleiben alte Alarme für immer in deinen DMs. Aktiviere ihn auf der Aufräumen-Seite oder pro Alarm im Zustellung-Tab.

\"Wo erreicht mich eine Meldung?\"

Das beantwortet jeder Alarm selbst, in seinem Zustellungs-Tab. Überall in meinen Gebieten folgt den Gebieten deines Profils. In der Nähe eines Punktes ist ein Radius um deinen Standort oder um einen gespeicherten Ort. Nur in bestimmten Gebieten beschränkt genau diesen Alarm auf eine Auswahl. Der Chip auf der Karte nennt immer die aktuelle Antwort, ein Klick ändert sie.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "DM-Alarme", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Anmelden", "SIGN_IN_DESC": "Melde dich an, um deine Pokemon GO-Benachrichtigungsalarme zu verwalten.", "SIGN_IN_DISCORD": "Mit Discord anmelden", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Mit Telegram anmelden", + "SIGN_IN_OIDC": "Mit {{provider}} anmelden", + "SIGNED_OUT_TITLE": "Abgemeldet", + "SIGNED_OUT_DESC": "Du wurdest von DM Alerts abgemeldet.", + "PROVIDER_DISABLED_BY_ADMIN": "Diese Anmeldemethode wurde von einem Administrator deaktiviert.", + "PROVIDER_DISABLED_HINT": "Diese Anmeldemethode ist für Nicht-Administratoren derzeit deaktiviert.", + "ERR_TELEGRAM_DISABLED": "Die Telegram-Anmeldung ist derzeit deaktiviert.", "OR": "oder", "NO_METHODS": "Derzeit sind keine Anmeldemethoden aktiviert. Bitte kontaktiere einen Administrator.", "AUTHENTICATING": "Authentifizierung...", "FOOTER": "Verwalte Alarme für Pokemon, Raids, Quests und mehr", "AUTH_FAILED": "Authentifizierung fehlgeschlagen", "BACK_TO_LOGIN": "Zurück zur Anmeldung", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "Die Discord-Anmeldung ist derzeit deaktiviert.", + "ERR_DISCORD_FETCH": "Dein Discord-Profil konnte nicht abgerufen werden. Bitte versuche es erneut.", + "ERR_MISSING_CODE": "Die Discord-Anmeldung wurde abgebrochen oder ist fehlgeschlagen.", + "ERR_MISSING_ROLE": "Dir fehlt die erforderliche Discord-Rolle für diese Seite.", + "ERR_NOT_IN_GUILD": "Du musst Mitglied des Discord-Servers sein, um diese Seite zu nutzen.", + "ERR_NOT_REGISTERED": "Dein Konto ist nicht registriert. Bitte melde dich an, um zu starten.", + "ERR_OIDC_DISABLED": "Die externe Anmeldung ist derzeit deaktiviert.", + "ERR_OIDC_NO_IDENTITY": "Dein externer Anmeldeanbieter hat kein Konto zurückgegeben, das wir zuordnen können. Stelle sicher, dass dein Discord-Konto verknüpft ist.", + "ERR_OIDC_TOKEN_EXCHANGE": "Die externe Anmeldung ist fehlgeschlagen. Bitte versuche es erneut.", + "ERR_OIDC_USERINFO": "Dein Profil konnte nicht vom externen Anmeldeanbieter abgerufen werden. Bitte versuche es erneut.", + "ERR_ROLE_CHECK_FAILED": "Deine Discord-Rollen konnten nicht geprüft werden. Bitte versuche es später erneut.", + "ERR_TELEGRAM_FAILED": "Die Telegram-Anmeldung ist fehlgeschlagen. Bitte versuche es erneut.", + "ERR_TOKEN_EXCHANGE": "Die Discord-Anmeldung ist fehlgeschlagen. Bitte versuche es erneut.", "ERR_GENERIC": "Authentifizierungsfehler: {{error}}", "ERR_NO_TOKEN": "Kein Authentifizierungstoken erhalten.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "Registrieren", + "SIGN_UP_DESC": "Noch kein Konto? Registriere dich, um zu starten.", + "SIGN_IN_AGAIN": "Erneut anmelden" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Diese Funktion wurde vom Administrator deaktiviert." }, "ADMIN": { "USERS_TITLE": "Benutzerverwaltung", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Beworbener Name", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Name für den beworbenen Geofence", "APPROVAL_PROMOTED_NAME_HINT": "Optional. Standardmäßig der aktuelle Anzeigename.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Ablehnungsgrund", "APPROVAL_REJECT_PLACEHOLDER": "Erkläre, warum dieser Geofence abgelehnt wird...", "USERS_DESC_FULL": "Registrierte Discord-Benutzer verwalten. Gestoppt = Benutzer hat Alarme pausiert oder Ratenbegrenzung erreicht. Gesperrt = vom Admin blockiert.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Einreichung konnte nicht genehmigt werden", "SNACK_APPROVED": "\"{{name}}\" genehmigt", "SNACK_FAILED_REJECT": "Einreichung konnte nicht abgelehnt werden", - "SNACK_REJECTED": "\"{{name}}\" abgelehnt" + "SNACK_REJECTED": "\"{{name}}\" abgelehnt", + "APPROVAL_REGION_HINT": "Wähle die Region, unter der dieses Geofence erscheinen soll.", + "SERVER_TITLE": "Poracle-Server", + "SERVER_REFRESH": "Erneut prüfen", + "SERVER_VERSION": "Version", + "SERVER_SCHEMA": "Datenbankschema", + "SERVER_CHECKED": "Zuletzt geprüft", + "SERVER_CAPABILITIES": "Funktionen", + "SERVER_NO_CAPABILITIES": "Dieser Server meldet keine.", + "SERVER_UNKNOWN": "Unbekannt", + "SERVER_UNREACHABLE": "Poracle hat nicht geantwortet. Alarme, Profile und Orte laufen darüber und schlagen fehl, bis es wieder da ist.", + "SERVER_TOO_OLD": "Poracle {{version}} ist älter als {{minimum}}, das diese Version der Seite benötigt. Zustellung pro Alarm, der PVP-Mega-Filter und der Filter für die Mindestrestzeit scheinen zu speichern, ändern aber nichts.", + "UPDATE_AVAILABLE": "{{name}} {{running}} läuft, und {{latest}} ist erschienen.", + "UPDATE_PRERELEASE": "{{name}} {{running}} ist neuer als jede Veröffentlichung — das ist ein Entwicklungsbuild.", + "VERSIONS_TITLE": "Versionen", + "VERSIONS_WEB": "Diese Seite", + "VERSIONS_BUILD": "Build", + "UPDATE_CURRENT": "Aktuell.", + "UPDATE_UNCOMPARABLE": "Entwicklungskanal. Neueste Veröffentlichung ist {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Punkt auswählen", "CANCEL": "Abbrechen", "CONFIRM": "Bestätigen", "DONT_ASK_AGAIN": "Für diese Sitzung nicht erneut fragen", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Alle Entfernungen aktualisieren", "DISTANCE_DESC": "Lege den Standortmodus für alle Alarme dieses Typs fest.", "DISTANCE_UPDATE_ALL": "Alle aktualisieren", + "DISTANCE_MUST_BE_POSITIVE": "Die Entfernung muss größer als null sein.", "LOCATION_SAVE_ERROR": "Standort konnte nicht aktualisiert werden", "LOCATION_SAVE_SUCCESS": "Standort erfolgreich aktualisiert", "LOCATION_GEO_UNSUPPORTED": "Geolokalisierung wird von deinem Browser nicht unterstützt", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Zu viele Testalarme. Bitte warte einen Moment.", "ERROR_NOT_FOUND": "Alarm nicht gefunden — er wurde möglicherweise gelöscht.", "ERROR_GENERIC": "Testalarm konnte nicht gesendet werden. Versuche es später erneut.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Zu viele Testmeldungen. Bitte warte einen Moment.", + "NOT_FOUND": "Meldung nicht gefunden — sie wurde möglicherweise gelöscht.", + "UNSUPPORTED": "Testmeldungen werden für diesen Meldungstyp nicht unterstützt.", + "FAILED": "Testmeldung konnte nicht gesendet werden. Versuche es später erneut." }, "COMMON": { "CANCEL": "Abbrechen", @@ -1296,6 +1448,7 @@ "EDIT": "Bearbeiten", "ADD": "Hinzufügen", "OK": "OK", + "UNDO": "Rückgängig", "CONFIRM": "Bestätigen", "DELETE_ALL": "Alle löschen", "CLOSE": "Schließen", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Arena suchen (optional)", "SEARCH_HINT": "Arena-Name eingeben...", - "CLEAR_ARIA": "Arena-Auswahl löschen" + "CLEAR_ARIA": "Arena-Auswahl löschen", + "RATE_LIMITED": "Zu viele Scanner-Anfragen — bitte etwas langsamer." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Benachrichtigungen werden für diese Gebiete gesendet:", @@ -1392,12 +1546,9 @@ "GROUP_ALARM_TYPES": "Alarmtypen", "GROUP_FEATURES": "Funktionen", "GROUP_ADMINISTRATION": "Verwaltung", - "GROUP_COMMANDS": "Befehle", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Karten & Ressourcen", "GROUP_ANALYTICS_LINKS": "Analyse & Links", - "GROUP_DEBUG": "Debug", "GROUP_ICON_REPO": "Icon-Repository", "GROUP_OTHER": "Sonstiges", "CUSTOM_TITLE_LABEL": "Seitentitel", @@ -1411,52 +1562,51 @@ "FAVICON_URL_PREVIEW": "Favicon-Vorschau (32×32)", "FAVICON_URL_CACHE_WARNING": "Browser cachen Favicons aggressiv. Nach dem Speichern müssen Benutzer ihren Browser-Cache leeren oder einen Hard-Refresh (Strg+F5 / Cmd+Umschalt+R) durchführen, um das neue Symbol zu sehen.", "FAVICON_URL_CSP_NOTE": "Wenn Ihre Site eine Content Security Policy verwendet, muss der Ursprung der Favicon-URL von Ihrer img-src-Direktive zugelassen sein; andernfalls blockiert der Browser den Abruf und greift auf das Standardsymbol zurück.", + "FORCED_BY_PORACLE": "In der Poracle-Konfiguration deaktiviert. Poracle verwirft diese Webhooks und der Bot lehnt den Befehl ab, daher kann dies hier nicht aktiviert werden.", + "FORCED_BY_PORACLE_TOOLTIP": "Wird von der Poracle-Konfiguration gesteuert, nicht von dieser Seite.", "CUSTOM_PAGE_NAME_LABEL": "Navigationslink-Bezeichnung", "CUSTOM_PAGE_NAME_DESC": "Bezeichnung für den benutzerdefinierten Navigationslink (z. B. „Zurück zur Karte“).", "CUSTOM_PAGE_URL_LABEL": "Navigationslink-URL", "CUSTOM_PAGE_URL_DESC": "URL, auf die der benutzerdefinierte Navigationslink verweist.", "CUSTOM_PAGE_ICON_LABEL": "Navigationslink-Symbol", "CUSTOM_PAGE_ICON_DESC": "FontAwesome-Klasse für das Navigationslink-Symbol (z. B. „fas fa-map“).", - "DISABLE_MONS_LABEL": "Pokémon deaktivieren", - "DISABLE_MONS_DESC": "Pokémon-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_RAIDS_LABEL": "Raids deaktivieren", - "DISABLE_RAIDS_DESC": "Raid-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_QUESTS_LABEL": "Aufgaben deaktivieren", - "DISABLE_QUESTS_DESC": "Aufgaben-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_INVASIONS_LABEL": "Invasionen deaktivieren", - "DISABLE_INVASIONS_DESC": "Invasions-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_LURES_LABEL": "Lockmodule deaktivieren", - "DISABLE_LURES_DESC": "Lockmodul-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_NESTS_LABEL": "Nester deaktivieren", - "DISABLE_NESTS_DESC": "Nest-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_GYMS_LABEL": "Arenen deaktivieren", - "DISABLE_GYMS_DESC": "Arena-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_FORT_CHANGES_LABEL": "Fort-Änderungen deaktivieren", - "DISABLE_FORT_CHANGES_DESC": "Verwaltung von Fort-Änderungsalarmen für alle Benutzer ausblenden.", - "DISABLE_MAXBATTLES_LABEL": "Dynamax-Kämpfe deaktivieren", - "DISABLE_MAXBATTLES_DESC": "Dynamax-Kampf-Alarmverwaltung für alle Benutzer ausblenden.", - "DISABLE_AREAS_LABEL": "Gebiete deaktivieren", - "DISABLE_AREAS_DESC": "Benutzer daran hindern, ihre Gebietsabonnements zu verwalten.", - "DISABLE_PROFILES_LABEL": "Profile deaktivieren", - "DISABLE_PROFILES_DESC": "Benutzer daran hindern, Alarmprofile zu erstellen und zu wechseln.", - "DISABLE_LOCATION_LABEL": "Standort deaktivieren", - "DISABLE_LOCATION_DESC": "Benutzer daran hindern, einen Heimatstandort festzulegen.", - "DISABLE_NOMINATIM_LABEL": "Geocoding deaktivieren", - "DISABLE_NOMINATIM_DESC": "Nominatim-Adresssuche für die Standortauswahl deaktivieren.", - "DISABLE_GEOMAP_LABEL": "Kartenansicht deaktivieren", - "DISABLE_GEOMAP_DESC": "Interaktive Geofence-Karte vollständig ausblenden.", - "DISABLE_GEOMAP_SELECT_LABEL": "Gebietsauswahl auf Karte deaktivieren", - "DISABLE_GEOMAP_SELECT_DESC": "Benutzer daran hindern, Gebiete durch Klicken auf die Karte auszuwählen.", - "ENABLE_TEMPLATES_LABEL": "Vorlagen aktivieren", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Benutzern erlauben, Pokémon-Alarme zu verwalten.", + "DISABLE_RAIDS_LABEL": "Raids", + "DISABLE_RAIDS_DESC": "Benutzern erlauben, Raid-Alarme zu verwalten.", + "DISABLE_QUESTS_LABEL": "Aufgaben", + "DISABLE_QUESTS_DESC": "Benutzern erlauben, Aufgaben-Alarme zu verwalten.", + "DISABLE_INVASIONS_LABEL": "Invasionen", + "DISABLE_INVASIONS_DESC": "Benutzern erlauben, Invasions-Alarme zu verwalten.", + "DISABLE_LURES_LABEL": "Lockmodule", + "DISABLE_LURES_DESC": "Benutzern erlauben, Lockmodul-Alarme zu verwalten.", + "DISABLE_NESTS_LABEL": "Nester", + "DISABLE_NESTS_DESC": "Benutzern erlauben, Nest-Alarme zu verwalten.", + "DISABLE_GYMS_LABEL": "Arenen", + "DISABLE_GYMS_DESC": "Benutzern erlauben, Arena-Alarme zu verwalten.", + "DISABLE_FORT_CHANGES_LABEL": "Fort-Änderungen", + "DISABLE_FORT_CHANGES_DESC": "Benutzern erlauben, Fort-Änderungsalarme zu verwalten.", + "DISABLE_MAXBATTLES_LABEL": "Dynamax-Kämpfe", + "DISABLE_MAXBATTLES_DESC": "Benutzern erlauben, Dynamax-Kampf-Alarme zu verwalten.", + "DISABLE_AREAS_LABEL": "Gebiete", + "DISABLE_AREAS_DESC": "Benutzern erlauben, ihre Gebietsabonnements zu verwalten.", + "DISABLE_PROFILES_LABEL": "Profile", + "DISABLE_PROFILES_DESC": "Benutzern erlauben, Alarmprofile zu erstellen und zu wechseln.", + "DISABLE_LOCATION_LABEL": "Standort", + "DISABLE_LOCATION_DESC": "Benutzern erlauben, einen Heimatstandort festzulegen.", + "DISABLE_NOMINATIM_LABEL": "Geocoding", + "DISABLE_NOMINATIM_DESC": "Nominatim-Adresssuche für die Standortauswahl erlauben.", + "DISABLE_USER_GEOFENCES_LABEL": "Eigene Geofences", + "DISABLE_USER_GEOFENCES_DESC": "Benutzern erlauben, eigene Geofences zu zeichnen, zu importieren und einzureichen. Bestehende Geofences bleiben aktiv.", + "ENABLE_TEMPLATES_LABEL": "Vorlagen", "ENABLE_TEMPLATES_DESC": "Benutzern erlauben, Benachrichtigungsvorlagen auszuwählen.", "ALLOWED_LANGUAGES_LABEL": "Erlaubte UI-Sprachen", "ALLOWED_LANGUAGES_DESC": "Kommagetrennte Sprachcodes, die im Sprachauswahl-Menü angezeigt werden (z. B. „en,de,fr,es“). Leer lassen, um alle 11 Sprachen anzuzeigen.", + "PORACLE_LOCALE_HINT": "Standardsprache für neue Benutzer: {{locale}}, aus Poracles eigener Konfiguration. Wer eine Sprache wählt oder dessen Browser eine hier vorhandene anfordert, erhält stattdessen diese.", "ENABLE_ROLES_LABEL": "Rollenbasierte Zugriffskontrolle aktivieren", "ENABLE_ROLES_DESC": "Nur Benutzer mit bestimmten Discord-Rollen dürfen sich anmelden. Erfordert Bot-Token und Guild-ID.", "ALLOWED_ROLE_IDS_LABEL": "Zulässige Rollen-IDs", - "ALLOWED_ROLE_IDS_DESC": "Kommagetrennte Discord-Rollen-IDs, die Zugriff gewähren (z. B. „123456789,987654321“). Leer lassen, um alle zuzulassen.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Erlaubte Sprachen", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Kommagetrennte Liste der Sprachcodes, die Benutzer auswählen können (z. B. „en,de,fr“).", + "ALLOWED_ROLE_IDS_DESC": "Kommagetrennte Discord-Rollen-IDs, z. B. 123456789,987654321. Ein Benutzer benötigt mindestens eine dieser Rollen, um sich anzumelden. Leer lassen, um alle zuzulassen.", "REGISTER_COMMAND_LABEL": "Registrierungsbefehl", "REGISTER_COMMAND_DESC": "Poracle-Bot-Befehl, den Benutzer zur Registrierung ausführen (z. B. „$!register“).", "LOCATION_COMMAND_LABEL": "Standortbefehl", @@ -1464,9 +1614,31 @@ "ENABLE_TELEGRAM_LABEL": "Telegram-Anmeldung aktivieren", "ENABLE_TELEGRAM_DESC": "Telegram-Anmeldung auf dieser Website zulassen. Erfordert TELEGRAM_ENABLED=true, Bot-Token und Bot-Benutzernamen in .env (Server-Neustart nach .env-Änderungen erforderlich).", "TELEGRAM_BOT_LABEL": "Bot-Benutzername", - "TELEGRAM_BOT_DESC": "Telegram-Bot-Benutzername (ohne @).", + "TELEGRAM_BOT_DESC": "Telegram-Bot-Benutzername (ohne @). Wird verwendet, wenn TELEGRAM_BOT_USERNAME nicht konfiguriert ist.", "ENABLE_DISCORD_LABEL": "Discord-Anmeldung aktivieren", "ENABLE_DISCORD_DESC": "Discord-Anmeldung auf dieser Website zulassen. Erfordert Discord Client ID und Client Secret in .env (Server-Neustart nach .env-Änderungen erforderlich). Betrifft nicht die PoracleNG-Bot-Zustellung.", + "ENABLE_OIDC_LABEL": "Externe SSO-Anmeldung aktivieren", + "ENABLE_OIDC_DESC": "Anmeldung über den konfigurierten externen OIDC/OAuth2-Anbieter zulassen. Erfordert OIDC_*-Einstellungen (Anbieter-URLs, Client ID und Secret) in .env (Server-Neustart nach .env-Änderungen erforderlich).", + "GROUP_OIDC": "Externes SSO", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Alle Benutzer werden zum externen SSO-Anbieter weitergeleitet. Die lokale Anmeldung wird übersprungen.", + "AUTH_MODE_SWITCH_CONFIRM": "Zu SSO wechseln", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Zur SSO-Anmeldung wechseln?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Nach dem Speichern werden alle Benutzer (einschließlich Administratoren) zur Anmeldung an {{provider}} weitergeleitet — die lokale Discord-/Telegram-Anmeldeseite wird übersprungen. Wenn der Anbieter nicht erreichbar ist, kannst du ausgesperrt werden; stelle den Zugriff wieder her, indem du AUTH_FORCE_LOCAL=true in der Serverumgebung setzt.", + "AUTH_OIDC_NOT_CONFIGURED": "SSO ist nicht verfügbar, bis der OIDC-Anbieter in der Serverumgebung konfiguriert ist (OIDC_*-Umgebungsvariablen).", + "AUTH_OIDC_HIDES_LOCAL": "Discord und Telegram werden ausgeblendet, solange SSO der aktive Anmeldemodus ist.", + "AUTH_SLO_LABEL": "Einmalabmeldung", + "AUTH_SLO_DESC": "Wenn aktiviert, beendet „Überall abmelden“ auch die Anbietersitzung (nicht nur diese Website). Erfordert den End-Session-Endpunkt des Anbieters (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Die Einmalabmeldung ist nicht verfügbar, bis der End-Session-Endpunkt des Anbieters konfiguriert ist (OIDC_END_SESSION_URL-Umgebungsvariable).", + "OIDC_SERVER_CONFIG": "OIDC-Anbieterkonfiguration", + "OIDC_PROVIDER_LABEL": "Anbietername", + "OIDC_AUTHORIZATION_URL_LABEL": "Authorization-URL", + "OIDC_TOKEN_URL_LABEL": "Token-URL", + "OIDC_USERINFO_URL_LABEL": "UserInfo-URL", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Identitäts-Claim", + "OIDC_USE_PKCE_LABEL": "PKCE verwenden", "PROVIDER_URL_LABEL": "Kartenkachel-URL", "PROVIDER_URL_DESC": "URL-Vorlage für den Kartenkachel-Anbieter (für statische Karten).", "GANALYTICSID_LABEL": "Google Analytics-ID", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "Admin-IDs", "DISCORD_ADMIN_IDS_DESC": "Discord-Benutzer-IDs mit Admin-Zugriff (maskiert).", "DISCORD_GEOFENCE_FORUM_LABEL": "Geofence-Forumskanal", - "DISCORD_GEOFENCE_FORUM_DESC": "Discord-Forumskanal für Geofence-Einreichungsthreads." + "DISCORD_GEOFENCE_FORUM_DESC": "Discord-Forumskanal für Geofence-Einreichungsthreads.", + "SEARCH_PLACEHOLDER": "Einstellungen durchsuchen…", + "SEARCH_CLEAR": "Suche löschen", + "UNSAVED_CHANGES": "{{count}} ungespeichert", + "SAVE_CHANGES": "Änderungen speichern", + "DISCARD_CHANGES": "Verwerfen", + "COLLAPSE_SECTION": "Abschnitt einklappen", + "EXPAND_SECTION": "Abschnitt ausklappen", + "SUMMARY_ENABLED": "{{count}} von {{total}} aktiviert", + "GROUP_AUTH": "Authentifizierung", + "AUTH_MODE_LABEL": "Anmeldemodus", + "AUTH_MODE_LOCAL": "Lokal", + "AUTH_MODE_LOCAL_DESC": "Direkt mit Discord oder Telegram anmelden.", + "AUTH_FORCE_LOCAL_ACTIVE": "Die lokale Anmeldung wird durch die Serverkonfiguration erzwungen.", + "DISABLE_UPDATE_CHECK_LABEL": "Nicht nach Updates suchen", + "DISABLE_UPDATE_CHECK_DESC": "Verhindert, dass die Seite bei GitHub nachfragt, ob ein neueres PoracleWeb oder Poracle erschienen ist. Das ist die einzige Anfrage außerhalb deines Netzwerks; es werden keine Daten übermittelt." }, "GEOFENCE_DETAIL": { "NAME": "Name", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Ihr Standort", "SELECTED_COUNT": "{{count}} ausgewählt:", "AREAS_SELECTED": "{{count}} Gebiet(e) ausgewählt" + }, + "ALERT_DEFAULTS": { + "TITLE": "Benachrichtigungs-Standards", + "DESC": "Lege fest, wie neue Benachrichtigungen standardmäßig zugestellt werden. Beim Erstellen jeder Benachrichtigung kannst du dies weiterhin ändern.", + "DEFAULT_DISTANCE": "Standard-Entfernung", + "DEFAULT_DISTANCE_HINT": "Wird verwendet, um den Radius für neue entfernungsbasierte Benachrichtigungen vorzubelegen.", + "FOOTNOTE": "Gilt nur für neu erstellte Benachrichtigungen – bestehende bleiben unverändert.", + "DISTANCE_TOO_SMALL": "Muss mindestens 0,1 km sein.", + "DISTANCE_TOO_LARGE": "Darf höchstens 100 km sein." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Einträge pro Seite:", + "RANGE": "{{start}} - {{end}} von {{total}}", + "RANGE_EMPTY": "0 von {{total}}", + "NEXT_PAGE": "Nächste Seite", + "PREVIOUS_PAGE": "Vorherige Seite", + "FIRST_PAGE": "Erste Seite", + "LAST_PAGE": "Letzte Seite" + }, + "WHERE": { + "SET_PIN": "Standort setzen", + "PIN_MISSING_WARNING": "Du hast noch keinen Standort gesetzt, diese Meldung hätte also keinen Bezugspunkt.", + "PLACES_EMPTY_TITLE": "Noch keine Orte", + "PIN_UNSET": "Nicht gesetzt", + "PLACES_PAGE_DESC": "Benannte Punkte, auf die deine Meldungen statt auf deinen Standort ausgerichtet werden können.", + "ADD_PLACE": "Ort hinzufügen", + "AREAS_LABEL": "Gebiete", + "AREA_LIST_MORE": "{{areas}} und {{count}} weitere", + "MEASURED_FROM": "Gemessen ab", + "MY_PIN": "Mein Standort", + "NAME_PLACE_MESSAGE": "Wie soll dieser Ort heißen?", + "NAME_PLACE_TITLE": "Ort benennen", + "NEAR_PIN": "Im Umkreis von {{distance}} km um meinen Standort", + "NEAR_PLACE": "Im Umkreis von {{distance}} km um {{place}}", + "NO_PLACES": "Noch keine Orte. Füge unten einen hinzu, um diese Meldung woandershin als an deinen Standort zu richten.", + "ONLY_IN": "Nur in {{areas}}", + "OPTION_AREAS": "Nur in bestimmten Gebieten", + "OPTION_NEAR": "In der Nähe eines Punktes", + "OPTION_PLACE": "In der Nähe eines Ortes", + "OPTION_PROFILE": "Überall in meinen Gebieten", + "PIN_NOTE": "Der Rückfallwert für jede Meldung ohne eigenes Ziel.", + "PIN_TITLE": "Mein Standort", + "PLACES_EMPTY": "Füge einen hinzu, um Meldungen woandershin als an deinen Standort zu schicken: Arbeit, Fitnessstudio, bei den Eltern.", + "PLACES_TITLE": "Orte", + "PLACE_DELETED": "{{place}} gelöscht.", + "PLACE_DELETE_CONFIRM": "Meldungen für {{place}} nutzen künftig wieder deinen Standort.", + "PLACE_DELETE_ERROR": "Ort konnte nicht gelöscht werden.", + "PLACE_DELETE_TITLE": "Diesen Ort löschen?", + "PLACE_IN_USE": "{{place}} wird von {{count}} Meldung(en) genutzt. Stelle diese zuerst um.", + "PLACE_LABEL": "Ort", + "PLACE_NAME": "Name", + "PLACE_SAVED": "{{place}} gespeichert.", + "PLACE_SAVE_ERROR": "Ort konnte nicht gespeichert werden.", + "PROFILE_ANYWHERE": "Überall, wo ich Meldungen erhalte", + "PROFILE_AREAS": "Überall in meinen Gebieten", + "RADIUS_KM": "Radius (km)", + "SAVE": "Ziel festlegen", + "SCOPE_SAVED": "Ziel aktualisiert.", + "SCOPE_SAVE_ERROR": "Das Ziel dieser Meldung konnte nicht geändert werden.", + "SHEET_TITLE": "Wo soll dich diese Meldung erreichen?", + "USE_THIS_POINT": "Diesen Punkt verwenden" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json index c8d7f005..da8737f2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -16,7 +16,7 @@ "GYMS": "Gyms", "FORT_CHANGES": "Fort Changes", "PROFILES": "Profiles", - "AREAS": "Areas", + "AREAS": "Areas & Places", "MY_GEOFENCES": "My Geofences", "CLEANING": "Cleaning", "HELP": "Help", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Viewing as", - "BACK_TO_ADMIN": "Back to Admin", + "EXIT_IMPERSONATION": "Back to your account", "DISABLED_ACCOUNT": "Your account has been disabled. This may be due to rate limiting or an administrative action.", + "DISABLED_ACCOUNT_INSPECTED": "This account has been disabled by an administrator and is not receiving notifications.", "DISABLED_SUPPORT": "To get help, ask in", "PAUSED_ALERTS": "Your alerts are paused. You will not receive notifications.", "RESUME": "Resume" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Changes this site's text only.", "PROFILE_PREFIX": "Profile #", "PAUSE_ALERTS": "Pause Alerts", "RESUME_ALERTS": "Resume Alerts", "SWITCH_PROFILE": "Switch Profile", - "AREAS_LOCATION": "Areas & Location", "CLEANING": "Cleaning", "ACCENT_THEME": "Accent Theme", - "LANGUAGE": "Language", + "DISPLAY_LANGUAGE": "Display language", + "ALERT_LANGUAGE": "Alert language", + "ALERT_LANGUAGE_HINT": "Used for alert text and Pokemon names.", "LOGOUT": "Logout", + "LOGOUT_EVERYWHERE": "Sign out everywhere", "ACCENT_DEFAULT": "Default", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Alert Defaults" }, "SHORTCUTS": { "TITLE": "Keyboard Shortcuts", @@ -77,6 +82,7 @@ "NETWORK": "Unable to reach the server. Please check your connection.", "BAD_REQUEST": "Invalid request. Please check your input.", "UNAUTHORIZED": "Your session has expired. Please sign in again.", + "INSPECTION_ENDED": "Inspection ended — you are back in your own session.", "FORBIDDEN": "You do not have permission to perform this action.", "NOT_FOUND": "The requested resource was not found.", "CONFLICT": "A conflict occurred. The item may have been modified.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Welcome onboarding" }, "POKEMON": { + "PVP_EVOLUTION": "Mega evolution", + "PVP_EVOLUTION_HINT": "Rank the base forms, or a mega. Megas are ranked separately, so a mega rule will not match a base-form spawn.", + "PVP_EVO_BASE": "Base", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Pokemon Alarms", "PAGE_DESC": "Track wild Pokemon spawns with custom IV, CP, level, and PVP filters.", "SEARCH_PLACEHOLDER": "Search by name or #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Form & Gender", "LABEL_FORM": "Form", "ALL_FORMS": "All Forms", + "FORM_MULTI_HINT": "Leave empty to match all forms", "LABEL_GENDER": "Gender", "GENDER_ALL": "All", "GENDER_MALE": "Male", @@ -255,7 +268,12 @@ "PVP_MIN_CP": "Min CP for League", "PVP_MIN_CP_HINT": "Only alert if evolved CP meets this minimum", "PVP_DISABLED_HINT": "Select a league to filter by PVP rank.", + "PVP_CAP": "Level Cap", + "PVP_CAP_ALL": "All", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Default · from Poracle config", "SNACK_CREATED": "{{count}} Pokemon alarm(s) created", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} Pokemon alarm(s) created, {{duplicates}} already tracked", "SNACK_UPDATED": "Pokemon alarm updated", "SNACK_DELETED": "Pokemon alarm deleted", "SNACK_DELETED_ALL": "All Pokemon alarms deleted", @@ -294,7 +312,15 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "FILTER_TIME_LEFT": "Time Left", + "LABEL_MIN_TIME": "Minimum Time Left", + "MIN_TIME_HINT": "Skip spawns that will be gone before you get there.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}}s", + "PILL_TIME_LEFT_MINUTES": "{{count}} min left", + "PILL_TIME_LEFT_SECONDS": "{{count}}s left", + "MIN_TIME_ANY": "Any" }, "ALARM": { "LOCATION_MODE": "Location Mode", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Automatically deletes the notification from Discord after the lure expires", "CLEAN_HINT_NEST": "Automatically deletes the notification from Discord when nests migrate", "CLEAN_HINT_GYM": "Automatically deletes the notification from Discord after gym activity changes", - "CLEAN_HINT_FORT": "Automatically deletes the notification from Discord after it expires", "CLEAN_HINT_MAX_BATTLE": "Automatically deletes the notification from Discord after the max battle ends", "SAVING": "Saving...", "SAVE": "Save", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Cooldown active", "TEST_SEND": "Send test notification", "TAB_DELIVERY": "Delivery", - "COMMON_SETTINGS": "Common Settings" + "COMMON_SETTINGS": "Common Settings", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} created, {{duplicates}} already tracked" }, "RAIDS": { + "RSVP_LABEL": "RSVP notifications", + "RSVP_OFF": "Matches only", + "RSVP_INCLUDE": "Matches + RSVP updates", + "RSVP_ONLY": "RSVP updates only", + "RSVP_OFF_DESC": "Standard raid/egg alerts only.", + "RSVP_INCLUDE_DESC": "Also re-notify when RSVP counts change.", + "RSVP_ONLY_DESC": "Skip initial matches; only notify on RSVP changes. Without an RSVP-emitting scanner this silences the alarm.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "RSVP only", "PAGE_TITLE": "Raid & Egg Alarms", "PAGE_DESC": "Get notified about raid bosses and egg hatches at nearby gyms.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Are you sure you want to delete ALL raid and egg alarms? This action cannot be undone.", "CONFIRM_BULK_DELETE_TITLE": "Delete Selected Alarms", "CONFIRM_BULK_DELETE_MSG": "Are you sure you want to delete {{count}} alarms?", - "CONFIRM_DELETE_SELECTED": "Delete Selected" + "CONFIRM_DELETE_SELECTED": "Delete Selected", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest Alarms", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "All quest alarms deleted", "SNACK_FAILED_DELETE_ALL": "Failed to delete alarms", "SNACK_FAILED_DISTANCE": "Failed to update distances", - "CONFIRM_DELETE_SELECTED": "Delete Selected" + "CONFIRM_DELETE_SELECTED": "Delete Selected", + "SUMMARY_MODE": "Daily summary", + "SUMMARY_HINT": "Collect matching quests into a single summary message instead of one notification each. Requires a configured summary schedule on the bot.", + "SUMMARY_BADGE": "Summary", + "SUMMARY_SCHEDULE": "Quest summary delivery", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Quest summary", + "SUMMARY_SCHEDULE_EMPTY": "No summary schedule set. Quests are delivered individually.", + "SUMMARY_SCHEDULE_EDIT": "Edit schedule", + "SUMMARY_SCHEDULE_CLEAR": "Remove schedule", + "SUMMARY_SCHEDULE_SEND_NOW": "Send summary now", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Delivers quest matches collected since your last summary. If none are buffered yet, nothing is sent.", + "SUMMARY_SCHEDULE_SAVED": "Summary schedule saved", + "SUMMARY_SCHEDULE_CLEARED": "Summary schedule removed", + "SUMMARY_SCHEDULE_SENT": "Summary sent", + "SUMMARY_SCHEDULE_FAILED": "Couldn't update the summary schedule", + "SUMMARY_SCHEDULE_UNAVAILABLE": "Summary delivery is temporarily unavailable. Please try again later.", + "SUMMARY_DISABLED_HINT": "Summary scheduling isn't available on this server.", + "TAB_STARDUST": "Stardust", + "MIN_AMOUNT": "Minimum Amount", + "MIN_AMOUNT_HINT": "0 = any amount", + "MIN_STARDUST": "Minimum Stardust", + "MIN_STARDUST_HINT": "0 = any stardust quest", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Invasion Alarms", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnetic", "TYPE_RAINY": "Rainy", "TYPE_GOLDEN": "Golden", - "TYPE_UNKNOWN": "Lure #{{id}}" + "TYPE_UNKNOWN": "Lure #{{id}}", + "EDIT_MODE": "Edit message in place", + "EDIT_HINT": "Update the existing Discord message when the lure changes instead of sending a new one.", + "EDIT_BADGE": "Edit", + "CONFIRM_DELETE_TITLE": "Delete Lure Alarm?", + "SNACK_FAILED_DISTANCE": "Could not update the distance." }, "NESTS": { "PAGE_TITLE": "Nest Alarms", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Nest alarm deleted", "SNACK_FAILED_CREATE": "Failed to create alarm", "SNACK_FAILED_UPDATE": "Failed to update alarm", - "SNACK_FAILED_DELETE": "Failed to delete alarm" + "SNACK_FAILED_DELETE": "Failed to delete alarm", + "CONFIRM_DELETE_TITLE": "Delete Nest Alarm?", + "SNACK_FAILED_DISTANCE": "Could not update the distance." }, "GYMS": { "PAGE_TITLE": "Gym Alarms", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Mystic", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinct", - "TEAM_UNKNOWN": "Team {{id}}" + "TEAM_UNKNOWN": "Team {{id}}", + "CONFIRM_DELETE_TITLE": "Delete Gym Alarm?", + "SNACK_FAILED_DISTANCE": "Could not update the distance." }, "FORT_CHANGES": { "PAGE_TITLE": "Fort Change Alarms", @@ -640,7 +746,11 @@ "CREATE_FAILED": "Failed to create alarm", "CREATE_SUCCESS": "Fort change alarm created", "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated" + "UPDATE_SUCCESS": "Fort change alarm updated", + "FORT_TYPE_LABEL": "Fort Type", + "CHANGE_TYPES_LABEL": "Change Types", + "TRACKING_SUBTITLE": "Fort Change Tracking", + "CHANGE_DESCRIPTION": "Description changed" }, "MAX_BATTLES": { "PAGE_TITLE": "Max Battle Alarms", @@ -681,24 +791,35 @@ "LEVEL_4": "4 Star", "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", - "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax" + "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", + "HINT_BY_LEVEL": "Track any Pokemon at these battle tiers. Each tier you pick becomes its own alarm.", + "HINT_BY_POKEMON": "Track specific Pokemon in Max Battles, whatever the tier.", + "HINT_GMAX_ONLY_ADD": "Only alerts on Gigantamax battles for the Pokemon you picked.", + "HINT_GMAX_ONLY_EDIT": "Only alerts on Gigantamax battles for this Pokemon.", + "HINT_ALL_LEVELS": "This alarm follows one Pokemon across every Max Battle level.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Areas & Location", + "MANAGE_PLACES": "Manage places", + "PAGE_TITLE": "Areas & Places", "PAGE_DESC": "Control where you receive notifications.", "METHOD_AREAS": "Areas", "METHOD_AREAS_ACTIVE": "{{count}} area(s) active", "METHOD_NOT_CONFIGURED": "Not configured", "METHOD_AREAS_DESC": "Get notified about anything that happens inside your selected geofenced zones.", "METHOD_AREAS_TIP": "Best for: covering entire cities, neighborhoods, or parks", - "METHOD_LOCATION": "Location", - "METHOD_LOCATION_NOT_SET": "Not set", - "METHOD_LOCATION_DESC": "Get notified about anything within a set distance from your pinned location.", + "METHOD_LOCATION": "My pin", + "METHOD_LOCATION_NOT_SET": "No pin set", + "METHOD_LOCATION_DESC": "Get notified about anything within a set distance of your pin.", "METHOD_LOCATION_TIP": "Best for: alerts near your home, work, or a specific spot", "CLEAR_LOCATION": "Clear", "CHANGE_LOCATION": "Change", "SET_LOCATION": "Set", "METHOD_NOTE": "Each alarm chooses one method in its Delivery tab.", + "NOTIFICATION_LANGUAGE": "Notification language", + "NOTIFICATION_LANGUAGE_DESC": "The language Poracle uses for your alert messages and Pokémon names. This is separate from the display language in the top menu.", + "SNACK_LANGUAGE_UPDATED": "Notification language updated", + "SNACK_LANGUAGE_FAILED": "Failed to update notification language", "SELECT_AREAS": "Select Areas", "MAP_VIEW": "Map", "LIST_VIEW": "List", @@ -721,7 +842,9 @@ "SNACK_LOCATION_CLEARED": "Location cleared", "SNACK_LOCATION_FAILED": "Failed to update location", "SEARCH_AREAS": "Search areas", - "FILTER_PLACEHOLDER": "Filter by name..." + "FILTER_PLACEHOLDER": "Filter by name...", + "SNACK_LOAD_SELECTED_FAILED": "Could not load your current areas. Reload before changing them.", + "SELECTION_UNKNOWN": "Your current areas could not be loaded — reload the page before saving." }, "PROFILES": { "PAGE_TITLE": "Profiles", @@ -897,6 +1020,7 @@ "NAME_WHITESPACE_ERROR": "Name cannot be only whitespace", "NAME_INVALID_CHARS_ERROR": "Only letters, numbers, spaces, hyphens, apostrophes, and parentheses allowed", "REGION_LABEL": "Region", + "REGION_OPTIONAL_HINT": "Optional — used only if an admin later approves this geofence as a public area.", "CHANGE_REGION": "Change", "SELECT_REGION": "Select Region", "SEARCH_REGIONS": "Search regions...", @@ -953,7 +1077,7 @@ "DELETE_PICK": "Delete", "SNACK_FAILED_LOAD": "Failed to load quick picks", "CONFIRM_DELETE_TITLE": "Delete Quick Pick", - "CONFIRM_DELETE_MSG": "Permanently delete \"{{name}}\"?", + "CONFIRM_DELETE_MSG": "Permanently delete \"{{name}}\"? Any alarms it created stay — use Remove to delete those.", "SNACK_FAILED_DELETE": "Failed to delete", "SNACK_DELETED": "Quick pick deleted", "CONFIRM_REMOVE_TITLE": "Remove Quick Pick", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Some help content may not be available in your language yet.", "TRANSLATION_CTA_LINK": "Help translate", "FALLBACK_CHIP": "English", + "IMAGE_ENLARGE": "Click to enlarge", "SECTION_GETTING_STARTED": "Getting Started", "SECTION_GETTING_STARTED_SUB": "Login, onboarding wizard, and initial setup", "SECTION_DASHBOARD": "Dashboard", "SECTION_DASHBOARD_SUB": "Your overview of alarms, areas, and status", - "SECTION_LOCATION": "Setting Your Location", + "SECTION_LOCATION": "Setting Your Pin", "SECTION_LOCATION_SUB": "GPS, address search, and coordinates", - "SECTION_AREAS": "Choosing Your Areas", - "SECTION_AREAS_SUB": "Map view, list view, and region filtering", + "SECTION_AREAS": "Areas & Places", + "SECTION_AREAS_SUB": "Map view, list view, region filtering, and places", "SECTION_GEOFENCES": "Custom Geofences", "SECTION_GEOFENCES_SUB": "Draw boundaries, submit for public approval", "SECTION_POKEMON": "Pokemon Alarms", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Other Alarm Types", "SECTION_OTHER_ALARMS_SUB": "Raids, eggs, quests, rockets, lures, nests, gyms, fort changes", "SECTION_DELIVERY": "Delivery Settings", - "SECTION_DELIVERY_SUB": "Areas vs distance, templates, and clean mode", + "SECTION_DELIVERY_SUB": "Delivery scope, templates, and clean mode", + "SECTION_QUEST_SUMMARY": "Quest Summary Delivery", + "SECTION_QUEST_SUMMARY_SUB": "Batch noisy quests into one scheduled digest", "SECTION_TEST_ALERTS": "Test Alerts", "SECTION_TEST_ALERTS_SUB": "Send sample notifications to preview your alarms", "SECTION_POKEMON_AVAILABILITY": "Pokemon Availability", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Common issues and how to fix them", "CONTENT_GETTING_STARTED": "

The DM Alerts site lets you customize exactly which Pokemon GO notifications you receive as direct messages. Instead of getting every alert, you choose what matters to you — specific Pokemon, raids, quests, and more — and only get notified about those.

ℹ️
Before you can use the site, you need to register with the Poracle bot on Discord or Telegram first. Once registered, come back here and sign in.

Signing In

  • Discord — Click \"Sign in with Discord\" on the login page. You'll be taken to Discord to authorize the app, then redirected back automatically.
  • Telegram — If enabled, use the Telegram login widget on the login page. Confirm the login in your Telegram app.
\"Login

First-Time Setup

When you first sign in, a welcome wizard walks you through three steps:

  1. Set your location — Used to calculate distances for nearby notifications.
  2. Choose your areas — Select the geographic zones you want alerts from.
  3. Add your first alarm — Create a Pokemon, Raid, or Quest alarm to start getting notified.
\"Onboarding

You can skip any step and come back later. The wizard won't appear again once you dismiss it or complete all steps.

", "CONTENT_DASHBOARD": "\"Dashboard

The Dashboard is your home base. It shows an overview of your current setup at a glance.

Status Cards

  • Location — Shows your saved coordinates or address. Click to set or update your location.
  • Active Areas — Shows how many areas you're tracking. Click to manage your areas.
  • Profile — Shows your active profile. If you have multiple profiles, click to switch between them.

Active Filters

A grid of cards shows how many alarms you have for each type (Pokemon, Raids, Quests, etc.). Click any card to jump to that alarm list.

Weather

If you have a location set, the dashboard shows the current in-game weather at your coordinates along with the last update time. Area weather is also displayed for each of your selected areas, so you can see weather conditions across all the zones you track.

Quick Actions

Shortcut buttons to add Pokemon, Raid, or Quest alarms, manage areas, or configure cleaning — all without navigating through the sidebar.

Tips

Helpful reminders appear when your setup is incomplete — like missing location, no areas selected, or no alarms configured. Each tip has an action button to fix it. You can dismiss tips you don't need.

Navigation

Use the sidebar to navigate between sections. Alarm types are listed at the top, followed by settings like Areas, Geofences, Profiles, and Cleaning. Help is always at the bottom.

\"Sidebar", - "CONTENT_LOCATION": "\"Dashboard

Your location is used for distance-based notifications. When an alarm uses \"Set Distance\" mode, you'll get notified about events within a radius of this location.

Setting Your Location

Open the location dialog from the Dashboard or Areas page. You have four ways to set it:

  • Search by address — Type an address, city, or landmark name. Select from the suggestions that appear.
  • Enter coordinates — Type latitude and longitude directly if you know them.
  • Use your GPS — Click \"Use My Location\" to use your device's current location. Your browser will ask for permission.
  • Click the map — Click anywhere on the mini-map to set that point as your location.

After selecting a location, the address is shown automatically. Click Save to confirm.

💡
You can clear your location from the Areas page if you only want area-based alerts.
", - "CONTENT_AREAS": "\"Areas

Areas are predefined geographic zones set up by your community. When an alarm uses \"Use Areas\" mode, you get notified about events that happen inside your selected areas.

Selecting Areas

Go to Areas & Location from the sidebar. You can select areas two ways:

  • Map view — Click colored polygons on the map to select or deselect areas. Selected areas turn green. Hover over any area to see its name.
  • List view — Use checkboxes to pick areas from a searchable list.

Region Filtering

If your community has many areas across different regions, use the region dropdown to zoom in on a specific region. This makes it easier to find areas near you.

Nested Areas

Some areas overlap — a smaller zone inside a larger one. Both are clickable. Zoom in to make it easier to click the smaller area.

Saving

A save bar appears at the bottom when you've made changes. Click Save to confirm your selections, or Cancel to revert.

ℹ️
Areas are per-profile. Each profile has its own set of selected areas. Switching profiles will show different area selections. Custom geofences can also be toggled on or off per profile from the Geofences page.
", - "CONTENT_GEOFENCES": "\"My

If the predefined areas don't cover where you want alerts, you can draw your own custom geofence boundaries on the map.

Drawing a Geofence

  1. Go to My Geofences from the sidebar.
  2. Click Draw Geofence.
  3. Click on the map to place points of your polygon boundary. Click the first point again to close the shape (minimum 3 points).
  4. Give your geofence a name and select which region it belongs to. The region is usually auto-detected for you.
  5. Click Save.

Managing Geofences

  • Edit — Rename your geofence or change its region.
  • Delete — Remove a geofence you no longer need. The geofence is removed from all profiles automatically.

Profile Toggle

Each geofence card has a slide toggle to activate or deactivate it for your current profile. When you create a geofence, it's automatically activated on the profile you're using. Switch to another profile and the toggle will show \"Inactive\" — flip it on to receive alerts for that geofence on that profile too. This lets you control which profiles get notifications for each geofence without recreating it.

ℹ️
Approved geofences (promoted to public areas) don't show the toggle — manage them from the Areas page instead.

GeoJSON Import & Export

You can import and export geofences using the standard GeoJSON format, making it easy to share boundaries or create them in external tools like geojson.io.

  • Import — Click the upload icon and paste or upload a GeoJSON file. Each polygon in the file becomes a new geofence. You can review and rename each one before saving.
  • Export — Click the download icon and select which geofences to include. The exported GeoJSON file contains all selected polygons and can be opened in any GIS tool or map editor.
💡
GeoJSON import is useful for migrating geofences from other systems or drawing complex boundaries in a desktop GIS tool and then importing them here.

Submitting for Public Approval

If you think your geofence would be useful for the whole community, you can submit it for admin review. If approved, it becomes a public area everyone can select. Your private geofence continues working while the review is pending.

Status Badges

  • Active — Your private geofence, working for you only.
  • Pending Review — Submitted and waiting for admin review.
  • Approved — Promoted to a public area.
  • Rejected — Not approved. You can see the admin's feedback and the geofence remains active as a private zone.
ℹ️
You can have up to 10 custom geofences, each with up to 500 boundary points.
", - "CONTENT_POKEMON": "\"Pokemon

Pokemon alarms notify you when a wild Pokemon spawns that matches your filters.

Adding a Pokemon Alarm

\"Add
  1. Go to Pokemon from the sidebar and click the + button.
  2. Select Pokemon — Search by name or Pokedex number, or use the generation and type filter buttons to browse. You can select multiple Pokemon at once.
  3. Set Filters — Choose what makes a spawn worth notifying about:
  • IV range — Minimum and maximum IV percentage (0-100%)
  • CP range — Filter by combat power
  • Level range — Filter by Pokemon level (0-55)
  • Individual stats — Filter by ATK, DEF, and STA values (0-15 each)
  • Form — Track specific forms (e.g. Alolan, Galarian) or all forms
  • Gender — Male, female, genderless, or all
  • Weight — Filter by weight range
  • Size — Filter by size category: select ALL (no filter) to match any size, or pick specific sizes from XXS through XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Default filter values are set so that all Pokemon match when no filters are explicitly configured. For example, IV defaults to 0-100%, level to 0-55, and size to ALL. You only need to adjust the filters you care about.

PVP Filters

Get notified when a Pokemon has great PVP IVs. Select a league (Great, Ultra, or Little Cup) and set the rank range you care about (e.g. rank 1-50).

\"All Pokemon\" Alarm

💡
Select \"All Pokemon\" (ID 0) to create one alarm that covers every species. Useful with a high IV filter like 96-100% to catch any valuable spawn.

Reading Alarm Cards

Each alarm card shows colored pills summarizing your filters at a glance:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Raids

Raid & Egg Alarms

Get notified when a raid boss or egg appears that you're interested in.

  • By Level — Select raid levels (1-6) or egg levels to track all raids of that tier.
  • By Boss — Select specific Pokemon raid bosses you want to hunt.
  • Team filter — Only notify for raids at gyms controlled by a specific team (Mystic, Valor, Instinct).
  • Gym tracking — Track raids at specific gyms by name so you only get notified about your favorite gyms.
  • Move filter — Filter raid bosses by their fast or charged moves.
  • RSVP notifications — Get notified when other trainers RSVP to a raid or egg you're tracking.

Raid and Egg alarms are managed on separate tabs within the Raids page. Eggs also support gym-specific tracking and RSVP notifications.

Max Battle (Dynamax) Alarms

Get notified about Dynamax and Gigantamax battles at Power Spots.

  • By Level — Select battle tiers to track any Pokemon at those levels. Tiers range from 1 Star through 5 Star (Legendary) for Dynamax, plus Gigantamax and Legendary Gigantamax for the largest battles. One alarm is created per selected level.
  • By Pokemon — Select specific Pokemon you want to battle across all Max Battle levels. If the scanner database is configured, the selector is filtered to only show Pokemon that have appeared in Max Battles.
  • Gigantamax only — When tracking by Pokemon, toggle this to only receive notifications when that Pokemon appears in Gigantamax battles (the highest-tier battles with unique G-Max moves). For level-based tracking, Gigantamax is handled by selecting the Gigantamax or Legendary Gigantamax levels directly.
  • Select All — Quickly select all available levels at once (equivalent to the bot's !maxbattle everything command).

Quest Alarms

Get notified about field research tasks with specific rewards.

  • Pokemon encounters — Select Pokemon you want as quest rewards.
  • Items — Track quests that reward specific items.
  • Mega Energy — Track quests that give mega energy for specific Pokemon.
  • Candy — Track quests that reward candy for specific Pokemon.

Invasion Alarms

Get notified about Team Rocket invasions.

  • Track All — One alarm for every grunt type and leader.
  • By Type — Select specific grunt types (Bug, Dragon, Fire, etc.), Rocket Leaders, or Giovanni. Grunt type names are automatically normalized (case-insensitive), so you don't need to worry about exact capitalization.
  • Gender — Filter by grunt gender.

Lure Alarms

Get notified when a specific lure type is placed. Choose from Normal, Glacial, Mossy, Magnetic, Rainy, and Golden lures.

Nest Alarms

Track nesting Pokemon species. Set a minimum spawns per hour threshold so you only get notified about nests with enough activity.

Gym Alarms

Track gym team changes. Select which teams (Neutral, Mystic, Valor, Instinct) to monitor. Enable Slot Changes tracking to get notified when gym slots open up, or enable Battle Changes tracking to get notified when a gym is under attack.

Fort Change Alarms

Track changes to pokestops and gyms themselves — not the activities at them, but changes to the actual points of interest.

  • Fort Type — Choose to track Pokestops, Gyms, or Everything.
  • Change Types — Select which changes to monitor: Name changed, Location changed, Image changed, Removal, or New fort added.
  • Include Empty — Include forts that have no name set.
💡
Fort change alarms are useful for tracking map database updates — new pokestops appearing, gyms being relocated, or POIs being removed from the game.

Targeting a Specific Gym

When creating or editing a Raid, Egg, or Gym alarm, you can optionally search for and select a specific gym. This is useful when you only care about activity at your favorite gym — like the one on your lunch route or near your house.

  • How to use it — In the add or edit dialog, type a gym name into the gym search field. Results show the gym's photo, name, and area so you can identify the right one.
  • When a gym is selected — The alarm only fires for events at that specific gym. The gym name appears on the alarm card in your list so you can see which gym it targets at a glance.
  • When no gym is selected — This is the default. The alarm works normally for all gyms in your selected areas or within your distance radius.
💡
You can combine a gym-specific alarm with a broader alarm. For example, create one raid alarm targeting your local gym for all levels, and a second alarm for level 5 raids across all your areas.
", - "CONTENT_DELIVERY": "\"Pokemon

Every alarm has delivery settings that control where you get notified.

Areas vs Distance

Each alarm uses one of two delivery modes:

🗺
Use AreasNotified when events happen inside your selected areas. Good for tracking specific neighborhoods.
📏
Set DistanceNotified within a radius (km) of your saved location. Good for tracking everything near you.

You can use different modes for different alarms — for example, use areas for Pokemon and distance for raids.

Notification Templates

If templates are enabled, you can choose how your notification messages look. The template selector shows a live preview of what your Discord DM will look like, including the embed format, fields, and images.

Clean Mode

When enabled, the bot automatically deletes the notification from Discord after the event expires (e.g. a Pokemon despawns or a raid ends). This keeps your DMs tidy. You can enable clean mode per-alarm or in bulk from the Cleaning page.

Ping / Role Mentions

If you use webhooks, you can set a Discord role to mention in the notification (e.g. @Pokemon). This is only relevant for webhook setups.

", + "CONTENT_LOCATION": "\"Dashboard

Your pin is the point your alerts measure from. An alarm that reaches you within a radius uses the pin, unless you aim that alarm at a saved place instead.

Setting Your Pin

Open the pin dialog from the Dashboard or the Areas & Places page. You have four ways to set it:

  • Search by address — Type an address, city, or landmark name. Select from the suggestions that appear.
  • Enter coordinates — Type latitude and longitude directly if you know them.
  • Use your GPS — Click \"Use My Location\" to use your device's current location. Your browser will ask for permission.
  • Click the map — Click anywhere on the mini-map to set that point as your pin.

After picking a point, the address is shown automatically. Click Save to confirm.

The same dialog is reused when you add a place, or pick a one-off point for a single alarm. It is then titled Pick a point and you confirm with Use this point, which leaves your pin alone.

💡
You can clear your pin from the Areas & Places page if you only want area-based alerts.
", + "CONTENT_AREAS": "\"Areas

Areas are predefined geographic zones set up by your community. The areas you pick here are what every alarm follows by default: an alarm set to Anywhere in my areas fires on events inside them.

Selecting Areas

Go to Areas & Places from the sidebar. You can select areas two ways:

  • Map view — Click colored polygons on the map to select or deselect areas. Selected areas turn green. Hover over any area to see its name.
  • List view — Use checkboxes to pick areas from a searchable list.

Places

A place is a named point — work, the gym, your parents' house — that an alarm can measure its radius from instead of your pin. Add one in the Places section of the same page, then choose it under Measured from when you set an alarm's delivery scope. Deleting a place is refused while alarms still point at it, and the message names how many.

Region Filtering

If your community has many areas across different regions, use the region dropdown to zoom in on a specific region. This makes it easier to find areas near you.

Nested Areas

Some areas overlap — a smaller zone inside a larger one. Both are clickable. Zoom in to make it easier to click the smaller area.

Saving

A save bar appears at the bottom when you've made changes. Click Save to confirm your selections, or Cancel to revert.

ℹ️
Areas are per-profile. Each profile has its own set of selected areas. Switching profiles will show different area selections. Custom geofences can also be toggled on or off per profile from the Geofences page.
", + "CONTENT_GEOFENCES": "\"My

If the predefined areas don't cover where you want alerts, you can draw your own custom geofence boundaries on the map.

Drawing a Geofence

  1. Go to My Geofences from the sidebar.
  2. Click Draw Geofence.
  3. Click on the map to place points of your polygon boundary. Click the first point again to close the shape (minimum 3 points).
  4. Give your geofence a name and select which region it belongs to. The region is usually auto-detected for you.
  5. Click Save.

Managing Geofences

  • Edit — Rename your geofence or change its region.
  • Delete — Remove a geofence you no longer need. The geofence is removed from all profiles automatically.

Profile Toggle

Each geofence card has a slide toggle to activate or deactivate it for your current profile. When you create a geofence, it's automatically activated on the profile you're using. Switch to another profile and the toggle will show \"Inactive\" — flip it on to receive alerts for that geofence on that profile too. This lets you control which profiles get notifications for each geofence without recreating it.

ℹ️
Approved geofences (promoted to public areas) don't show the toggle — manage them from the Areas page instead.

Using a Geofence for One Alarm

A geofence you drew also appears in the Only in specific areas list when you set a single alarm's delivery scope, marked with a draw icon. That confines one alarm to it without activating the geofence for the whole profile.

GeoJSON Import & Export

You can import and export geofences using the standard GeoJSON format, making it easy to share boundaries or create them in external tools like geojson.io.

  • Import — Click the upload icon and paste or upload a GeoJSON file. Each polygon in the file becomes a new geofence. You can review and rename each one before saving.
  • Export — Click the download icon and select which geofences to include. The exported GeoJSON file contains all selected polygons and can be opened in any GIS tool or map editor.
💡
GeoJSON import is useful for migrating geofences from other systems or drawing complex boundaries in a desktop GIS tool and then importing them here.

Submitting for Public Approval

If you think your geofence would be useful for the whole community, you can submit it for admin review. If approved, it becomes a public area everyone can select. Your private geofence continues working while the review is pending.

Status Badges

  • Active — Your private geofence, working for you only.
  • Pending Review — Submitted and waiting for admin review.
  • Approved — Promoted to a public area.
  • Rejected — Not approved. You can see the admin's feedback and the geofence remains active as a private zone.
ℹ️
You can have up to 10 custom geofences, each with up to 500 boundary points.
", + "CONTENT_POKEMON": "\"Pokemon

Pokemon alarms notify you when a wild Pokemon spawns that matches your filters.

Adding a Pokemon Alarm

\"Add
  1. Go to Pokemon from the sidebar and click the + button.
  2. Select Pokemon — Search by name or Pokedex number, or use the generation and type filter buttons to browse. You can select multiple Pokemon at once.
  3. Set Filters — Choose what makes a spawn worth notifying about:
  • IV range — Minimum and maximum IV percentage (0-100%)
  • CP range — Filter by combat power
  • Level range — Filter by Pokemon level (0-55)
  • Individual stats — Filter by ATK, DEF, and STA values (0-15 each)
  • Form — Track specific forms (e.g. Alolan, Galarian) or all forms
  • Gender — Male, female, genderless, or all
  • Weight — Filter by weight range
  • Size — Filter by size category: select ALL (no filter) to match any size, or pick specific sizes from XXS through XXL (XXS, XS, Normal, XL, XXL)
  • Minimum time left — Skip spawns that will be gone before you get there. Set it under More Filters; the card then shows a pill like "10 min left"
ℹ️
Default filter values are set so that all Pokemon match when no filters are explicitly configured. For example, IV defaults to 0-100%, level to 0-55, and size to ALL. You only need to adjust the filters you care about.

PVP Filters

Get notified when a Pokemon has great PVP IVs. Select a league (Great, Ultra, or Little Cup) and set the rank range you care about (e.g. rank 1-50).

The Level Cap buttons choose which cap the ranks are read at. Leave it on All to use whatever your community's Poracle config sets.

Mega evolution chooses whether the rule ranks the base form or a mega: Base, Mega, Mega X, or Mega Y. Megas are ranked separately, so a mega rule will not match a base-form spawn.

\"All Pokemon\" Alarm

💡
Select \"All Pokemon\" (ID 0) to create one alarm that covers every species. Useful with a high IV filter like 96-100% to catch any valuable spawn.

Reading Alarm Cards

Each alarm card shows colored pills summarizing your filters at a glance:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Raids

Raid & Egg Alarms

Get notified when a raid boss or egg appears that you're interested in.

  • By Level — Select raid levels (1-6) or egg levels to track all raids of that tier.
  • By Boss — Select specific Pokemon raid bosses you want to hunt.
  • Team filter — Only notify for raids at gyms controlled by a specific team (Mystic, Valor, Instinct).
  • Gym tracking — Track raids at specific gyms by name so you only get notified about your favorite gyms.
  • Move filter — Filter raid bosses by their fast or charged moves.
  • RSVP notifications — Get notified when other trainers RSVP to a raid or egg you're tracking.

Raid and Egg alarms are managed on separate tabs within the Raids page. Eggs also support gym-specific tracking and RSVP notifications.

Max Battle (Dynamax) Alarms

Get notified about Dynamax and Gigantamax battles at Power Spots.

  • By Level — Select battle tiers to track any Pokemon at those levels. Tiers range from 1 Star through 5 Star (Legendary) for Dynamax, plus Gigantamax and Legendary Gigantamax for the largest battles. One alarm is created per selected level.
  • By Pokemon — Select specific Pokemon you want to battle across all Max Battle levels. If the scanner database is configured, the selector is filtered to only show Pokemon that have appeared in Max Battles.
  • Gigantamax only — When tracking by Pokemon, toggle this to only receive notifications when that Pokemon appears in Gigantamax battles (the highest-tier battles with unique G-Max moves). For level-based tracking, Gigantamax is handled by selecting the Gigantamax or Legendary Gigantamax levels directly.
  • Select All — Quickly select all available levels at once (equivalent to the bot's !maxbattle everything command).

Quest Alarms

Get notified about field research tasks with specific rewards.

  • Pokemon encounters — Select Pokemon you want as quest rewards.
  • Items — Track quests that reward specific items.
  • Mega Energy — Track quests that give mega energy for specific Pokemon.
  • Candy — Track quests that reward candy for specific Pokemon.
  • Stardust — Track quests that reward stardust.

The item, mega energy and candy tabs each take a Minimum Amount, and the stardust tab a Minimum Stardust. Leave it at 0 to match any amount. Cards show the amount alongside the reward, like "3× Rare Candy".

Invasion Alarms

Get notified about Team Rocket invasions.

  • Track All — One alarm for every grunt type and leader.
  • By Type — Select specific grunt types (Bug, Dragon, Fire, etc.), Rocket Leaders, or Giovanni. Grunt type names are automatically normalized (case-insensitive), so you don't need to worry about exact capitalization.
  • Gender — Filter by grunt gender.

Lure Alarms

Get notified when a specific lure type is placed. Choose from Normal, Glacial, Mossy, Magnetic, Rainy, and Golden lures.

Nest Alarms

Track nesting Pokemon species. Set a minimum spawns per hour threshold so you only get notified about nests with enough activity.

Gym Alarms

Track gym team changes. Select which teams (Neutral, Mystic, Valor, Instinct) to monitor. Enable Slot Changes tracking to get notified when gym slots open up, or enable Battle Changes tracking to get notified when a gym is under attack.

Fort Change Alarms

Track changes to pokestops and gyms themselves — not the activities at them, but changes to the actual points of interest.

  • Fort Type — Choose to track Pokestops, Gyms, or Everything.
  • Change Types — Select which changes to monitor: Name changed, Description changed, Location changed, Image changed, Removed, or New fort.
  • Include Empty — Include forts that have no name set.
💡
Fort change alarms are useful for tracking map database updates — new pokestops appearing, gyms being relocated, or POIs being removed from the game.

Targeting a Specific Gym

When creating or editing a Raid, Egg, or Gym alarm, you can optionally search for and select a specific gym. This is useful when you only care about activity at your favorite gym — like the one on your lunch route or near your house.

  • How to use it — In the add or edit dialog, type a gym name into the gym search field. Results show the gym's photo, name, and area so you can identify the right one.
  • When a gym is selected — The alarm only fires for events at that specific gym. The gym name appears on the alarm card in your list so you can see which gym it targets at a glance.
  • When no gym is selected — This is the default. The alarm works normally for all gyms in your selected areas or within your distance radius.
💡
You can combine a gym-specific alarm with a broader alarm. For example, create one raid alarm targeting your local gym for all levels, and a second alarm for level 5 raids across all your areas.
", + "CONTENT_DELIVERY": "\"Pokemon

Every alarm has delivery settings that control where you get notified.

Where an alert reaches you

The Delivery tab of every add and edit dialog asks Where should this alert reach you? and offers three answers:

  • Anywhere in my areas — The default. The alarm follows the areas your profile has selected, so changing your areas changes this alarm too.
  • Near a point — A radius in kilometres, measured from your pin or from a saved place you choose under Measured from. If you have no pin yet, the picker says so and offers to set one.
  • Only in specific areas — A subset of areas for this one alarm, picked from the public areas and any geofence you drew yourself.

Different alarms can answer differently: areas for Pokemon, a radius from your pin for raids, one named place for quests.

The chip on an alarm card

Most alarm cards carry a chip stating the answer — "Anywhere in my areas", "Anywhere I get alerts", "Within 5 km of my pin", "Within 2 km of Home", "Only in Terrigal, Erina". Click the chip to change that one alarm without opening the full edit dialog.

Default for new alarms

New alarms open in Areas mode by default. To change that, open the user menu (your avatar, top-right) and choose Alert Defaults — pick whether new alarms default to Areas or Distance, set a default radius, and choose whether that radius is measured from your pin or from a saved place. The preference is saved in your browser and also seeds the Quick Pick apply dialog. It only affects newly created alarms; existing ones are unchanged, and you can still change where any individual alarm reaches you.

Notification Templates

If templates are enabled, you can choose how your notification messages look. The template selector shows a live preview of what your Discord DM will look like, including the embed format, fields, and images.

Clean Mode

When enabled, the bot automatically deletes the notification from Discord after the event expires (e.g. a Pokemon despawns or a raid ends). This keeps your DMs tidy. You can enable clean mode per-alarm or in bulk from the Cleaning page.

Edit in place & summaries

Some alarms support extra delivery modes. Turn on Edit message in place for a lure to update the existing Discord message when the lure changes instead of sending a new one, or Daily summary for a quest to collect matching quests into one summary message (you choose when it is delivered in the Quest Summary Delivery section). Raids and eggs edit in place automatically when you pick an RSVP mode. These settings are remembered even if you set them from the bot — editing the alarm here will not clear them.

RSVP updates (raids & eggs)

Raid and egg alarms add an RSVP notifications setting in the add/edit dialog with three choices: Matches only sends standard raid/egg alerts; Matches + RSVP updates also re-notifies when RSVP counts change (trainers signing up); and RSVP updates only skips the initial match and notifies you only on RSVP changes. Choosing either RSVP mode makes the bot edit the existing Discord message in place as counts change instead of sending new ones, and the card shows an "RSVP" or "RSVP only" pill. Note that RSVP updates only goes silent unless your community’s scanner emits RSVP events — pick it only if you know RSVPs are reported.

", + "CONTENT_QUEST_SUMMARY": "

Field Research quests rotate daily and can match in bulk, so a busy quest filter can flood your DMs. Quest summary delivery collects matching quests into one scheduled digest instead of many separate alerts.

Two parts that work together

  • Daily summary toggle — turn this on for a quest alarm (in its add/edit dialog) to mark its matches for the digest instead of immediate delivery.
  • Delivery schedule — choose when the collected quests are sent.

Both are needed: the toggle says which quests to collect, the schedule says when to deliver them.

Setting your schedule

Open the Quests page, then the menu in the toolbar and choose Quest summary delivery. Use Edit schedule to pick days and times — the same editor used for profile active hours. Saved times appear as amber pills.

The schedule is per user and shared across all of your profiles — unlike profile active hours, which are configured per profile.

Send summary now

Send summary now delivers whatever has been collected since your last summary, immediately. If nothing has been collected yet, nothing is sent — quests are buffered as they match, so give it time or wait for the schedule to fire.

Good to know

  • The menu only appears when your server’s bot has quest summaries enabled.
  • Delivery timing uses your saved location for the timezone — set a location, or summaries may arrive at the wrong local time (the dialog warns you when no location is set).
  • Removing the schedule keeps the per-alarm toggle; quests still collect but fall back to the bot’s default timing.
", "CONTENT_TEST_ALERTS": "

Every alarm card has a Test button (paper plane icon) that sends a sample notification to your Discord or Telegram, using the alarm's exact filters and your current delivery template.

How It Works

  1. Find any alarm card in your list (Pokemon, Raid, Quest, etc.).
  2. Click the send icon in the card's action row.
  3. A mock event matching your alarm's filters is generated and sent through the notification pipeline. You'll receive a DM just like a real alert.

What Gets Tested

The test uses your alarm's filter values (Pokemon ID, raid level, quest reward, etc.) and your saved location as the mock event coordinates. The notification is formatted using your selected template, so you see exactly what a real alert would look like.

Cooldown

To prevent spam, each alarm has a 15-second cooldown between test sends. The button is disabled during the cooldown and a snackbar shows feedback (success, error, or cooldown remaining).

💡
Test alerts are great for verifying your template looks right or confirming your webhook delivery is working before waiting for a real event to trigger.
", "CONTENT_POKEMON_AVAILABILITY": "

When adding or editing Pokemon alarms, the Pokemon selector can show availability indicators — small badges that tell you which Pokemon are currently spawning in the wild.

How It Works

If your community has a Golbat scanner configured, the selector shows colored dots next to Pokemon names:

  • Green dot — This Pokemon has been seen spawning recently.
  • No dot — Not currently reported in the scanner data.

This helps you avoid creating alarms for Pokemon that aren't spawning in your area right now (e.g., seasonal or event-exclusive species).

Availability Refresh

The data refreshes automatically in the background. You don't need to do anything — just look for the dots when browsing the Pokemon selector.

ℹ️
This feature is only visible if your admin has configured the Golbat scanner integration. If you don't see availability dots, the feature is not enabled for your community.
", "CONTENT_BULK": "\"Pokemon

All alarm pages support bulk operations so you can manage many alarms at once.

Select Mode

Click the checklist icon in the toolbar to enter select mode. Then click individual alarm cards to select them, or use Select All to grab everything visible.

Bulk Actions

  • Update Distance — Change the delivery mode (areas or distance) for all selected alarms at once.
  • Delete — Remove all selected alarms with one confirmation.
💡
At the bottom of each alarm list, you'll also find Update All Distance and Delete All buttons that apply to every alarm of that type.
", - "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks are pre-built alarm templates created by your community's admins. They let you set up common alarm configurations with one click instead of creating each alarm individually.

Applying a Quick Pick

  1. Go to Quick Picks from the sidebar.
  2. Browse the available picks, optionally filtering by category.
  3. Click Apply on a Quick Pick you want.
  4. Customize before applying: choose your delivery mode (areas or distance), enable clean mode, and optionally exclude specific Pokemon.
  5. Confirm to create all the alarms at once.

Removing Quick Pick Alarms

If you no longer want alarms from a Quick Pick, click Remove to delete all alarms it created.

", - "CONTENT_PROFILES": "

The Profiles page is your unified hub for managing profiles and viewing all alarms across every profile in one place.

Why Use Profiles?

Profiles let you maintain completely separate alarm configurations. Each profile has its own set of alarms, selected areas, location, and custom geofence activations. Useful for different situations — for example, a \"Home\" profile for your neighborhood and a \"Work\" profile for around your office.

Overview

The page shows a stats bar with total alarm counts per type, a search bar to filter across all profiles, and type filter chips to show only specific alarm types (Pokemon, Raids, Quests, etc.).

Each profile appears as an expandable panel. Click to expand and see all alarms grouped by type, with game asset images (Pokemon sprites, raid eggs, lure icons) and filter pills showing IV, CP, Level, PVP, and other settings at a glance.

Managing Profiles

  • Create — Click the + button in the top right. Profile names must be unique (up to 32 characters).
  • Switch — Click Switch inside a profile panel to make it your active profile. Your active profile is marked with a green badge and left border.
  • Edit — Click the pencil icon to rename a profile.
  • Delete — Click the trash icon to remove a profile and all its alarms. You can't delete your active profile.

Duplicate

Click the copy icon on any profile to create an exact copy with all its alarms. You'll be prompted to name the new profile — a default name like \"Profile (Copy)\" is suggested. The duplicate includes all alarm filters but gets a fresh set of area selections.

Export & Import

  • Export — Click the download icon on a profile to save a backup file (JSON). The file contains all alarm filters, stripped of internal IDs so it's portable.
  • Import — Click the Import button in the top right, select a backup file, and choose a name for the new profile. All alarms from the backup are restored. If a profile with the same name exists, a number suffix is added automatically.

Duplicate Detection

If the same alarm exists on multiple profiles (e.g., tracking Pikachu on both \"Home\" and \"Work\"), those alarms are highlighted with an orange border and a copy icon. When duplicates exist, a Duplicates filter chip appears in the filter bar — click it to show only duplicated alarms across profiles.

⚠️
Warning: Deleting a profile permanently removes all alarms in that profile. You can't delete your currently active profile. Consider exporting a backup first.
", - "CONTENT_CLEANING": "\"Cleaning

The Cleaning page lets you control clean mode across all your alarm types at once.

When clean mode is on for an alarm type, the bot automatically deletes notifications from Discord after the event expires:

  • Pokemon — Deleted when the spawn despawns
  • Raids — Deleted when the raid ends
  • Eggs — Deleted when the egg hatches
  • Quests — Deleted when quests reset at midnight
  • Invasions — Deleted when the grunt leaves
  • Lures — Deleted when the lure expires
  • Nests — Deleted when nests migrate
  • Gyms — Deleted after gym changes
  • Fort Changes — Deleted after fort change notification expires
  • Max Battles — Deleted when the battle ends

Use Enable All or Disable All to toggle everything at once.

💡
Recommended: Keep clean mode enabled to prevent outdated alerts from piling up in your DMs.
", - "CONTENT_APPEARANCE": "

Dark / Light Mode

Click the sun/moon icon in the top toolbar to switch between dark and light themes. Your choice is saved automatically.

\"Toolbar

Accent Colors

Open the user menu (your avatar in the top-right) and select Accent Theme. Choose from:

  • Default — Blue
  • Pokemon — Green
  • Raids — Red
  • Mystic — Blue
  • Valor — Red
  • Instinct — Yellow

The accent color changes the toolbar gradient, active navigation highlight, and other UI accents throughout the site.

\"Dashboard

Language

If available, use the language selector in the toolbar to switch the interface language. 18 languages are supported.

Keyboard Shortcuts

?Show keyboard shortcuts
EscClose menus or dialogs
[Collapse sidebar
]Expand sidebar
", - "CONTENT_ALERTS_LOGOUT": "\"User

Pausing Alerts

Open the user menu (your avatar) and click Pause Alerts. A red banner will appear at the top of the site confirming your alerts are paused. You won't receive any notifications while paused.

To resume, click Resume Alerts from the user menu or the banner.

Logging Out

Open the user menu and click Logout. You'll be returned to the login page.

", - "CONTENT_FAQ": "

\"I can't log in\"

You must register with the Poracle bot on Discord or Telegram before you can sign in to this site. If you see \"Your account is not registered,\" contact your community admin for registration instructions.

\"I'm not getting notifications\"

Check these common causes:

  1. Alerts paused — Look for a red banner at the top of the site. Resume alerts from the user menu.
  2. No location set — If your alarms use distance mode, you need a saved location.
  3. No areas selected — If your alarms use areas mode, make sure you've selected areas on the Areas page.
  4. Wrong profile — You might have alarms on a different profile. Check which profile is active on the Dashboard.
  5. Filters too strict — Try relaxing your IV, CP, or level filters to see if notifications start coming through.

\"My alarms disappeared\"

Alarms are profile-specific. If you switched profiles, your alarms from the other profile are still there — just switch back from the Dashboard or Profiles page.

\"I can't click a small area on the map\"

When areas overlap, zoom in to make the smaller area easier to click. Smaller areas are always on top of larger ones.

\"What does Clean mode do?\"

Clean mode tells the bot to automatically delete a notification from Discord after the event expires (e.g. a Pokemon despawns). Without it, old alerts stay in your DMs forever. Enable it on the Cleaning page or per-alarm in the Delivery tab.

\"What's the difference between Areas and Distance?\"

Each alarm uses one delivery mode. Areas notifies you about events inside specific geographic zones. Distance notifies you about events within a radius of your saved location. You can mix both across different alarms.

" + "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks are pre-built alarm templates created by your community's admins. They let you set up common alarm configurations with one click instead of creating each alarm individually.

Applying a Quick Pick

  1. Go to Quick Picks from the sidebar.
  2. Browse the available picks, optionally filtering by category.
  3. Click Apply on a Quick Pick you want.
  4. Customize before applying: set where the alerts should reach you — the Delivery tab is the same three-option picker an individual alarm uses, so you can aim them at a saved place or a subset of areas — enable clean mode, and optionally exclude specific Pokemon.
  5. Confirm to create all the alarms at once.

Removing Quick Pick Alarms

If you no longer want alarms from a Quick Pick, click Remove to delete all alarms it created.

", + "CONTENT_PROFILES": "

The Profiles page is your unified hub for managing profiles and viewing all alarms across every profile in one place.

Why Use Profiles?

Profiles let you maintain completely separate alarm configurations. Each profile has its own set of alarms, selected areas, location, and custom geofence activations. Useful for different situations — for example, a \"Home\" profile for your neighborhood and a \"Work\" profile for around your office.

Overview

The page shows a stats bar with total alarm counts per type, a search bar to filter across all profiles, and type filter chips to show only specific alarm types (Pokemon, Raids, Quests, etc.).

Each profile appears as an expandable panel. Click to expand and see all alarms grouped by type, with game asset images (Pokemon sprites, raid eggs, lure icons) and filter pills showing IV, CP, Level, PVP, and other settings at a glance.

Managing Profiles

  • Create — Click the + button in the top right. Profile names must be unique (up to 32 characters).
  • Switch — Click Switch inside a profile panel to make it your active profile. Your active profile is marked with a green badge and left border.
  • Edit — Click the pencil icon to rename a profile.
  • Delete — Click the trash icon to remove a profile and all its alarms. You can't delete your active profile.

Duplicate

Click the copy icon on any profile to create an exact copy with all its alarms. You'll be prompted to name the new profile — a default name like \"Profile (Copy)\" is suggested. The duplicate includes all alarm filters, and its areas, location and active hours are copied from the source profile too, so it starts alerting straight away.

Export & Import

  • Export — Click the download icon on a profile to save a backup file (JSON). The file contains all alarm filters, stripped of internal IDs so it's portable.
  • Import — Click the Import button in the top right, select a backup file, and choose a name for the new profile. All alarms from the backup are restored. If a profile with the same name exists, a number suffix is added automatically.

Duplicate Detection

If the same alarm exists on multiple profiles (e.g., tracking Pikachu on both \"Home\" and \"Work\"), those alarms are highlighted with an orange border and a copy icon. When duplicates exist, a Duplicates filter chip appears in the filter bar — click it to show only duplicated alarms across profiles.

⚠️
Warning: Deleting a profile permanently removes all alarms in that profile. You can't delete your currently active profile. Consider exporting a backup first.
", + "CONTENT_CLEANING": "\"Cleaning

The Cleaning page lets you control clean mode across all your alarm types at once.

When clean mode is on for an alarm type, the bot automatically deletes notifications from Discord after the event expires:

  • Pokemon — Deleted when the spawn despawns
  • Raids — Deleted when the raid ends
  • Eggs — Deleted when the egg hatches
  • Quests — Deleted when quests reset at midnight
  • Invasions — Deleted when the grunt leaves
  • Lures — Deleted when the lure expires
  • Nests — Deleted when nests migrate
  • Gyms — Deleted after gym changes
  • Max Battles — Deleted when the battle ends

Use Enable All or Disable All to toggle everything at once.

💡
Recommended: Keep clean mode enabled to prevent outdated alerts from piling up in your DMs.
", + "CONTENT_APPEARANCE": "

Dark / Light Mode

Click the sun/moon icon in the top toolbar to switch between dark and light themes. Your choice is saved automatically.

\"Toolbar

Accent Colors

Open the user menu (your avatar in the top-right) and select Accent Theme. Choose from:

  • Default — Blue
  • Pokemon — Green
  • Raids — Red
  • Mystic — Blue
  • Valor — Red
  • Instinct — Yellow

The accent color changes the toolbar gradient, active navigation highlight, and other UI accents throughout the site.

\"Dashboard

Display Language

Open the user menu (your avatar in the top-right) and choose Display language. 11 languages are available. It changes this site's text, and also the Pokemon names, types and forms shown in the pickers and on your alarm cards. If you have never chosen one, you get whichever language your browser asks for, or the one your Poracle server is set to.

Alert Language

Directly below it sits Alert language, a separate setting. It controls the language Poracle writes your DMs in. The two are independent: a German site with English DMs, or the reverse, is a perfectly normal thing to want. It used to live on the Areas page.

Keyboard Shortcuts

?Show keyboard shortcuts
EscClose menus or dialogs
[Collapse sidebar
]Expand sidebar
", + "CONTENT_ALERTS_LOGOUT": "\"User

Pausing Alerts

Open the user menu (your avatar) and click Pause Alerts. A red banner will appear at the top of the site confirming your alerts are paused. You won't receive any notifications while paused.

To resume, click Resume Alerts from the user menu or the banner.

Logging Out

Open the user menu and click Logout. You'll be returned to the login page.

If you signed in through an SSO provider that supports single logout, the menu also offers Sign out everywhere — that ends your session with the provider too, not just here.

", + "CONTENT_FAQ": "

\"I can't log in\"

You must register with the Poracle bot on Discord or Telegram before you can sign in to this site. If you see \"Your account is not registered,\" contact your community admin for registration instructions.

\"I'm not getting notifications\"

Check these common causes:

  1. Alerts paused — Look for a red banner at the top of the site. Resume alerts from the user menu.
  2. No pin set — An alarm that reaches you within a radius measures from your pin, or from a saved place. Set one on the Areas & Places page.
  3. Nothing in scope — Check the chip on the alarm card. It states where that alarm reaches you, and it may be aimed at areas your profile no longer covers.
  4. Wrong profile — You might have alarms on a different profile. Check which profile is active on the Dashboard.
  5. Filters too strict — Try relaxing your IV, CP, or level filters to see if notifications start coming through.

\"My alarms disappeared\"

Alarms are profile-specific. If you switched profiles, your alarms from the other profile are still there — just switch back from the Dashboard or Profiles page.

\"I can't click a small area on the map\"

When areas overlap, zoom in to make the smaller area easier to click. Smaller areas are always on top of larger ones.

\"What does Clean mode do?\"

Clean mode tells the bot to automatically delete a notification from Discord after the event expires (e.g. a Pokemon despawns). Without it, old alerts stay in your DMs forever. Enable it on the Cleaning page or per-alarm in the Delivery tab.

\"Where does an alert reach me?\"

Each alarm answers that on its own, in its Delivery tab. Anywhere in my areas follows the areas your profile has selected. Near a point is a radius from your pin or from a saved place. Only in specific areas confines that one alarm to a subset of areas. The chip on the card always states the current answer, and clicking it changes it.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "DM Alerts", @@ -1075,6 +1203,10 @@ "SIGN_IN_DESC": "Sign in to manage your Pokemon GO notification alarms.", "SIGN_IN_DISCORD": "Sign in with Discord", "SIGN_IN_TELEGRAM": "Sign in with Telegram", + "SIGN_IN_OIDC": "Sign in with {{provider}}", + "SIGNED_OUT_TITLE": "Signed out", + "SIGNED_OUT_DESC": "You've been signed out of DM Alerts.", + "SIGN_IN_AGAIN": "Sign in again", "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", @@ -1090,6 +1222,10 @@ "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", + "ERR_OIDC_DISABLED": "External login is currently disabled.", + "ERR_OIDC_NO_IDENTITY": "Your external login provider did not return an account we can match. Make sure your Discord account is linked.", + "ERR_OIDC_TOKEN_EXCHANGE": "External login failed. Please try again.", + "ERR_OIDC_USERINFO": "Could not retrieve your profile from the external login provider. Please try again.", "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", @@ -1099,13 +1235,7 @@ "SIGN_UP_DESC": "Don't have an account? Sign up to get started." }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "This feature has been disabled by the administrator." }, "ADMIN": { "USERS_TITLE": "User Management", @@ -1160,9 +1290,12 @@ "APPROVAL_PROMOTED_NAME": "Promoted name", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Name for the promoted geofence", "APPROVAL_PROMOTED_NAME_HINT": "Optional. Defaults to the current display name.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", + "APPROVAL_REGION_HINT": "Region this geofence is filed under once public. Leave as-is to keep the submitter's choice.", "APPROVAL_REJECT_REASON": "Reason for rejection", "APPROVAL_REJECT_PLACEHOLDER": "Explain why this geofence is being rejected...", - "USERS_DESC_FULL": "Manage registered Discord users. Stopped = user paused alerts or hit rate limits. Blocked = hard-blocked by admin.", + "USERS_DESC_FULL": "Manage registered users — Discord, Telegram and OIDC accounts all appear here; webhooks have their own tab. Stopped = user paused alerts or hit rate limits. Blocked = hard-blocked by admin.", "COLUMN_DISABLED_SINCE": "Disabled Since", "COLUMN_URL": "URL", "COLUMN_DELEGATES": "Delegates", @@ -1255,9 +1388,27 @@ "SNACK_FAILED_APPROVE": "Failed to approve submission", "SNACK_APPROVED": "\"{{name}}\" approved", "SNACK_FAILED_REJECT": "Failed to reject submission", - "SNACK_REJECTED": "\"{{name}}\" rejected" + "SNACK_REJECTED": "\"{{name}}\" rejected", + "SERVER_TITLE": "Poracle server", + "SERVER_REFRESH": "Check again", + "SERVER_VERSION": "Version", + "SERVER_SCHEMA": "Database schema", + "SERVER_CHECKED": "Last checked", + "SERVER_CAPABILITIES": "Capabilities", + "SERVER_NO_CAPABILITIES": "This server reports none.", + "SERVER_UNKNOWN": "Unknown", + "SERVER_UNREACHABLE": "Poracle did not answer. Alarms, profiles and locations all go through it and will fail until it does.", + "SERVER_TOO_OLD": "Poracle {{version}} is older than {{minimum}}, which this version of the site needs. Per-alarm delivery, the PVP mega filter and the minimum time filter will appear to save and change nothing.", + "UPDATE_AVAILABLE": "{{name}} {{running}} is running, and {{latest}} is out.", + "UPDATE_PRERELEASE": "{{name}} {{running}} is newer than any release — this is a development build.", + "VERSIONS_TITLE": "Versions", + "VERSIONS_WEB": "This site", + "VERSIONS_BUILD": "Build", + "UPDATE_CURRENT": "Up to date.", + "UPDATE_UNCOMPARABLE": "Development channel. Latest release is {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Pick a point", "CANCEL": "Cancel", "CONFIRM": "Confirm", "DONT_ASK_AGAIN": "Don't ask again for this session", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Update All Distance", "DISTANCE_DESC": "Set the location mode for all alarms of this type.", "DISTANCE_UPDATE_ALL": "Update All", + "DISTANCE_MUST_BE_POSITIVE": "Distance must be greater than zero.", "LOCATION_SAVE_ERROR": "Failed to update location", "LOCATION_SAVE_SUCCESS": "Location updated successfully", "LOCATION_GEO_UNSUPPORTED": "Geolocation is not supported by your browser", @@ -1296,6 +1448,7 @@ "EDIT": "Edit", "ADD": "Add", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirm", "DELETE_ALL": "Delete All", "CLOSE": "Close", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Search for a gym (optional)", "SEARCH_HINT": "Type gym name...", - "CLEAR_ARIA": "Clear gym selection" + "CLEAR_ARIA": "Clear gym selection", + "RATE_LIMITED": "Too many scanner requests — please slow down." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Notifications will be sent for these areas:", @@ -1386,12 +1540,10 @@ "GROUP_ALARM_TYPES": "Alarm Types", "GROUP_FEATURES": "Features", "GROUP_ADMINISTRATION": "Administration", - "GROUP_COMMANDS": "Commands", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Maps & Assets", + "GROUP_OIDC": "External SSO", "GROUP_ANALYTICS_LINKS": "Analytics & Links", - "GROUP_DEBUG": "Debug", "GROUP_ICON_REPO": "Icon Repository", "GROUP_OTHER": "Other", "CUSTOM_TITLE_LABEL": "Site Title", @@ -1405,52 +1557,51 @@ "FAVICON_URL_PREVIEW": "Favicon preview (32×32)", "FAVICON_URL_CACHE_WARNING": "Browsers aggressively cache favicons. After saving, users must clear their browser cache or hard-refresh (Ctrl+F5 / Cmd+Shift+R) to see the new icon.", "FAVICON_URL_CSP_NOTE": "If your site uses a Content Security Policy, the favicon URL's origin must be allowed by your img-src directive; otherwise the browser blocks the fetch and falls back to the default icon.", + "FORCED_BY_PORACLE": "Disabled in Poracle's own config. Poracle drops these webhooks and its bot refuses the command, so this cannot be enabled here.", + "FORCED_BY_PORACLE_TOOLTIP": "Controlled by Poracle's config, not by this page.", "CUSTOM_PAGE_NAME_LABEL": "Nav Link Label", "CUSTOM_PAGE_NAME_DESC": "Label for the custom navigation link (e.g. \"Back To Map\").", "CUSTOM_PAGE_URL_LABEL": "Nav Link URL", "CUSTOM_PAGE_URL_DESC": "URL the custom nav link points to.", "CUSTOM_PAGE_ICON_LABEL": "Nav Link Icon", "CUSTOM_PAGE_ICON_DESC": "FontAwesome class for the nav link icon (e.g. \"fas fa-map\").", - "DISABLE_MONS_LABEL": "Disable Pokemon", - "DISABLE_MONS_DESC": "Hide Pokemon alarm management from all users.", - "DISABLE_RAIDS_LABEL": "Disable Raids", - "DISABLE_RAIDS_DESC": "Hide raid alarm management from all users.", - "DISABLE_QUESTS_LABEL": "Disable Quests", - "DISABLE_QUESTS_DESC": "Hide quest alarm management from all users.", - "DISABLE_INVASIONS_LABEL": "Disable Invasions", - "DISABLE_INVASIONS_DESC": "Hide invasion alarm management from all users.", - "DISABLE_LURES_LABEL": "Disable Lures", - "DISABLE_LURES_DESC": "Hide lure alarm management from all users.", - "DISABLE_NESTS_LABEL": "Disable Nests", - "DISABLE_NESTS_DESC": "Hide nest alarm management from all users.", - "DISABLE_GYMS_LABEL": "Disable Gyms", - "DISABLE_GYMS_DESC": "Hide gym alarm management from all users.", - "DISABLE_FORT_CHANGES_LABEL": "Disable Fort Changes", - "DISABLE_FORT_CHANGES_DESC": "Hide fort change alarm management from all users.", - "DISABLE_MAXBATTLES_LABEL": "Disable Max Battles", - "DISABLE_MAXBATTLES_DESC": "Hide max battle alarm management from all users.", - "DISABLE_AREAS_LABEL": "Disable Areas", - "DISABLE_AREAS_DESC": "Prevent users from managing their area subscriptions.", - "DISABLE_PROFILES_LABEL": "Disable Profiles", - "DISABLE_PROFILES_DESC": "Prevent users from creating and switching alarm profiles.", - "DISABLE_LOCATION_LABEL": "Disable Location", - "DISABLE_LOCATION_DESC": "Prevent users from setting a home location.", - "DISABLE_NOMINATIM_LABEL": "Disable Geocoding", - "DISABLE_NOMINATIM_DESC": "Disable Nominatim address search for location picking.", - "DISABLE_GEOMAP_LABEL": "Disable Map View", - "DISABLE_GEOMAP_DESC": "Hide the interactive geofence map entirely.", - "DISABLE_GEOMAP_SELECT_LABEL": "Disable Map Area Selection", - "DISABLE_GEOMAP_SELECT_DESC": "Prevent users from selecting areas by clicking the map.", - "ENABLE_TEMPLATES_LABEL": "Enable Templates", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Let users manage Pokémon alarms.", + "DISABLE_RAIDS_LABEL": "Raids", + "DISABLE_RAIDS_DESC": "Let users manage raid alarms.", + "DISABLE_QUESTS_LABEL": "Quests", + "DISABLE_QUESTS_DESC": "Let users manage quest alarms.", + "DISABLE_INVASIONS_LABEL": "Invasions", + "DISABLE_INVASIONS_DESC": "Let users manage invasion alarms.", + "DISABLE_LURES_LABEL": "Lures", + "DISABLE_LURES_DESC": "Let users manage lure alarms.", + "DISABLE_NESTS_LABEL": "Nests", + "DISABLE_NESTS_DESC": "Let users manage nest alarms.", + "DISABLE_GYMS_LABEL": "Gyms", + "DISABLE_GYMS_DESC": "Let users manage gym alarms.", + "DISABLE_FORT_CHANGES_LABEL": "Fort Changes", + "DISABLE_FORT_CHANGES_DESC": "Let users manage fort change alarms.", + "DISABLE_MAXBATTLES_LABEL": "Max Battles", + "DISABLE_MAXBATTLES_DESC": "Let users manage max battle alarms.", + "DISABLE_AREAS_LABEL": "Areas", + "DISABLE_AREAS_DESC": "Let users manage their area subscriptions.", + "DISABLE_PROFILES_LABEL": "Profiles", + "DISABLE_PROFILES_DESC": "Let users create and switch alarm profiles.", + "DISABLE_LOCATION_LABEL": "Location", + "DISABLE_LOCATION_DESC": "Let users set a home location.", + "DISABLE_NOMINATIM_LABEL": "Geocoding", + "DISABLE_NOMINATIM_DESC": "Allow Nominatim address search for location picking.", + "DISABLE_USER_GEOFENCES_LABEL": "Custom Geofences", + "DISABLE_USER_GEOFENCES_DESC": "Let users draw, import, and submit their own geofences. Existing geofences keep working.", + "ENABLE_TEMPLATES_LABEL": "Templates", "ENABLE_TEMPLATES_DESC": "Allow users to choose notification message templates.", "ALLOWED_LANGUAGES_LABEL": "Allowed UI Languages", "ALLOWED_LANGUAGES_DESC": "Comma-separated language codes to show in the UI language selector (e.g. \"en,de,fr,es\"). Leave empty to show all 11 languages.", + "PORACLE_LOCALE_HINT": "Default language for new users: {{locale}}, taken from Poracle's own configuration. Anyone who picks a language, or whose browser asks for one this site has, gets that instead.", "ENABLE_ROLES_LABEL": "Enable Role-Based Access", "ENABLE_ROLES_DESC": "Only allow users with specific Discord roles to log in. Requires Bot Token and Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "Allowed Role IDs", - "ALLOWED_ROLE_IDS_DESC": "Comma-separated Discord role IDs that grant access (e.g. \"123456789,987654321\"). Leave empty to allow all.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Allowed Languages", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Comma-separated list of language codes users can select (e.g. \"en,de,fr\").", + "ALLOWED_ROLE_IDS_DESC": "Comma-separated Discord role IDs, e.g. 123456789,987654321. A user needs at least one of these roles to log in. Leave empty to allow all.", "REGISTER_COMMAND_LABEL": "Register Command", "REGISTER_COMMAND_DESC": "The Poracle bot command users run to register (e.g. \"$!register\").", "LOCATION_COMMAND_LABEL": "Location Command", @@ -1458,9 +1609,35 @@ "ENABLE_TELEGRAM_LABEL": "Enable Telegram Login", "ENABLE_TELEGRAM_DESC": "Allow Telegram login on this site. Requires TELEGRAM_ENABLED=true, bot token, and bot username in .env (server restart required for .env changes).", "TELEGRAM_BOT_LABEL": "Bot Username", - "TELEGRAM_BOT_DESC": "Telegram bot username (without @).", + "TELEGRAM_BOT_DESC": "Telegram bot username (without @). Used when TELEGRAM_BOT_USERNAME is not configured.", "ENABLE_DISCORD_LABEL": "Enable Discord Login", "ENABLE_DISCORD_DESC": "Allow Discord login on this site. Requires Discord Client ID and Client Secret in .env (server restart required for .env changes). Does not affect PoracleNG bot delivery.", + "ENABLE_OIDC_LABEL": "Enable External SSO Login", + "ENABLE_OIDC_DESC": "Allow login via the configured external OIDC/OAuth2 provider. Requires OIDC_* settings (provider URLs, client ID, and secret) in .env (server restart required for .env changes).", + "GROUP_AUTH": "Authentication", + "AUTH_MODE_LABEL": "Sign-in mode", + "AUTH_MODE_LOCAL": "Local", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_LOCAL_DESC": "Users sign in with the Discord / Telegram methods configured below.", + "AUTH_MODE_OIDC_DESC": "All users are redirected to the external SSO provider. Local sign-in is bypassed.", + "AUTH_MODE_SWITCH_CONFIRM": "Switch to SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Switch to SSO sign-in?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "After saving, all users (including admins) will be redirected to {{provider}} to sign in — the local Discord/Telegram login page is bypassed. If the provider is unreachable you can be locked out; recover by setting AUTH_FORCE_LOCAL=true in the server environment.", + "AUTH_OIDC_NOT_CONFIGURED": "SSO is unavailable until the OIDC provider is configured in the server environment (OIDC_* env vars).", + "AUTH_OIDC_HIDES_LOCAL": "Discord and Telegram are hidden while SSO is the active sign-in mode.", + "AUTH_FORCE_LOCAL_ACTIVE": "AUTH_FORCE_LOCAL is set in the server environment, so the local login page is being shown regardless of this mode (break-glass override).", + "AUTH_SLO_LABEL": "Single logout", + "AUTH_SLO_DESC": "When enabled, \"Sign out everywhere\" also ends the provider session (not just this site). Requires the provider's end-session endpoint (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Single logout is unavailable until the provider's end-session endpoint is configured (OIDC_END_SESSION_URL env var).", + "OIDC_SERVER_CONFIG": "OIDC Provider Configuration", + "OIDC_PROVIDER_LABEL": "Provider name", + "OIDC_AUTHORIZATION_URL_LABEL": "Authorization URL", + "OIDC_TOKEN_URL_LABEL": "Token URL", + "OIDC_USERINFO_URL_LABEL": "UserInfo URL", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Identity claim", + "OIDC_USE_PKCE_LABEL": "Use PKCE", "PROVIDER_URL_LABEL": "Map Tile URL", "PROVIDER_URL_DESC": "URL template for the map tile provider (used for static maps).", "SIGNUP_URL_LABEL": "Signup URL", @@ -1498,7 +1675,17 @@ "LOAD_FAILED": "Failed to load settings", "SAVE_SUCCESS": "{{count}} setting(s) saved", "SAVE_PARTIAL": "{{done}} saved, {{errors}} failed", - "ICONS_SELECTED": "Selected {{repo}} icons — click Save to apply" + "ICONS_SELECTED": "Selected {{repo}} icons — click Save to apply", + "SEARCH_PLACEHOLDER": "Search settings…", + "SEARCH_CLEAR": "Clear search", + "UNSAVED_CHANGES": "{{count}} unsaved", + "SAVE_CHANGES": "Save changes", + "DISCARD_CHANGES": "Discard", + "COLLAPSE_SECTION": "Collapse section", + "EXPAND_SECTION": "Expand section", + "SUMMARY_ENABLED": "{{count}} of {{total}} enabled", + "DISABLE_UPDATE_CHECK_LABEL": "Do not check for updates", + "DISABLE_UPDATE_CHECK_DESC": "Stops the site asking GitHub whether a newer PoracleWeb or Poracle has been released. This is the only request it makes outside your own network; nothing is sent with it." }, "GEOFENCE_DETAIL": { "NAME": "Name", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Your Location", "SELECTED_COUNT": "{{count}} selected:", "AREAS_SELECTED": "{{count}} area(s) selected" + }, + "ALERT_DEFAULTS": { + "TITLE": "Alert Defaults", + "DESC": "Choose how new alerts are delivered by default. You can still change this for each alert when you create it.", + "DEFAULT_DISTANCE": "Default distance", + "DEFAULT_DISTANCE_HINT": "Used to pre-fill the radius for new distance-based alerts.", + "FOOTNOTE": "Applies to newly created alerts only — existing alerts are unchanged.", + "DISTANCE_TOO_SMALL": "Must be at least 0.1 km.", + "DISTANCE_TOO_LARGE": "Must be 100 km or less." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Items per page:", + "RANGE": "{{start}} - {{end}} of {{total}}", + "RANGE_EMPTY": "0 of {{total}}", + "NEXT_PAGE": "Next page", + "PREVIOUS_PAGE": "Previous page", + "FIRST_PAGE": "First page", + "LAST_PAGE": "Last page" + }, + "WHERE": { + "SET_PIN": "Set your pin", + "PIN_MISSING_WARNING": "You have not set a pin yet, so this alert would have nothing to measure from.", + "PLACES_EMPTY_TITLE": "No places yet", + "PIN_UNSET": "Not set", + "PLACES_PAGE_DESC": "Named points your alerts can be aimed at, instead of your pin.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "No places yet. Add one below to aim this alert somewhere other than your pin.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_NEAR": "Near a point", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json index 4a2f65b4..1f98b733 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -16,7 +16,7 @@ "GYMS": "Gimnasios", "FORT_CHANGES": "Cambios de fort", "PROFILES": "Perfiles", - "AREAS": "Zonas", + "AREAS": "Áreas y lugares", "MY_GEOFENCES": "Mis Geofences", "CLEANING": "Limpieza", "HELP": "Ayuda", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Viendo como", - "BACK_TO_ADMIN": "Volver al Admin", + "EXIT_IMPERSONATION": "Volver a tu cuenta", "DISABLED_ACCOUNT": "Tu cuenta ha sido desactivada. Esto puede deberse a un límite de solicitudes o a una acción administrativa.", + "DISABLED_ACCOUNT_INSPECTED": "Esta cuenta ha sido desactivada por un administrador y no recibe notificaciones.", "DISABLED_SUPPORT": "Para obtener ayuda, pregunta en", "PAUSED_ALERTS": "Tus alertas están en pausa. No recibirás notificaciones.", "RESUME": "Reanudar" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Solo cambia el texto de este sitio.", "PROFILE_PREFIX": "Perfil #", "PAUSE_ALERTS": "Pausar alertas", "RESUME_ALERTS": "Reanudar alertas", "SWITCH_PROFILE": "Cambiar perfil", - "AREAS_LOCATION": "Zonas y ubicación", "CLEANING": "Limpieza", "ACCENT_THEME": "Tema de acento", - "LANGUAGE": "Idioma", + "DISPLAY_LANGUAGE": "Idioma de la interfaz", + "ALERT_LANGUAGE": "Idioma de las alertas", + "ALERT_LANGUAGE_HINT": "Se usa en el texto de las alertas y los nombres de Pokemon.", "LOGOUT": "Cerrar sesión", + "LOGOUT_EVERYWHERE": "Cerrar sesión en todas partes", "ACCENT_DEFAULT": "Predeterminado", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Valores predeterminados de alertas" }, "SHORTCUTS": { "TITLE": "Atajos de teclado", @@ -77,6 +82,7 @@ "NETWORK": "No se puede conectar al servidor. Verifica tu conexión.", "BAD_REQUEST": "Solicitud inválida. Verifica tus datos.", "UNAUTHORIZED": "Tu sesión ha expirado. Inicia sesión de nuevo.", + "INSPECTION_ENDED": "Se ha finalizado la inspección: has vuelto a tu propia sesión.", "FORBIDDEN": "No tienes permiso para realizar esta acción.", "NOT_FOUND": "El recurso solicitado no fue encontrado.", "CONFLICT": "Ocurrió un conflicto. El elemento puede haber sido modificado.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Bienvenida de inicio" }, "POKEMON": { + "PVP_EVOLUTION": "Megaevolución", + "PVP_EVOLUTION_HINT": "Clasifica las formas base o una mega. Las megas se clasifican aparte, así que una regla mega no coincidirá con una forma base.", + "PVP_EVO_BASE": "Base", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Alarmas Pokemon", "PAGE_DESC": "Rastrea spawns salvajes de Pokemon con filtros personalizados de IV, CP, nivel y PVP.", "SEARCH_PLACEHOLDER": "Buscar por nombre o #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Forma y género", "LABEL_FORM": "Forma", "ALL_FORMS": "Todas las formas", + "FORM_MULTI_HINT": "Déjalo vacío para incluir todas las formas", "LABEL_GENDER": "Género", "GENDER_ALL": "Todos", "GENDER_MALE": "Macho", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Solo alertar si el CP evolucionado alcanza este mínimo", "PVP_DISABLED_HINT": "Selecciona una liga para filtrar por rango PVP.", "SNACK_CREATED": "{{count}} alarma(s) Pokemon creada(s)", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} alarma(s) de Pokemon creada(s), {{duplicates}} ya rastreada(s)", "SNACK_UPDATED": "Alarma Pokemon actualizada", "SNACK_DELETED": "Alarma Pokemon eliminada", "SNACK_DELETED_ALL": "Todas las alarmas Pokemon eliminadas", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Límite de nivel", + "PVP_CAP_ALL": "Todos", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Predeterminado — de la configuración de Poracle", + "FILTER_TIME_LEFT": "Tiempo Restante", + "LABEL_MIN_TIME": "Tiempo restante mínimo", + "MIN_TIME_HINT": "Omite apariciones que desaparecerán antes de que llegues.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "quedan {{count}} min", + "PILL_TIME_LEFT_SECONDS": "quedan {{count}} s", + "MIN_TIME_ANY": "Cualquiera" }, "ALARM": { "LOCATION_MODE": "Modo de ubicación", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Elimina automáticamente la notificación de Discord cuando el señuelo expira", "CLEAN_HINT_NEST": "Elimina automáticamente la notificación de Discord cuando los nidos migran", "CLEAN_HINT_GYM": "Elimina automáticamente la notificación de Discord cuando la actividad del gimnasio cambia", - "CLEAN_HINT_FORT": "Elimina automáticamente la notificación de Discord tras expirar", "CLEAN_HINT_MAX_BATTLE": "Elimina automáticamente la notificación de Discord cuando el combate max termina", "SAVING": "Guardando...", "SAVE": "Guardar", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Tiempo de espera activo", "TEST_SEND": "Enviar notificación de prueba", "TAB_DELIVERY": "Entrega", - "COMMON_SETTINGS": "Ajustes comunes" + "COMMON_SETTINGS": "Ajustes comunes", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} creadas, {{duplicates}} ya rastreadas" }, "RAIDS": { + "RSVP_LABEL": "Notificaciones RSVP", + "RSVP_OFF": "Solo coincidencias", + "RSVP_INCLUDE": "Coincidencias + actualizaciones RSVP", + "RSVP_ONLY": "Solo actualizaciones RSVP", + "RSVP_OFF_DESC": "Solo alertas estándar de raids/huevos.", + "RSVP_INCLUDE_DESC": "También volver a notificar cuando cambien los recuentos de RSVP.", + "RSVP_ONLY_DESC": "Omitir coincidencias iniciales; solo notificar cambios de RSVP. Sin un escáner que emita RSVP esta alarma queda silenciada.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Solo RSVP", "PAGE_TITLE": "Alarmas de Raid y Huevo", "PAGE_DESC": "Recibe notificaciones sobre jefes de raid y eclosiones de huevos en gimnasios cercanos.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "¿Seguro que quieres eliminar TODAS las alarmas de raid y huevo? Esta acción no se puede deshacer.", "CONFIRM_BULK_DELETE_TITLE": "Eliminar alarmas seleccionadas", "CONFIRM_BULK_DELETE_MSG": "¿Seguro que quieres eliminar {{count}} alarmas?", - "CONFIRM_DELETE_SELECTED": "Eliminar seleccionadas" + "CONFIRM_DELETE_SELECTED": "Eliminar seleccionadas", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Nivel", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Estándar", + "SECTION_SPECIAL": "Especiales", + "SECTION_CUSTOM": "Personalizados", + "ADD": "Añadir nivel", + "ADD_PLACEHOLDER": "p. ej. 42", + "ADD_HELP": "Cualquier entero positivo que use tu servidor. 9000 significa «cualquier nivel».", + "INVALID": "El nivel debe ser 1 o superior.", + "DUPLICATE": "El nivel {{value}} ya está en la lista.", + "SR_REMOVE": "Eliminar el nivel personalizado {{value}}", + "REMOVED": "Nivel {{value}} eliminado", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmas de Misión", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Todas las alarmas de misión eliminadas", "SNACK_FAILED_DELETE_ALL": "Error al eliminar las alarmas", "SNACK_FAILED_DISTANCE": "Error al actualizar las distancias", - "CONFIRM_DELETE_SELECTED": "Eliminar seleccionadas" + "CONFIRM_DELETE_SELECTED": "Eliminar seleccionadas", + "SUMMARY_MODE": "Resumen diario", + "SUMMARY_HINT": "Agrupa las misiones coincidentes en un único mensaje de resumen en lugar de una notificación por cada una. Requiere una programación de resumen configurada en el bot.", + "SUMMARY_BADGE": "Resumen", + "SUMMARY_SCHEDULE": "Entrega del resumen de misiones", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Resumen de misiones", + "SUMMARY_SCHEDULE_EMPTY": "No hay ninguna programación de resumen. Las misiones se entregan de forma individual.", + "SUMMARY_SCHEDULE_EDIT": "Editar programación", + "SUMMARY_SCHEDULE_CLEAR": "Quitar programación", + "SUMMARY_SCHEDULE_SEND_NOW": "Enviar resumen ahora", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Envía las coincidencias de misiones acumuladas desde tu último resumen. Si aún no hay nada en búfer, no se envía nada.", + "SUMMARY_SCHEDULE_SAVED": "Programación de resumen guardada", + "SUMMARY_SCHEDULE_CLEARED": "Programación de resumen eliminada", + "SUMMARY_SCHEDULE_SENT": "Resumen enviado", + "SUMMARY_SCHEDULE_FAILED": "No se pudo actualizar la programación del resumen", + "SUMMARY_SCHEDULE_UNAVAILABLE": "La entrega del resumen no está disponible temporalmente. Inténtalo de nuevo más tarde.", + "SUMMARY_DISABLED_HINT": "La programación de resúmenes no está disponible en este servidor.", + "TAB_STARDUST": "Polvo Estelar", + "MIN_AMOUNT": "Cantidad mínima", + "MIN_AMOUNT_HINT": "0 = cualquier cantidad", + "MIN_STARDUST": "Polvo estelar mínimo", + "MIN_STARDUST_HINT": "0 = cualquier tarea de polvo estelar", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Alarmas de Invasión", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnético", "TYPE_RAINY": "Lluvioso", "TYPE_GOLDEN": "Dorado", - "TYPE_UNKNOWN": "Señuelo #{{id}}" + "TYPE_UNKNOWN": "Señuelo #{{id}}", + "EDIT_MODE": "Editar el mensaje en su lugar", + "EDIT_HINT": "Actualiza el mensaje de Discord existente cuando cambia el cebo en lugar de enviar uno nuevo.", + "EDIT_BADGE": "Editar", + "CONFIRM_DELETE_TITLE": "¿Eliminar alarma de cebo?", + "SNACK_FAILED_DISTANCE": "No se pudo actualizar la distancia." }, "NESTS": { "PAGE_TITLE": "Alarmas de Nido", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Alarma de Nido eliminada", "SNACK_FAILED_CREATE": "Error al crear la alarma", "SNACK_FAILED_UPDATE": "Error al actualizar la alarma", - "SNACK_FAILED_DELETE": "Error al eliminar la alarma" + "SNACK_FAILED_DELETE": "Error al eliminar la alarma", + "CONFIRM_DELETE_TITLE": "¿Eliminar alarma de nido?", + "SNACK_FAILED_DISTANCE": "No se pudo actualizar la distancia." }, "GYMS": { "PAGE_TITLE": "Alarmas de Gimnasio", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Sabiduría", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinto", - "TEAM_UNKNOWN": "Equipo {{id}}" + "TEAM_UNKNOWN": "Equipo {{id}}", + "CONFIRM_DELETE_TITLE": "¿Eliminar alarma de gimnasio?", + "SNACK_FAILED_DISTANCE": "No se pudo actualizar la distancia." }, "FORT_CHANGES": { "PAGE_TITLE": "Alarmas de Cambio de Fort", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Eliminado", "CHANGE_NEW": "Nuevo fort", "INCLUDE_EMPTY": "Incluir forts sin nombre", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "No se pudo crear la alerta", + "CREATE_SUCCESS": "Alerta de cambios de gimnasio creada", + "UPDATE_FAILED": "No se pudo actualizar la alerta", + "UPDATE_SUCCESS": "Alerta de cambios de gimnasio actualizada", "ALL_CHANGES": "Todos los cambios", "LABEL_NAME": "Nombre", "LABEL_LOCATION": "Ubicación", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "¿Eliminar la alarma de cambio {{type}}?", "SNACK_DELETED": "Alarma de cambio eliminada", "SNACK_FAILED_DISTANCE": "Error al actualizar las distancias", - "SNACK_ALL_DISTANCE": "Todas las distancias actualizadas" + "SNACK_ALL_DISTANCE": "Todas las distancias actualizadas", + "FORT_TYPE_LABEL": "Tipo de fort", + "CHANGE_TYPES_LABEL": "Tipos de cambio", + "TRACKING_SUBTITLE": "Seguimiento de cambios de fort", + "CHANGE_DESCRIPTION": "Descripción cambiada" }, "MAX_BATTLES": { "PAGE_TITLE": "Alarmas de Combate Max", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "No se pudieron crear las alertas", + "CREATE_SUCCESS": "{{count}} alerta(s) creada(s)", "ANY_POKEMON": "Cualquier Pokémon", "ANY_LEVEL": "Cualquier nivel", "STAR_LABEL": "{{stars}} estrellas", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Error al actualizar las distancias", "SNACK_ALL_DISTANCE": "Todas las distancias actualizadas", "SNACK_FAILED_UPDATE": "Error al actualizar la alarma", - "SNACK_UPDATED": "Alarma de Combate Dinamax actualizada" + "SNACK_UPDATED": "Alarma de Combate Dinamax actualizada", + "HINT_BY_LEVEL": "Sigue cualquier Pokemon en estos niveles de combate. Cada nivel que elijas crea su propia alarma.", + "HINT_BY_POKEMON": "Sigue Pokemon concretos en Combates Max, sea cual sea el nivel.", + "HINT_GMAX_ONLY_ADD": "Solo avisa de combates Gigantamax de los Pokemon elegidos.", + "HINT_GMAX_ONLY_EDIT": "Solo avisa de combates Gigantamax de este Pokemon.", + "HINT_ALL_LEVELS": "Esta alarma sigue a un Pokemon en todos los niveles de Combate Max.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Zonas y ubicación", + "MANAGE_PLACES": "Gestionar lugares", + "PAGE_TITLE": "Áreas y lugares", "PAGE_DESC": "Controla dónde recibes las notificaciones.", "METHOD_AREAS": "Zonas", "METHOD_AREAS_ACTIVE": "{{count}} zona(s) activa(s)", "METHOD_NOT_CONFIGURED": "No configurado", "METHOD_AREAS_DESC": "Recibe notificaciones de todo lo que ocurra dentro de tus zonas geofencadas seleccionadas.", "METHOD_AREAS_TIP": "Ideal para: cubrir ciudades enteras, barrios o parques", - "METHOD_LOCATION": "Ubicación", - "METHOD_LOCATION_NOT_SET": "No establecida", - "METHOD_LOCATION_DESC": "Recibe notificaciones de todo dentro de una distancia definida desde tu ubicación.", + "METHOD_LOCATION": "Mi ubicación", + "METHOD_LOCATION_NOT_SET": "Sin ubicación definida", + "METHOD_LOCATION_DESC": "Recibe avisos de todo lo que esté a una distancia definida de tu ubicación.", "METHOD_LOCATION_TIP": "Ideal para: alertas cerca de casa, el trabajo o un lugar específico", "CLEAR_LOCATION": "Borrar", "CHANGE_LOCATION": "Cambiar", "SET_LOCATION": "Establecer", "METHOD_NOTE": "Cada alarma elige un método en su pestaña de Entrega.", + "NOTIFICATION_LANGUAGE": "Idioma de notificaciones", + "NOTIFICATION_LANGUAGE_DESC": "El idioma que Poracle usa para tus mensajes de alerta y los nombres de Pokémon. Es distinto del idioma de la interfaz en el menú superior.", + "SNACK_LANGUAGE_UPDATED": "Idioma de notificaciones actualizado", + "SNACK_LANGUAGE_FAILED": "No se pudo actualizar el idioma de notificaciones", "SELECT_AREAS": "Seleccionar zonas", "MAP_VIEW": "Mapa", "LIST_VIEW": "Lista", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Error al actualizar la ubicación", "SEARCH_AREAS": "Buscar zonas", "MANUAL_ADD_PLACEHOLDER": "Escribe un nombre de área y pulsa Intro", - "FILTER_PLACEHOLDER": "Filtrar por nombre..." + "FILTER_PLACEHOLDER": "Filtrar por nombre...", + "SNACK_LOAD_SELECTED_FAILED": "No se pudieron cargar tus áreas actuales. Recarga antes de cambiarlas.", + "SELECTION_UNKNOWN": "No se pudieron cargar tus áreas actuales: recarga la página antes de guardar." }, "PROFILES": { "PAGE_TITLE": "Perfiles", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Seleccionar región", "SEARCH_REGIONS": "Buscar regiones...", "TOGGLE_TOOLTIP": "Activar/desactivar notificaciones para este geocerca en el perfil actual", - "CREATED_PREFIX": "Creado" + "CREATED_PREFIX": "Creado", + "REGION_OPTIONAL_HINT": "Opcional. Elige una región si tu geocerca pertenece a una." }, "CLEANING": { "PAGE_TITLE": "Modo limpieza", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Parte del contenido de ayuda puede no estar disponible en tu idioma todavía.", "TRANSLATION_CTA_LINK": "Ayudar a traducir", "FALLBACK_CHIP": "Inglés", + "IMAGE_ENLARGE": "Haz clic para ampliar", "SECTION_GETTING_STARTED": "Primeros pasos", "SECTION_GETTING_STARTED_SUB": "Inicio de sesión, asistente de configuración inicial", "SECTION_DASHBOARD": "Panel", "SECTION_DASHBOARD_SUB": "Tu resumen de alarmas, zonas y estado", "SECTION_LOCATION": "Configurar tu ubicación", "SECTION_LOCATION_SUB": "GPS, búsqueda de dirección y coordenadas", - "SECTION_AREAS": "Elegir tus zonas", - "SECTION_AREAS_SUB": "Vista de mapa, vista de lista y filtro por región", + "SECTION_AREAS": "Áreas y lugares", + "SECTION_AREAS_SUB": "Vista de mapa, vista de lista, filtro por región y lugares", "SECTION_GEOFENCES": "Geofences personalizadas", "SECTION_GEOFENCES_SUB": "Dibujar límites, enviar para aprobación pública", "SECTION_POKEMON": "Alarmas Pokemon", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Otros tipos de alarma", "SECTION_OTHER_ALARMS_SUB": "Raids, huevos, misiones, rockets, señuelos, nidos, gimnasios, cambios de fort", "SECTION_DELIVERY": "Ajustes de entrega", - "SECTION_DELIVERY_SUB": "Zonas vs distancia, plantillas y modo limpieza", + "SECTION_DELIVERY_SUB": "Alcance de entrega, plantillas y modo limpieza", + "SECTION_QUEST_SUMMARY": "Entrega del resumen de misiones", + "SECTION_QUEST_SUMMARY_SUB": "Agrupa las misiones ruidosas en un único resumen programado", "SECTION_TEST_ALERTS": "Alertas de prueba", "SECTION_TEST_ALERTS_SUB": "Enviar notificaciones de muestra para previsualizar tus alarmas", "SECTION_POKEMON_AVAILABILITY": "Disponibilidad de Pokemon", @@ -1050,23 +1177,24 @@ "SECTION_ALERTS_LOGOUT_SUB": "Pausar notificaciones y cerrar sesión", "SECTION_FAQ": "Preguntas frecuentes", "SECTION_FAQ_SUB": "Problemas comunes y cómo solucionarlos", - "CONTENT_GETTING_STARTED": "

El sitio de Alertas DM te permite personalizar exactamente qué notificaciones de Pokemon GO recibes como mensajes directos. En lugar de recibir cada alerta, tú eliges lo que te importa — Pokemon específicos, raids, misiones y más — y solo recibes notificaciones sobre eso.

ℹ️
Antes de poder usar el sitio, necesitas registrarte con el bot Poracle en Discord o Telegram primero. Una vez registrado, vuelve aquí e inicia sesión.

Iniciar sesión

  • Discord — Haz clic en \\\"Iniciar sesión con Discord\\\" en la página de inicio de sesión. Serás redirigido a Discord para autorizar la aplicación y luego redirigido de vuelta automáticamente.
  • Telegram — Si está habilitado, usa el widget de inicio de sesión de Telegram en la página de inicio de sesión. Confirma el inicio de sesión en tu app de Telegram.
\"Página

Configuración inicial

Cuando inicias sesión por primera vez, un asistente de bienvenida te guía a través de tres pasos:

  1. Establece tu ubicación — Se usa para calcular distancias para notificaciones cercanas.
  2. Elige tus áreas — Selecciona las zonas geográficas de las que quieres recibir alertas.
  3. Añade tu primera alarma — Crea una alarma de Pokemon, Raid o Misión para empezar a recibir notificaciones.
\"Asistente

Puedes saltar cualquier paso y volver más tarde. El asistente no aparecerá de nuevo una vez que lo cierres o completes todos los pasos.

", + "CONTENT_GETTING_STARTED": "

El sitio de Alertas DM te permite personalizar exactamente qué notificaciones de Pokemon GO recibes como mensajes directos. En lugar de recibir cada alerta, tú eliges lo que te importa — Pokemon específicos, raids, misiones y más — y solo recibes notificaciones sobre eso.

ℹ️
Antes de poder usar el sitio, necesitas registrarte con el bot Poracle en Discord o Telegram primero. Una vez registrado, vuelve aquí e inicia sesión.

Iniciar sesión

  • Discord — Haz clic en \"Iniciar sesión con Discord\" en la página de inicio de sesión. Serás redirigido a Discord para autorizar la aplicación y luego redirigido de vuelta automáticamente.
  • Telegram — Si está habilitado, usa el widget de inicio de sesión de Telegram en la página de inicio de sesión. Confirma el inicio de sesión en tu app de Telegram.
\"Página

Configuración inicial

Cuando inicias sesión por primera vez, un asistente de bienvenida te guía a través de tres pasos:

  1. Establece tu ubicación — Se usa para calcular distancias para notificaciones cercanas.
  2. Elige tus áreas — Selecciona las zonas geográficas de las que quieres recibir alertas.
  3. Añade tu primera alarma — Crea una alarma de Pokemon, Raid o Misión para empezar a recibir notificaciones.
\"Asistente

Puedes saltar cualquier paso y volver más tarde. El asistente no aparecerá de nuevo una vez que lo cierres o completes todos los pasos.

", "CONTENT_DASHBOARD": "\"Panel

El Panel es tu base de operaciones. Muestra un resumen de tu configuración actual de un vistazo.

Tarjetas de estado

  • Ubicación — Muestra tus coordenadas o dirección guardadas. Haz clic para establecer o actualizar tu ubicación.
  • Áreas activas — Muestra cuántas áreas estás rastreando. Haz clic para gestionar tus áreas.
  • Perfil — Muestra tu perfil activo. Si tienes múltiples perfiles, haz clic para cambiar entre ellos.

Filtros activos

Una cuadrícula de tarjetas muestra cuántas alarmas tienes para cada tipo (Pokemon, Raids, Misiones, etc.). Haz clic en cualquier tarjeta para ir a esa lista de alarmas.

Clima

Si tienes una ubicación establecida, el panel muestra el clima actual del juego en tus coordenadas junto con la hora de la última actualización. El clima del área también se muestra para cada una de tus áreas seleccionadas, para que puedas ver las condiciones climáticas en todas las zonas que rastreas.

Acciones rápidas

Botones de acceso rápido para añadir alarmas de Pokemon, Raid o Misión, gestionar áreas o configurar la limpieza — todo sin navegar por la barra lateral.

Consejos

Aparecen recordatorios útiles cuando tu configuración está incompleta — como ubicación faltante, sin áreas seleccionadas o sin alarmas configuradas. Cada consejo tiene un botón de acción para solucionarlo. Puedes descartar los consejos que no necesites.

Navegación

Usa la barra lateral para navegar entre secciones. Los tipos de alarma están listados arriba, seguidos de ajustes como Áreas, Geofences, Perfiles y Limpieza. La Ayuda está siempre al final.

\"Barra", - "CONTENT_LOCATION": "\"Panel

Tu ubicación se usa para notificaciones basadas en distancia. Cuando una alarma usa el modo \\\"Establecer distancia\\\", recibirás notificaciones sobre eventos dentro de un radio de esta ubicación.

Establecer tu ubicación

Abre el diálogo de ubicación desde el Panel o la página de Áreas. Tienes cuatro formas de establecerla:

  • Buscar por dirección — Escribe una dirección, ciudad o nombre de lugar. Selecciona de las sugerencias que aparecen.
  • Introducir coordenadas — Escribe la latitud y longitud directamente si las conoces.
  • Usar tu GPS — Haz clic en \\\"Usar mi ubicación\\\" para usar la ubicación actual de tu dispositivo. Tu navegador pedirá permiso.
  • Clic en el mapa — Haz clic en cualquier lugar del mini-mapa para establecer ese punto como tu ubicación.

Después de seleccionar una ubicación, la dirección se muestra automáticamente. Haz clic en Guardar para confirmar.

💡
Puedes borrar tu ubicación desde la página de Áreas si solo quieres alertas basadas en áreas.
", - "CONTENT_AREAS": "\"Página

Las áreas son zonas geográficas predefinidas configuradas por tu comunidad. Cuando una alarma usa el modo \\\"Usar áreas\\\", recibes notificaciones sobre eventos que ocurren dentro de tus áreas seleccionadas.

Seleccionar áreas

Ve a Áreas y Ubicación desde la barra lateral. Puedes seleccionar áreas de dos formas:

  • Vista de mapa — Haz clic en los polígonos coloreados del mapa para seleccionar o deseleccionar áreas. Las áreas seleccionadas se vuelven verdes. Pasa el ratón sobre cualquier área para ver su nombre.
  • Vista de lista — Usa casillas de verificación para elegir áreas de una lista con búsqueda.

Filtro por región

Si tu comunidad tiene muchas áreas en diferentes regiones, usa el desplegable de región para acercarte a una región específica. Esto facilita encontrar áreas cerca de ti.

Áreas anidadas

Algunas áreas se superponen — una zona más pequeña dentro de una más grande. Ambas son clicables. Acércate con el zoom para hacer más fácil clic en el área más pequeña.

Guardar

Una barra de guardado aparece en la parte inferior cuando has hecho cambios. Haz clic en Guardar para confirmar tus selecciones, o Cancelar para revertir.

ℹ️
Las áreas son por perfil. Cada perfil tiene su propio conjunto de áreas seleccionadas. Cambiar de perfil mostrará diferentes selecciones de áreas. Los geofences personalizados también pueden activarse o desactivarse por perfil desde la página de Geofences.
", - "CONTENT_GEOFENCES": "\"Página

Si las áreas predefinidas no cubren donde quieres alertas, puedes dibujar tus propios límites de geofence personalizados en el mapa.

Dibujar un Geofence

  1. Ve a Mis Geofences desde la barra lateral.
  2. Haz clic en Dibujar Geofence.
  3. Haz clic en el mapa para colocar puntos del límite de tu polígono. Haz clic en el primer punto de nuevo para cerrar la forma (mínimo 3 puntos).
  4. Dale un nombre a tu geofence y selecciona a qué región pertenece. La región normalmente se detecta automáticamente.
  5. Haz clic en Guardar.

Gestionar Geofences

  • Editar — Renombra tu geofence o cambia su región.
  • Eliminar — Elimina un geofence que ya no necesitas. El geofence se elimina de todos los perfiles automáticamente.

Interruptor de perfil

Cada tarjeta de geofence tiene un interruptor deslizante para activar o desactivar para tu perfil actual. Cuando creas un geofence, se activa automáticamente en el perfil que estás usando. Cambia a otro perfil y el interruptor mostrará \\\"Inactivo\\\" — actívalo para recibir alertas de ese geofence en ese perfil también. Esto te permite controlar qué perfiles reciben notificaciones para cada geofence sin recrearlo.

ℹ️
Los geofences aprobados (promovidos a áreas públicas) no muestran el interruptor — gestiónales desde la página de Áreas en su lugar.

GeoJSON Import & Export

Puedes importar y exportar geofences usando el formato estándar GeoJSON, facilitando compartir límites o crearlos en herramientas externas como geojson.io.

  • Importar — Haz clic en el icono de subida y pega o sube un archivo GeoJSON. Cada polígono en el archivo se convierte en un nuevo geofence. Puedes revisar y renombrar cada uno antes de guardar.
  • Exportar — Haz clic en el icono de descarga y selecciona qué geofences incluir. El archivo GeoJSON exportado contiene todos los polígonos seleccionados y puede abrirse en cualquier herramienta GIS o editor de mapas.
💡
La importación GeoJSON es útil para migrar geofences de otros sistemas o dibujar límites complejos en una herramienta GIS de escritorio y luego importarlos aquí.

Enviar para aprobación pública

Si crees que tu geofence sería útil para toda la comunidad, puedes enviarlo para revisión de administradores. Si se aprueba, se convierte en un área pública que todos pueden seleccionar. Tu geofence privado sigue funcionando mientras la revisión está pendiente.

Insignias de estado

  • Activo — Tu geofence privado, funcionando solo para ti.
  • Revisión pendiente — Enviado y esperando revisión del administrador.
  • Aprobado — Promovido a área pública.
  • Rechazado — No aprobado. Puedes ver los comentarios del administrador y el geofence sigue activo como zona privada.
ℹ️
Puedes tener hasta 10 geofences personalizados, cada uno con hasta 500 puntos de límite.
", - "CONTENT_POKEMON": "\"Página

Las alarmas de Pokemon te notifican cuando aparece un Pokemon salvaje que coincide con tus filtros.

Añadir una alarma de Pokemon

\"Diálogo
  1. Ve a Pokemon desde la barra lateral y haz clic en el botón +.
  2. Seleccionar Pokemon — Busca por nombre o número de Pokedex, o usa los botones de filtro de generación y tipo para explorar. Puedes seleccionar múltiples Pokemon a la vez.
  3. Establecer filtros — Elige qué hace que una aparición valga la pena notificar:
  • Rango de IV — Porcentaje mínimo y máximo de IV (0-100%)
  • Rango de CP — Filtrar por poder de combate
  • Rango de nivel — Filtrar por nivel de Pokemon (0-55)
  • Estadísticas individuales — Filtrar por valores de ATK, DEF y STA (0-15 cada uno)
  • Forma — Rastrear formas específicas (ej. Alolan, Galarian) o todas las formas
  • Género — Macho, hembra, sin género, o todos
  • Peso — Filtrar por rango de peso
  • Tamaño — Filtrar por categoría de tamaño: selecciona TODO (sin filtro) para cualquier tamaño, o elige tamaños específicos de XXS a XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Los valores de filtro por defecto están configurados para que todos los Pokemon coincidan cuando no se configuran filtros explícitamente. Por ejemplo, IV por defecto es 0-100%, nivel 0-55 y tamaño TODO. Solo necesitas ajustar los filtros que te importen.

Filtros PVP

Recibe notificaciones cuando un Pokemon tiene buenos IVs para PVP. Selecciona una liga (Grande, Ultra o Copa Pequeña) y establece el rango de clasificación que te interesa (ej. rango 1-50).

Alarma \\\"Todos los Pokemon\\\"

💡
Selecciona \\\"Todos los Pokemon\\\" (ID 0) para crear una alarma que cubra todas las especies. Útil con un filtro de IV alto como 96-100% para captar cualquier aparición valiosa.

Leer las tarjetas de alarma

Cada tarjeta de alarma muestra píldoras coloreadas que resumen tus filtros de un vistazo:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Página

Alarmas de Raid y Huevo

Recibe notificaciones cuando aparece un jefe de raid o huevo que te interesa.

  • Por nivel — Selecciona niveles de raid (1-6) o niveles de huevo para rastrear todos los raids de ese nivel.
  • Por jefe — Selecciona jefes de raid Pokemon específicos que quieras cazar.
  • Filtro de equipo — Solo notificar para raids en gimnasios controlados por un equipo específico (Mystic, Valor, Instinct).
  • Rastreo de gimnasio — Rastrea raids en gimnasios específicos por nombre para que solo recibas notificaciones de tus gimnasios favoritos.
  • Filtro de movimientos — Filtra jefes de raid por sus movimientos rápidos o cargados.
  • Notificaciones RSVP — Recibe notificaciones cuando otros entrenadores confirman asistencia a un raid o huevo que estás rastreando.

Las alarmas de Raid y Huevo se gestionan en pestañas separadas dentro de la página de Raids. Los Huevos también admiten rastreo específico de gimnasio y notificaciones RSVP.

Alarmas de Max Batalla (Dynamax)

Recibe notificaciones sobre batallas Dynamax y Gigantamax en Puntos de Poder.

  • Por nivel — Selecciona niveles de batalla para rastrear cualquier Pokemon en esos niveles. Los niveles van de 1 Estrella a 5 Estrellas (Legendario) para Dynamax, más Gigantamax y Gigantamax Legendario para las batallas más grandes. Se crea una alarma por cada nivel seleccionado.
  • Por Pokemon — Selecciona Pokemon específicos contra los que quieras luchar en todos los niveles de Max Batalla. Si la base de datos del escáner está configurada, el selector se filtra para mostrar solo Pokemon que han aparecido en Max Batallas.
  • Solo Gigantamax — Al rastrear por Pokemon, activa esto para solo recibir notificaciones cuando ese Pokemon aparezca en batallas Gigantamax (las batallas de mayor nivel con movimientos G-Max únicos). Para rastreo por nivel, Gigantamax se maneja seleccionando los niveles Gigantamax o Gigantamax Legendario directamente.
  • Seleccionar todo — Selecciona rápidamente todos los niveles disponibles a la vez (equivalente al comando !maxbattle everything del bot).

Alarmas de Misiones

Recibe notificaciones sobre tareas de investigación de campo con recompensas específicas.

  • Encuentros Pokemon — Selecciona Pokemon que quieras como recompensas de misiones.
  • Objetos — Rastrea misiones que recompensan objetos específicos.
  • Mega Energía — Rastrea misiones que dan mega energía para Pokemon específicos.
  • Caramelos — Rastrea misiones que recompensan caramelos para Pokemon específicos.

Alarmas de Invasión

Recibe notificaciones sobre invasiones de Team Rocket.

  • Rastrear todo — Una alarma para cada tipo de recluta y líder.
  • Por tipo — Selecciona tipos específicos de reclutas (Bicho, Dragón, Fuego, etc.), Líderes Rocket o Giovanni. Los nombres de tipo de recluta se normalizan automáticamente (sin distinción de mayúsculas), así que no necesitas preocuparte por la capitalización exacta.
  • Género — Filtrar por género del recluta.

Alarmas de Señuelo

Recibe notificaciones cuando se coloca un tipo específico de señuelo. Elige entre Normal, Glacial, Musgo, Magnético, Lluvioso y Dorado.

Alarmas de Nidos

Rastrea especies de Pokemon que anidan. Establece un umbral de apariciones mínimas por hora para que solo recibas notificaciones de nidos con suficiente actividad.

Alarmas de Gimnasio

Rastrea cambios de equipo en gimnasios. Selecciona qué equipos monitorear (Neutral, Mystic, Valor, Instinct). Activa el rastreo de Cambios de plaza para recibir notificaciones cuando se abren plazas en el gimnasio, o activa el rastreo de Cambios de batalla para recibir notificaciones cuando un gimnasio está siendo atacado.

Alarmas de Cambios de Fort

Rastrea cambios en PokéStops y gimnasios en sí — no las actividades en ellos, sino cambios en los propios puntos de interés.

  • Tipo de fort — Elige rastrear PokéStops, Gimnasios, o Todo.
  • Tipos de cambio — Selecciona qué cambios monitorear: Nombre cambiado, Ubicación cambiada, Imagen cambiada, Eliminación, o Nuevo fort añadido.
  • Incluir vacíos — Incluir forts que no tienen nombre establecido.
💡
Las alarmas de cambios de fort son útiles para rastrear actualizaciones de la base de datos del mapa — nuevos PokéStops apareciendo, gimnasios siendo reubicados, o POIs siendo eliminados del juego.

Seleccionar un gimnasio específico

Al crear o editar una alarma de Raid, Huevo o Gimnasio, puedes opcionalmente buscar y seleccionar un gimnasio específico. Esto es útil cuando solo te importa la actividad en tu gimnasio favorito — como el de tu ruta del almuerzo o cerca de tu casa.

  • Cómo usarlo — En el diálogo de añadir o editar, escribe un nombre de gimnasio en el campo de búsqueda de gimnasio. Los resultados muestran la foto, nombre y área del gimnasio para que puedas identificar el correcto.
  • Cuando se selecciona un gimnasio — La alarma solo se activa para eventos en ese gimnasio específico. El nombre del gimnasio aparece en la tarjeta de alarma en tu lista para que puedas ver qué gimnasio rastrea de un vistazo.
  • Cuando no se selecciona ningún gimnasio — Es el valor por defecto. La alarma funciona normalmente para todos los gimnasios en tus áreas seleccionadas o dentro de tu radio de distancia.
💡
Puedes combinar una alarma específica de gimnasio con una alarma más amplia. Por ejemplo, crea una alarma de raid para tu gimnasio local para todos los niveles, y una segunda alarma para raids de nivel 5 en todas tus áreas.
", - "CONTENT_DELIVERY": "\"Tarjetas

Cada alarma tiene ajustes de entrega que controlan dónde recibes notificaciones.

Áreas vs Distancia

Cada alarma usa uno de dos modos de entrega:

🗺
Usar áreasNotificación cuando los eventos ocurren dentro de tus áreas seleccionadas. Bueno para rastrear vecindarios específicos.
📏
Establecer distanciaNotificación dentro de un radio (km) de tu ubicación guardada. Bueno para rastrear todo cerca de ti.

Puedes usar diferentes modos para diferentes alarmas — por ejemplo, usar áreas para Pokemon y distancia para raids.

Plantillas de notificación

Si las plantillas están habilitadas, puedes elegir cómo se ven tus mensajes de notificación. El selector de plantillas muestra una vista previa en vivo de cómo se verá tu DM de Discord, incluyendo el formato del embed, campos e imágenes.

Modo limpieza

Cuando está activado, el bot elimina automáticamente la notificación de Discord después de que el evento expire (ej. un Pokemon desaparece o un raid termina). Esto mantiene tus DMs ordenados. Puedes activar el modo limpieza por alarma o en masa desde la página de Limpieza.

Ping / Menciones de rol

Si usas webhooks, puedes establecer un rol de Discord para mencionar en la notificación (ej. @Pokemon). Esto solo es relevante para configuraciones de webhook.

", + "CONTENT_LOCATION": "\"Panel

Tu ubicación es el punto desde el que se miden tus alertas. Una alarma que te alcanza dentro de un radio usa esa ubicación, salvo que apuntes esa alarma a un lugar guardado.

Establecer tu ubicación

Abre el diálogo de ubicación desde el Panel o la página Áreas y lugares. Tienes cuatro formas de establecerla:

  • Buscar por dirección — Escribe una dirección, ciudad o nombre de lugar. Selecciona de las sugerencias que aparecen.
  • Introducir coordenadas — Escribe la latitud y longitud directamente si las conoces.
  • Usar tu GPS — Haz clic en \"Usar mi ubicación\" para usar la ubicación actual de tu dispositivo. Tu navegador pedirá permiso.
  • Clic en el mapa — Haz clic en cualquier lugar del mini-mapa para establecer ese punto como tu ubicación.

Después de elegir un punto, la dirección se muestra automáticamente. Haz clic en Guardar para confirmar.

El mismo diálogo se reutiliza cuando añades un lugar o eliges un punto para una sola alarma. Entonces se titula Elige un punto y se confirma con Usar este punto, sin tocar tu propia ubicación.

💡
Puedes borrar tu ubicación desde la página Áreas y lugares si solo quieres alertas basadas en áreas.
", + "CONTENT_AREAS": "\"Página

Las áreas son zonas geográficas predefinidas configuradas por tu comunidad. Las que elijas aquí son las que sigue cada alarma por defecto: una alarma puesta en En cualquier parte de mis áreas se dispara con los eventos que ocurren dentro.

Seleccionar áreas

Ve a Áreas y lugares desde la barra lateral. Puedes seleccionar áreas de dos formas:

  • Vista de mapa — Haz clic en los polígonos coloreados del mapa para seleccionar o deseleccionar áreas. Las áreas seleccionadas se vuelven verdes. Pasa el ratón sobre cualquier área para ver su nombre.
  • Vista de lista — Usa casillas de verificación para elegir áreas de una lista con búsqueda.

Lugares

Un lugar es un punto con nombre — el trabajo, el gimnasio, la casa de tus padres — desde el que una alarma puede medir su radio en vez de hacerlo desde tu ubicación. Añádelo en la sección Lugares de la misma página y luego elígelo en Medido desde al decidir dónde quieres recibir una alarma. Un lugar no se puede borrar mientras haya alarmas apuntando a él, y el mensaje dice cuántas.

Filtro por región

Si tu comunidad tiene muchas áreas en diferentes regiones, usa el desplegable de región para acercarte a una región específica. Esto facilita encontrar áreas cerca de ti.

Áreas anidadas

Algunas áreas se superponen — una zona más pequeña dentro de una más grande. Ambas son clicables. Acércate con el zoom para hacer más fácil clic en el área más pequeña.

Guardar

Una barra de guardado aparece en la parte inferior cuando has hecho cambios. Haz clic en Guardar para confirmar tus selecciones, o Cancelar para revertir.

ℹ️
Las áreas son por perfil. Cada perfil tiene su propio conjunto de áreas seleccionadas. Cambiar de perfil mostrará diferentes selecciones de áreas. Los geofences personalizados también pueden activarse o desactivarse por perfil desde la página de Geofences.
", + "CONTENT_GEOFENCES": "\"Página

Si las áreas predefinidas no cubren donde quieres alertas, puedes dibujar tus propios límites de geofence personalizados en el mapa.

Dibujar un Geofence

  1. Ve a Mis Geofences desde la barra lateral.
  2. Haz clic en Dibujar Geofence.
  3. Haz clic en el mapa para colocar puntos del límite de tu polígono. Haz clic en el primer punto de nuevo para cerrar la forma (mínimo 3 puntos).
  4. Dale un nombre a tu geofence y selecciona a qué región pertenece. La región normalmente se detecta automáticamente.
  5. Haz clic en Guardar.

Gestionar Geofences

  • Editar — Renombra tu geofence o cambia su región.
  • Eliminar — Elimina un geofence que ya no necesitas. El geofence se elimina de todos los perfiles automáticamente.

Interruptor de perfil

Cada tarjeta de geofence tiene un interruptor deslizante para activar o desactivar para tu perfil actual. Cuando creas un geofence, se activa automáticamente en el perfil que estás usando. Cambia a otro perfil y el interruptor mostrará \"Inactivo\" — actívalo para recibir alertas de ese geofence en ese perfil también. Esto te permite controlar qué perfiles reciben notificaciones para cada geofence sin recrearlo.

ℹ️
Los geofences aprobados (promovidos a áreas públicas) no muestran el interruptor — gestiónales desde la página de Áreas en su lugar.

Usar un geofence en una sola alarma

Un geofence que hayas dibujado también aparece en la lista Solo en áreas concretas cuando decides dónde debe alcanzarte una alarma concreta, marcado con un icono de dibujo. Eso limita una alarma a él sin activar el geofence para todo el perfil.

GeoJSON Import & Export

Puedes importar y exportar geofences usando el formato estándar GeoJSON, facilitando compartir límites o crearlos en herramientas externas como geojson.io.

  • Importar — Haz clic en el icono de subida y pega o sube un archivo GeoJSON. Cada polígono en el archivo se convierte en un nuevo geofence. Puedes revisar y renombrar cada uno antes de guardar.
  • Exportar — Haz clic en el icono de descarga y selecciona qué geofences incluir. El archivo GeoJSON exportado contiene todos los polígonos seleccionados y puede abrirse en cualquier herramienta GIS o editor de mapas.
💡
La importación GeoJSON es útil para migrar geofences de otros sistemas o dibujar límites complejos en una herramienta GIS de escritorio y luego importarlos aquí.

Enviar para aprobación pública

Si crees que tu geofence sería útil para toda la comunidad, puedes enviarlo para revisión de administradores. Si se aprueba, se convierte en un área pública que todos pueden seleccionar. Tu geofence privado sigue funcionando mientras la revisión está pendiente.

Insignias de estado

  • Activo — Tu geofence privado, funcionando solo para ti.
  • Revisión pendiente — Enviado y esperando revisión del administrador.
  • Aprobado — Promovido a área pública.
  • Rechazado — No aprobado. Puedes ver los comentarios del administrador y el geofence sigue activo como zona privada.
ℹ️
Puedes tener hasta 10 geofences personalizados, cada uno con hasta 500 puntos de límite.
", + "CONTENT_POKEMON": "\"Página

Las alarmas de Pokemon te notifican cuando aparece un Pokemon salvaje que coincide con tus filtros.

Añadir una alarma de Pokemon

\"Diálogo
  1. Ve a Pokemon desde la barra lateral y haz clic en el botón +.
  2. Seleccionar Pokemon — Busca por nombre o número de Pokedex, o usa los botones de filtro de generación y tipo para explorar. Puedes seleccionar múltiples Pokemon a la vez.
  3. Establecer filtros — Elige qué hace que una aparición valga la pena notificar:
  • Rango de IV — Porcentaje mínimo y máximo de IV (0-100%)
  • Rango de CP — Filtrar por poder de combate
  • Rango de nivel — Filtrar por nivel de Pokemon (0-55)
  • Estadísticas individuales — Filtrar por valores de ATK, DEF y STA (0-15 cada uno)
  • Forma — Rastrear formas específicas (ej. Alolan, Galarian) o todas las formas
  • Género — Macho, hembra, sin género, o todos
  • Peso — Filtrar por rango de peso
  • Tamaño — Filtrar por categoría de tamaño: selecciona TODO (sin filtro) para cualquier tamaño, o elige tamaños específicos de XXS a XXL (XXS, XS, Normal, XL, XXL)
  • Tiempo mínimo restante — Descarta las apariciones que se habrán ido antes de que llegues. Se ajusta en Más filtros; la tarjeta muestra entonces una etiqueta como "quedan 10 min"
ℹ️
Los valores de filtro por defecto están configurados para que todos los Pokemon coincidan cuando no se configuran filtros explícitamente. Por ejemplo, IV por defecto es 0-100%, nivel 0-55 y tamaño TODO. Solo necesitas ajustar los filtros que te importen.

Filtros PVP

Recibe notificaciones cuando un Pokemon tiene buenos IVs para PVP. Selecciona una liga (Grande, Ultra o Copa Pequeña) y establece el rango de clasificación que te interesa (ej. rango 1-50).

Los botones Nivel máximo eligen con qué tope se leen las clasificaciones. Déjalo en Todos para usar el valor de la configuración de Poracle de tu comunidad.

Megaevolución decide si la regla clasifica la forma base o una mega: Base, Mega, Mega X o Mega Y. Las megas se clasifican aparte, así que una regla de mega no coincidirá con una aparición en forma base.

Alarma \"Todos los Pokemon\"

💡
Selecciona \"Todos los Pokemon\" (ID 0) para crear una alarma que cubra todas las especies. Útil con un filtro de IV alto como 96-100% para captar cualquier aparición valiosa.

Leer las tarjetas de alarma

Cada tarjeta de alarma muestra píldoras coloreadas que resumen tus filtros de un vistazo:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Página

Alarmas de Raid y Huevo

Recibe notificaciones cuando aparece un jefe de raid o huevo que te interesa.

  • Por nivel — Selecciona niveles de raid (1-6) o niveles de huevo para rastrear todos los raids de ese nivel.
  • Por jefe — Selecciona jefes de raid Pokemon específicos que quieras cazar.
  • Filtro de equipo — Solo notificar para raids en gimnasios controlados por un equipo específico (Mystic, Valor, Instinct).
  • Rastreo de gimnasio — Rastrea raids en gimnasios específicos por nombre para que solo recibas notificaciones de tus gimnasios favoritos.
  • Filtro de movimientos — Filtra jefes de raid por sus movimientos rápidos o cargados.
  • Notificaciones RSVP — Recibe notificaciones cuando otros entrenadores confirman asistencia a un raid o huevo que estás rastreando.

Las alarmas de Raid y Huevo se gestionan en pestañas separadas dentro de la página de Raids. Los Huevos también admiten rastreo específico de gimnasio y notificaciones RSVP.

Alarmas de Max Batalla (Dynamax)

Recibe notificaciones sobre batallas Dynamax y Gigantamax en Puntos de Poder.

  • Por nivel — Selecciona niveles de batalla para rastrear cualquier Pokemon en esos niveles. Los niveles van de 1 Estrella a 5 Estrellas (Legendario) para Dynamax, más Gigantamax y Gigantamax Legendario para las batallas más grandes. Se crea una alarma por cada nivel seleccionado.
  • Por Pokemon — Selecciona Pokemon específicos contra los que quieras luchar en todos los niveles de Max Batalla. Si la base de datos del escáner está configurada, el selector se filtra para mostrar solo Pokemon que han aparecido en Max Batallas.
  • Solo Gigantamax — Al rastrear por Pokemon, activa esto para solo recibir notificaciones cuando ese Pokemon aparezca en batallas Gigantamax (las batallas de mayor nivel con movimientos G-Max únicos). Para rastreo por nivel, Gigantamax se maneja seleccionando los niveles Gigantamax o Gigantamax Legendario directamente.
  • Seleccionar todo — Selecciona rápidamente todos los niveles disponibles a la vez (equivalente al comando !maxbattle everything del bot).

Alarmas de Misiones

Recibe notificaciones sobre tareas de investigación de campo con recompensas específicas.

  • Encuentros Pokemon — Selecciona Pokemon que quieras como recompensas de misiones.
  • Objetos — Rastrea misiones que recompensan objetos específicos.
  • Mega Energía — Rastrea misiones que dan mega energía para Pokemon específicos.
  • Caramelos — Rastrea misiones que recompensan caramelos para Pokemon específicos.
  • Polvo estelar — Rastrea misiones que recompensan polvo estelar.

Las pestañas de objetos, megaenergía y caramelos tienen cada una un campo Cantidad mínima, y la de polvo estelar un Polvo estelar mínimo. Déjalo en 0 para aceptar cualquier cantidad. Las tarjetas muestran la cantidad junto a la recompensa, por ejemplo "3× Rare Candy".

Alarmas de Invasión

Recibe notificaciones sobre invasiones de Team Rocket.

  • Rastrear todo — Una alarma para cada tipo de recluta y líder.
  • Por tipo — Selecciona tipos específicos de reclutas (Bicho, Dragón, Fuego, etc.), Líderes Rocket o Giovanni. Los nombres de tipo de recluta se normalizan automáticamente (sin distinción de mayúsculas), así que no necesitas preocuparte por la capitalización exacta.
  • Género — Filtrar por género del recluta.

Alarmas de Señuelo

Recibe notificaciones cuando se coloca un tipo específico de señuelo. Elige entre Normal, Glacial, Musgo, Magnético, Lluvioso y Dorado.

Alarmas de Nidos

Rastrea especies de Pokemon que anidan. Establece un umbral de apariciones mínimas por hora para que solo recibas notificaciones de nidos con suficiente actividad.

Alarmas de Gimnasio

Rastrea cambios de equipo en gimnasios. Selecciona qué equipos monitorear (Neutral, Mystic, Valor, Instinct). Activa el rastreo de Cambios de plaza para recibir notificaciones cuando se abren plazas en el gimnasio, o activa el rastreo de Cambios de batalla para recibir notificaciones cuando un gimnasio está siendo atacado.

Alarmas de Cambios de Fort

Rastrea cambios en PokéStops y gimnasios en sí — no las actividades en ellos, sino cambios en los propios puntos de interés.

  • Tipo de fort — Elige rastrear PokéStops, Gimnasios, o Todo.
  • Tipos de cambio — Selecciona qué cambios monitorear: Nombre cambiado, Descripción cambiada, Ubicación cambiada, Imagen cambiada, Eliminado o Nuevo fort.
  • Incluir vacíos — Incluir forts que no tienen nombre establecido.
💡
Las alarmas de cambios de fort son útiles para rastrear actualizaciones de la base de datos del mapa — nuevos PokéStops apareciendo, gimnasios siendo reubicados, o POIs siendo eliminados del juego.

Seleccionar un gimnasio específico

Al crear o editar una alarma de Raid, Huevo o Gimnasio, puedes opcionalmente buscar y seleccionar un gimnasio específico. Esto es útil cuando solo te importa la actividad en tu gimnasio favorito — como el de tu ruta del almuerzo o cerca de tu casa.

  • Cómo usarlo — En el diálogo de añadir o editar, escribe un nombre de gimnasio en el campo de búsqueda de gimnasio. Los resultados muestran la foto, nombre y área del gimnasio para que puedas identificar el correcto.
  • Cuando se selecciona un gimnasio — La alarma solo se activa para eventos en ese gimnasio específico. El nombre del gimnasio aparece en la tarjeta de alarma en tu lista para que puedas ver qué gimnasio rastrea de un vistazo.
  • Cuando no se selecciona ningún gimnasio — Es el valor por defecto. La alarma funciona normalmente para todos los gimnasios en tus áreas seleccionadas o dentro de tu radio de distancia.
💡
Puedes combinar una alarma específica de gimnasio con una alarma más amplia. Por ejemplo, crea una alarma de raid para tu gimnasio local para todos los niveles, y una segunda alarma para raids de nivel 5 en todas tus áreas.
", + "CONTENT_DELIVERY": "\"Tarjetas

Cada alarma tiene ajustes de entrega que controlan dónde recibes notificaciones.

Dónde te alcanza una alerta

La pestaña Entrega de cada diálogo de creación y edición pregunta ¿Dónde quieres recibir esta alerta? y ofrece tres respuestas:

  • En cualquier parte de mis áreas — La opción por defecto. La alarma sigue las áreas que ha seleccionado tu perfil, así que cambiar tus áreas también cambia esta alarma.
  • Cerca de un punto — Un radio en kilómetros, medido desde tu ubicación o desde un lugar guardado que elijas en Medido desde. Si aún no tienes ubicación, el selector lo avisa y ofrece definirla.
  • Solo en áreas concretas — Un subconjunto de áreas para esta alarma en particular, elegido entre las áreas públicas y los geofences que hayas dibujado tú.

Cada alarma puede responder distinto: áreas para Pokemon, un radio desde tu ubicación para raids, un lugar con nombre para misiones.

La etiqueta de la tarjeta

La mayoría de las tarjetas de alarma llevan una etiqueta con su respuesta — "En cualquier parte de mis áreas", "Donde sea que reciba alertas", "A menos de 5 km de mi ubicación", "A menos de 2 km de Casa", "Solo en Terrigal, Erina". Haz clic en ella para cambiar esa alarma sin abrir el diálogo de edición completo.

Valor por defecto para alarmas nuevas

Las alarmas nuevas se abren en modo Áreas. Para cambiarlo, abre el menú de usuario (tu avatar, arriba a la derecha) y elige Valores predeterminados de alertas — decide si las alarmas nuevas empiezan en Áreas o en Distancia, fija un radio por defecto y elige si ese radio se mide desde tu ubicación o desde un lugar guardado. La preferencia se guarda en tu navegador y también rellena el diálogo de Selección rápida. Solo afecta a las alarmas que crees a partir de ahora; las existentes no cambian, y puedes seguir ajustando dónde te alcanza cada alarma.

Plantillas de notificación

Si las plantillas están habilitadas, puedes elegir cómo se ven tus mensajes de notificación. El selector de plantillas muestra una vista previa en vivo de cómo se verá tu DM de Discord, incluyendo el formato del embed, campos e imágenes.

Modo limpieza

Cuando está activado, el bot elimina automáticamente la notificación de Discord después de que el evento expire (ej. un Pokemon desaparece o un raid termina). Esto mantiene tus DMs ordenados. Puedes activar el modo limpieza por alarma o en masa desde la página de Limpieza.

Editar en el sitio y resúmenes

Algunas alarmas admiten modos de entrega adicionales. Activa Editar mensaje en el sitio en un señuelo para actualizar el mensaje de Discord existente cuando cambie el señuelo en lugar de enviar uno nuevo, o Resumen diario en una misión para agrupar las misiones coincidentes en un único mensaje de resumen (requiere un horario de resumen configurado en el bot). Las incursiones y los huevos se editan en el sitio automáticamente cuando eliges un modo RSVP. Estos ajustes se conservan aunque los establezcas desde el bot.

Actualizaciones RSVP (incursiones y huevos)

Las alarmas de incursión y de huevo añaden un ajuste de notificaciones RSVP en el diálogo de añadir/editar con tres opciones: Solo coincidencias envía alertas estándar de incursiones/huevos; Coincidencias + actualizaciones RSVP también vuelve a notificar cuando cambian los recuentos de RSVP (entrenadores que se apuntan); y Solo actualizaciones RSVP omite la coincidencia inicial y te notifica únicamente los cambios de RSVP. Al elegir cualquiera de los modos RSVP, el bot edita el mensaje de Discord existente en el sitio a medida que cambian los recuentos en lugar de enviar nuevos, y la tarjeta muestra una etiqueta "RSVP" o "Solo RSVP". Ten en cuenta que Solo actualizaciones RSVP queda en silencio a menos que el escáner de tu comunidad emita eventos RSVP — eliígelo solo si sabes que se informan los RSVP.

", + "CONTENT_QUEST_SUMMARY": "

Las misiones de Investigación de campo rotan a diario y pueden coincidir en grandes cantidades, así que un filtro de misiones muy activo puede inundar tus MD. Entrega del resumen de misiones reúne las misiones coincidentes en un único resumen programado en lugar de muchas alertas separadas.

Dos partes que funcionan juntas

  • Interruptor de resumen diario — actívalo en una alarma de misión (en su diálogo de añadir/editar) para marcar sus coincidencias para el resumen en lugar de entregarlas de inmediato.
  • Programación de entrega — elige cuándo se envían las misiones recopiladas.

Ambas cosas son necesarias: el interruptor indica qué misiones recopilar, y la programación indica cuándo entregarlas.

Configurar tu programación

Abre la página Misiones, luego el menú de la barra de herramientas y elige Entrega del resumen de misiones. Usa Editar programación para elegir días y horas — el mismo editor que se usa para las horas activas de los perfiles. Las horas guardadas aparecen como fichas ámbar.

La programación es por usuario y se comparte entre todos tus perfiles — a diferencia de las horas activas de los perfiles, que se configuran por perfil.

Enviar resumen ahora

Enviar resumen ahora entrega de inmediato todo lo que se haya recopilado desde tu último resumen. Si aún no se ha recopilado nada, no se envía nada — las misiones se almacenan en búfer a medida que coinciden, así que dale tiempo o espera a que se active la programación.

Bueno saberlo

  • El menú solo aparece cuando el bot de tu servidor tiene los resúmenes de misiones activados.
  • El momento de entrega usa tu ubicación guardada para la zona horaria — establece una ubicación, o los resúmenes podrían llegar a la hora local equivocada (el diálogo te avisa cuando no hay ninguna ubicación establecida).
  • Quitar la programación conserva el interruptor por alarma; las misiones se siguen recopilando, pero vuelven al horario predeterminado del bot.
", "CONTENT_TEST_ALERTS": "

Cada tarjeta de alarma tiene un botón Test (icono de avión de papel) que envía una notificación de ejemplo a tu Discord o Telegram, usando los filtros exactos de la alarma y tu plantilla de entrega actual.

Cómo funciona

  1. Encuentra cualquier tarjeta de alarma en tu lista (Pokemon, Raid, Misión, etc.).
  2. Haz clic en el icono de enviar en la fila de acciones de la tarjeta.
  3. Se genera un evento simulado que coincide con los filtros de tu alarma y se envía a través del sistema de notificaciones. Recibirás un DM igual que una alerta real.

Qué se prueba

La prueba usa los valores de filtro de tu alarma (ID de Pokemon, nivel de raid, recompensa de misión, etc.) y tu ubicación guardada como coordenadas del evento simulado. La notificación se formatea usando tu plantilla seleccionada, así que ves exactamente cómo se vería una alerta real.

Tiempo de espera

Para evitar spam, cada alarma tiene un tiempo de espera de 15 segundos entre envíos de prueba. El botón se desactiva durante el tiempo de espera y una notificación muestra el resultado (éxito, error o tiempo restante).

💡
Las alertas de prueba son ideales para verificar que tu plantilla se ve bien o confirmar que la entrega por webhook funciona antes de esperar a que un evento real se active.
", "CONTENT_POKEMON_AVAILABILITY": "

Al añadir o editar alarmas de Pokemon, el selector de Pokemon puede mostrar indicadores de disponibilidad — pequeñas insignias que te dicen qué Pokemon están apareciendo actualmente en estado salvaje.

Cómo funciona

Si tu comunidad tiene un escáner Golbat configurado, el selector muestra puntos coloreados junto a los nombres de Pokemon:

  • Punto verde — Este Pokemon ha sido visto apareciendo recientemente.
  • Sin punto — No reportado actualmente en los datos del escáner.

Esto te ayuda a evitar crear alarmas para Pokemon que no están apareciendo en tu área ahora mismo (ej. especies de temporada o exclusivas de eventos).

Actualización de disponibilidad

Los datos se actualizan automáticamente en segundo plano. No necesitas hacer nada — solo busca los puntos cuando explores el selector de Pokemon.

ℹ️
Esta función solo es visible si tu administrador ha configurado la integración del escáner Golbat. Si no ves puntos de disponibilidad, la función no está habilitada para tu comunidad.
", "CONTENT_BULK": "\"Lista

Todas las páginas de alarmas admiten operaciones masivas para que puedas gestionar muchas alarmas a la vez.

Modo de selección

Haz clic en el icono de lista de verificación en la barra de herramientas para entrar en modo de selección. Luego haz clic en tarjetas de alarma individuales para seleccionarlas, o usa Seleccionar todo para abarcar todo lo visible.

Acciones masivas

  • Actualizar distancia — Cambiar el modo de entrega (áreas o distancia) para todas las alarmas seleccionadas a la vez.
  • Eliminar — Eliminar todas las alarmas seleccionadas con una confirmación.
💡
Al final de cada lista de alarmas, también encontrarás botones de Actualizar toda la distancia y Eliminar todo que se aplican a cada alarma de ese tipo.
", - "CONTENT_QUICK_PICKS": "\"Página

Las selecciones rápidas son plantillas de alarma predefinidas creadas por los administradores de tu comunidad. Te permiten configurar configuraciones de alarma comunes con un clic en lugar de crear cada alarma individualmente.

Aplicar una selección rápida

  1. Ve a Selección rápida desde la barra lateral.
  2. Explora las selecciones disponibles, opcionalmente filtrando por categoría.
  3. Haz clic en Aplicar en la selección rápida que desees.
  4. Personaliza antes de aplicar: elige tu modo de entrega (áreas o distancia), activa el modo limpieza, y opcionalmente excluye Pokemon específicos.
  5. Confirma para crear todas las alarmas a la vez.

Eliminar alarmas de selección rápida

Si ya no quieres las alarmas de una selección rápida, haz clic en Eliminar para borrar todas las alarmas que creó.

", - "CONTENT_PROFILES": "

La página de Perfiles es tu centro unificado para gestionar perfiles y ver todas las alarmas de todos los perfiles en un solo lugar.

¿Por qué usar perfiles?

Los perfiles te permiten mantener configuraciones de alarma completamente separadas. Cada perfil tiene su propio conjunto de alarmas, áreas seleccionadas, ubicación y activaciones de geofence personalizados. Útil para diferentes situaciones — por ejemplo, un perfil \\\"Casa\\\" para tu vecindario y un perfil \\\"Trabajo\\\" para los alrededores de tu oficina.

Vista general

La página muestra una barra de estadísticas con contadores totales de alarmas por tipo, una barra de búsqueda para filtrar en todos los perfiles, y chips de filtro por tipo para mostrar solo tipos específicos de alarma (Pokemon, Raids, Misiones, etc.).

Cada perfil aparece como un panel expandible. Haz clic para expandir y ver todas las alarmas agrupadas por tipo, con imágenes de assets del juego (sprites de Pokemon, huevos de raid, iconos de señuelo) y píldoras de filtro mostrando IV, CP, Nivel, PVP y otros ajustes de un vistazo.

Gestionar perfiles

  • Crear — Haz clic en el botón + arriba a la derecha. Los nombres de perfil deben ser únicos (hasta 32 caracteres).
  • Cambiar — Haz clic en Cambiar dentro de un panel de perfil para hacerlo tu perfil activo. Tu perfil activo está marcado con una insignia verde y borde izquierdo.
  • Editar — Haz clic en el icono de lápiz para renombrar un perfil.
  • Eliminar — Haz clic en el icono de papelera para eliminar un perfil y todas sus alarmas. No puedes eliminar tu perfil activo.

Duplicar

Haz clic en el icono de copiar en cualquier perfil para crear una copia exacta con todas sus alarmas. Se te pedirá nombrar el nuevo perfil — se sugiere un nombre por defecto como \\\"Perfil (Copia)\\\". El duplicado incluye todos los filtros de alarma pero obtiene un nuevo conjunto de selecciones de áreas.

Exportar & Importar

  • Exportar — Haz clic en el icono de descarga en un perfil para guardar un archivo de respaldo (JSON). El archivo contiene todos los filtros de alarma, sin IDs internos para que sea portable.
  • Importar — Haz clic en el botón Importar arriba a la derecha, selecciona un archivo de respaldo y elige un nombre para el nuevo perfil. Todas las alarmas del respaldo se restauran. Si existe un perfil con el mismo nombre, se añade automáticamente un sufijo numérico.

Detección de duplicados

Si la misma alarma existe en múltiples perfiles (ej. rastreando Pikachu tanto en \\\"Casa\\\" como en \\\"Trabajo\\\"), esas alarmas se resaltan con un borde naranja y un icono de copia. Cuando existen duplicados, aparece un chip de filtro Duplicados en la barra de filtros — haz clic para mostrar solo alarmas duplicadas entre perfiles.

⚠️
Advertencia: Eliminar un perfil elimina permanentemente todas las alarmas en ese perfil. No puedes eliminar tu perfil activo actualmente. Considera exportar un respaldo primero.
", - "CONTENT_CLEANING": "\"Página

La página de Limpieza te permite controlar el modo limpieza en todos tus tipos de alarma a la vez.

Cuando el modo limpieza está activado para un tipo de alarma, el bot elimina automáticamente las notificaciones de Discord después de que el evento expire:

  • Pokemon — Eliminado cuando la aparición desaparece
  • Raids — Eliminado cuando el raid termina
  • Huevos — Eliminado cuando el huevo eclosiona
  • Misiones — Eliminado cuando las misiones se reinician a medianoche
  • Invasiones — Eliminado cuando el recluta se va
  • Señuelos — Eliminado cuando el señuelo expira
  • Nidos — Eliminado cuando los nidos migran
  • Gimnasios — Eliminado después de cambios en el gimnasio
  • Cambios de fort — Eliminado después de que la notificación de cambio de fort expire
  • Max Batallas — Eliminado cuando la batalla termina

Usa Activar todo o Desactivar todo para cambiar todo a la vez.

💡
Recomendado: Mantén el modo limpieza activado para evitar que las alertas obsoletas se acumulen en tus DMs.
", - "CONTENT_APPEARANCE": "

Modo oscuro / claro

Haz clic en el icono de sol/luna en la barra de herramientas superior para cambiar entre temas oscuro y claro. Tu elección se guarda automáticamente.

\"Barra

Colores de acento

Abre el menú de usuario (tu avatar arriba a la derecha) y selecciona Tema de acento. Elige entre:

  • Predeterminado — Azul
  • Pokemon — Verde
  • Raids — Rojo
  • Mystic — Azul
  • Valor — Rojo
  • Instinct — Amarillo

El color de acento cambia el degradado de la barra de herramientas, el resaltado de navegación activa y otros acentos de la interfaz en todo el sitio.

\"Panel

Idioma

Si está disponible, usa el selector de idioma en la barra de herramientas para cambiar el idioma de la interfaz. Se admiten 18 idiomas.

Atajos de teclado

?Mostrar atajos de teclado
EscCerrar menús o diálogos
[Contraer barra lateral
]Expandir barra lateral
", - "CONTENT_ALERTS_LOGOUT": "\"Menú

Pausar alertas

Abre el menú de usuario (tu avatar) y haz clic en Pausar alertas. Aparecerá un banner rojo en la parte superior del sitio confirmando que tus alertas están pausadas. No recibirás ninguna notificación mientras estén pausadas.

Para reanudar, haz clic en Reanudar alertas desde el menú de usuario o el banner.

Cerrar sesión

Abre el menú de usuario y haz clic en Cerrar sesión. Serás redirigido a la página de inicio de sesión.

", - "CONTENT_FAQ": "

\\\"No puedo iniciar sesión\\\"

Debes registrarte con el bot Poracle en Discord o Telegram antes de poder iniciar sesión en este sitio. Si ves \\\"Tu cuenta no está registrada\\\", contacta al administrador de tu comunidad para instrucciones de registro.

\\\"No estoy recibiendo notificaciones\\\"

Revisa estas causas comunes:

  1. Alertas pausadas — Busca un banner rojo en la parte superior del sitio. Reanuda las alertas desde el menú de usuario.
  2. Sin ubicación establecida — Si tus alarmas usan modo distancia, necesitas una ubicación guardada.
  3. Sin áreas seleccionadas — Si tus alarmas usan modo áreas, asegúrate de haber seleccionado áreas en la página de Áreas.
  4. Perfil equivocado — Puede que tengas alarmas en un perfil diferente. Comprueba qué perfil está activo en el Panel.
  5. Filtros demasiado estrictos — Intenta relajar tus filtros de IV, CP o nivel para ver si las notificaciones empiezan a llegar.

\\\"Mis alarmas desaparecieron\\\"

Las alarmas son específicas de cada perfil. Si cambiaste de perfil, tus alarmas del otro perfil siguen ahí — simplemente vuelve a cambiar desde el Panel o la página de Perfiles.

\\\"No puedo hacer clic en un área pequeña del mapa\\\"

Cuando las áreas se superponen, acércate con el zoom para hacer más fácil clic en el área más pequeña. Las áreas más pequeñas siempre están encima de las más grandes.

\\\"¿Qué hace el modo limpieza?\\\"

El modo limpieza le dice al bot que elimine automáticamente una notificación de Discord después de que el evento expire (ej. un Pokemon desaparece). Sin él, las alertas antiguas permanecen en tus DMs para siempre. Actívalo en la página de Limpieza o por alarma en la pestaña de Entrega.

\\\"¿Cuál es la diferencia entre Áreas y Distancia?\\\"

Cada alarma usa un modo de entrega. Áreas te notifica sobre eventos dentro de zonas geográficas específicas. Distancia te notifica sobre eventos dentro de un radio de tu ubicación guardada. Puedes mezclar ambos en diferentes alarmas.

" + "CONTENT_QUICK_PICKS": "\"Página

Las selecciones rápidas son plantillas de alarma predefinidas creadas por los administradores de tu comunidad. Te permiten configurar configuraciones de alarma comunes con un clic en lugar de crear cada alarma individualmente.

Aplicar una selección rápida

  1. Ve a Selección rápida desde la barra lateral.
  2. Explora las selecciones disponibles, opcionalmente filtrando por categoría.
  3. Haz clic en Aplicar en la selección rápida que desees.
  4. Personaliza antes de aplicar: decide dónde quieres recibir las alertas — la pestaña Entrega es el mismo selector de tres opciones que usa una alarma individual, así que puedes apuntarlas a un lugar guardado o a un subconjunto de áreas —, activa el modo limpieza y opcionalmente excluye Pokemon específicos.
  5. Confirma para crear todas las alarmas a la vez.

Eliminar alarmas de selección rápida

Si ya no quieres las alarmas de una selección rápida, haz clic en Eliminar para borrar todas las alarmas que creó.

", + "CONTENT_PROFILES": "

La página de Perfiles es tu centro unificado para gestionar perfiles y ver todas las alarmas de todos los perfiles en un solo lugar.

¿Por qué usar perfiles?

Los perfiles te permiten mantener configuraciones de alarma completamente separadas. Cada perfil tiene su propio conjunto de alarmas, áreas seleccionadas, ubicación y activaciones de geofence personalizados. Útil para diferentes situaciones — por ejemplo, un perfil \"Casa\" para tu vecindario y un perfil \"Trabajo\" para los alrededores de tu oficina.

Vista general

La página muestra una barra de estadísticas con contadores totales de alarmas por tipo, una barra de búsqueda para filtrar en todos los perfiles, y chips de filtro por tipo para mostrar solo tipos específicos de alarma (Pokemon, Raids, Misiones, etc.).

Cada perfil aparece como un panel expandible. Haz clic para expandir y ver todas las alarmas agrupadas por tipo, con imágenes de assets del juego (sprites de Pokemon, huevos de raid, iconos de señuelo) y píldoras de filtro mostrando IV, CP, Nivel, PVP y otros ajustes de un vistazo.

Gestionar perfiles

  • Crear — Haz clic en el botón + arriba a la derecha. Los nombres de perfil deben ser únicos (hasta 32 caracteres).
  • Cambiar — Haz clic en Cambiar dentro de un panel de perfil para hacerlo tu perfil activo. Tu perfil activo está marcado con una insignia verde y borde izquierdo.
  • Editar — Haz clic en el icono de lápiz para renombrar un perfil.
  • Eliminar — Haz clic en el icono de papelera para eliminar un perfil y todas sus alarmas. No puedes eliminar tu perfil activo.

Duplicar

Haz clic en el icono de copiar en cualquier perfil para crear una copia exacta con todas sus alarmas. Se te pedirá nombrar el nuevo perfil — se sugiere un nombre por defecto como \"Perfil (Copia)\". El duplicado incluye todos los filtros de alarma, y sus áreas, ubicación y horas activas también se copian del perfil de origen.

Exportar & Importar

  • Exportar — Haz clic en el icono de descarga en un perfil para guardar un archivo de respaldo (JSON). El archivo contiene todos los filtros de alarma, sin IDs internos para que sea portable.
  • Importar — Haz clic en el botón Importar arriba a la derecha, selecciona un archivo de respaldo y elige un nombre para el nuevo perfil. Todas las alarmas del respaldo se restauran. Si existe un perfil con el mismo nombre, se añade automáticamente un sufijo numérico.

Detección de duplicados

Si la misma alarma existe en múltiples perfiles (ej. rastreando Pikachu tanto en \"Casa\" como en \"Trabajo\"), esas alarmas se resaltan con un borde naranja y un icono de copia. Cuando existen duplicados, aparece un chip de filtro Duplicados en la barra de filtros — haz clic para mostrar solo alarmas duplicadas entre perfiles.

⚠️
Advertencia: Eliminar un perfil elimina permanentemente todas las alarmas en ese perfil. No puedes eliminar tu perfil activo actualmente. Considera exportar un respaldo primero.
", + "CONTENT_CLEANING": "\"Página

La página de Limpieza te permite controlar el modo limpieza en todos tus tipos de alarma a la vez.

Cuando el modo limpieza está activado para un tipo de alarma, el bot elimina automáticamente las notificaciones de Discord después de que el evento expire:

  • Pokemon — Eliminado cuando la aparición desaparece
  • Raids — Eliminado cuando el raid termina
  • Huevos — Eliminado cuando el huevo eclosiona
  • Misiones — Eliminado cuando las misiones se reinician a medianoche
  • Invasiones — Eliminado cuando el recluta se va
  • Señuelos — Eliminado cuando el señuelo expira
  • Nidos — Eliminado cuando los nidos migran
  • Gimnasios — Eliminado después de cambios en el gimnasio
  • Max Batallas — Eliminado cuando la batalla termina

Usa Activar todo o Desactivar todo para cambiar todo a la vez.

💡
Recomendado: Mantén el modo limpieza activado para evitar que las alertas obsoletas se acumulen en tus DMs.
", + "CONTENT_APPEARANCE": "

Modo oscuro / claro

Haz clic en el icono de sol/luna en la barra de herramientas superior para cambiar entre temas oscuro y claro. Tu elección se guarda automáticamente.

\"Barra

Colores de acento

Abre el menú de usuario (tu avatar arriba a la derecha) y selecciona Tema de acento. Elige entre:

  • Predeterminado — Azul
  • Pokemon — Verde
  • Raids — Rojo
  • Mystic — Azul
  • Valor — Rojo
  • Instinct — Amarillo

El color de acento cambia el degradado de la barra de herramientas, el resaltado de navegación activa y otros acentos de la interfaz en todo el sitio.

\"Panel

Idioma de la interfaz

Abre el menú de usuario (tu avatar, arriba a la derecha) y elige Idioma de la interfaz. Hay 11 idiomas. Cambia el texto del sitio y también los nombres, tipos y formas de Pokemon que ves en los selectores y en tus tarjetas de alarma. Si nunca has elegido uno, recibes el de tu navegador o el que tenga configurado tu servidor Poracle.

Idioma de las alertas

Justo debajo está Idioma de las alertas, un ajuste aparte. Controla en qué idioma escribe Poracle tus DM. Son independientes: un sitio en español con DM en inglés, o al revés, es algo perfectamente normal. Antes estaba en la página de Áreas.

Atajos de teclado

?Mostrar atajos de teclado
EscCerrar menús o diálogos
[Contraer barra lateral
]Expandir barra lateral
", + "CONTENT_ALERTS_LOGOUT": "\"Menú

Pausar alertas

Abre el menú de usuario (tu avatar) y haz clic en Pausar alertas. Aparecerá un banner rojo en la parte superior del sitio confirmando que tus alertas están pausadas. No recibirás ninguna notificación mientras estén pausadas.

Para reanudar, haz clic en Reanudar alertas desde el menú de usuario o el banner.

Cerrar sesión

Abre el menú de usuario y haz clic en Cerrar sesión. Serás redirigido a la página de inicio de sesión.

Si has iniciado sesión con un proveedor SSO compatible con el cierre de sesión único, el menú también ofrece Cerrar sesión en todas partes: eso cierra también tu sesión en el proveedor, no solo aquí.

", + "CONTENT_FAQ": "

\"No puedo iniciar sesión\"

Debes registrarte con el bot Poracle en Discord o Telegram antes de poder iniciar sesión en este sitio. Si ves \"Tu cuenta no está registrada\", contacta al administrador de tu comunidad para instrucciones de registro.

\"No estoy recibiendo notificaciones\"

Revisa estas causas comunes:

  1. Alertas pausadas — Busca un banner rojo en la parte superior del sitio. Reanuda las alertas desde el menú de usuario.
  2. Sin ubicación establecida — Una alarma que te alcanza dentro de un radio mide desde tu ubicación o desde un lugar guardado. Define una en la página Áreas y lugares.
  3. Nada dentro del alcance — Mira la etiqueta de la tarjeta de la alarma. Dice dónde te alcanza, y puede estar apuntando a áreas que tu perfil ya no cubre.
  4. Perfil equivocado — Puede que tengas alarmas en un perfil diferente. Comprueba qué perfil está activo en el Panel.
  5. Filtros demasiado estrictos — Intenta relajar tus filtros de IV, CP o nivel para ver si las notificaciones empiezan a llegar.

\"Mis alarmas desaparecieron\"

Las alarmas son específicas de cada perfil. Si cambiaste de perfil, tus alarmas del otro perfil siguen ahí — simplemente vuelve a cambiar desde el Panel o la página de Perfiles.

\"No puedo hacer clic en un área pequeña del mapa\"

Cuando las áreas se superponen, acércate con el zoom para hacer más fácil clic en el área más pequeña. Las áreas más pequeñas siempre están encima de las más grandes.

\"¿Qué hace el modo limpieza?\"

El modo limpieza le dice al bot que elimine automáticamente una notificación de Discord después de que el evento expire (ej. un Pokemon desaparece). Sin él, las alertas antiguas permanecen en tus DMs para siempre. Actívalo en la página de Limpieza o por alarma en la pestaña de Entrega.

\"¿Dónde me alcanza una alerta?\"

Cada alarma lo responde por su cuenta, en su pestaña Entrega. En cualquier parte de mis áreas sigue las áreas seleccionadas en tu perfil. Cerca de un punto es un radio desde tu ubicación o desde un lugar guardado. Solo en áreas concretas limita esa alarma a un subconjunto de áreas. La etiqueta de la tarjeta siempre dice la respuesta actual, y al hacer clic la cambias.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "Alertas DM", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Iniciar sesión", "SIGN_IN_DESC": "Inicia sesión para gestionar tus alarmas de notificación de Pokemon GO.", "SIGN_IN_DISCORD": "Iniciar sesión con Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Iniciar sesión con Telegram", + "SIGN_IN_OIDC": "Iniciar sesión con {{provider}}", + "SIGNED_OUT_TITLE": "Sesión cerrada", + "SIGNED_OUT_DESC": "Has cerrado sesión en DM Alerts.", + "PROVIDER_DISABLED_BY_ADMIN": "Un administrador ha desactivado este método de acceso.", + "PROVIDER_DISABLED_HINT": "Este método de acceso está desactivado para usuarios no administradores.", + "ERR_TELEGRAM_DISABLED": "El inicio de sesión con Telegram está desactivado.", "OR": "o", "NO_METHODS": "No hay métodos de inicio de sesión habilitados actualmente. Contacta a un administrador.", "AUTHENTICATING": "Autenticando...", "FOOTER": "Gestiona alarmas para Pokemon, Raids, Misiones y más", "AUTH_FAILED": "Error de autenticación", "BACK_TO_LOGIN": "Volver al inicio de sesión", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "El inicio de sesión con Discord está desactivado.", + "ERR_DISCORD_FETCH": "No se pudo obtener tu perfil de Discord. Inténtalo de nuevo.", + "ERR_MISSING_CODE": "El inicio de sesión con Discord se canceló o falló.", + "ERR_MISSING_ROLE": "No tienes el rol de Discord necesario para acceder a este sitio.", + "ERR_NOT_IN_GUILD": "Debes ser miembro del servidor de Discord para acceder a este sitio.", + "ERR_NOT_REGISTERED": "Tu cuenta no está registrada. Regístrate para empezar.", + "ERR_OIDC_DISABLED": "El inicio de sesión externo está deshabilitado actualmente.", + "ERR_OIDC_NO_IDENTITY": "Tu proveedor de inicio de sesión externo no devolvió una cuenta que podamos asociar. Asegúrate de que tu cuenta de Discord esté vinculada.", + "ERR_OIDC_TOKEN_EXCHANGE": "El inicio de sesión externo falló. Inténtalo de nuevo.", + "ERR_OIDC_USERINFO": "No se pudo obtener tu perfil del proveedor de inicio de sesión externo. Inténtalo de nuevo.", + "ERR_ROLE_CHECK_FAILED": "No se pudieron verificar tus roles de Discord. Inténtalo más tarde.", + "ERR_TELEGRAM_FAILED": "El inicio de sesión con Telegram falló. Inténtalo de nuevo.", + "ERR_TOKEN_EXCHANGE": "El inicio de sesión con Discord falló. Inténtalo de nuevo.", "ERR_GENERIC": "Error de autenticación: {{error}}", "ERR_NO_TOKEN": "No se recibió token de autenticación.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "Registrarse", + "SIGN_UP_DESC": "¿No tienes cuenta? Regístrate para empezar.", + "SIGN_IN_AGAIN": "Iniciar sesión de nuevo" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "El administrador ha desactivado esta función." }, "ADMIN": { "USERS_TITLE": "Gestión de usuarios", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Nombre promocionado", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Nombre para la geofence promocionada", "APPROVAL_PROMOTED_NAME_HINT": "Opcional. Por defecto usa el nombre actual.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Motivo del rechazo", "APPROVAL_REJECT_PLACEHOLDER": "Explica por qué se rechaza esta geofence...", "USERS_DESC_FULL": "Gestionar usuarios registrados de Discord. Detenido = el usuario pausó las alertas o alcanzó el límite. Bloqueado = bloqueado por el admin.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Error al aprobar el envío", "SNACK_APPROVED": "\"{{name}}\" aprobada", "SNACK_FAILED_REJECT": "Error al rechazar el envío", - "SNACK_REJECTED": "\"{{name}}\" rechazada" + "SNACK_REJECTED": "\"{{name}}\" rechazada", + "APPROVAL_REGION_HINT": "Elige la región bajo la que aparecerá esta geocerca.", + "SERVER_TITLE": "Servidor Poracle", + "SERVER_REFRESH": "Volver a comprobar", + "SERVER_VERSION": "Versión", + "SERVER_SCHEMA": "Esquema de base de datos", + "SERVER_CHECKED": "Última comprobación", + "SERVER_CAPABILITIES": "Funciones", + "SERVER_NO_CAPABILITIES": "Este servidor no indica ninguna.", + "SERVER_UNKNOWN": "Desconocida", + "SERVER_UNREACHABLE": "Poracle no respondió. Las alarmas, los perfiles y las ubicaciones pasan por él y fallarán hasta que vuelva.", + "SERVER_TOO_OLD": "Poracle {{version}} es anterior a {{minimum}}, que necesita esta versión del sitio. El alcance por alarma, el filtro mega de PVP y el de tiempo restante parecerán guardarse sin cambiar nada.", + "UPDATE_AVAILABLE": "Se está ejecutando {{name}} {{running}} y ya está disponible {{latest}}.", + "UPDATE_PRERELEASE": "{{name}} {{running}} es más reciente que cualquier versión publicada: es una compilación de desarrollo.", + "VERSIONS_TITLE": "Versiones", + "VERSIONS_WEB": "Este sitio", + "VERSIONS_BUILD": "Compilación", + "UPDATE_CURRENT": "Al día.", + "UPDATE_UNCOMPARABLE": "Canal de desarrollo. La última versión publicada es {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Elige un punto", "CANCEL": "Cancelar", "CONFIRM": "Confirmar", "DONT_ASK_AGAIN": "No volver a preguntar en esta sesión", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Actualizar todas las distancias", "DISTANCE_DESC": "Establece el modo de ubicación para todas las alarmas de este tipo.", "DISTANCE_UPDATE_ALL": "Actualizar todo", + "DISTANCE_MUST_BE_POSITIVE": "La distancia debe ser mayor que cero.", "LOCATION_SAVE_ERROR": "Error al actualizar la ubicación", "LOCATION_SAVE_SUCCESS": "Ubicación actualizada correctamente", "LOCATION_GEO_UNSUPPORTED": "Tu navegador no soporta geolocalización", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Demasiadas alertas de prueba. Espera un momento.", "ERROR_NOT_FOUND": "Alarma no encontrada — puede haber sido eliminada.", "ERROR_GENERIC": "Error al enviar la alerta de prueba. Inténtalo más tarde.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Demasiadas alertas de prueba. Espera un momento.", + "NOT_FOUND": "Alerta no encontrada: puede que se haya eliminado.", + "UNSUPPORTED": "Las alertas de prueba no están disponibles para este tipo.", + "FAILED": "No se pudo enviar la alerta de prueba. Inténtalo más tarde." }, "COMMON": { "CANCEL": "Cancelar", @@ -1296,6 +1448,7 @@ "EDIT": "Editar", "ADD": "Añadir", "OK": "OK", + "UNDO": "Deshacer", "CONFIRM": "Confirmar", "DELETE_ALL": "Eliminar todo", "CLOSE": "Cerrar", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Buscar un gimnasio (opcional)", "SEARCH_HINT": "Escribe nombre del gimnasio...", - "CLEAR_ARIA": "Borrar selección de gimnasio" + "CLEAR_ARIA": "Borrar selección de gimnasio", + "RATE_LIMITED": "Demasiadas solicitudes al escáner: reduce el ritmo." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Se enviarán notificaciones para estas zonas:", @@ -1392,12 +1546,10 @@ "GROUP_ALARM_TYPES": "Tipos de alarma", "GROUP_FEATURES": "Funciones", "GROUP_ADMINISTRATION": "Administración", - "GROUP_COMMANDS": "Comandos", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Mapas y recursos", + "GROUP_OIDC": "SSO externo", "GROUP_ANALYTICS_LINKS": "Analítica y enlaces", - "GROUP_DEBUG": "Depuración", "GROUP_ICON_REPO": "Repositorio de iconos", "GROUP_OTHER": "Otros", "CUSTOM_TITLE_LABEL": "Título del sitio", @@ -1411,52 +1563,51 @@ "FAVICON_URL_PREVIEW": "Vista previa del favicon (32×32)", "FAVICON_URL_CACHE_WARNING": "Los navegadores almacenan los favicons en caché de forma agresiva. Tras guardar, los usuarios deben borrar la caché del navegador o realizar una actualización completa (Ctrl+F5 / Cmd+Mayús+R) para ver el nuevo icono.", "FAVICON_URL_CSP_NOTE": "Si tu sitio usa una Content Security Policy, el origen de la URL del favicon debe estar permitido por la directiva img-src; de lo contrario, el navegador bloqueará la descarga y mostrará el icono predeterminado.", + "FORCED_BY_PORACLE": "Desactivado en la propia configuración de Poracle. Poracle descarta estos webhooks y su bot rechaza el comando, así que esto no se puede activar aquí.", + "FORCED_BY_PORACLE_TOOLTIP": "Lo controla la configuración de Poracle, no esta página.", "CUSTOM_PAGE_NAME_LABEL": "Etiqueta del enlace de navegación", "CUSTOM_PAGE_NAME_DESC": "Etiqueta del enlace de navegación personalizado (ej. «Volver al mapa»).", "CUSTOM_PAGE_URL_LABEL": "URL del enlace de navegación", "CUSTOM_PAGE_URL_DESC": "URL a la que apunta el enlace de navegación personalizado.", "CUSTOM_PAGE_ICON_LABEL": "Icono del enlace de navegación", "CUSTOM_PAGE_ICON_DESC": "Clase FontAwesome para el icono del enlace de navegación (ej. «fas fa-map»).", - "DISABLE_MONS_LABEL": "Desactivar Pokémon", - "DISABLE_MONS_DESC": "Oculta la gestión de alarmas de Pokémon para todos los usuarios.", - "DISABLE_RAIDS_LABEL": "Desactivar Incursiones", - "DISABLE_RAIDS_DESC": "Oculta la gestión de alarmas de incursión para todos los usuarios.", - "DISABLE_QUESTS_LABEL": "Desactivar Misiones", - "DISABLE_QUESTS_DESC": "Oculta la gestión de alarmas de misiones para todos los usuarios.", - "DISABLE_INVASIONS_LABEL": "Desactivar Invasiones", - "DISABLE_INVASIONS_DESC": "Oculta la gestión de alarmas de invasión para todos los usuarios.", - "DISABLE_LURES_LABEL": "Desactivar Señuelos", - "DISABLE_LURES_DESC": "Oculta la gestión de alarmas de señuelo para todos los usuarios.", - "DISABLE_NESTS_LABEL": "Desactivar Nidos", - "DISABLE_NESTS_DESC": "Oculta la gestión de alarmas de nido para todos los usuarios.", - "DISABLE_GYMS_LABEL": "Desactivar Gimnasios", - "DISABLE_GYMS_DESC": "Oculta la gestión de alarmas de gimnasio para todos los usuarios.", - "DISABLE_FORT_CHANGES_LABEL": "Desactivar cambios de fortaleza", - "DISABLE_FORT_CHANGES_DESC": "Oculta la gestión de alarmas de cambios de fortaleza para todos los usuarios.", - "DISABLE_MAXBATTLES_LABEL": "Desactivar Combates Dinamax", - "DISABLE_MAXBATTLES_DESC": "Oculta la gestión de alarmas de Combate Dinamax para todos los usuarios.", - "DISABLE_AREAS_LABEL": "Desactivar áreas", - "DISABLE_AREAS_DESC": "Impide que los usuarios gestionen sus suscripciones a áreas.", - "DISABLE_PROFILES_LABEL": "Desactivar perfiles", - "DISABLE_PROFILES_DESC": "Impide que los usuarios creen y cambien perfiles de alarma.", - "DISABLE_LOCATION_LABEL": "Desactivar ubicación", - "DISABLE_LOCATION_DESC": "Impide que los usuarios establezcan una ubicación de inicio.", - "DISABLE_NOMINATIM_LABEL": "Desactivar geocodificación", - "DISABLE_NOMINATIM_DESC": "Desactiva la búsqueda de direcciones de Nominatim para elegir ubicación.", - "DISABLE_GEOMAP_LABEL": "Desactivar vista de mapa", - "DISABLE_GEOMAP_DESC": "Oculta completamente el mapa interactivo de geocercas.", - "DISABLE_GEOMAP_SELECT_LABEL": "Desactivar selección de áreas en el mapa", - "DISABLE_GEOMAP_SELECT_DESC": "Impide que los usuarios seleccionen áreas haciendo clic en el mapa.", - "ENABLE_TEMPLATES_LABEL": "Activar plantillas", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Permite a los usuarios gestionar alarmas de Pokémon.", + "DISABLE_RAIDS_LABEL": "Incursiones", + "DISABLE_RAIDS_DESC": "Permite a los usuarios gestionar alarmas de incursión.", + "DISABLE_QUESTS_LABEL": "Misiones", + "DISABLE_QUESTS_DESC": "Permite a los usuarios gestionar alarmas de misiones.", + "DISABLE_INVASIONS_LABEL": "Invasiones", + "DISABLE_INVASIONS_DESC": "Permite a los usuarios gestionar alarmas de invasión.", + "DISABLE_LURES_LABEL": "Señuelos", + "DISABLE_LURES_DESC": "Permite a los usuarios gestionar alarmas de señuelo.", + "DISABLE_NESTS_LABEL": "Nidos", + "DISABLE_NESTS_DESC": "Permite a los usuarios gestionar alarmas de nido.", + "DISABLE_GYMS_LABEL": "Gimnasios", + "DISABLE_GYMS_DESC": "Permite a los usuarios gestionar alarmas de gimnasio.", + "DISABLE_FORT_CHANGES_LABEL": "Cambios de fortaleza", + "DISABLE_FORT_CHANGES_DESC": "Permite a los usuarios gestionar alarmas de cambios de fortaleza.", + "DISABLE_MAXBATTLES_LABEL": "Combates Dinamax", + "DISABLE_MAXBATTLES_DESC": "Permite a los usuarios gestionar alarmas de Combate Dinamax.", + "DISABLE_AREAS_LABEL": "Áreas", + "DISABLE_AREAS_DESC": "Permite a los usuarios gestionar sus suscripciones a áreas.", + "DISABLE_PROFILES_LABEL": "Perfiles", + "DISABLE_PROFILES_DESC": "Permite a los usuarios crear y cambiar perfiles de alarma.", + "DISABLE_LOCATION_LABEL": "Ubicación", + "DISABLE_LOCATION_DESC": "Permite a los usuarios establecer una ubicación de inicio.", + "DISABLE_NOMINATIM_LABEL": "Geocodificación", + "DISABLE_NOMINATIM_DESC": "Permite la búsqueda de direcciones de Nominatim para elegir ubicación.", + "DISABLE_USER_GEOFENCES_LABEL": "Geocercas personalizadas", + "DISABLE_USER_GEOFENCES_DESC": "Permite a los usuarios dibujar, importar y enviar sus propias geocercas. Las geocercas existentes siguen funcionando.", + "ENABLE_TEMPLATES_LABEL": "Plantillas", "ENABLE_TEMPLATES_DESC": "Permite a los usuarios elegir plantillas de mensaje de notificación.", "ALLOWED_LANGUAGES_LABEL": "Idiomas de interfaz permitidos", "ALLOWED_LANGUAGES_DESC": "Códigos de idioma separados por comas para mostrar en el selector (ej. «en,de,fr,es»). Deja en blanco para mostrar los 11 idiomas.", + "PORACLE_LOCALE_HINT": "Idioma predeterminado para usuarios nuevos: {{locale}}, tomado de la configuración de Poracle. Quien elija un idioma, o cuyo navegador pida uno disponible en este sitio, recibe ese en su lugar.", "ENABLE_ROLES_LABEL": "Activar acceso basado en roles", "ENABLE_ROLES_DESC": "Permitir iniciar sesión solo a usuarios con roles de Discord específicos. Requiere Bot Token y Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "IDs de roles permitidos", - "ALLOWED_ROLE_IDS_DESC": "IDs de roles de Discord separados por comas que conceden acceso (ej. «123456789,987654321»). Deja en blanco para permitir todos.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Idiomas permitidos", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Lista de códigos de idioma separados por comas que los usuarios pueden seleccionar (ej. «en,de,fr»).", + "ALLOWED_ROLE_IDS_DESC": "IDs de roles de Discord separados por comas, p. ej. 123456789,987654321. Un usuario necesita al menos uno de estos roles para iniciar sesión. Deja en blanco para permitir todos.", "REGISTER_COMMAND_LABEL": "Comando de registro", "REGISTER_COMMAND_DESC": "Comando del bot Poracle que los usuarios ejecutan para registrarse (ej. «$!register»).", "LOCATION_COMMAND_LABEL": "Comando de ubicación", @@ -1464,9 +1615,30 @@ "ENABLE_TELEGRAM_LABEL": "Activar inicio de sesión con Telegram", "ENABLE_TELEGRAM_DESC": "Permite iniciar sesión con Telegram en este sitio. Requiere TELEGRAM_ENABLED=true, bot token y bot username en .env (reinicio necesario tras cambios en .env).", "TELEGRAM_BOT_LABEL": "Nombre de usuario del bot", - "TELEGRAM_BOT_DESC": "Nombre de usuario del bot de Telegram (sin @).", + "TELEGRAM_BOT_DESC": "Nombre de usuario del bot de Telegram (sin @). Se usa cuando TELEGRAM_BOT_USERNAME no está configurado.", "ENABLE_DISCORD_LABEL": "Activar inicio de sesión con Discord", "ENABLE_DISCORD_DESC": "Permite iniciar sesión con Discord en este sitio. Requiere Discord Client ID y Client Secret en .env (reinicio necesario tras cambios en .env). No afecta la entrega del bot PoracleNG.", + "ENABLE_OIDC_LABEL": "Habilitar inicio de sesión SSO externo", + "ENABLE_OIDC_DESC": "Permite iniciar sesión mediante el proveedor OIDC/OAuth2 externo configurado. Requiere los ajustes OIDC_* (URLs del proveedor, client ID y secreto) en .env (reinicio necesario tras cambios en .env).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Todos los usuarios son redirigidos al proveedor SSO externo. Se omite el inicio de sesión local.", + "AUTH_MODE_SWITCH_CONFIRM": "Cambiar a SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "¿Cambiar al inicio de sesión SSO?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Tras guardar, todos los usuarios (incluidos los administradores) serán redirigidos a {{provider}} para iniciar sesión; se omite la página de inicio de sesión local de Discord/Telegram. Si el proveedor no está disponible podrías quedar bloqueado; recupera el acceso estableciendo AUTH_FORCE_LOCAL=true en el entorno del servidor.", + "AUTH_OIDC_NOT_CONFIGURED": "El SSO no está disponible hasta que el proveedor OIDC esté configurado en el entorno del servidor (variables de entorno OIDC_*).", + "AUTH_OIDC_HIDES_LOCAL": "Discord y Telegram se ocultan mientras el SSO sea el modo de inicio de sesión activo.", + "AUTH_SLO_LABEL": "Cierre de sesión único", + "AUTH_SLO_DESC": "Cuando está habilitado, \"Cerrar sesión en todas partes\" también finaliza la sesión del proveedor (no solo la de este sitio). Requiere el endpoint de fin de sesión del proveedor (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "El cierre de sesión único no está disponible hasta que se configure el endpoint de fin de sesión del proveedor (variable de entorno OIDC_END_SESSION_URL).", + "OIDC_SERVER_CONFIG": "Configuración del proveedor OIDC", + "OIDC_PROVIDER_LABEL": "Nombre del proveedor", + "OIDC_AUTHORIZATION_URL_LABEL": "URL de autorización", + "OIDC_TOKEN_URL_LABEL": "URL de token", + "OIDC_USERINFO_URL_LABEL": "URL de UserInfo", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Claim de identidad", + "OIDC_USE_PKCE_LABEL": "Usar PKCE", "PROVIDER_URL_LABEL": "URL de teselas de mapa", "PROVIDER_URL_DESC": "Plantilla de URL del proveedor de teselas de mapa (para mapas estáticos).", "GANALYTICSID_LABEL": "ID de Google Analytics", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "IDs de admin", "DISCORD_ADMIN_IDS_DESC": "IDs de usuarios de Discord con acceso admin (oculto).", "DISCORD_GEOFENCE_FORUM_LABEL": "Canal del foro de geocercas", - "DISCORD_GEOFENCE_FORUM_DESC": "Canal de foro de Discord para hilos de envío de geocercas." + "DISCORD_GEOFENCE_FORUM_DESC": "Canal de foro de Discord para hilos de envío de geocercas.", + "SEARCH_PLACEHOLDER": "Buscar ajustes…", + "SEARCH_CLEAR": "Borrar búsqueda", + "UNSAVED_CHANGES": "{{count}} sin guardar", + "SAVE_CHANGES": "Guardar cambios", + "DISCARD_CHANGES": "Descartar", + "COLLAPSE_SECTION": "Contraer sección", + "EXPAND_SECTION": "Expandir sección", + "SUMMARY_ENABLED": "{{count}} de {{total}} activados", + "GROUP_AUTH": "Autenticación", + "AUTH_MODE_LABEL": "Modo de inicio de sesión", + "AUTH_MODE_LOCAL": "Local", + "AUTH_MODE_LOCAL_DESC": "Inicia sesión directamente con Discord o Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "El inicio de sesión local está forzado por la configuración del servidor.", + "DISABLE_UPDATE_CHECK_LABEL": "No buscar actualizaciones", + "DISABLE_UPDATE_CHECK_DESC": "Impide que el sitio pregunte a GitHub si hay una versión más reciente de PoracleWeb o Poracle. Es la única petición que sale de tu red y no envía ningún dato." }, "GEOFENCE_DETAIL": { "NAME": "Nombre", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Tu ubicación", "SELECTED_COUNT": "{{count}} seleccionado(s):", "AREAS_SELECTED": "{{count}} área(s) seleccionada(s)" + }, + "ALERT_DEFAULTS": { + "TITLE": "Valores predeterminados de alertas", + "DESC": "Elige cómo se entregan las nuevas alertas de forma predeterminada. Podrás cambiarlo para cada alerta al crearla.", + "DEFAULT_DISTANCE": "Distancia predeterminada", + "DEFAULT_DISTANCE_HINT": "Se usa para rellenar el radio de las nuevas alertas basadas en distancia.", + "FOOTNOTE": "Solo se aplica a las alertas nuevas; las existentes no se modifican.", + "DISTANCE_TOO_SMALL": "Debe ser al menos 0,1 km.", + "DISTANCE_TOO_LARGE": "Debe ser 100 km o menos." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Elementos por página:", + "RANGE": "{{start}} - {{end}} de {{total}}", + "RANGE_EMPTY": "0 de {{total}}", + "NEXT_PAGE": "Página siguiente", + "PREVIOUS_PAGE": "Página anterior", + "FIRST_PAGE": "Primera página", + "LAST_PAGE": "Última página" + }, + "WHERE": { + "SET_PIN": "Definir tu ubicación", + "PIN_MISSING_WARNING": "Aún no has definido tu ubicación, así que esta alerta no tendría desde dónde medir.", + "PLACES_EMPTY_TITLE": "Todavía no hay lugares", + "PIN_UNSET": "Sin definir", + "PLACES_PAGE_DESC": "Puntos con nombre a los que puedes dirigir tus alertas en vez de a tu ubicación.", + "ADD_PLACE": "Añadir un lugar", + "AREAS_LABEL": "Áreas", + "AREA_LIST_MORE": "{{areas}} y {{count}} más", + "MEASURED_FROM": "Medido desde", + "MY_PIN": "Mi ubicación", + "NAME_PLACE_MESSAGE": "¿Cómo quieres llamar a este lugar?", + "NAME_PLACE_TITLE": "Nombrar este lugar", + "NEAR_PIN": "A menos de {{distance}} km de mi ubicación", + "NEAR_PLACE": "A menos de {{distance}} km de {{place}}", + "NO_PLACES": "Todavía no hay lugares. Añade uno abajo para dirigir esta alerta fuera de tu ubicación.", + "ONLY_IN": "Solo en {{areas}}", + "OPTION_AREAS": "Solo en áreas concretas", + "OPTION_NEAR": "Cerca de un punto", + "OPTION_PLACE": "Cerca de un lugar", + "OPTION_PROFILE": "En cualquier parte de mis áreas", + "PIN_NOTE": "El valor por defecto para toda alerta sin destino propio.", + "PIN_TITLE": "Mi ubicación", + "PLACES_EMPTY": "Añade uno para recibir alertas fuera de tu ubicación: el trabajo, el gimnasio, casa de tus padres.", + "PLACES_TITLE": "Lugares", + "PLACE_DELETED": "{{place}} eliminado.", + "PLACE_DELETE_CONFIRM": "Las alertas dirigidas a {{place}} volverán a usar tu ubicación.", + "PLACE_DELETE_ERROR": "No se pudo eliminar ese lugar.", + "PLACE_DELETE_TITLE": "¿Eliminar este lugar?", + "PLACE_IN_USE": "{{place}} lo usan {{count}} alerta(s). Redirígelas primero.", + "PLACE_LABEL": "Lugar", + "PLACE_NAME": "Nombre", + "PLACE_SAVED": "{{place}} guardado.", + "PLACE_SAVE_ERROR": "No se pudo guardar ese lugar.", + "PROFILE_ANYWHERE": "Donde sea que reciba alertas", + "PROFILE_AREAS": "En cualquier parte de mis áreas", + "RADIUS_KM": "Radio (km)", + "SAVE": "Guardar destino", + "SCOPE_SAVED": "Destino actualizado.", + "SCOPE_SAVE_ERROR": "No se pudo cambiar dónde te llega esa alerta.", + "SHEET_TITLE": "¿Dónde quieres recibir esta alerta?", + "USE_THIS_POINT": "Usar este punto" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json index b39de4a9..7966d034 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -16,7 +16,7 @@ "GYMS": "Arènes", "FORT_CHANGES": "Changements de fort", "PROFILES": "Profils", - "AREAS": "Zones", + "AREAS": "Zones et lieux", "MY_GEOFENCES": "Mes Geofences", "CLEANING": "Nettoyage", "HELP": "Aide", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Vu en tant que", - "BACK_TO_ADMIN": "Retour à l'admin", + "EXIT_IMPERSONATION": "Retour à ton compte", "DISABLED_ACCOUNT": "Ton compte a été désactivé. Cela peut être dû à une limitation de débit ou à une action administrative.", + "DISABLED_ACCOUNT_INSPECTED": "Ce compte a été désactivé par un administrateur et ne reçoit aucune notification.", "DISABLED_SUPPORT": "Pour obtenir de l'aide, demande dans", "PAUSED_ALERTS": "Tes alertes sont en pause. Tu ne recevras pas de notifications.", "RESUME": "Reprendre" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Change uniquement le texte du site.", "PROFILE_PREFIX": "Profil #", "PAUSE_ALERTS": "Mettre en pause", "RESUME_ALERTS": "Reprendre les alertes", "SWITCH_PROFILE": "Changer de profil", - "AREAS_LOCATION": "Zones et localisation", "CLEANING": "Nettoyage", "ACCENT_THEME": "Thème d'accent", - "LANGUAGE": "Langue", + "DISPLAY_LANGUAGE": "Langue de l'interface", + "ALERT_LANGUAGE": "Langue des alertes", + "ALERT_LANGUAGE_HINT": "Utilisée pour le texte des alertes et les noms de Pokemon.", "LOGOUT": "Déconnexion", + "LOGOUT_EVERYWHERE": "Se déconnecter partout", "ACCENT_DEFAULT": "Par défaut", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Réglages par défaut des alertes" }, "SHORTCUTS": { "TITLE": "Raccourcis clavier", @@ -77,6 +82,7 @@ "NETWORK": "Impossible de joindre le serveur. Vérifie ta connexion.", "BAD_REQUEST": "Requête invalide. Vérifie tes données.", "UNAUTHORIZED": "Ta session a expiré. Reconnecte-toi.", + "INSPECTION_ENDED": "Inspection terminée : vous êtes de retour dans votre propre session.", "FORBIDDEN": "Tu n'as pas la permission d'effectuer cette action.", "NOT_FOUND": "La ressource demandée est introuvable.", "CONFLICT": "Un conflit est survenu. L'élément a peut-être été modifié.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Assistant de bienvenue" }, "POKEMON": { + "PVP_EVOLUTION": "Méga-évolution", + "PVP_EVOLUTION_HINT": "Classez les formes de base ou une méga. Les mégas sont classées à part : une règle méga ne correspondra pas à une forme de base.", + "PVP_EVO_BASE": "Base", + "PVP_EVO_MEGA": "Méga", + "PVP_EVO_MEGA_X": "Méga X", + "PVP_EVO_MEGA_Y": "Méga Y", "PAGE_TITLE": "Alarmes Pokemon", "PAGE_DESC": "Suis les spawns sauvages de Pokemon avec des filtres IV, CP, niveau et PVP personnalisés.", "SEARCH_PLACEHOLDER": "Rechercher par nom ou #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Forme et genre", "LABEL_FORM": "Forme", "ALL_FORMS": "Toutes les formes", + "FORM_MULTI_HINT": "Laissez vide pour inclure toutes les formes", "LABEL_GENDER": "Genre", "GENDER_ALL": "Tous", "GENDER_MALE": "Mâle", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "N'alerter que si le CP évolué atteint ce minimum", "PVP_DISABLED_HINT": "Sélectionne une ligue pour filtrer par rang PVP.", "SNACK_CREATED": "{{count}} alarme(s) Pokemon créée(s)", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} alarme(s) Pokemon creee(s), {{duplicates}} deja suivie(s)", "SNACK_UPDATED": "Alarme Pokemon mise à jour", "SNACK_DELETED": "Alarme Pokemon supprimée", "SNACK_DELETED_ALL": "Toutes les alarmes Pokemon supprimées", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normale", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Plafond de niveau", + "PVP_CAP_ALL": "Tous", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Par défaut — depuis la configuration Poracle", + "FILTER_TIME_LEFT": "Temps Restant", + "LABEL_MIN_TIME": "Temps restant minimum", + "MIN_TIME_HINT": "Ignore les apparitions qui disparaîtront avant votre arrivée.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "{{count}} min restantes", + "PILL_TIME_LEFT_SECONDS": "{{count}} s restantes", + "MIN_TIME_ANY": "Toute" }, "ALARM": { "LOCATION_MODE": "Mode de localisation", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Supprime automatiquement la notification de Discord quand le leurre expire", "CLEAN_HINT_NEST": "Supprime automatiquement la notification de Discord quand les nids migrent", "CLEAN_HINT_GYM": "Supprime automatiquement la notification de Discord quand l'activité d'arène change", - "CLEAN_HINT_FORT": "Supprime automatiquement la notification de Discord après expiration", "CLEAN_HINT_MAX_BATTLE": "Supprime automatiquement la notification de Discord quand le combat max se termine", "SAVING": "Enregistrement...", "SAVE": "Enregistrer", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Temps de recharge actif", "TEST_SEND": "Envoyer une notification test", "TAB_DELIVERY": "Livraison", - "COMMON_SETTINGS": "Paramètres communs" + "COMMON_SETTINGS": "Paramètres communs", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} creees, {{duplicates}} deja suivies" }, "RAIDS": { + "RSVP_LABEL": "Notifications RSVP", + "RSVP_OFF": "Correspondances uniquement", + "RSVP_INCLUDE": "Correspondances + mises à jour RSVP", + "RSVP_ONLY": "Mises à jour RSVP uniquement", + "RSVP_OFF_DESC": "Alertes raid/œuf standard uniquement.", + "RSVP_INCLUDE_DESC": "Notifier également lorsque les RSVP changent.", + "RSVP_ONLY_DESC": "Ignorer les correspondances initiales ; notifier uniquement les changements de RSVP. Sans un scanner émettant des RSVP, cette alerte est silencieuse.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "RSVP uniquement", "PAGE_TITLE": "Alarmes Raid et Œuf", "PAGE_DESC": "Sois notifié des boss de raid et des éclosions d'œufs dans les arènes proches.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Es-tu sûr de vouloir supprimer TOUTES les alarmes Raid et Œuf ? Cette action est irréversible.", "CONFIRM_BULK_DELETE_TITLE": "Supprimer les alarmes sélectionnées", "CONFIRM_BULK_DELETE_MSG": "Es-tu sûr de vouloir supprimer {{count}} alarmes ?", - "CONFIRM_DELETE_SELECTED": "Supprimer la sélection" + "CONFIRM_DELETE_SELECTED": "Supprimer la sélection", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Niveau", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Spéciaux", + "SECTION_CUSTOM": "Personnalisés", + "ADD": "Ajouter un niveau", + "ADD_PLACEHOLDER": "ex. 42", + "ADD_HELP": "Tout entier positif utilisé par votre serveur. 9000 signifie « tous niveaux ».", + "INVALID": "Le niveau doit être au moins 1.", + "DUPLICATE": "Le niveau {{value}} est déjà dans la liste.", + "SR_REMOVE": "Supprimer le niveau personnalisé {{value}}", + "REMOVED": "Niveau {{value}} supprimé", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes Quête", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Méga-Énergie", "TAB_CANDY": "Bonbons", "ITEM_REWARD": "Récompense objet", - "ANY_ITEM": "Tout objet", + "ANY_ITEM": "Objet quelconque", "QUEST_TYPE_LABEL": "Type de quête :", "SNACK_CREATED": "Alarme Quête créée", "SNACK_UPDATED": "Alarme Quête mise à jour", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Toutes les alarmes Quête supprimées", "SNACK_FAILED_DELETE_ALL": "Échec de la suppression des alarmes", "SNACK_FAILED_DISTANCE": "Échec de la mise à jour des distances", - "CONFIRM_DELETE_SELECTED": "Supprimer la sélection" + "CONFIRM_DELETE_SELECTED": "Supprimer la sélection", + "SUMMARY_MODE": "Résumé quotidien", + "SUMMARY_HINT": "Regroupe les quêtes correspondantes dans un seul message de résumé au lieu d’une notification par quête. Nécessite une planification de résumé configurée sur le bot.", + "SUMMARY_BADGE": "Résumé", + "SUMMARY_SCHEDULE": "Livraison du résumé des quêtes", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Résumé des quêtes", + "SUMMARY_SCHEDULE_EMPTY": "Aucune planification de résumé définie. Les quêtes sont livrées individuellement.", + "SUMMARY_SCHEDULE_EDIT": "Modifier la planification", + "SUMMARY_SCHEDULE_CLEAR": "Supprimer la planification", + "SUMMARY_SCHEDULE_SEND_NOW": "Envoyer le résumé maintenant", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Envoie les quêtes correspondantes accumulées depuis votre dernier résumé. Si rien n'est encore en mémoire tampon, rien n'est envoyé.", + "SUMMARY_SCHEDULE_SAVED": "Planification du résumé enregistrée", + "SUMMARY_SCHEDULE_CLEARED": "Planification du résumé supprimée", + "SUMMARY_SCHEDULE_SENT": "Résumé envoyé", + "SUMMARY_SCHEDULE_FAILED": "Impossible de mettre à jour la planification du résumé", + "SUMMARY_SCHEDULE_UNAVAILABLE": "La livraison du résumé est temporairement indisponible. Veuillez réessayer plus tard.", + "SUMMARY_DISABLED_HINT": "La planification des résumés n'est pas disponible sur ce serveur.", + "TAB_STARDUST": "Poussière Étoilée", + "MIN_AMOUNT": "Quantité minimum", + "MIN_AMOUNT_HINT": "0 = toute quantité", + "MIN_STARDUST": "Poussière étoilée minimum", + "MIN_STARDUST_HINT": "0 = toute étude en poussière étoilée", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Alarmes Invasion", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnétique", "TYPE_RAINY": "Pluvieux", "TYPE_GOLDEN": "Doré", - "TYPE_UNKNOWN": "Module #{{id}}" + "TYPE_UNKNOWN": "Module #{{id}}", + "EDIT_MODE": "Modifier le message sur place", + "EDIT_HINT": "Met à jour le message Discord existant lorsque le leurre change au lieu d’en envoyer un nouveau.", + "EDIT_BADGE": "Modifier", + "CONFIRM_DELETE_TITLE": "Supprimer l'alerte de module leurre ?", + "SNACK_FAILED_DISTANCE": "Impossible de mettre à jour la distance." }, "NESTS": { "PAGE_TITLE": "Alarmes Nid", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Alarme Nid supprimée", "SNACK_FAILED_CREATE": "Échec de la création de l'alarme", "SNACK_FAILED_UPDATE": "Échec de la mise à jour de l'alarme", - "SNACK_FAILED_DELETE": "Échec de la suppression de l'alarme" + "SNACK_FAILED_DELETE": "Échec de la suppression de l'alarme", + "CONFIRM_DELETE_TITLE": "Supprimer l'alerte de nid ?", + "SNACK_FAILED_DISTANCE": "Impossible de mettre à jour la distance." }, "GYMS": { "PAGE_TITLE": "Alarmes Arène", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Sagesse", "TEAM_VALOR": "Bravoure", "TEAM_INSTINCT": "Intuition", - "TEAM_UNKNOWN": "Équipe {{id}}" + "TEAM_UNKNOWN": "Équipe {{id}}", + "CONFIRM_DELETE_TITLE": "Supprimer l'alerte d'arène ?", + "SNACK_FAILED_DISTANCE": "Impossible de mettre à jour la distance." }, "FORT_CHANGES": { "PAGE_TITLE": "Alarmes Changement de fort", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Supprimé", "CHANGE_NEW": "Nouveau fort", "INCLUDE_EMPTY": "Inclure les forts sans nom", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Impossible de créer l'alerte", + "CREATE_SUCCESS": "Alerte de changement d'arène créée", + "UPDATE_FAILED": "Impossible de mettre à jour l'alerte", + "UPDATE_SUCCESS": "Alerte de changement d'arène mise à jour", "ALL_CHANGES": "Tous les changements", "LABEL_NAME": "Nom", "LABEL_LOCATION": "Position", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Supprimer l'alarme de changement {{type}} ?", "SNACK_DELETED": "Alarme de changement supprimée", "SNACK_FAILED_DISTANCE": "Échec de la mise à jour des distances", - "SNACK_ALL_DISTANCE": "Toutes les distances mises à jour" + "SNACK_ALL_DISTANCE": "Toutes les distances mises à jour", + "FORT_TYPE_LABEL": "Type de fort", + "CHANGE_TYPES_LABEL": "Types de changement", + "TRACKING_SUBTITLE": "Suivi des changements de fort", + "CHANGE_DESCRIPTION": "Description modifiée" }, "MAX_BATTLES": { "PAGE_TITLE": "Alarmes Combat Max", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Impossible de créer les alertes", + "CREATE_SUCCESS": "{{count}} alerte(s) créée(s)", "ANY_POKEMON": "N'importe quel Pokémon", "ANY_LEVEL": "N'importe quel niveau", "STAR_LABEL": "{{stars}} étoiles", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Échec de la mise à jour des distances", "SNACK_ALL_DISTANCE": "Toutes les distances mises à jour", "SNACK_FAILED_UPDATE": "Échec de la mise à jour de l'alarme", - "SNACK_UPDATED": "Alarme Combat Dynamax mise à jour" + "SNACK_UPDATED": "Alarme Combat Dynamax mise à jour", + "HINT_BY_LEVEL": "Suit n’importe quel Pokemon à ces niveaux de combat. Chaque niveau choisi devient sa propre alarme.", + "HINT_BY_POKEMON": "Suit des Pokemon précis en Combat Max, quel que soit le niveau.", + "HINT_GMAX_ONLY_ADD": "Ne signale que les combats Gigantamax des Pokemon choisis.", + "HINT_GMAX_ONLY_EDIT": "Ne signale que les combats Gigantamax de ce Pokemon.", + "HINT_ALL_LEVELS": "Cette alarme suit un Pokemon à tous les niveaux de Combat Max.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Zones et localisation", + "MANAGE_PLACES": "Gérer les lieux", + "PAGE_TITLE": "Zones et lieux", "PAGE_DESC": "Contrôle où tu reçois les notifications.", "METHOD_AREAS": "Zones", "METHOD_AREAS_ACTIVE": "{{count}} zone(s) active(s)", "METHOD_NOT_CONFIGURED": "Non configuré", "METHOD_AREAS_DESC": "Sois notifié de tout ce qui se passe dans tes zones geofencées sélectionnées.", "METHOD_AREAS_TIP": "Idéal pour : couvrir des villes entières, des quartiers ou des parcs", - "METHOD_LOCATION": "Localisation", - "METHOD_LOCATION_NOT_SET": "Non définie", - "METHOD_LOCATION_DESC": "Sois notifié de tout dans un rayon défini autour de ta position.", + "METHOD_LOCATION": "Ma position", + "METHOD_LOCATION_NOT_SET": "Aucune position définie", + "METHOD_LOCATION_DESC": "Soyez averti de tout ce qui se trouve à une distance définie de votre position.", "METHOD_LOCATION_TIP": "Idéal pour : alertes près de chez toi, du travail ou d'un lieu précis", "CLEAR_LOCATION": "Effacer", "CHANGE_LOCATION": "Modifier", "SET_LOCATION": "Définir", "METHOD_NOTE": "Chaque alarme choisit une méthode dans son onglet Livraison.", + "NOTIFICATION_LANGUAGE": "Langue des notifications", + "NOTIFICATION_LANGUAGE_DESC": "La langue utilisée par Poracle pour vos messages d'alerte et les noms de Pokémon. Elle est distincte de la langue d'affichage du menu en haut.", + "SNACK_LANGUAGE_UPDATED": "Langue des notifications mise à jour", + "SNACK_LANGUAGE_FAILED": "Échec de la mise à jour de la langue des notifications", "SELECT_AREAS": "Sélectionner les zones", "MAP_VIEW": "Carte", "LIST_VIEW": "Liste", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Échec de la mise à jour de la localisation", "SEARCH_AREAS": "Rechercher des zones", "MANUAL_ADD_PLACEHOLDER": "Saisissez un nom de zone et appuyez sur Entrée", - "FILTER_PLACEHOLDER": "Filtrer par nom..." + "FILTER_PLACEHOLDER": "Filtrer par nom...", + "SNACK_LOAD_SELECTED_FAILED": "Impossible de charger vos zones actuelles. Rechargez avant de les modifier.", + "SELECTION_UNKNOWN": "Vos zones actuelles n’ont pas pu être chargées — rechargez la page avant d’enregistrer." }, "PROFILES": { "PAGE_TITLE": "Profils", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Sélectionner la région", "SEARCH_REGIONS": "Rechercher des régions...", "TOGGLE_TOOLTIP": "Activer/désactiver les notifications pour ce geofence sur le profil actuel", - "CREATED_PREFIX": "Créé" + "CREATED_PREFIX": "Créé", + "REGION_OPTIONAL_HINT": "Facultatif. Choisissez une région si votre géorepère appartient à l’une d’elles." }, "CLEANING": { "PAGE_TITLE": "Mode nettoyage", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Certains contenus d'aide ne sont peut-être pas encore disponibles dans ta langue.", "TRANSLATION_CTA_LINK": "Aider à traduire", "FALLBACK_CHIP": "Anglais", + "IMAGE_ENLARGE": "Cliquez pour agrandir", "SECTION_GETTING_STARTED": "Premiers pas", "SECTION_GETTING_STARTED_SUB": "Connexion, assistant d'accueil et configuration initiale", "SECTION_DASHBOARD": "Tableau de bord", "SECTION_DASHBOARD_SUB": "Ton aperçu des alarmes, zones et statut", - "SECTION_LOCATION": "Définir ta localisation", + "SECTION_LOCATION": "Définir ta position", "SECTION_LOCATION_SUB": "GPS, recherche d'adresse et coordonnées", - "SECTION_AREAS": "Choisir tes zones", - "SECTION_AREAS_SUB": "Vue carte, vue liste et filtrage par région", + "SECTION_AREAS": "Zones et lieux", + "SECTION_AREAS_SUB": "Vue carte, vue liste, filtrage par région et lieux", "SECTION_GEOFENCES": "Geofences personnalisées", "SECTION_GEOFENCES_SUB": "Dessiner des limites, soumettre pour approbation publique", "SECTION_POKEMON": "Alarmes Pokemon", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Autres types d'alarme", "SECTION_OTHER_ALARMS_SUB": "Raids, œufs, quêtes, Rocket, leurres, nids, arènes, changements de fort", "SECTION_DELIVERY": "Paramètres de livraison", - "SECTION_DELIVERY_SUB": "Zones vs distance, modèles et mode nettoyage", + "SECTION_DELIVERY_SUB": "Portée de livraison, modèles et mode nettoyage", + "SECTION_QUEST_SUMMARY": "Livraison du résumé des quêtes", + "SECTION_QUEST_SUMMARY_SUB": "Regroupez les quêtes bruyantes dans un seul résumé planifié", "SECTION_TEST_ALERTS": "Alertes test", "SECTION_TEST_ALERTS_SUB": "Envoyer des notifications test pour prévisualiser tes alarmes", "SECTION_POKEMON_AVAILABILITY": "Disponibilité des Pokemon", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Problèmes courants et comment les résoudre", "CONTENT_GETTING_STARTED": "

Le site DM Alertes te permet de personnaliser exactement quelles notifications Pokemon GO tu reçois en messages directs. Au lieu de recevoir chaque alerte, tu choisis ce qui t'intéresse — des Pokemon spécifiques, des raids, des quêtes et plus — et tu n'es notifié que pour ceux-là.

ℹ️
Avant de pouvoir utiliser le site, tu dois d'abord t'inscrire auprès du bot Poracle sur Discord ou Telegram. Une fois inscrit, reviens ici et connecte-toi.

Se connecter

  • Discord — Clique sur \"Se connecter avec Discord\" sur la page de connexion. Tu seras redirigé vers Discord pour autoriser l'application, puis redirigé automatiquement.
  • Telegram — Si activé, utilise le widget de connexion Telegram sur la page de connexion. Confirme la connexion dans ton application Telegram.
\"Login

Première configuration

Lors de ta première connexion, un assistant de bienvenue te guide en trois étapes :

  1. Définis ta localisation — Utilisée pour calculer les distances des notifications proches.
  2. Choisis tes zones — Sélectionne les zones géographiques pour lesquelles tu veux des alertes.
  3. Ajoute ta première alarme — Crée une alarme Pokemon, Raid ou Quête pour commencer à être notifié.
\"Onboarding

Tu peux sauter une étape et y revenir plus tard. L'assistant n'apparaîtra plus une fois que tu l'as fermé ou que tu as complété toutes les étapes.

", "CONTENT_DASHBOARD": "\"Dashboard

Le tableau de bord est ta page d'accueil. Il montre un aperçu de ta configuration actuelle en un coup d'œil.

Cartes de statut

  • Localisation — Affiche tes coordonnées ou adresse enregistrées. Clique pour définir ou mettre à jour ta localisation.
  • Zones actives — Affiche combien de zones tu suis. Clique pour gérer tes zones.
  • Profil — Affiche ton profil actif. Si tu as plusieurs profils, clique pour basculer entre eux.

Filtres actifs

Une grille de cartes montre le nombre d'alarmes par type (Pokemon, Raids, Quêtes, etc.). Clique sur une carte pour accéder à cette liste d'alarmes.

Météo

Si tu as une localisation définie, le tableau de bord affiche la météo en jeu actuelle à tes coordonnées ainsi que l'heure de la dernière mise à jour. La météo par zone est aussi affichée pour chacune de tes zones sélectionnées.

Actions rapides

Boutons raccourcis pour ajouter des alarmes Pokemon, Raid ou Quête, gérer les zones ou configurer le nettoyage — le tout sans naviguer dans la barre latérale.

Astuces

Des rappels utiles apparaissent quand ta configuration est incomplète — comme une localisation manquante, aucune zone sélectionnée ou aucune alarme configurée. Chaque astuce a un bouton d'action pour corriger le problème.

Navigation

Utilise la barre latérale pour naviguer entre les sections. Les types d'alarme sont listés en haut, suivis des paramètres comme Zones, Geofences, Profils et Nettoyage. L'aide est toujours en bas.

\"Sidebar", - "CONTENT_LOCATION": "\"Dashboard

Ta localisation est utilisée pour les notifications basées sur la distance. Quand une alarme utilise le mode \"Définir la distance\", tu seras notifié des événements dans un rayon autour de cette position.

Définir ta localisation

Ouvre le dialogue de localisation depuis le Tableau de bord ou la page Zones. Tu as quatre options :

  • Rechercher une adresse — Tape une adresse, ville ou nom de lieu. Sélectionne parmi les suggestions.
  • Entrer des coordonnées — Tape directement la latitude et la longitude.
  • Utiliser ton GPS — Clique \"Utiliser ma position\" pour utiliser la position actuelle de ton appareil.
  • Cliquer sur la carte — Clique n'importe où sur la mini-carte pour définir ce point comme ta position.

Après avoir sélectionné une localisation, l'adresse s'affiche automatiquement. Clique Enregistrer pour confirmer.

💡
Tu peux effacer ta localisation depuis la page Zones si tu ne veux que des alertes basées sur les zones.
", - "CONTENT_AREAS": "\"Areas

Les zones sont des zones géographiques prédéfinies configurées par ta communauté. Quand une alarme utilise le mode \"Utiliser les zones\", tu es notifié des événements qui se produisent dans tes zones sélectionnées.

Sélectionner des zones

Va dans Zones et localisation depuis la barre latérale. Tu peux sélectionner des zones de deux façons :

  • Vue carte — Clique sur les polygones colorés sur la carte pour sélectionner ou désélectionner des zones. Les zones sélectionnées deviennent vertes. Survole une zone pour voir son nom.
  • Vue liste — Utilise les cases à cocher pour choisir des zones dans une liste avec recherche.

Filtrage par région

Si ta communauté a beaucoup de zones dans différentes régions, utilise le menu déroulant de région pour filtrer une région spécifique.

Zones imbriquées

Certaines zones se chevauchent — une zone plus petite à l'intérieur d'une plus grande. Les deux sont cliquables. Zoome pour cliquer plus facilement sur la plus petite.

Enregistrer

Une barre d'enregistrement apparaît en bas quand tu as fait des modifications. Clique Enregistrer pour confirmer ou Annuler pour revenir en arrière.

ℹ️
Les zones sont par profil. Chaque profil a ses propres zones sélectionnées. Changer de profil montrera des sélections de zones différentes. Les geofences personnalisées peuvent aussi être activées ou désactivées par profil depuis la page Geofences.
", - "CONTENT_GEOFENCES": "\"My

Si les zones prédéfinies ne couvrent pas l'endroit où tu veux des alertes, tu peux dessiner tes propres limites de geofence sur la carte.

Dessiner une geofence

  1. Va dans Mes Geofences depuis la barre latérale.
  2. Clique sur Dessiner une geofence.
  3. Clique sur la carte pour placer les points de ton polygone. Clique sur le premier point pour fermer la forme (minimum 3 points).
  4. Donne un nom à ta geofence et sélectionne la région. La région est généralement détectée automatiquement.
  5. Clique Enregistrer.

Gérer les geofences

  • Modifier — Renommer ta geofence ou changer sa région.
  • Supprimer — Supprimer une geofence dont tu n'as plus besoin. Elle est retirée de tous les profils automatiquement.

Bascule par profil

Chaque carte de geofence a un curseur pour l'activer ou la désactiver pour ton profil actuel. Quand tu crées une geofence, elle est automatiquement activée sur le profil en cours. Passe à un autre profil et le curseur affichera \"Inactive\" — active-le pour recevoir des alertes pour cette geofence sur ce profil aussi.

ℹ️
Les geofences approuvées (promues en zones publiques) n'affichent pas le curseur — gère-les depuis la page Zones.

Import et export GeoJSON

Tu peux importer et exporter des geofences au format standard GeoJSON.

  • Import — Clique sur l'icône d'envoi et charge un fichier GeoJSON. Chaque polygone du fichier devient une nouvelle geofence.
  • Export — Clique sur l'icône de téléchargement et sélectionne les geofences à exporter.
💡
L'import GeoJSON est utile pour migrer des geofences d'autres systèmes ou dessiner des limites complexes dans un outil SIG.

Soumettre pour approbation publique

Si tu penses que ta geofence serait utile pour toute la communauté, tu peux la soumettre pour révision admin. Si approuvée, elle devient une zone publique que tout le monde peut sélectionner.

Badges de statut

  • Active — Ta geofence privée, fonctionnelle pour toi uniquement.
  • En attente de révision — Soumise et en attente de révision admin.
  • Approuvée — Promue en zone publique.
  • Rejetée — Non approuvée. Tu peux voir les commentaires de l'admin et la geofence reste active en zone privée.
ℹ️
Tu peux avoir jusqu'à 10 geofences personnalisées, chacune avec jusqu'à 500 points de limite.
", - "CONTENT_POKEMON": "\"Pokemon

Les alarmes Pokemon te notifient quand un Pokemon sauvage apparaît et correspond à tes filtres.

Ajouter une alarme Pokemon

\"Add
  1. Va dans Pokemon depuis la barre latérale et clique sur le bouton +.
  2. Sélectionner des Pokemon — Recherche par nom ou numéro Pokédex, ou utilise les boutons de filtre par génération et type. Tu peux sélectionner plusieurs Pokemon à la fois.
  3. Définir les filtres — Choisis ce qui rend un spawn digne de notification :
  • Plage d'IV — Pourcentage d'IV minimum et maximum (0-100%)
  • Plage de CP — Filtrer par puissance de combat
  • Plage de niveau — Filtrer par niveau du Pokemon (0-55)
  • Stats individuelles — Filtrer par valeurs ATK, DEF et STA (0-15 chacune)
  • Forme — Suivre des formes spécifiques (ex. Alola, Galar) ou toutes les formes
  • Genre — Mâle, femelle, asexué ou tous
  • Poids — Filtrer par plage de poids
  • Taille — Filtrer par catégorie de taille : TOUTES (pas de filtre) pour toute taille, ou des tailles spécifiques de XXS à XXL
ℹ️
Valeurs de filtre par défaut sont réglées pour que tous les Pokemon correspondent quand aucun filtre n'est configuré. Par exemple, IV par défaut 0-100%, niveau 0-55 et taille TOUTES. Tu n'as qu'à ajuster les filtres qui t'intéressent.

Filtres PVP

Sois notifié quand un Pokemon a de bons IVs PVP. Sélectionne une ligue (Super, Hyper ou Coupe Junior) et définis la plage de rang (ex. rang 1-50).

Alarme \"Tous les Pokemon\"

💡
Sélectionne \"Tous les Pokemon\" (ID 0) pour créer une seule alarme qui couvre chaque espèce. Utile avec un filtre IV élevé comme 96-100%.

Lire les cartes d'alarme

Chaque carte d'alarme affiche des pastilles colorées résumant tes filtres en un coup d'œil :

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Raids

Alarmes Raid et Œuf

Sois notifié quand un boss de raid ou un œuf apparaît qui t'intéresse.

  • Par niveau — Sélectionne des niveaux de raid (1-6) ou d'œuf pour suivre tous les raids de ce palier.
  • Par boss — Sélectionne des boss de raid Pokemon spécifiques.
  • Filtre d'équipe — Ne notifier que pour les raids aux arènes contrôlées par une équipe spécifique (Mystic, Valor, Instinct).
  • Suivi d'arène — Suivre les raids à des arènes spécifiques par nom.
  • Filtre d'attaque — Filtrer les boss de raid par leurs attaques immédiates ou chargées.

Alarmes Combat Max (Dynamax)

Sois notifié des combats Dynamax et Gigantamax aux Power Spots.

  • Par niveau — Sélectionne des paliers de combat de 1 Étoile à 5 Étoiles (Légendaire), plus Gigantamax et Gigantamax Légendaire.
  • Par Pokemon — Sélectionne des Pokemon spécifiques à travers tous les niveaux de Combat Max.
  • Gigantamax uniquement — Ne recevoir que les notifications pour les combats Gigantamax.
  • Tout sélectionner — Sélectionner tous les niveaux disponibles d'un coup.

Alarmes Quête

Sois notifié des études de terrain avec des récompenses spécifiques.

  • Rencontres Pokemon — Pokemon en récompense de quête.
  • Objets — Quêtes avec des récompenses d'objets spécifiques.
  • Méga-Énergie — Quêtes donnant de la méga-énergie pour des Pokemon spécifiques.
  • Bonbons — Quêtes donnant des bonbons pour des Pokemon spécifiques.

Alarmes Invasion

Sois notifié des invasions Team Rocket.

  • Tout suivre — Une alarme pour chaque type de sbire et chef.
  • Par type — Sélectionne des types de sbires spécifiques (Insecte, Dragon, Feu, etc.), des chefs Rocket ou Giovanni.
  • Genre — Filtrer par genre du sbire.

Alarmes Leurre

Sois notifié quand un type de leurre spécifique est placé. Choisis parmi Normal, Glacial, Mousse, Magnétique, Pluvieux et Doré.

Alarmes Nid

Suis les espèces Pokemon nidifiantes. Définis un seuil minimum de spawns par heure.

Alarmes Arène

Suis les changements d'équipe d'arène. Sélectionne les équipes (Neutre, Mystic, Valor, Instinct). Active le suivi des changements de place ou des changements de combat.

Alarmes Changement de fort

Suis les changements aux PokéStops et arènes eux-mêmes.

  • Type de fort — PokéStops, Arènes ou Tout.
  • Types de changement — Nom modifié, Emplacement modifié, Image modifiée, Suppression ou Nouveau fort.
  • Inclure les vides — Inclure les forts sans nom.
💡
Les alarmes de changement de fort sont utiles pour suivre les mises à jour de la carte — nouveaux PokéStops, arènes déplacées ou POIs supprimés.

Cibler une arène spécifique

Pour les alarmes Raid, Œuf ou Arène, tu peux rechercher et sélectionner une arène spécifique. Utile quand tu ne t'intéresses qu'à l'activité de ton arène préférée.

  • Comment l'utiliser — Tape un nom d'arène dans le champ de recherche. Les résultats montrent la photo, le nom et la zone de l'arène.
  • Arène sélectionnée — L'alarme ne se déclenche que pour les événements à cette arène.
  • Aucune arène sélectionnée — Par défaut. L'alarme fonctionne pour toutes les arènes.
", - "CONTENT_DELIVERY": "\"Pokemon

Chaque alarme a des paramètres de livraison qui contrôlent tu es notifié.

Zones vs Distance

Chaque alarme utilise l'un des deux modes de livraison :

🗺
Utiliser les zonesNotifié quand des événements se produisent dans tes zones sélectionnées. Bon pour suivre des quartiers spécifiques.
📏
Définir la distanceNotifié dans un rayon (km) autour de ta position. Bon pour suivre tout ce qui est proche.

Tu peux utiliser des modes différents pour des alarmes différentes — par exemple, zones pour Pokemon et distance pour les raids.

Modèles de notification

Si les modèles sont activés, tu peux choisir l'apparence de tes notifications. Le sélecteur de modèle montre un aperçu de ce que ton DM Discord ressemblera.

Mode nettoyage

Quand activé, le bot supprime automatiquement la notification de Discord après l'expiration de l'événement. Tu peux activer le mode nettoyage par alarme ou en masse depuis la page Nettoyage.

Ping / Mentions de rôle

Si tu utilises des webhooks, tu peux définir un rôle Discord à mentionner dans la notification (ex. @Pokemon).

", + "CONTENT_LOCATION": "\"Tableau

Ta position est le point depuis lequel tes alertes sont mesurées. Une alarme qui te joint dans un rayon part de cette position, sauf si tu diriges cette alarme vers un lieu enregistré.

Définir ta localisation

Ouvre le dialogue de position depuis le Tableau de bord ou la page Zones et lieux. Tu as quatre options :

  • Rechercher une adresse — Tape une adresse, ville ou nom de lieu. Sélectionne parmi les suggestions.
  • Entrer des coordonnées — Tape directement la latitude et la longitude.
  • Utiliser ton GPS — Clique \"Utiliser ma position\" pour utiliser la position actuelle de ton appareil.
  • Cliquer sur la carte — Clique n'importe où sur la mini-carte pour définir ce point comme ta position.

Après avoir choisi un point, l'adresse s'affiche automatiquement. Clique Enregistrer pour confirmer.

Le même dialogue sert quand tu ajoutes un lieu ou choisis un point pour une seule alarme. Il s'intitule alors Choisir un point et se valide avec Utiliser ce point, sans toucher à ta propre position.

💡
Tu peux effacer ta position depuis la page Zones et lieux si tu ne veux que des alertes basées sur les zones.
", + "CONTENT_AREAS": "\"Page

Les zones sont des zones géographiques prédéfinies configurées par ta communauté. Celles que tu choisis ici sont celles que chaque alarme suit par défaut : une alarme réglée sur Partout dans mes zones se déclenche pour les événements qui s'y produisent.

Sélectionner des zones

Va dans Zones et lieux depuis la barre latérale. Tu peux sélectionner des zones de deux façons :

  • Vue carte — Clique sur les polygones colorés sur la carte pour sélectionner ou désélectionner des zones. Les zones sélectionnées deviennent vertes. Survole une zone pour voir son nom.
  • Vue liste — Utilise les cases à cocher pour choisir des zones dans une liste avec recherche.

Lieux

Un lieu est un point nommé — le travail, la salle de sport, la maison de tes parents — depuis lequel une alarme peut mesurer son rayon, à la place de ta position. Ajoute-le dans la section Lieux de la même page, puis choisis-le sous Mesuré depuis au moment de dire où une alarme doit te joindre. Un lieu ne peut pas être supprimé tant que des alarmes pointent dessus, et le message indique combien.

Filtrage par région

Si ta communauté a beaucoup de zones dans différentes régions, utilise le menu déroulant de région pour filtrer une région spécifique.

Zones imbriquées

Certaines zones se chevauchent — une zone plus petite à l'intérieur d'une plus grande. Les deux sont cliquables. Zoome pour cliquer plus facilement sur la plus petite.

Enregistrer

Une barre d'enregistrement apparaît en bas quand tu as fait des modifications. Clique Enregistrer pour confirmer ou Annuler pour revenir en arrière.

ℹ️
Les zones sont par profil. Chaque profil a ses propres zones sélectionnées. Changer de profil montrera des sélections de zones différentes. Les geofences personnalisées peuvent aussi être activées ou désactivées par profil depuis la page Geofences.
", + "CONTENT_GEOFENCES": "\"My

Si les zones prédéfinies ne couvrent pas l'endroit où tu veux des alertes, tu peux dessiner tes propres limites de geofence sur la carte.

Dessiner une geofence

  1. Va dans Mes Geofences depuis la barre latérale.
  2. Clique sur Dessiner une geofence.
  3. Clique sur la carte pour placer les points de ton polygone. Clique sur le premier point pour fermer la forme (minimum 3 points).
  4. Donne un nom à ta geofence et sélectionne la région. La région est généralement détectée automatiquement.
  5. Clique Enregistrer.

Gérer les geofences

  • Modifier — Renommer ta geofence ou changer sa région.
  • Supprimer — Supprimer une geofence dont tu n'as plus besoin. Elle est retirée de tous les profils automatiquement.

Bascule par profil

Chaque carte de geofence a un curseur pour l'activer ou la désactiver pour ton profil actuel. Quand tu crées une geofence, elle est automatiquement activée sur le profil en cours. Passe à un autre profil et le curseur affichera \"Inactive\" — active-le pour recevoir des alertes pour cette geofence sur ce profil aussi.

ℹ️
Les geofences approuvées (promues en zones publiques) n'affichent pas le curseur — gère-les depuis la page Zones.

Utiliser une geofence pour une seule alarme

Une geofence que tu as dessinée apparaît aussi dans la liste Uniquement dans certaines zones quand tu indiques où une alarme précise doit te joindre ; elle porte une icône de dessin. Cela restreint une alarme à cette geofence sans l'activer pour tout le profil.

Import et export GeoJSON

Tu peux importer et exporter des geofences au format standard GeoJSON.

  • Import — Clique sur l'icône d'envoi et charge un fichier GeoJSON. Chaque polygone du fichier devient une nouvelle geofence.
  • Export — Clique sur l'icône de téléchargement et sélectionne les geofences à exporter.
💡
L'import GeoJSON est utile pour migrer des geofences d'autres systèmes ou dessiner des limites complexes dans un outil SIG.

Soumettre pour approbation publique

Si tu penses que ta geofence serait utile pour toute la communauté, tu peux la soumettre pour révision admin. Si approuvée, elle devient une zone publique que tout le monde peut sélectionner.

Badges de statut

  • Active — Ta geofence privée, fonctionnelle pour toi uniquement.
  • En attente de révision — Soumise et en attente de révision admin.
  • Approuvée — Promue en zone publique.
  • Rejetée — Non approuvée. Tu peux voir les commentaires de l'admin et la geofence reste active en zone privée.
ℹ️
Tu peux avoir jusqu'à 10 geofences personnalisées, chacune avec jusqu'à 500 points de limite.
", + "CONTENT_POKEMON": "\"Pokemon

Les alarmes Pokemon te notifient quand un Pokemon sauvage apparaît et correspond à tes filtres.

Ajouter une alarme Pokemon

\"Add
  1. Va dans Pokemon depuis la barre latérale et clique sur le bouton +.
  2. Sélectionner des Pokemon — Recherche par nom ou numéro Pokédex, ou utilise les boutons de filtre par génération et type. Tu peux sélectionner plusieurs Pokemon à la fois.
  3. Définir les filtres — Choisis ce qui rend un spawn digne de notification :
  • Plage d'IV — Pourcentage d'IV minimum et maximum (0-100%)
  • Plage de CP — Filtrer par puissance de combat
  • Plage de niveau — Filtrer par niveau du Pokemon (0-55)
  • Stats individuelles — Filtrer par valeurs ATK, DEF et STA (0-15 chacune)
  • Forme — Suivre des formes spécifiques (ex. Alola, Galar) ou toutes les formes
  • Genre — Mâle, femelle, asexué ou tous
  • Poids — Filtrer par plage de poids
  • Taille — Filtrer par catégorie de taille : TOUTES (pas de filtre) pour toute taille, ou des tailles spécifiques de XXS à XXL
  • Temps restant minimum — Ignore les apparitions qui auront disparu avant ton arrivée. À régler dans Plus de filtres ; la carte affiche ensuite une pastille du type "10 min restantes"
ℹ️
Valeurs de filtre par défaut sont réglées pour que tous les Pokemon correspondent quand aucun filtre n'est configuré. Par exemple, IV par défaut 0-100%, niveau 0-55 et taille TOUTES. Tu n'as qu'à ajuster les filtres qui t'intéressent.

Filtres PVP

Sois notifié quand un Pokemon a de bons IVs PVP. Sélectionne une ligue (Super, Hyper ou Coupe Junior) et définis la plage de rang (ex. rang 1-50).

Les boutons Niveau max choisissent le plafond auquel les rangs sont lus. Laisse Tous pour utiliser la valeur définie par la configuration Poracle de ta communauté.

Méga-évolution détermine si la règle classe la forme de base ou une méga : Base, Méga, Méga X ou Méga Y. Les mégas sont classées à part : une règle méga ne correspondra jamais à une apparition en forme de base.

Alarme \"Tous les Pokemon\"

💡
Sélectionne \"Tous les Pokemon\" (ID 0) pour créer une seule alarme qui couvre chaque espèce. Utile avec un filtre IV élevé comme 96-100%.

Lire les cartes d'alarme

Chaque carte d'alarme affiche des pastilles colorées résumant tes filtres en un coup d'œil :

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Raids

Alarmes Raid et Œuf

Sois notifié quand un boss de raid ou un œuf apparaît qui t'intéresse.

  • Par niveau — Sélectionne des niveaux de raid (1-6) ou d'œuf pour suivre tous les raids de ce palier.
  • Par boss — Sélectionne des boss de raid Pokemon spécifiques.
  • Filtre d'équipe — Ne notifier que pour les raids aux arènes contrôlées par une équipe spécifique (Mystic, Valor, Instinct).
  • Suivi d'arène — Suivre les raids à des arènes spécifiques par nom.
  • Filtre d'attaque — Filtrer les boss de raid par leurs attaques immédiates ou chargées.

Alarmes Combat Max (Dynamax)

Sois notifié des combats Dynamax et Gigantamax aux Power Spots.

  • Par niveau — Sélectionne des paliers de combat de 1 Étoile à 5 Étoiles (Légendaire), plus Gigantamax et Gigantamax Légendaire.
  • Par Pokemon — Sélectionne des Pokemon spécifiques à travers tous les niveaux de Combat Max.
  • Gigantamax uniquement — Ne recevoir que les notifications pour les combats Gigantamax.
  • Tout sélectionner — Sélectionner tous les niveaux disponibles d'un coup.

Alarmes Quête

Sois notifié des études de terrain avec des récompenses spécifiques.

  • Rencontres Pokemon — Pokemon en récompense de quête.
  • Objets — Quêtes avec des récompenses d'objets spécifiques.
  • Méga-Énergie — Quêtes donnant de la méga-énergie pour des Pokemon spécifiques.
  • Bonbons — Quêtes donnant des bonbons pour des Pokemon spécifiques.
  • Poussière étoile — Quêtes donnant de la poussière étoile.

Les onglets objets, méga-énergie et bonbons ont chacun un champ Quantité minimum, et l'onglet poussière étoile un Poussière étoile minimum. Laisse 0 pour accepter n'importe quelle quantité. Les cartes affichent la quantité à côté de la récompense, par exemple "3× Rare Candy".

Alarmes Invasion

Sois notifié des invasions Team Rocket.

  • Tout suivre — Une alarme pour chaque type de sbire et chef.
  • Par type — Sélectionne des types de sbires spécifiques (Insecte, Dragon, Feu, etc.), des chefs Rocket ou Giovanni.
  • Genre — Filtrer par genre du sbire.

Alarmes Leurre

Sois notifié quand un type de leurre spécifique est placé. Choisis parmi Normal, Glacial, Mousse, Magnétique, Pluvieux et Doré.

Alarmes Nid

Suis les espèces Pokemon nidifiantes. Définis un seuil minimum de spawns par heure.

Alarmes Arène

Suis les changements d'équipe d'arène. Sélectionne les équipes (Neutre, Mystic, Valor, Instinct). Active le suivi des changements de place ou des changements de combat.

Alarmes Changement de fort

Suis les changements aux PokéStops et arènes eux-mêmes.

  • Type de fort — PokéStops, Arènes ou Tout.
  • Types de changement — Nom modifié, Description modifiée, Emplacement modifié, Image modifiée, Suppression ou Nouveau fort.
  • Inclure les vides — Inclure les forts sans nom.
💡
Les alarmes de changement de fort sont utiles pour suivre les mises à jour de la carte — nouveaux PokéStops, arènes déplacées ou POIs supprimés.

Cibler une arène spécifique

Pour les alarmes Raid, Œuf ou Arène, tu peux rechercher et sélectionner une arène spécifique. Utile quand tu ne t'intéresses qu'à l'activité de ton arène préférée.

  • Comment l'utiliser — Tape un nom d'arène dans le champ de recherche. Les résultats montrent la photo, le nom et la zone de l'arène.
  • Arène sélectionnée — L'alarme ne se déclenche que pour les événements à cette arène.
  • Aucune arène sélectionnée — Par défaut. L'alarme fonctionne pour toutes les arènes.
", + "CONTENT_DELIVERY": "\"Cartes

Chaque alarme a des paramètres de livraison qui contrôlent tu es notifié.

Où une alerte te joint

L'onglet Livraison de chaque dialogue de création et de modification demande Où cette alerte doit-elle vous parvenir ? et propose trois réponses :

  • Partout dans mes zones — Le réglage par défaut. L'alarme suit les zones sélectionnées sur ton profil : changer tes zones change aussi cette alarme.
  • Près d'un point — Un rayon en kilomètres, mesuré depuis ta position ou depuis un lieu enregistré choisi sous Mesuré depuis. Si tu n'as pas encore de position, le sélecteur le signale et propose d'en définir une.
  • Uniquement dans certaines zones — Un sous-ensemble de zones pour cette alarme précise, choisi parmi les zones publiques et les geofences que tu as dessinées.

Chaque alarme peut répondre différemment : les zones pour les Pokemon, un rayon autour de ta position pour les raids, un lieu nommé pour les quêtes.

La puce sur la carte

La plupart des cartes d'alarme portent une puce qui donne leur réponse — "Partout dans mes zones", "Partout où je reçois des alertes", "À moins de 5 km de ma position", "À moins de 2 km de Maison", "Uniquement dans Terrigal, Erina". Clique dessus pour modifier cette alarme sans ouvrir le dialogue de modification complet.

Par défaut pour les nouvelles alarmes

Les nouvelles alarmes s'ouvrent en mode Zones. Pour changer cela, ouvre le menu utilisateur (ton avatar, en haut à droite) et choisis Réglages par défaut des alertes — décide si les nouvelles alarmes démarrent en Zones ou en Distance, fixe un rayon par défaut, et choisis si ce rayon est mesuré depuis ta position ou depuis un lieu enregistré. La préférence est enregistrée dans ton navigateur et pré-remplit aussi le dialogue des sélections rapides. Elle ne concerne que les alarmes créées ensuite ; les alarmes existantes ne changent pas, et tu peux toujours modifier où chaque alarme te joint.

Modèles de notification

Si les modèles sont activés, tu peux choisir l'apparence de tes notifications. Le sélecteur de modèle montre un aperçu de ce que ton DM Discord ressemblera.

Mode nettoyage

Quand activé, le bot supprime automatiquement la notification de Discord après l'expiration de l'événement. Tu peux activer le mode nettoyage par alarme ou en masse depuis la page Nettoyage.

Modifier sur place & résumés

Certaines alertes prennent en charge des modes de diffusion supplémentaires. Activez Modifier le message sur place pour un leurre afin de mettre à jour le message Discord existant lorsque le leurre change, au lieu d'en envoyer un nouveau, ou Résumé quotidien pour une quête afin de regrouper les quêtes correspondantes en un seul message récapitulatif (nécessite une planification de résumé configurée sur le bot). Les raids et les œufs sont modifiés sur place automatiquement lorsque vous choisissez un mode RSVP. Ces réglages sont conservés même si vous les définissez depuis le bot.

Mises à jour RSVP (raids & œufs)

Les alertes de raid et d'œuf ajoutent un réglage Notifications RSVP dans la boîte de dialogue d'ajout/modification avec trois choix : Correspondances uniquement envoie les alertes raid/œuf standard ; Correspondances + mises à jour RSVP notifie aussi à nouveau lorsque les RSVP changent (des dresseurs s'inscrivent) ; et Mises à jour RSVP uniquement ignore la correspondance initiale et ne vous notifie que les changements de RSVP. Le choix de l'un ou l'autre mode RSVP amène le bot à modifier sur place le message Discord existant au fur et à mesure que les chiffres changent au lieu d'en envoyer de nouveaux, et la carte affiche une pastille "RSVP" ou "RSVP uniquement". Notez que Mises à jour RSVP uniquement reste silencieux à moins que le scanner de votre communauté n'émette des événements RSVP — ne le choisissez que si vous savez que les RSVP sont reportés.

", + "CONTENT_QUEST_SUMMARY": "

Les quêtes d’Étude de terrain changent chaque jour et peuvent correspondre en grand nombre ; un filtre de quêtes chargé peut donc inonder vos MP. Livraison du résumé des quêtes regroupe les quêtes correspondantes dans un seul résumé planifié au lieu de nombreuses alertes séparées.

Deux parties complémentaires

  • Bouton Résumé quotidien — activez-le sur une alarme de quête (dans sa boîte de dialogue d’ajout/de modification) pour marquer ses correspondances pour le résumé au lieu d’une livraison immédiate.
  • Planification de la livraison — choisissez quand les quêtes collectées sont envoyées.

Les deux sont nécessaires : le bouton indique quelles quêtes collecter, la planification indique quand les livrer.

Configurer votre planification

Ouvrez la page Quêtes, puis le menu de la barre d’outils et choisissez Livraison du résumé des quêtes. Utilisez Modifier la planification pour choisir les jours et les heures — le même éditeur que pour les heures actives des profils. Les heures enregistrées apparaissent sous forme de pastilles ambre.

La planification est par utilisateur et partagée entre tous vos profils — contrairement aux heures actives des profils, qui se configurent par profil.

Envoyer le résumé maintenant

Envoyer le résumé maintenant livre immédiatement tout ce qui a été collecté depuis votre dernier résumé. Si rien n’a encore été collecté, rien n’est envoyé — les quêtes sont mises en mémoire tampon au fur et à mesure qu’elles correspondent, alors laissez-lui le temps ou attendez le déclenchement de la planification.

Bon à savoir

  • Le menu n’apparaît que lorsque le bot de votre serveur a activé les résumés de quêtes.
  • L’heure de livraison utilise votre position enregistrée pour le fuseau horaire — définissez une position, sinon les résumés risquent d’arriver à la mauvaise heure locale (la boîte de dialogue vous avertit lorsqu’aucune position n’est définie).
  • Supprimer la planification conserve le bouton par alarme ; les quêtes continuent d’être collectées, mais reviennent à l’horaire par défaut du bot.
", "CONTENT_TEST_ALERTS": "

Chaque carte d'alarme a un bouton Test (icône avion en papier) qui envoie une notification test à ton Discord ou Telegram, en utilisant les filtres exacts de l'alarme et ton modèle de livraison actuel.

Comment ça marche

  1. Trouve une carte d'alarme dans ta liste (Pokemon, Raid, Quête, etc.).
  2. Clique sur l'icône envoyer dans la rangée d'actions de la carte.
  3. Un événement simulé correspondant aux filtres de ton alarme est généré et envoyé via le pipeline de notification. Tu recevras un DM comme pour une vraie alerte.

Ce qui est testé

Le test utilise les valeurs de filtre de ton alarme et ta localisation enregistrée comme coordonnées de l'événement. La notification est formatée avec ton modèle sélectionné.

Temps de recharge

Chaque alarme a un temps de recharge de 15 secondes entre les envois de test. Le bouton est désactivé pendant le temps de recharge.

💡
Les alertes test sont idéales pour vérifier que ton modèle est correct ou que ta livraison webhook fonctionne.
", "CONTENT_POKEMON_AVAILABILITY": "

En ajoutant ou modifiant des alarmes Pokemon, le sélecteur de Pokemon peut afficher des indicateurs de disponibilité — de petits badges qui montrent quels Pokemon apparaissent actuellement à l'état sauvage.

Comment ça marche

Si ta communauté a un scanner Golbat configuré, le sélecteur affiche des points colorés à côté des noms de Pokemon :

  • Point vert — Ce Pokemon a été vu récemment en train de spawner.
  • Pas de point — Non signalé actuellement dans les données du scanner.

Cela t'aide à éviter de créer des alarmes pour des Pokemon qui n'apparaissent pas dans ta zone actuellement.

Rafraîchissement

Les données se rafraîchissent automatiquement en arrière-plan. Regarde simplement les points en parcourant le sélecteur de Pokemon.

ℹ️
Cette fonctionnalité n'est visible que si ton admin a configuré l'intégration du scanner Golbat.
", "CONTENT_BULK": "\"Pokemon

Toutes les pages d'alarme supportent les opérations en masse pour gérer plusieurs alarmes à la fois.

Mode sélection

Clique sur l'icône de checklist dans la barre d'outils pour activer le mode sélection. Puis clique sur les cartes d'alarme individuelles ou utilise Tout sélectionner.

Actions en masse

  • Mettre à jour la distance — Changer le mode de livraison pour toutes les alarmes sélectionnées à la fois.
  • Supprimer — Supprimer toutes les alarmes sélectionnées avec une seule confirmation.
💡
En bas de chaque liste d'alarme, tu trouveras aussi les boutons Mettre à jour toutes les distances et Tout supprimer.
", - "CONTENT_QUICK_PICKS": "\"Quick

Les sélections rapides sont des modèles d'alarme prédéfinis créés par les admins de ta communauté. Elles te permettent de configurer des alarmes courantes en un clic.

Appliquer une sélection rapide

  1. Va dans Sélections rapides depuis la barre latérale.
  2. Parcours les sélections disponibles.
  3. Clique Appliquer sur une sélection rapide.
  4. Personnalise avant d'appliquer : choisis ton mode de livraison, active le mode nettoyage et exclus optionnellement certains Pokemon.
  5. Confirme pour créer toutes les alarmes d'un coup.

Retirer les alarmes d'une sélection rapide

Si tu ne veux plus des alarmes d'une sélection rapide, clique Supprimer pour supprimer toutes les alarmes qu'elle a créées.

", - "CONTENT_PROFILES": "

La page Profils est ton hub centralisé pour gérer les profils et voir toutes les alarmes de chaque profil.

Pourquoi utiliser des profils ?

Les profils te permettent de maintenir des configurations d'alarme complètement séparées. Chaque profil a ses propres alarmes, zones sélectionnées, localisation et activations de geofences. Utile pour différentes situations — par exemple, un profil \"Maison\" et un profil \"Travail\".

Aperçu

La page affiche une barre de statistiques avec les totaux d'alarmes par type, une barre de recherche et des puces de filtre par type. Chaque profil apparaît comme un panneau dépliable avec toutes les alarmes groupées par type.

Gérer les profils

  • Créer — Clique sur + en haut à droite. Les noms de profil doivent être uniques (jusqu'à 32 caractères).
  • Changer — Clique Changer dans un panneau de profil. Le profil actif a un badge vert.
  • Modifier — Clique l'icône crayon pour renommer.
  • Supprimer — Clique l'icône corbeille. Le profil actif ne peut pas être supprimé.

Dupliquer

Clique l'icône copier pour créer une copie exacte avec toutes les alarmes.

Export et import

  • Export — Clique l'icône de téléchargement pour sauvegarder un fichier de sauvegarde.
  • Import — Clique Importer, sélectionne un fichier de sauvegarde et nomme le nouveau profil.

Détection des doublons

Si la même alarme existe sur plusieurs profils, elle est mise en surbrillance avec une bordure orange et une icône de copie.

⚠️
Attention : Supprimer un profil supprime définitivement toutes les alarmes qu'il contient. Tu ne peux pas supprimer ton profil actif. Pense à exporter une sauvegarde avant.
", - "CONTENT_CLEANING": "\"Cleaning

La page Nettoyage te permet de contrôler le mode nettoyage pour tous tes types d'alarme à la fois.

Quand le mode nettoyage est actif pour un type d'alarme, le bot supprime automatiquement les notifications de Discord quand l'événement expire :

  • Pokemon — Supprimé quand le spawn disparaît
  • Raids — Supprimé quand le raid se termine
  • Œufs — Supprimé quand l'œuf éclot
  • Quêtes — Supprimé à minuit
  • Invasions — Supprimé quand le sbire part
  • Leurres — Supprimé quand le leurre expire
  • Nids — Supprimé quand les nids migrent
  • Arènes — Supprimé après les changements d'arène
  • Changements de fort — Supprimé après expiration
  • Combats Max — Supprimé quand le combat se termine

Utilise Tout activer ou Tout désactiver pour tout basculer d'un coup.

💡
Recommandé : Garde le mode nettoyage activé pour éviter que les alertes obsolètes s'accumulent dans tes DMs.
", - "CONTENT_APPEARANCE": "

Mode sombre / clair

Clique sur l'icône soleil/lune dans la barre d'outils pour basculer entre les thèmes sombre et clair. Ton choix est sauvegardé automatiquement.

\"Toolbar

Couleurs d'accent

Ouvre le menu utilisateur (ton avatar en haut à droite) et sélectionne Thème d'accent. Choisis parmi :

  • Par défaut — Bleu
  • Pokemon — Vert
  • Raids — Rouge
  • Mystic — Bleu
  • Valor — Rouge
  • Instinct — Jaune

La couleur d'accent modifie le dégradé de la barre d'outils, la surbrillance de navigation active et d'autres accents de l'interface.

\"Dashboard

Langue

Si disponible, utilise le sélecteur de langue dans la barre d'outils. 18 langues sont supportées.

Raccourcis clavier

?Afficher les raccourcis clavier
EscFermer les menus ou dialogues
[Réduire la barre latérale
]Développer la barre latérale
", - "CONTENT_ALERTS_LOGOUT": "\"User

Mettre en pause les alertes

Ouvre le menu utilisateur (ton avatar) et clique Mettre en pause. Une bannière rouge apparaîtra en haut du site confirmant que tes alertes sont en pause. Tu ne recevras aucune notification tant que c'est en pause.

Pour reprendre, clique Reprendre les alertes depuis le menu utilisateur ou la bannière.

Se déconnecter

Ouvre le menu utilisateur et clique Déconnexion. Tu seras redirigé vers la page de connexion.

", - "CONTENT_FAQ": "

\"Je ne peux pas me connecter\"

Tu dois t'inscrire auprès du bot Poracle sur Discord ou Telegram avant de pouvoir te connecter à ce site. Si tu vois \"Ton compte n'est pas enregistré\", contacte l'admin de ta communauté.

\"Je ne reçois pas de notifications\"

Vérifie ces causes courantes :

  1. Alertes en pause — Cherche une bannière rouge en haut du site. Reprends les alertes depuis le menu utilisateur.
  2. Pas de localisation — Si tes alarmes utilisent le mode distance, tu as besoin d'une localisation enregistrée.
  3. Pas de zones sélectionnées — Si tes alarmes utilisent le mode zones, assure-toi d'avoir sélectionné des zones.
  4. Mauvais profil — Tu as peut-être des alarmes sur un autre profil. Vérifie quel profil est actif sur le tableau de bord.
  5. Filtres trop stricts — Essaie d'assouplir tes filtres IV, CP ou niveau.

\"Mes alarmes ont disparu\"

Les alarmes sont spécifiques au profil. Si tu as changé de profil, tes alarmes de l'autre profil sont toujours là — rebascule simplement.

\"Je ne peux pas cliquer sur une petite zone sur la carte\"

Quand des zones se chevauchent, zoome pour cliquer plus facilement sur la plus petite. Les zones plus petites sont toujours au-dessus des plus grandes.

\"Que fait le mode nettoyage ?\"

Le mode nettoyage dit au bot de supprimer automatiquement une notification de Discord quand l'événement expire. Sans lui, les anciennes alertes restent dans tes DMs pour toujours.

\"Quelle est la différence entre Zones et Distance ?\"

Chaque alarme utilise un mode de livraison. Zones te notifie des événements dans des zones géographiques spécifiques. Distance te notifie des événements dans un rayon autour de ta position. Tu peux mélanger les deux sur différentes alarmes.

" + "CONTENT_QUICK_PICKS": "\"Quick

Les sélections rapides sont des modèles d'alarme prédéfinis créés par les admins de ta communauté. Elles te permettent de configurer des alarmes courantes en un clic.

Appliquer une sélection rapide

  1. Va dans Sélections rapides depuis la barre latérale.
  2. Parcours les sélections disponibles.
  3. Clique Appliquer sur une sélection rapide.
  4. Personnalise avant d'appliquer : indique où les alertes doivent te joindre — l'onglet Livraison est le même sélecteur à trois options qu'une alarme individuelle, tu peux donc les diriger vers un lieu enregistré ou un sous-ensemble de zones —, active le mode nettoyage et exclus optionnellement certains Pokemon.
  5. Confirme pour créer toutes les alarmes d'un coup.

Retirer les alarmes d'une sélection rapide

Si tu ne veux plus des alarmes d'une sélection rapide, clique Supprimer pour supprimer toutes les alarmes qu'elle a créées.

", + "CONTENT_PROFILES": "

La page Profils est ton hub centralisé pour gérer les profils et voir toutes les alarmes de chaque profil.

Pourquoi utiliser des profils ?

Les profils te permettent de maintenir des configurations d'alarme complètement séparées. Chaque profil a ses propres alarmes, zones sélectionnées, localisation et activations de geofences. Utile pour différentes situations — par exemple, un profil \"Maison\" et un profil \"Travail\".

Aperçu

La page affiche une barre de statistiques avec les totaux d'alarmes par type, une barre de recherche et des puces de filtre par type. Chaque profil apparaît comme un panneau dépliable avec toutes les alarmes groupées par type.

Gérer les profils

  • Créer — Clique sur + en haut à droite. Les noms de profil doivent être uniques (jusqu'à 32 caractères).
  • Changer — Clique Changer dans un panneau de profil. Le profil actif a un badge vert.
  • Modifier — Clique l'icône crayon pour renommer.
  • Supprimer — Clique l'icône corbeille. Le profil actif ne peut pas être supprimé.

Dupliquer

Clique l’icône copier pour créer une copie exacte avec toutes les alarmes. Ses zones, sa position et ses heures actives sont reprises du profil source.

Export et import

  • Export — Clique l'icône de téléchargement pour sauvegarder un fichier de sauvegarde.
  • Import — Clique Importer, sélectionne un fichier de sauvegarde et nomme le nouveau profil.

Détection des doublons

Si la même alarme existe sur plusieurs profils, elle est mise en surbrillance avec une bordure orange et une icône de copie.

⚠️
Attention : Supprimer un profil supprime définitivement toutes les alarmes qu'il contient. Tu ne peux pas supprimer ton profil actif. Pense à exporter une sauvegarde avant.
", + "CONTENT_CLEANING": "\"Cleaning

La page Nettoyage te permet de contrôler le mode nettoyage pour tous tes types d'alarme à la fois.

Quand le mode nettoyage est actif pour un type d'alarme, le bot supprime automatiquement les notifications de Discord quand l'événement expire :

  • Pokemon — Supprimé quand le spawn disparaît
  • Raids — Supprimé quand le raid se termine
  • Œufs — Supprimé quand l'œuf éclot
  • Quêtes — Supprimé à minuit
  • Invasions — Supprimé quand le sbire part
  • Leurres — Supprimé quand le leurre expire
  • Nids — Supprimé quand les nids migrent
  • Arènes — Supprimé après les changements d'arène
  • Combats Max — Supprimé quand le combat se termine

Utilise Tout activer ou Tout désactiver pour tout basculer d'un coup.

💡
Recommandé : Garde le mode nettoyage activé pour éviter que les alertes obsolètes s'accumulent dans tes DMs.
", + "CONTENT_APPEARANCE": "

Mode sombre / clair

Clique sur l'icône soleil/lune dans la barre d'outils pour basculer entre les thèmes sombre et clair. Ton choix est sauvegardé automatiquement.

\"Toolbar

Couleurs d'accent

Ouvre le menu utilisateur (ton avatar en haut à droite) et sélectionne Thème d'accent. Choisis parmi :

  • Par défaut — Bleu
  • Pokemon — Vert
  • Raids — Rouge
  • Mystic — Bleu
  • Valor — Rouge
  • Instinct — Jaune

La couleur d'accent modifie le dégradé de la barre d'outils, la surbrillance de navigation active et d'autres accents de l'interface.

\"Dashboard

Langue de l'interface

Ouvre le menu utilisateur (ton avatar, en haut à droite) et choisis Langue de l'interface. 11 langues sont disponibles. Elle change le texte du site, ainsi que les noms, types et formes de Pokemon affichés dans les sélecteurs et sur tes cartes d'alerte. Si tu n'en as jamais choisi une, tu obtiens celle de ton navigateur, ou celle configurée sur ton serveur Poracle.

Langue des alertes

Juste en dessous se trouve Langue des alertes, un réglage distinct. Elle détermine la langue dans laquelle Poracle rédige tes DM. Les deux sont indépendants : un site en français avec des DM en anglais, ou l'inverse, est tout à fait normal. Elle se trouvait auparavant sur la page Zones.

Raccourcis clavier

?Afficher les raccourcis clavier
EscFermer les menus ou dialogues
[Réduire la barre latérale
]Développer la barre latérale
", + "CONTENT_ALERTS_LOGOUT": "\"Menu

Mettre en pause les alertes

Ouvre le menu utilisateur (ton avatar) et clique Mettre en pause. Une bannière rouge apparaîtra en haut du site confirmant que tes alertes sont en pause. Tu ne recevras aucune notification tant que c'est en pause.

Pour reprendre, clique Reprendre les alertes depuis le menu utilisateur ou la bannière.

Se déconnecter

Ouvre le menu utilisateur et clique Déconnexion. Tu seras redirigé vers la page de connexion.

Si tu t’es connecté via un fournisseur SSO prenant en charge la déconnexion unique, le menu propose aussi Se déconnecter partout — cela met également fin à ta session chez le fournisseur, pas seulement ici.

", + "CONTENT_FAQ": "

\"Je ne peux pas me connecter\"

Tu dois t'inscrire auprès du bot Poracle sur Discord ou Telegram avant de pouvoir te connecter à ce site. Si tu vois \"Ton compte n'est pas enregistré\", contacte l'admin de ta communauté.

\"Je ne reçois pas de notifications\"

Vérifie ces causes courantes :

  1. Alertes en pause — Cherche une bannière rouge en haut du site. Reprends les alertes depuis le menu utilisateur.
  2. Pas de position — Une alarme qui te joint dans un rayon mesure depuis ta position ou depuis un lieu enregistré. Définis-en une sur la page Zones et lieux.
  3. Rien à portée — Regarde la puce sur la carte de l'alarme. Elle dit où l'alarme te joint, et elle peut viser des zones que ton profil ne couvre plus.
  4. Mauvais profil — Tu as peut-être des alarmes sur un autre profil. Vérifie quel profil est actif sur le tableau de bord.
  5. Filtres trop stricts — Essaie d'assouplir tes filtres IV, CP ou niveau.

\"Mes alarmes ont disparu\"

Les alarmes sont spécifiques au profil. Si tu as changé de profil, tes alarmes de l'autre profil sont toujours là — rebascule simplement.

\"Je ne peux pas cliquer sur une petite zone sur la carte\"

Quand des zones se chevauchent, zoome pour cliquer plus facilement sur la plus petite. Les zones plus petites sont toujours au-dessus des plus grandes.

\"Que fait le mode nettoyage ?\"

Le mode nettoyage dit au bot de supprimer automatiquement une notification de Discord quand l'événement expire. Sans lui, les anciennes alertes restent dans tes DMs pour toujours.

\"Où une alerte me joint-elle ?\"

Chaque alarme répond pour elle-même, dans son onglet Livraison. Partout dans mes zones suit les zones sélectionnées sur ton profil. Près d'un point est un rayon autour de ta position ou d'un lieu enregistré. Uniquement dans certaines zones restreint cette alarme à un sous-ensemble de zones. La puce sur la carte donne toujours la réponse en cours, et un clic la modifie.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "Alertes DM", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Connexion", "SIGN_IN_DESC": "Connecte-toi pour gérer tes alarmes de notification Pokemon GO.", "SIGN_IN_DISCORD": "Se connecter avec Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Se connecter avec Telegram", + "SIGN_IN_OIDC": "Se connecter avec {{provider}}", + "SIGNED_OUT_TITLE": "Déconnecté", + "SIGNED_OUT_DESC": "Tu as été déconnecté de Alertes DM.", + "PROVIDER_DISABLED_BY_ADMIN": "Cette méthode de connexion a été désactivée par un administrateur.", + "PROVIDER_DISABLED_HINT": "Cette méthode de connexion est désactivée pour les non-administrateurs.", + "ERR_TELEGRAM_DISABLED": "La connexion Telegram est actuellement désactivée.", "OR": "ou", "NO_METHODS": "Aucune méthode de connexion n'est actuellement activée. Contacte un administrateur.", "AUTHENTICATING": "Authentification...", "FOOTER": "Gère les alarmes pour Pokemon, Raids, Quêtes et plus", "AUTH_FAILED": "Échec de l'authentification", "BACK_TO_LOGIN": "Retour à la connexion", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "La connexion Discord est actuellement désactivée.", + "ERR_DISCORD_FETCH": "Impossible de récupérer votre profil Discord. Réessayez.", + "ERR_MISSING_CODE": "La connexion Discord a été annulée ou a échoué.", + "ERR_MISSING_ROLE": "Vous n'avez pas le rôle Discord requis pour accéder à ce site.", + "ERR_NOT_IN_GUILD": "Vous devez être membre du serveur Discord pour accéder à ce site.", + "ERR_NOT_REGISTERED": "Votre compte n'est pas enregistré. Inscrivez-vous pour commencer.", + "ERR_OIDC_DISABLED": "La connexion externe est actuellement désactivée.", + "ERR_OIDC_NO_IDENTITY": "Ton fournisseur de connexion externe n'a pas renvoyé de compte que nous puissions associer. Assure-toi que ton compte Discord est lié.", + "ERR_OIDC_TOKEN_EXCHANGE": "Échec de la connexion externe. Réessaie.", + "ERR_OIDC_USERINFO": "Impossible de récupérer ton profil auprès du fournisseur de connexion externe. Réessaie.", + "ERR_ROLE_CHECK_FAILED": "Impossible de vérifier vos rôles Discord. Réessayez plus tard.", + "ERR_TELEGRAM_FAILED": "La connexion Telegram a échoué. Réessayez.", + "ERR_TOKEN_EXCHANGE": "La connexion Discord a échoué. Réessayez.", "ERR_GENERIC": "Erreur d'authentification : {{error}}", "ERR_NO_TOKEN": "Aucun jeton d'authentification reçu.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "S'inscrire", + "SIGN_UP_DESC": "Pas encore de compte ? Inscrivez-vous pour commencer.", + "SIGN_IN_AGAIN": "Se reconnecter" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Cette fonctionnalité a été désactivée par l’administrateur." }, "ADMIN": { "USERS_TITLE": "Gestion des utilisateurs", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Nom promu", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Nom pour la geofence promue", "APPROVAL_PROMOTED_NAME_HINT": "Optionnel. Par défaut le nom d'affichage actuel.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Raison du rejet", "APPROVAL_REJECT_PLACEHOLDER": "Explique pourquoi cette geofence est rejetée...", "USERS_DESC_FULL": "Gérer les utilisateurs Discord inscrits. Arrêté = l'utilisateur a mis en pause les alertes ou atteint la limite de débit. Bloqué = bloqué par l'admin.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Échec de l'approbation de la soumission", "SNACK_APPROVED": "\"{{name}}\" approuvée", "SNACK_FAILED_REJECT": "Échec du rejet de la soumission", - "SNACK_REJECTED": "\"{{name}}\" rejetée" + "SNACK_REJECTED": "\"{{name}}\" rejetée", + "APPROVAL_REGION_HINT": "Choisissez la région sous laquelle ce géorepère apparaîtra.", + "SERVER_TITLE": "Serveur Poracle", + "SERVER_REFRESH": "Vérifier à nouveau", + "SERVER_VERSION": "Version", + "SERVER_SCHEMA": "Schéma de base de données", + "SERVER_CHECKED": "Dernière vérification", + "SERVER_CAPABILITIES": "Fonctionnalités", + "SERVER_NO_CAPABILITIES": "Ce serveur n’en signale aucune.", + "SERVER_UNKNOWN": "Inconnue", + "SERVER_UNREACHABLE": "Poracle n’a pas répondu. Les alarmes, profils et lieux passent par lui et échoueront tant qu’il ne répond pas.", + "SERVER_TOO_OLD": "Poracle {{version}} est antérieur à {{minimum}}, requis par cette version du site. La portée par alarme, le filtre méga PVP et celui du temps restant sembleront enregistrés sans rien changer.", + "UPDATE_AVAILABLE": "{{name}} {{running}} est en cours d’exécution, et {{latest}} est disponible.", + "UPDATE_PRERELEASE": "{{name}} {{running}} est plus récent que toute version publiée : c’est une version de développement.", + "VERSIONS_TITLE": "Versions", + "VERSIONS_WEB": "Ce site", + "VERSIONS_BUILD": "Build", + "UPDATE_CURRENT": "À jour.", + "UPDATE_UNCOMPARABLE": "Canal de développement. La dernière version publiée est {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Choisir un point", "CANCEL": "Annuler", "CONFIRM": "Confirmer", "DONT_ASK_AGAIN": "Ne plus demander pour cette session", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Mettre à jour toutes les distances", "DISTANCE_DESC": "Définis le mode de localisation pour toutes les alarmes de ce type.", "DISTANCE_UPDATE_ALL": "Tout mettre à jour", + "DISTANCE_MUST_BE_POSITIVE": "La distance doit être supérieure à zéro.", "LOCATION_SAVE_ERROR": "Échec de la mise à jour de la localisation", "LOCATION_SAVE_SUCCESS": "Localisation mise à jour avec succès", "LOCATION_GEO_UNSUPPORTED": "La géolocalisation n'est pas supportée par ton navigateur", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Trop d'alertes test. Attends un moment.", "ERROR_NOT_FOUND": "Alarme introuvable — elle a peut-être été supprimée.", "ERROR_GENERIC": "Échec de l'envoi de l'alerte test. Réessaie plus tard.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Trop d'alertes de test. Patientez un instant.", + "NOT_FOUND": "Alerte introuvable : elle a peut-être été supprimée.", + "UNSUPPORTED": "Les alertes de test ne sont pas disponibles pour ce type.", + "FAILED": "Impossible d'envoyer l'alerte de test. Réessayez plus tard." }, "COMMON": { "CANCEL": "Annuler", @@ -1296,6 +1448,7 @@ "EDIT": "Modifier", "ADD": "Ajouter", "OK": "OK", + "UNDO": "Annuler", "CONFIRM": "Confirmer", "DELETE_ALL": "Tout supprimer", "CLOSE": "Fermer", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Rechercher une arène (optionnel)", "SEARCH_HINT": "Tape le nom de l'arène...", - "CLEAR_ARIA": "Effacer la sélection d'arène" + "CLEAR_ARIA": "Effacer la sélection d'arène", + "RATE_LIMITED": "Trop de requêtes au scanner — ralentissez un peu." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Les notifications seront envoyées pour ces zones :", @@ -1392,12 +1546,10 @@ "GROUP_ALARM_TYPES": "Types d'alarmes", "GROUP_FEATURES": "Fonctionnalités", "GROUP_ADMINISTRATION": "Administration", - "GROUP_COMMANDS": "Commandes", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Cartes et ressources", + "GROUP_OIDC": "SSO externe", "GROUP_ANALYTICS_LINKS": "Analytique et liens", - "GROUP_DEBUG": "Débogage", "GROUP_ICON_REPO": "Dépôt d'icônes", "GROUP_OTHER": "Autre", "CUSTOM_TITLE_LABEL": "Titre du site", @@ -1411,52 +1563,51 @@ "FAVICON_URL_PREVIEW": "Aperçu du favicon (32×32)", "FAVICON_URL_CACHE_WARNING": "Les navigateurs mettent les favicons en cache de manière agressive. Après l'enregistrement, les utilisateurs doivent vider le cache de leur navigateur ou effectuer une actualisation forcée (Ctrl+F5 / Cmd+Maj+R) pour voir la nouvelle icône.", "FAVICON_URL_CSP_NOTE": "Si votre site utilise une Content Security Policy, l'origine de l'URL du favicon doit être autorisée par votre directive img-src ; sinon, le navigateur bloque la récupération et revient à l'icône par défaut.", + "FORCED_BY_PORACLE": "Désactivé dans la configuration de Poracle. Poracle ignore ces webhooks et son bot refuse la commande, cela ne peut donc pas être activé ici.", + "FORCED_BY_PORACLE_TOOLTIP": "Contrôlé par la configuration de Poracle, pas par cette page.", "CUSTOM_PAGE_NAME_LABEL": "Étiquette du lien de navigation", "CUSTOM_PAGE_NAME_DESC": "Étiquette du lien de navigation personnalisé (ex. « Retour à la carte »).", "CUSTOM_PAGE_URL_LABEL": "URL du lien de navigation", "CUSTOM_PAGE_URL_DESC": "URL vers laquelle pointe le lien de navigation personnalisé.", "CUSTOM_PAGE_ICON_LABEL": "Icône du lien de navigation", "CUSTOM_PAGE_ICON_DESC": "Classe FontAwesome pour l'icône du lien de navigation (ex. « fas fa-map »).", - "DISABLE_MONS_LABEL": "Désactiver les Pokémon", - "DISABLE_MONS_DESC": "Masquer la gestion des alarmes Pokémon pour tous les utilisateurs.", - "DISABLE_RAIDS_LABEL": "Désactiver les Raids", - "DISABLE_RAIDS_DESC": "Masquer la gestion des alarmes Raid pour tous les utilisateurs.", - "DISABLE_QUESTS_LABEL": "Désactiver les Études", - "DISABLE_QUESTS_DESC": "Masquer la gestion des alarmes d'études pour tous les utilisateurs.", - "DISABLE_INVASIONS_LABEL": "Désactiver les Invasions", - "DISABLE_INVASIONS_DESC": "Masquer la gestion des alarmes d'invasion pour tous les utilisateurs.", - "DISABLE_LURES_LABEL": "Désactiver les Modules Leurre", - "DISABLE_LURES_DESC": "Masquer la gestion des alarmes Module Leurre pour tous les utilisateurs.", - "DISABLE_NESTS_LABEL": "Désactiver les Nids", - "DISABLE_NESTS_DESC": "Masquer la gestion des alarmes de nid pour tous les utilisateurs.", - "DISABLE_GYMS_LABEL": "Désactiver les Arènes", - "DISABLE_GYMS_DESC": "Masquer la gestion des alarmes d'arène pour tous les utilisateurs.", - "DISABLE_FORT_CHANGES_LABEL": "Désactiver les changements de fortifications", - "DISABLE_FORT_CHANGES_DESC": "Masquer la gestion des alarmes de changements de fortifications pour tous les utilisateurs.", - "DISABLE_MAXBATTLES_LABEL": "Désactiver les Combats Dynamax", - "DISABLE_MAXBATTLES_DESC": "Masquer la gestion des alarmes Combat Dynamax pour tous les utilisateurs.", - "DISABLE_AREAS_LABEL": "Désactiver les zones", - "DISABLE_AREAS_DESC": "Empêcher les utilisateurs de gérer leurs abonnements aux zones.", - "DISABLE_PROFILES_LABEL": "Désactiver les profils", - "DISABLE_PROFILES_DESC": "Empêcher les utilisateurs de créer des profils d'alarmes et d'en changer.", - "DISABLE_LOCATION_LABEL": "Désactiver la localisation", - "DISABLE_LOCATION_DESC": "Empêcher les utilisateurs de définir une position d'accueil.", - "DISABLE_NOMINATIM_LABEL": "Désactiver le géocodage", - "DISABLE_NOMINATIM_DESC": "Désactive la recherche d'adresse Nominatim pour le choix de la position.", - "DISABLE_GEOMAP_LABEL": "Désactiver la vue carte", - "DISABLE_GEOMAP_DESC": "Masquer entièrement la carte interactive des geofences.", - "DISABLE_GEOMAP_SELECT_LABEL": "Désactiver la sélection de zones sur la carte", - "DISABLE_GEOMAP_SELECT_DESC": "Empêcher les utilisateurs de sélectionner des zones en cliquant sur la carte.", - "ENABLE_TEMPLATES_LABEL": "Activer les modèles", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Autoriser les utilisateurs à gérer les alarmes Pokémon.", + "DISABLE_RAIDS_LABEL": "Raids", + "DISABLE_RAIDS_DESC": "Autoriser les utilisateurs à gérer les alarmes Raid.", + "DISABLE_QUESTS_LABEL": "Études", + "DISABLE_QUESTS_DESC": "Autoriser les utilisateurs à gérer les alarmes d'études.", + "DISABLE_INVASIONS_LABEL": "Invasions", + "DISABLE_INVASIONS_DESC": "Autoriser les utilisateurs à gérer les alarmes d'invasion.", + "DISABLE_LURES_LABEL": "Modules Leurre", + "DISABLE_LURES_DESC": "Autoriser les utilisateurs à gérer les alarmes Module Leurre.", + "DISABLE_NESTS_LABEL": "Nids", + "DISABLE_NESTS_DESC": "Autoriser les utilisateurs à gérer les alarmes de nid.", + "DISABLE_GYMS_LABEL": "Arènes", + "DISABLE_GYMS_DESC": "Autoriser les utilisateurs à gérer les alarmes d'arène.", + "DISABLE_FORT_CHANGES_LABEL": "Changements de fortifications", + "DISABLE_FORT_CHANGES_DESC": "Autoriser les utilisateurs à gérer les alarmes de changements de fortifications.", + "DISABLE_MAXBATTLES_LABEL": "Combats Dynamax", + "DISABLE_MAXBATTLES_DESC": "Autoriser les utilisateurs à gérer les alarmes Combat Dynamax.", + "DISABLE_AREAS_LABEL": "Zones", + "DISABLE_AREAS_DESC": "Autoriser les utilisateurs à gérer leurs abonnements aux zones.", + "DISABLE_PROFILES_LABEL": "Profils", + "DISABLE_PROFILES_DESC": "Autoriser les utilisateurs à créer des profils d'alarmes et à en changer.", + "DISABLE_LOCATION_LABEL": "Localisation", + "DISABLE_LOCATION_DESC": "Autoriser les utilisateurs à définir une position d'accueil.", + "DISABLE_NOMINATIM_LABEL": "Géocodage", + "DISABLE_NOMINATIM_DESC": "Autoriser la recherche d'adresse Nominatim pour le choix de la position.", + "DISABLE_USER_GEOFENCES_LABEL": "Geofences personnalisées", + "DISABLE_USER_GEOFENCES_DESC": "Autoriser les utilisateurs à dessiner, importer et soumettre leurs propres geofences. Les geofences existantes continuent de fonctionner.", + "ENABLE_TEMPLATES_LABEL": "Modèles", "ENABLE_TEMPLATES_DESC": "Autoriser les utilisateurs à choisir des modèles de messages de notification.", "ALLOWED_LANGUAGES_LABEL": "Langues d'interface autorisées", "ALLOWED_LANGUAGES_DESC": "Codes de langue séparés par des virgules à afficher dans le sélecteur de langue (ex. « en,de,fr,es »). Laissez vide pour afficher les 11 langues.", + "PORACLE_LOCALE_HINT": "Langue par défaut des nouveaux utilisateurs : {{locale}}, issue de la configuration de Poracle. Quiconque choisit une langue, ou dont le navigateur en demande une présente sur ce site, obtient celle-là.", "ENABLE_ROLES_LABEL": "Activer le contrôle d'accès par rôle", "ENABLE_ROLES_DESC": "Autoriser uniquement les utilisateurs avec des rôles Discord spécifiques à se connecter. Nécessite un Bot Token et un Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "IDs de rôles autorisés", - "ALLOWED_ROLE_IDS_DESC": "IDs de rôles Discord séparés par des virgules qui accordent l'accès (ex. « 123456789,987654321 »). Laissez vide pour autoriser tous.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Langues autorisées", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Liste de codes de langue séparés par des virgules que les utilisateurs peuvent sélectionner (ex. « en,de,fr »).", + "ALLOWED_ROLE_IDS_DESC": "IDs de rôles Discord séparés par des virgules, par ex. 123456789,987654321. Un utilisateur doit avoir au moins un de ces rôles pour se connecter. Laissez vide pour autoriser tous.", "REGISTER_COMMAND_LABEL": "Commande d'enregistrement", "REGISTER_COMMAND_DESC": "Commande du bot Poracle que les utilisateurs exécutent pour s'enregistrer (ex. « $!register »).", "LOCATION_COMMAND_LABEL": "Commande de localisation", @@ -1464,9 +1615,30 @@ "ENABLE_TELEGRAM_LABEL": "Activer la connexion Telegram", "ENABLE_TELEGRAM_DESC": "Autoriser la connexion Telegram sur ce site. Nécessite TELEGRAM_ENABLED=true, le bot token et le bot username dans .env (redémarrage du serveur requis pour les changements .env).", "TELEGRAM_BOT_LABEL": "Nom d'utilisateur du bot", - "TELEGRAM_BOT_DESC": "Nom d'utilisateur du bot Telegram (sans @).", + "TELEGRAM_BOT_DESC": "Nom d'utilisateur du bot Telegram (sans @). Utilisé lorsque TELEGRAM_BOT_USERNAME n’est pas configuré.", "ENABLE_DISCORD_LABEL": "Activer la connexion Discord", "ENABLE_DISCORD_DESC": "Autoriser la connexion Discord sur ce site. Nécessite le Discord Client ID et Client Secret dans .env (redémarrage du serveur requis pour les changements .env). N'affecte pas la livraison du bot PoracleNG.", + "ENABLE_OIDC_LABEL": "Activer la connexion SSO externe", + "ENABLE_OIDC_DESC": "Autoriser la connexion via le fournisseur OIDC/OAuth2 externe configuré. Nécessite les paramètres OIDC_* (URLs du fournisseur, client ID et secret) dans .env (redémarrage du serveur requis pour les changements .env).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Tous les utilisateurs sont redirigés vers le fournisseur SSO externe. La connexion locale est contournée.", + "AUTH_MODE_SWITCH_CONFIRM": "Passer en SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Passer à la connexion SSO ?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Après l'enregistrement, tous les utilisateurs (y compris les administrateurs) seront redirigés vers {{provider}} pour se connecter — la page de connexion locale Discord/Telegram est contournée. Si le fournisseur est injoignable, tu peux être bloqué ; récupère l'accès en définissant AUTH_FORCE_LOCAL=true dans l'environnement du serveur.", + "AUTH_OIDC_NOT_CONFIGURED": "Le SSO est indisponible tant que le fournisseur OIDC n'est pas configuré dans l'environnement du serveur (variables d'environnement OIDC_*).", + "AUTH_OIDC_HIDES_LOCAL": "Discord et Telegram sont masqués lorsque le SSO est le mode de connexion actif.", + "AUTH_SLO_LABEL": "Déconnexion unique", + "AUTH_SLO_DESC": "Lorsque cette option est activée, « Se déconnecter partout » met aussi fin à la session du fournisseur (pas seulement à celle de ce site). Nécessite le point de terminaison de fin de session du fournisseur (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "La déconnexion unique est indisponible tant que le point de terminaison de fin de session du fournisseur n'est pas configuré (variable d'environnement OIDC_END_SESSION_URL).", + "OIDC_SERVER_CONFIG": "Configuration du fournisseur OIDC", + "OIDC_PROVIDER_LABEL": "Nom du fournisseur", + "OIDC_AUTHORIZATION_URL_LABEL": "URL d'autorisation", + "OIDC_TOKEN_URL_LABEL": "URL du jeton", + "OIDC_USERINFO_URL_LABEL": "URL UserInfo", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Claim d'identité", + "OIDC_USE_PKCE_LABEL": "Utiliser PKCE", "PROVIDER_URL_LABEL": "URL des tuiles de carte", "PROVIDER_URL_DESC": "Modèle d'URL du fournisseur de tuiles de carte (utilisé pour les cartes statiques).", "GANALYTICSID_LABEL": "ID Google Analytics", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "IDs admin", "DISCORD_ADMIN_IDS_DESC": "IDs d'utilisateurs Discord avec accès admin (masqué).", "DISCORD_GEOFENCE_FORUM_LABEL": "Canal de forum des geofences", - "DISCORD_GEOFENCE_FORUM_DESC": "Canal de forum Discord pour les fils de discussion de soumission de geofences." + "DISCORD_GEOFENCE_FORUM_DESC": "Canal de forum Discord pour les fils de discussion de soumission de geofences.", + "SEARCH_PLACEHOLDER": "Rechercher des paramètres…", + "SEARCH_CLEAR": "Effacer la recherche", + "UNSAVED_CHANGES": "{{count}} non enregistré(s)", + "SAVE_CHANGES": "Enregistrer les modifications", + "DISCARD_CHANGES": "Annuler", + "COLLAPSE_SECTION": "Réduire la section", + "EXPAND_SECTION": "Développer la section", + "SUMMARY_ENABLED": "{{count}} sur {{total}} activé(s)", + "GROUP_AUTH": "Authentification", + "AUTH_MODE_LABEL": "Mode de connexion", + "AUTH_MODE_LOCAL": "Local", + "AUTH_MODE_LOCAL_DESC": "Connexion directe avec Discord ou Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "La connexion locale est imposée par la configuration du serveur.", + "DISABLE_UPDATE_CHECK_LABEL": "Ne pas rechercher de mises à jour", + "DISABLE_UPDATE_CHECK_DESC": "Empêche le site de demander à GitHub si une version plus récente de PoracleWeb ou de Poracle est parue. C’est la seule requête sortant de votre réseau, et elle n’envoie rien." }, "GEOFENCE_DETAIL": { "NAME": "Nom", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Votre position", "SELECTED_COUNT": "{{count}} sélectionné(s) :", "AREAS_SELECTED": "{{count}} zone(s) sélectionnée(s)" + }, + "ALERT_DEFAULTS": { + "TITLE": "Réglages par défaut des alertes", + "DESC": "Choisissez le mode de diffusion par défaut des nouvelles alertes. Vous pourrez toujours le modifier pour chaque alerte lors de sa création.", + "DEFAULT_DISTANCE": "Distance par défaut", + "DEFAULT_DISTANCE_HINT": "Utilisée pour préremplir le rayon des nouvelles alertes basées sur la distance.", + "FOOTNOTE": "S'applique uniquement aux nouvelles alertes — les alertes existantes ne sont pas modifiées.", + "DISTANCE_TOO_SMALL": "Doit être d’au moins 0,1 km.", + "DISTANCE_TOO_LARGE": "Doit être de 100 km au maximum." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Éléments par page :", + "RANGE": "{{start}} - {{end}} sur {{total}}", + "RANGE_EMPTY": "0 sur {{total}}", + "NEXT_PAGE": "Page suivante", + "PREVIOUS_PAGE": "Page précédente", + "FIRST_PAGE": "Première page", + "LAST_PAGE": "Dernière page" + }, + "WHERE": { + "SET_PIN": "Définir votre position", + "PIN_MISSING_WARNING": "Vous n'avez pas encore défini de position : cette alerte n'aurait rien pour mesurer.", + "PLACES_EMPTY_TITLE": "Aucun lieu pour l'instant", + "PIN_UNSET": "Non définie", + "PLACES_PAGE_DESC": "Des points nommés vers lesquels diriger vos alertes, au lieu de votre position.", + "ADD_PLACE": "Ajouter un lieu", + "AREAS_LABEL": "Zones", + "AREA_LIST_MORE": "{{areas}} et {{count}} autres", + "MEASURED_FROM": "Mesuré depuis", + "MY_PIN": "Ma position", + "NAME_PLACE_MESSAGE": "Comment nommer ce lieu ?", + "NAME_PLACE_TITLE": "Nommer ce lieu", + "NEAR_PIN": "À moins de {{distance}} km de ma position", + "NEAR_PLACE": "À moins de {{distance}} km de {{place}}", + "NO_PLACES": "Aucun lieu pour l'instant. Ajoutez-en un ci-dessous pour diriger cette alerte ailleurs que vers votre position.", + "ONLY_IN": "Uniquement dans {{areas}}", + "OPTION_AREAS": "Uniquement dans certaines zones", + "OPTION_NEAR": "Près d'un point", + "OPTION_PLACE": "Près d'un lieu", + "OPTION_PROFILE": "Partout dans mes zones", + "PIN_NOTE": "La valeur par défaut de toute alerte sans destination propre.", + "PIN_TITLE": "Ma position", + "PLACES_EMPTY": "Ajoutez-en un pour recevoir des alertes ailleurs qu'à votre position : le travail, la salle de sport, chez vos parents.", + "PLACES_TITLE": "Lieux", + "PLACE_DELETED": "{{place}} supprimé.", + "PLACE_DELETE_CONFIRM": "Les alertes visant {{place}} reviendront à votre position.", + "PLACE_DELETE_ERROR": "Impossible de supprimer ce lieu.", + "PLACE_DELETE_TITLE": "Supprimer ce lieu ?", + "PLACE_IN_USE": "{{place}} est utilisé par {{count}} alerte(s). Redirigez-les d'abord.", + "PLACE_LABEL": "Lieu", + "PLACE_NAME": "Nom", + "PLACE_SAVED": "{{place}} enregistré.", + "PLACE_SAVE_ERROR": "Impossible d'enregistrer ce lieu.", + "PROFILE_ANYWHERE": "Partout où je reçois des alertes", + "PROFILE_AREAS": "Partout dans mes zones", + "RADIUS_KM": "Rayon (km)", + "SAVE": "Définir la portée", + "SCOPE_SAVED": "Portée mise à jour.", + "SCOPE_SAVE_ERROR": "Impossible de modifier la portée de cette alerte.", + "SHEET_TITLE": "Où cette alerte doit-elle vous parvenir ?", + "USE_THIS_POINT": "Utiliser ce point" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json index 1e58d1fb..d99caac2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -16,7 +16,7 @@ "GYMS": "Palestre", "FORT_CHANGES": "Modifiche Forte", "PROFILES": "Profili", - "AREAS": "Aree", + "AREAS": "Aree e luoghi", "MY_GEOFENCES": "Le Mie Geofence", "CLEANING": "Pulizia", "HELP": "Aiuto", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Visualizzazione come", - "BACK_TO_ADMIN": "Torna all'Amministrazione", + "EXIT_IMPERSONATION": "Torna al tuo account", "DISABLED_ACCOUNT": "Il tuo account è stato disabilitato. Questo potrebbe essere dovuto a un limite di richieste o a un'azione amministrativa.", + "DISABLED_ACCOUNT_INSPECTED": "Questo account è stato disattivato da un amministratore e non riceve notifiche.", "DISABLED_SUPPORT": "Per assistenza, chiedi in", "PAUSED_ALERTS": "I tuoi avvisi sono in pausa. Non riceverai notifiche.", "RESUME": "Riprendi" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Cambia solo il testo di questo sito.", "PROFILE_PREFIX": "Profilo #", "PAUSE_ALERTS": "Metti in Pausa gli Avvisi", "RESUME_ALERTS": "Riprendi gli Avvisi", "SWITCH_PROFILE": "Cambia Profilo", - "AREAS_LOCATION": "Aree e Posizione", "CLEANING": "Pulizia", "ACCENT_THEME": "Tema Accento", - "LANGUAGE": "Lingua", + "DISPLAY_LANGUAGE": "Lingua dell'interfaccia", + "ALERT_LANGUAGE": "Lingua degli avvisi", + "ALERT_LANGUAGE_HINT": "Usata per il testo degli avvisi e i nomi dei Pokemon.", "LOGOUT": "Esci", + "LOGOUT_EVERYWHERE": "Esci ovunque", "ACCENT_DEFAULT": "Predefinito", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raid", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Impostazioni predefinite avvisi" }, "SHORTCUTS": { "TITLE": "Scorciatoie da Tastiera", @@ -77,6 +82,7 @@ "NETWORK": "Impossibile raggiungere il server. Controlla la tua connessione.", "BAD_REQUEST": "Richiesta non valida. Controlla i dati inseriti.", "UNAUTHORIZED": "La tua sessione è scaduta. Accedi di nuovo.", + "INSPECTION_ENDED": "Ispezione terminata: sei tornato alla tua sessione.", "FORBIDDEN": "Non hai i permessi per eseguire questa azione.", "NOT_FOUND": "La risorsa richiesta non è stata trovata.", "CONFLICT": "Si è verificato un conflitto. L'elemento potrebbe essere stato modificato.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Benvenuto iniziale" }, "POKEMON": { + "PVP_EVOLUTION": "Megaevoluzione", + "PVP_EVOLUTION_HINT": "Classifica le forme base o una mega. Le mega hanno classifiche separate, quindi una regola mega non corrisponderà a una forma base.", + "PVP_EVO_BASE": "Base", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Allarmi Pokemon", "PAGE_DESC": "Monitora gli spawn di Pokemon selvatici con filtri personalizzati per IV, CP, livello e PVP.", "SEARCH_PLACEHOLDER": "Cerca per nome o #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Forma e Genere", "LABEL_FORM": "Forma", "ALL_FORMS": "Tutte le Forme", + "FORM_MULTI_HINT": "Lascia vuoto per includere tutte le forme", "LABEL_GENDER": "Genere", "GENDER_ALL": "Tutti", "GENDER_MALE": "Maschio", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Avvisa solo se i CP da evoluto raggiungono questo minimo", "PVP_DISABLED_HINT": "Seleziona una lega per filtrare per rango PVP.", "SNACK_CREATED": "{{count}} allarme/i Pokemon creato/i", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} allarme/i Pokemon creato/i, {{duplicates}} gia tracciato/i", "SNACK_UPDATED": "Allarme Pokemon aggiornato", "SNACK_DELETED": "Allarme Pokemon eliminato", "SNACK_DELETED_ALL": "Tutti gli allarmi Pokemon eliminati", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normale", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Limite di livello", + "PVP_CAP_ALL": "Tutti", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Predefinito — dalla configurazione di Poracle", + "FILTER_TIME_LEFT": "Tempo Rimanente", + "LABEL_MIN_TIME": "Tempo rimanente minimo", + "MIN_TIME_HINT": "Ignora gli spawn che spariranno prima del tuo arrivo.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "{{count}} min rimasti", + "PILL_TIME_LEFT_SECONDS": "{{count}} s rimasti", + "MIN_TIME_ANY": "Qualsiasi" }, "ALARM": { "LOCATION_MODE": "Modalità Posizione", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Elimina automaticamente la notifica da Discord dopo che l'esca scade", "CLEAN_HINT_NEST": "Elimina automaticamente la notifica da Discord quando i nidi migrano", "CLEAN_HINT_GYM": "Elimina automaticamente la notifica da Discord dopo che l'attività della palestra cambia", - "CLEAN_HINT_FORT": "Elimina automaticamente la notifica da Discord dopo la scadenza", "CLEAN_HINT_MAX_BATTLE": "Elimina automaticamente la notifica da Discord dopo che la battaglia max finisce", "SAVING": "Salvataggio...", "SAVE": "Salva", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Tempo di attesa attivo", "TEST_SEND": "Invia notifica di prova", "TAB_DELIVERY": "Consegna", - "COMMON_SETTINGS": "Impostazioni comuni" + "COMMON_SETTINGS": "Impostazioni comuni", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} create, {{duplicates}} gia tracciate" }, "RAIDS": { + "RSVP_LABEL": "Notifiche RSVP", + "RSVP_OFF": "Solo corrispondenze", + "RSVP_INCLUDE": "Corrispondenze + aggiornamenti RSVP", + "RSVP_ONLY": "Solo aggiornamenti RSVP", + "RSVP_OFF_DESC": "Solo avvisi raid/uovo standard.", + "RSVP_INCLUDE_DESC": "Notifica di nuovo anche quando cambiano i conteggi RSVP.", + "RSVP_ONLY_DESC": "Salta le corrispondenze iniziali; notifica solo le modifiche RSVP. Senza uno scanner che emetta RSVP questo allarme è silenziato.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Solo RSVP", "PAGE_TITLE": "Allarmi Raid e Uova", "PAGE_DESC": "Ricevi notifiche sui boss dei raid e la schiusa delle uova nelle palestre vicine.", "TAB_RAIDS": "Raid ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Sei sicuro di voler eliminare TUTTI gli allarmi raid e uova? Questa azione non può essere annullata.", "CONFIRM_BULK_DELETE_TITLE": "Elimina Allarmi Selezionati", "CONFIRM_BULK_DELETE_MSG": "Sei sicuro di voler eliminare {{count}} allarmi?", - "CONFIRM_DELETE_SELECTED": "Elimina Selezionati" + "CONFIRM_DELETE_SELECTED": "Elimina Selezionati", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Livello", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Speciali", + "SECTION_CUSTOM": "Personalizzati", + "ADD": "Aggiungi livello", + "ADD_PLACEHOLDER": "es. 42", + "ADD_HELP": "Qualsiasi intero positivo usato dal tuo server. 9000 significa «qualsiasi livello».", + "INVALID": "Il livello deve essere almeno 1.", + "DUPLICATE": "Il livello {{value}} è già nella lista.", + "SR_REMOVE": "Rimuovi il livello personalizzato {{value}}", + "REMOVED": "Livello {{value}} rimosso", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Allarmi Missioni", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Mega Energia", "TAB_CANDY": "Caramelle", "ITEM_REWARD": "Ricompensa Oggetto", - "ANY_ITEM": "Qualsiasi Oggetto", + "ANY_ITEM": "Qualsiasi oggetto", "QUEST_TYPE_LABEL": "Tipo Missione:", "SNACK_CREATED": "Allarme missione creato", "SNACK_UPDATED": "Allarme missione aggiornato", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Tutti gli allarmi missione eliminati", "SNACK_FAILED_DELETE_ALL": "Eliminazione allarmi fallita", "SNACK_FAILED_DISTANCE": "Aggiornamento distanze fallito", - "CONFIRM_DELETE_SELECTED": "Elimina Selezionati" + "CONFIRM_DELETE_SELECTED": "Elimina Selezionati", + "SUMMARY_MODE": "Riepilogo giornaliero", + "SUMMARY_HINT": "Raccoglie le ricerche corrispondenti in un unico messaggio di riepilogo invece di una notifica per ciascuna. Richiede una pianificazione del riepilogo configurata sul bot.", + "SUMMARY_BADGE": "Riepilogo", + "SUMMARY_SCHEDULE": "Consegna del riepilogo delle missioni", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Riepilogo missioni", + "SUMMARY_SCHEDULE_EMPTY": "Nessuna pianificazione del riepilogo impostata. Le missioni vengono consegnate singolarmente.", + "SUMMARY_SCHEDULE_EDIT": "Modifica pianificazione", + "SUMMARY_SCHEDULE_CLEAR": "Rimuovi pianificazione", + "SUMMARY_SCHEDULE_SEND_NOW": "Invia riepilogo ora", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Invia le corrispondenze delle missioni raccolte dall'ultimo riepilogo. Se non c'è ancora nulla in buffer, non viene inviato nulla.", + "SUMMARY_SCHEDULE_SAVED": "Pianificazione del riepilogo salvata", + "SUMMARY_SCHEDULE_CLEARED": "Pianificazione del riepilogo rimossa", + "SUMMARY_SCHEDULE_SENT": "Riepilogo inviato", + "SUMMARY_SCHEDULE_FAILED": "Impossibile aggiornare la pianificazione del riepilogo", + "SUMMARY_SCHEDULE_UNAVAILABLE": "La consegna del riepilogo è temporaneamente non disponibile. Riprova più tardi.", + "SUMMARY_DISABLED_HINT": "La pianificazione dei riepiloghi non è disponibile su questo server.", + "TAB_STARDUST": "Polvere di Stelle", + "MIN_AMOUNT": "Quantità minima", + "MIN_AMOUNT_HINT": "0 = qualsiasi quantità", + "MIN_STARDUST": "Polvere di stelle minima", + "MIN_STARDUST_HINT": "0 = qualsiasi incarico con polvere di stelle", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Allarmi Invasioni", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnetico", "TYPE_RAINY": "Piovoso", "TYPE_GOLDEN": "Dorato", - "TYPE_UNKNOWN": "Esca #{{id}}" + "TYPE_UNKNOWN": "Esca #{{id}}", + "EDIT_MODE": "Modifica il messaggio sul posto", + "EDIT_HINT": "Aggiorna il messaggio Discord esistente quando il modulo esca cambia invece di inviarne uno nuovo.", + "EDIT_BADGE": "Modifica", + "CONFIRM_DELETE_TITLE": "Eliminare l'avviso esca?", + "SNACK_FAILED_DISTANCE": "Impossibile aggiornare la distanza." }, "NESTS": { "PAGE_TITLE": "Allarmi Nidi", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Allarme nido eliminato", "SNACK_FAILED_CREATE": "Creazione allarme fallita", "SNACK_FAILED_UPDATE": "Aggiornamento allarme fallito", - "SNACK_FAILED_DELETE": "Eliminazione allarme fallita" + "SNACK_FAILED_DELETE": "Eliminazione allarme fallita", + "CONFIRM_DELETE_TITLE": "Eliminare l'avviso nido?", + "SNACK_FAILED_DISTANCE": "Impossibile aggiornare la distanza." }, "GYMS": { "PAGE_TITLE": "Allarmi Palestre", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Saggezza", "TEAM_VALOR": "Coraggio", "TEAM_INSTINCT": "Istinto", - "TEAM_UNKNOWN": "Squadra {{id}}" + "TEAM_UNKNOWN": "Squadra {{id}}", + "CONFIRM_DELETE_TITLE": "Eliminare l'avviso palestra?", + "SNACK_FAILED_DISTANCE": "Impossibile aggiornare la distanza." }, "FORT_CHANGES": { "PAGE_TITLE": "Allarmi Modifiche Forte", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Rimosso", "CHANGE_NEW": "Nuovo forte", "INCLUDE_EMPTY": "Includi forti senza nome", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Impossibile creare l'avviso", + "CREATE_SUCCESS": "Avviso per i cambiamenti delle palestre creato", + "UPDATE_FAILED": "Impossibile aggiornare l'avviso", + "UPDATE_SUCCESS": "Avviso per i cambiamenti delle palestre aggiornato", "ALL_CHANGES": "Tutte le modifiche", "LABEL_NAME": "Nome", "LABEL_LOCATION": "Posizione", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Eliminare l'allarme di modifica {{type}}?", "SNACK_DELETED": "Allarme di modifica forte eliminato", "SNACK_FAILED_DISTANCE": "Impossibile aggiornare le distanze", - "SNACK_ALL_DISTANCE": "Tutte le distanze aggiornate" + "SNACK_ALL_DISTANCE": "Tutte le distanze aggiornate", + "FORT_TYPE_LABEL": "Tipo di forte", + "CHANGE_TYPES_LABEL": "Tipi di modifica", + "TRACKING_SUBTITLE": "Monitoraggio modifiche forte", + "CHANGE_DESCRIPTION": "Descrizione modificata" }, "MAX_BATTLES": { "PAGE_TITLE": "Allarmi Battaglie Max", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Impossibile creare gli avvisi", + "CREATE_SUCCESS": "{{count}} avviso/i creato/i", "ANY_POKEMON": "Qualsiasi Pokémon", "ANY_LEVEL": "Qualsiasi livello", "STAR_LABEL": "{{stars}} stelle", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Impossibile aggiornare le distanze", "SNACK_ALL_DISTANCE": "Tutte le distanze aggiornate", "SNACK_FAILED_UPDATE": "Impossibile aggiornare l'allarme", - "SNACK_UPDATED": "Allarme Lotta Dynamax aggiornato" + "SNACK_UPDATED": "Allarme Lotta Dynamax aggiornato", + "HINT_BY_LEVEL": "Segue qualsiasi Pokemon a questi livelli di battaglia. Ogni livello scelto diventa un allarme a sé.", + "HINT_BY_POKEMON": "Segue Pokemon specifici nelle Battaglie Max, a qualsiasi livello.", + "HINT_GMAX_ONLY_ADD": "Avvisa solo per le battaglie Gigantamax dei Pokemon scelti.", + "HINT_GMAX_ONLY_EDIT": "Avvisa solo per le battaglie Gigantamax di questo Pokemon.", + "HINT_ALL_LEVELS": "Questo allarme segue un Pokemon su tutti i livelli di Battaglia Max.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Aree e Posizione", + "MANAGE_PLACES": "Gestisci i luoghi", + "PAGE_TITLE": "Aree e luoghi", "PAGE_DESC": "Controlla dove ricevi le notifiche.", "METHOD_AREAS": "Aree", "METHOD_AREAS_ACTIVE": "{{count}} area/e attiva/e", "METHOD_NOT_CONFIGURED": "Non configurato", "METHOD_AREAS_DESC": "Ricevi notifiche su tutto ciò che accade all'interno delle tue zone geofence selezionate.", "METHOD_AREAS_TIP": "Ideale per: coprire intere città, quartieri o parchi", - "METHOD_LOCATION": "Posizione", - "METHOD_LOCATION_NOT_SET": "Non impostata", - "METHOD_LOCATION_DESC": "Ricevi notifiche su tutto ciò che si trova entro una distanza dalla tua posizione fissata.", + "METHOD_LOCATION": "La mia posizione", + "METHOD_LOCATION_NOT_SET": "Nessuna posizione impostata", + "METHOD_LOCATION_DESC": "Ricevi avvisi per tutto ciò che rientra in una distanza fissata dalla tua posizione.", "METHOD_LOCATION_TIP": "Ideale per: avvisi vicino a casa, lavoro o un punto specifico", "CLEAR_LOCATION": "Cancella", "CHANGE_LOCATION": "Modifica", "SET_LOCATION": "Imposta", "METHOD_NOTE": "Ogni allarme sceglie un metodo nella scheda Consegna.", + "NOTIFICATION_LANGUAGE": "Lingua delle notifiche", + "NOTIFICATION_LANGUAGE_DESC": "La lingua che Poracle usa per i messaggi di avviso e i nomi dei Pokémon. È distinta dalla lingua di visualizzazione nel menu in alto.", + "SNACK_LANGUAGE_UPDATED": "Lingua delle notifiche aggiornata", + "SNACK_LANGUAGE_FAILED": "Impossibile aggiornare la lingua delle notifiche", "SELECT_AREAS": "Seleziona Aree", "MAP_VIEW": "Mappa", "LIST_VIEW": "Lista", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Aggiornamento posizione fallito", "SEARCH_AREAS": "Cerca aree", "MANUAL_ADD_PLACEHOLDER": "Digita un nome di area e premi Invio", - "FILTER_PLACEHOLDER": "Filtra per nome..." + "FILTER_PLACEHOLDER": "Filtra per nome...", + "SNACK_LOAD_SELECTED_FAILED": "Impossibile caricare le tue aree attuali. Ricarica prima di modificarle.", + "SELECTION_UNKNOWN": "Le tue aree attuali non sono state caricate: ricarica la pagina prima di salvare." }, "PROFILES": { "PAGE_TITLE": "Profili", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Seleziona Regione", "SEARCH_REGIONS": "Cerca regioni...", "TOGGLE_TOOLTIP": "Attiva/disattiva notifiche per questo geofence sul profilo corrente", - "CREATED_PREFIX": "Creato" + "CREATED_PREFIX": "Creato", + "REGION_OPTIONAL_HINT": "Facoltativo. Scegli una regione se il tuo geofence ne fa parte." }, "CLEANING": { "PAGE_TITLE": "Modalità Pulizia", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Alcuni contenuti della guida potrebbero non essere ancora disponibili nella tua lingua.", "TRANSLATION_CTA_LINK": "Aiuta a tradurre", "FALLBACK_CHIP": "Inglese", + "IMAGE_ENLARGE": "Clicca per ingrandire", "SECTION_GETTING_STARTED": "Per Iniziare", "SECTION_GETTING_STARTED_SUB": "Accesso, configurazione guidata e impostazione iniziale", "SECTION_DASHBOARD": "Dashboard", "SECTION_DASHBOARD_SUB": "La tua panoramica di allarmi, aree e stato", "SECTION_LOCATION": "Impostare la Posizione", "SECTION_LOCATION_SUB": "GPS, ricerca indirizzo e coordinate", - "SECTION_AREAS": "Scegliere le Aree", - "SECTION_AREAS_SUB": "Vista mappa, vista lista e filtro per regione", + "SECTION_AREAS": "Aree e luoghi", + "SECTION_AREAS_SUB": "Vista mappa, vista lista, filtro per regione e luoghi", "SECTION_GEOFENCES": "Geofence Personalizzate", "SECTION_GEOFENCES_SUB": "Disegna confini, invia per approvazione pubblica", "SECTION_POKEMON": "Allarmi Pokemon", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Altri Tipi di Allarme", "SECTION_OTHER_ALARMS_SUB": "Raid, uova, missioni, rocket, esche, nidi, palestre, modifiche forte", "SECTION_DELIVERY": "Impostazioni di Consegna", - "SECTION_DELIVERY_SUB": "Aree vs distanza, template e modalità pulizia", + "SECTION_DELIVERY_SUB": "Ambito di consegna, template e modalità pulizia", + "SECTION_QUEST_SUMMARY": "Consegna del riepilogo delle missioni", + "SECTION_QUEST_SUMMARY_SUB": "Raggruppa le missioni rumorose in un unico riepilogo pianificato", "SECTION_TEST_ALERTS": "Avvisi di Prova", "SECTION_TEST_ALERTS_SUB": "Invia notifiche di esempio per visualizzare i tuoi allarmi", "SECTION_POKEMON_AVAILABILITY": "Disponibilità Pokemon", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Problemi comuni e come risolverli", "CONTENT_GETTING_STARTED": "

Il sito Avvisi DM ti permette di personalizzare esattamente quali notifiche di Pokemon GO ricevi come messaggi diretti. Invece di ricevere ogni avviso, scegli tu cosa ti interessa — Pokemon specifici, raid, missioni e altro — e vieni avvisato solo di quelli.

ℹ️
Prima di poter usare il sito, devi registrarti con il bot Poracle su Discord o Telegram. Una volta registrato, torna qui e accedi.

Accesso

  • Discord — Clicca \"Accedi con Discord\" nella pagina di login. Verrai reindirizzato su Discord per autorizzare l'app, poi tornerai qui automaticamente.
  • Telegram — Se abilitato, usa il widget di login Telegram nella pagina di accesso. Conferma il login nella tua app Telegram.
\"Pagina

Prima configurazione

Quando accedi per la prima volta, una procedura guidata ti accompagna in tre passaggi:

  1. Imposta la tua posizione — Usata per calcolare le distanze per le notifiche nelle vicinanze.
  2. Scegli le tue aree — Seleziona le zone geografiche da cui vuoi ricevere avvisi.
  3. Aggiungi il tuo primo allarme — Crea un allarme Pokemon, Raid o Missione per iniziare a ricevere notifiche.
\"Procedura

Puoi saltare qualsiasi passaggio e tornare più tardi. La procedura guidata non apparirà più una volta che la chiudi o completi tutti i passaggi.

", "CONTENT_DASHBOARD": "\"Dashboard

La Dashboard è la tua base. Mostra una panoramica della tua configurazione attuale a colpo d'occhio.

Schede di stato

  • Posizione — Mostra le tue coordinate o indirizzo salvati. Clicca per impostare o aggiornare la tua posizione.
  • Aree attive — Mostra quante aree stai monitorando. Clicca per gestire le tue aree.
  • Profilo — Mostra il tuo profilo attivo. Se hai più profili, clicca per passare da uno all'altro.

Filtri attivi

Una griglia di schede mostra quanti allarmi hai per ogni tipo (Pokemon, Raid, Missioni, ecc.). Clicca su qualsiasi scheda per andare alla lista degli allarmi corrispondente.

Meteo

Se hai una posizione impostata, la dashboard mostra il meteo attuale nel gioco alle tue coordinate insieme all'ora dell'ultimo aggiornamento. Il meteo dell'area viene mostrato anche per ciascuna delle tue aree selezionate, così puoi vedere le condizioni meteo in tutte le zone che monitori.

Azioni rapide

Pulsanti scorciatoia per aggiungere allarmi Pokemon, Raid o Missioni, gestire le aree o configurare la pulizia — tutto senza navigare nella barra laterale.

Suggerimenti

Promemoria utili appaiono quando la tua configurazione è incompleta — come posizione mancante, nessuna area selezionata o nessun allarme configurato. Ogni suggerimento ha un pulsante per risolvere il problema. Puoi chiudere i suggerimenti che non ti servono.

Navigazione

Usa la barra laterale per navigare tra le sezioni. I tipi di allarme sono elencati in alto, seguiti da impostazioni come Aree, Geofence, Profili e Pulizia. L'Aiuto è sempre in fondo.

\"Barra", - "CONTENT_LOCATION": "\"Dashboard

La tua posizione viene usata per le notifiche basate sulla distanza. Quando un allarme usa la modalità \"Imposta Distanza\", verrai avvisato degli eventi entro un raggio da questa posizione.

Impostare la posizione

Apri la finestra della posizione dalla Dashboard o dalla pagina Aree. Hai quattro modi per impostarla:

  • Cerca per indirizzo — Digita un indirizzo, una città o un punto di riferimento. Seleziona tra i suggerimenti che appaiono.
  • Inserisci coordinate — Digita latitudine e longitudine direttamente se le conosci.
  • Usa il tuo GPS — Clicca \"Usa la mia posizione\" per usare la posizione attuale del tuo dispositivo. Il browser ti chiederà il permesso.
  • Clicca sulla mappa — Clicca in un punto qualsiasi della mini-mappa per impostare quel punto come tua posizione.

Dopo aver selezionato una posizione, l'indirizzo viene mostrato automaticamente. Clicca Salva per confermare.

💡
Puoi cancellare la tua posizione dalla pagina Aree se vuoi solo avvisi basati sulle aree.
", - "CONTENT_AREAS": "\"Pagina

Le aree sono zone geografiche predefinite impostate dalla tua community. Quando un allarme usa la modalità \"Usa Aree\", vieni avvisato degli eventi che accadono nelle tue aree selezionate.

Selezionare le aree

Vai a Aree e Posizione dalla barra laterale. Puoi selezionare le aree in due modi:

  • Vista mappa — Clicca i poligoni colorati sulla mappa per selezionare o deselezionare le aree. Le aree selezionate diventano verdi. Passa il mouse su un'area per vederne il nome.
  • Vista lista — Usa le caselle di spunta per scegliere le aree da un elenco con ricerca.

Filtro per regione

Se la tua community ha molte aree in diverse regioni, usa il menu a tendina delle regioni per ingrandire una regione specifica. Questo rende più facile trovare le aree vicino a te.

Aree sovrapposte

Alcune aree si sovrappongono — una zona più piccola dentro una più grande. Entrambe sono cliccabili. Ingrandisci per rendere più facile cliccare l'area più piccola.

Salvataggio

Una barra di salvataggio appare in basso quando hai fatto modifiche. Clicca Salva per confermare le tue selezioni, o Annulla per ripristinare.

ℹ️
Le aree sono per profilo. Ogni profilo ha il proprio set di aree selezionate. Cambiando profilo vedrai selezioni di aree diverse. Le geofence personalizzate possono anche essere attivate o disattivate per profilo dalla pagina Geofence.
", - "CONTENT_GEOFENCES": "\"Pagina

Se le aree predefinite non coprono dove vuoi ricevere avvisi, puoi disegnare i tuoi confini geofence personalizzati sulla mappa.

Disegnare una geofence

  1. Vai a Le Mie Geofence dalla barra laterale.
  2. Clicca Disegna Geofence.
  3. Clicca sulla mappa per posizionare i punti del confine del tuo poligono. Clicca di nuovo sul primo punto per chiudere la forma (minimo 3 punti).
  4. Dai un nome alla tua geofence e seleziona a quale regione appartiene. La regione viene solitamente rilevata automaticamente.
  5. Clicca Salva.

Gestire le geofence

  • Modifica — Rinomina la tua geofence o cambia la sua regione.
  • Elimina — Rimuovi una geofence che non ti serve più. La geofence viene rimossa da tutti i profili automaticamente.

Interruttore profilo

Ogni scheda geofence ha un interruttore a scorrimento per attivarla o disattivarla per il tuo profilo attuale. Quando crei una geofence, viene automaticamente attivata sul profilo che stai usando. Passa a un altro profilo e l'interruttore mostrerà \"Inattiva\" — attivalo per ricevere avvisi per quella geofence anche su quel profilo. Questo ti permette di controllare quali profili ricevono notifiche per ogni geofence senza doverla ricreare.

ℹ️
Le geofence approvate (promosse ad aree pubbliche) non mostrano l'interruttore — gestiscile dalla pagina Aree.

Importazione & Esportazione GeoJSON

Puoi importare ed esportare geofence usando il formato standard GeoJSON, rendendo facile condividere confini o crearli in strumenti esterni come geojson.io.

  • Importa — Clicca l'icona di caricamento e incolla o carica un file GeoJSON. Ogni poligono nel file diventa una nuova geofence. Puoi revisionare e rinominare ciascuna prima di salvare.
  • Esporta — Clicca l'icona di download e seleziona quali geofence includere. Il file GeoJSON esportato contiene tutti i poligoni selezionati e può essere aperto in qualsiasi strumento GIS o editor di mappe.
💡
L'importazione GeoJSON è utile per migrare geofence da altri sistemi o disegnare confini complessi in uno strumento GIS desktop e poi importarli qui.

Invio per approvazione pubblica

Se pensi che la tua geofence possa essere utile per tutta la community, puoi inviarla per la revisione degli admin. Se approvata, diventa un'area pubblica che tutti possono selezionare. La tua geofence privata continua a funzionare mentre la revisione è in corso.

Badge di stato

  • Attiva — La tua geofence privata, funzionante solo per te.
  • In revisione — Inviata e in attesa di revisione da parte degli admin.
  • Approvata — Promossa ad area pubblica.
  • Rifiutata — Non approvata. Puoi vedere il feedback dell'admin e la geofence rimane attiva come zona privata.
ℹ️
Puoi avere fino a 10 geofence personalizzate, ciascuna con un massimo di 500 punti di confine.
", - "CONTENT_POKEMON": "\"Pagina

Gli allarmi Pokemon ti avvisano quando un Pokemon selvatico appare e corrisponde ai tuoi filtri.

Aggiungere un allarme Pokemon

\"Finestra
  1. Vai a Pokemon dalla barra laterale e clicca il pulsante +.
  2. Seleziona Pokemon — Cerca per nome o numero Pokedex, oppure usa i pulsanti filtro per generazione e tipo per sfogliare. Puoi selezionare più Pokemon contemporaneamente.
  3. Imposta i filtri — Scegli cosa rende uno spawn degno di notifica:
  • Intervallo IV — Percentuale IV minima e massima (0-100%)
  • Intervallo CP — Filtra per potenza di combattimento
  • Intervallo livello — Filtra per livello Pokemon (0-55)
  • Statistiche individuali — Filtra per valori ATK, DEF e STA (0-15 ciascuno)
  • Forma — Traccia forme specifiche (es. Alolan, Galarian) o tutte le forme
  • Genere — Maschio, femmina, senza genere, o tutti
  • Peso — Filtra per intervallo di peso
  • Taglia — Filtra per categoria di taglia: seleziona ALL (nessun filtro) per qualsiasi taglia, oppure scegli taglie specifiche da XXS a XXL (XXS, XS, Normal, XL, XXL)
ℹ️
I valori predefiniti dei filtri sono impostati in modo che tutti i Pokemon corrispondano quando nessun filtro è configurato esplicitamente. Ad esempio, IV predefinito 0-100%, livello 0-55 e taglia ALL. Devi modificare solo i filtri che ti interessano.

Filtri PVP

Ricevi una notifica quando un Pokemon ha ottimi IV per il PVP. Seleziona una lega (Grande, Ultra o Coppa Piccoli) e imposta l'intervallo di ranking che ti interessa (es. rank 1-50).

Allarme \"Tutti i Pokemon\"

💡
Seleziona \"Tutti i Pokemon\" (ID 0) per creare un unico allarme che copre ogni specie. Utile con un filtro IV alto come 96-100% per catturare qualsiasi spawn di valore.

Leggere le schede allarme

Ogni scheda allarme mostra pillole colorate che riassumono i tuoi filtri a colpo d'occhio:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Pagina

Allarmi Raid e Uova

Ricevi una notifica quando appare un boss raid o un uovo che ti interessa.

  • Per livello — Seleziona i livelli raid (1-6) o i livelli uovo per monitorare tutti i raid di quel livello.
  • Per boss — Seleziona specifici boss raid Pokemon che vuoi affrontare.
  • Filtro squadra — Avvisa solo per raid nelle palestre controllate da una squadra specifica (Mystic, Valor, Instinct).
  • Monitoraggio palestra — Monitora i raid in palestre specifiche per nome, così vieni avvisato solo per le tue palestre preferite.
  • Filtro mosse — Filtra i boss raid per le loro mosse veloci o caricate.
  • Notifiche RSVP — Ricevi una notifica quando altri allenatori confermano la partecipazione a un raid o uovo che stai monitorando.

Gli allarmi Raid e Uova sono gestiti in schede separate nella pagina Raid. Le Uova supportano anche il monitoraggio di palestre specifiche e le notifiche RSVP.

Allarmi Max Battle (Dynamax)

Ricevi notifiche sulle battaglie Dynamax e Gigantamax ai Power Spot.

  • Per livello — Seleziona i livelli di battaglia per monitorare qualsiasi Pokemon a quei livelli. I livelli vanno da 1 Stella a 5 Stelle (Leggendario) per Dynamax, più Gigantamax e Gigantamax Leggendario per le battaglie più grandi. Viene creato un allarme per ogni livello selezionato.
  • Per Pokemon — Seleziona Pokemon specifici che vuoi affrontare in tutti i livelli Max Battle. Se il database scanner è configurato, il selettore mostra solo i Pokemon apparsi nelle Max Battle.
  • Solo Gigantamax — Quando monitori per Pokemon, attiva questo per ricevere notifiche solo quando quel Pokemon appare nelle battaglie Gigantamax (le battaglie di livello più alto con mosse G-Max uniche). Per il monitoraggio per livello, il Gigantamax si gestisce selezionando direttamente i livelli Gigantamax o Gigantamax Leggendario.
  • Seleziona tutto — Seleziona rapidamente tutti i livelli disponibili (equivalente al comando !maxbattle everything del bot).

Allarmi Missioni

Ricevi notifiche sulle missioni di ricerca sul campo con ricompense specifiche.

  • Incontri Pokemon — Seleziona i Pokemon che vuoi come ricompensa delle missioni.
  • Strumenti — Monitora le missioni che ricompensano con strumenti specifici.
  • Mega Energia — Monitora le missioni che danno mega energia per Pokemon specifici.
  • Caramelle — Monitora le missioni che ricompensano con caramelle per Pokemon specifici.

Allarmi Invasioni

Ricevi notifiche sulle invasioni di Team Rocket.

  • Monitora tutto — Un allarme per ogni tipo di recluta e leader.
  • Per tipo — Seleziona tipi di reclute specifici (Coleottero, Drago, Fuoco, ecc.), Leader Rocket o Giovanni. I nomi dei tipi di recluta vengono normalizzati automaticamente (senza distinzione maiuscole/minuscole), quindi non devi preoccuparti della capitalizzazione esatta.
  • Genere — Filtra per genere della recluta.

Allarmi Esche

Ricevi una notifica quando viene piazzata un'esca di un tipo specifico. Scegli tra esche Normali, Glaciali, Muschiate, Magnetiche, Piovose e Dorate.

Allarmi Nidi

Monitora le specie Pokemon nei nidi. Imposta una soglia di spawn minimi per ora per essere avvisato solo dei nidi con attività sufficiente.

Allarmi Palestre

Monitora i cambi di squadra nelle palestre. Seleziona quali squadre (Neutrale, Mystic, Valor, Instinct) monitorare. Attiva il monitoraggio Cambi Posti per essere avvisato quando si liberano posti in palestra, o attiva il monitoraggio Cambi Battaglia per essere avvisato quando una palestra è sotto attacco.

Allarmi Modifiche Forte

Monitora le modifiche ai pokestop e alle palestre stesse — non le attività che vi si svolgono, ma le modifiche ai punti di interesse effettivi.

  • Tipo forte — Scegli se monitorare Pokestop, Palestre o Tutto.
  • Tipi di modifica — Seleziona quali modifiche monitorare: Nome cambiato, Posizione cambiata, Immagine cambiata, Rimozione o Nuovo forte aggiunto.
  • Includi vuoti — Includi i forti senza nome impostato.
💡
Gli allarmi modifiche forte sono utili per monitorare gli aggiornamenti del database mappa — nuovi pokestop che appaiono, palestre che vengono spostate o POI rimossi dal gioco.

Puntare a una palestra specifica

Quando crei o modifichi un allarme Raid, Uovo o Palestra, puoi opzionalmente cercare e selezionare una palestra specifica. Questo è utile quando ti interessa solo l'attività alla tua palestra preferita — come quella sul percorso per pranzo o vicino a casa tua.

  • Come usarlo — Nella finestra di aggiunta o modifica, digita il nome di una palestra nel campo di ricerca. I risultati mostrano la foto della palestra, il nome e l'area così puoi identificare quella giusta.
  • Quando una palestra è selezionata — L'allarme scatta solo per eventi in quella palestra specifica. Il nome della palestra appare sulla scheda allarme nella tua lista così puoi vedere a colpo d'occhio quale palestra è il bersaglio.
  • Quando nessuna palestra è selezionata — Questo è il comportamento predefinito. L'allarme funziona normalmente per tutte le palestre nelle tue aree selezionate o entro il tuo raggio di distanza.
💡
Puoi combinare un allarme per palestra specifica con un allarme più ampio. Ad esempio, crea un allarme raid per la tua palestra locale per tutti i livelli e un secondo allarme per raid di livello 5 in tutte le tue aree.
", - "CONTENT_DELIVERY": "\"Schede

Ogni allarme ha impostazioni di consegna che controllano dove ricevi le notifiche.

Aree vs Distanza

Ogni allarme usa una di due modalità di consegna:

🗺
Usa AreeRicevi notifiche quando gli eventi accadono nelle tue aree selezionate. Ideale per monitorare quartieri specifici.
📏
Imposta DistanzaRicevi notifiche entro un raggio (km) dalla tua posizione salvata. Ideale per monitorare tutto vicino a te.

Puoi usare modalità diverse per allarmi diversi — ad esempio, usa le aree per i Pokemon e la distanza per i raid.

Template di notifica

Se i template sono abilitati, puoi scegliere l'aspetto dei tuoi messaggi di notifica. Il selettore di template mostra un'anteprima dal vivo di come apparirà il tuo DM Discord, incluso il formato embed, i campi e le immagini.

Modalità Pulizia

Quando attivata, il bot elimina automaticamente la notifica da Discord dopo la scadenza dell'evento (es. un Pokemon scompare o un raid finisce). Questo mantiene i tuoi DM ordinati. Puoi attivare la modalità pulizia per singolo allarme o in blocco dalla pagina Pulizia.

Ping / Menzioni ruolo

Se usi webhook, puoi impostare un ruolo Discord da menzionare nella notifica (es. @Pokemon). Questo è rilevante solo per le configurazioni webhook.

", + "CONTENT_LOCATION": "\"Dashboard

La tua posizione è il punto da cui vengono misurati i tuoi avvisi. Un allarme che ti raggiunge entro un raggio parte da lì, a meno che tu non punti quel singolo allarme su un luogo salvato.

Impostare la posizione

Apri la finestra della posizione dalla Dashboard o dalla pagina Aree e luoghi. Hai quattro modi per impostarla:

  • Cerca per indirizzo — Digita un indirizzo, una città o un punto di riferimento. Seleziona tra i suggerimenti che appaiono.
  • Inserisci coordinate — Digita latitudine e longitudine direttamente se le conosci.
  • Usa il tuo GPS — Clicca \"Usa la mia posizione\" per usare la posizione attuale del tuo dispositivo. Il browser ti chiederà il permesso.
  • Clicca sulla mappa — Clicca in un punto qualsiasi della mini-mappa per impostare quel punto come tua posizione.

Dopo aver scelto un punto, l'indirizzo viene mostrato automaticamente. Clicca Salva per confermare.

La stessa finestra si riusa quando aggiungi un luogo o scegli un punto per un singolo allarme. In quel caso si intitola Scegli un punto e si conferma con Usa questo punto, senza toccare la tua posizione.

💡
Puoi cancellare la tua posizione dalla pagina Aree e luoghi se vuoi solo avvisi basati sulle aree.
", + "CONTENT_AREAS": "\"Pagina

Le aree sono zone geografiche predefinite impostate dalla tua community. Quelle che scegli qui sono ciò che ogni allarme segue per impostazione predefinita: un allarme impostato su Ovunque nelle mie aree scatta per gli eventi al loro interno.

Selezionare le aree

Vai a Aree e luoghi dalla barra laterale. Puoi selezionare le aree in due modi:

  • Vista mappa — Clicca i poligoni colorati sulla mappa per selezionare o deselezionare le aree. Le aree selezionate diventano verdi. Passa il mouse su un'area per vederne il nome.
  • Vista lista — Usa le caselle di spunta per scegliere le aree da un elenco con ricerca.

Luoghi

Un luogo è un punto con un nome — il lavoro, la palestra, la casa dei tuoi genitori — da cui un allarme può misurare il suo raggio al posto della tua posizione. Aggiungilo nella sezione Luoghi della stessa pagina, poi scegli sotto Misurato da quando decidi dove un allarme deve raggiungerti. Un luogo non si può eliminare finché ci sono allarmi che lo usano, e il messaggio dice quanti.

Filtro per regione

Se la tua community ha molte aree in diverse regioni, usa il menu a tendina delle regioni per ingrandire una regione specifica. Questo rende più facile trovare le aree vicino a te.

Aree sovrapposte

Alcune aree si sovrappongono — una zona più piccola dentro una più grande. Entrambe sono cliccabili. Ingrandisci per rendere più facile cliccare l'area più piccola.

Salvataggio

Una barra di salvataggio appare in basso quando hai fatto modifiche. Clicca Salva per confermare le tue selezioni, o Annulla per ripristinare.

ℹ️
Le aree sono per profilo. Ogni profilo ha il proprio set di aree selezionate. Cambiando profilo vedrai selezioni di aree diverse. Le geofence personalizzate possono anche essere attivate o disattivate per profilo dalla pagina Geofence.
", + "CONTENT_GEOFENCES": "\"Pagina

Se le aree predefinite non coprono dove vuoi ricevere avvisi, puoi disegnare i tuoi confini geofence personalizzati sulla mappa.

Disegnare una geofence

  1. Vai a Le Mie Geofence dalla barra laterale.
  2. Clicca Disegna Geofence.
  3. Clicca sulla mappa per posizionare i punti del confine del tuo poligono. Clicca di nuovo sul primo punto per chiudere la forma (minimo 3 punti).
  4. Dai un nome alla tua geofence e seleziona a quale regione appartiene. La regione viene solitamente rilevata automaticamente.
  5. Clicca Salva.

Gestire le geofence

  • Modifica — Rinomina la tua geofence o cambia la sua regione.
  • Elimina — Rimuovi una geofence che non ti serve più. La geofence viene rimossa da tutti i profili automaticamente.

Interruttore profilo

Ogni scheda geofence ha un interruttore a scorrimento per attivarla o disattivarla per il tuo profilo attuale. Quando crei una geofence, viene automaticamente attivata sul profilo che stai usando. Passa a un altro profilo e l'interruttore mostrerà \"Inattiva\" — attivalo per ricevere avvisi per quella geofence anche su quel profilo. Questo ti permette di controllare quali profili ricevono notifiche per ogni geofence senza doverla ricreare.

ℹ️
Le geofence approvate (promosse ad aree pubbliche) non mostrano l'interruttore — gestiscile dalla pagina Aree.

Usare una geofence per un solo allarme

Una geofence che hai disegnato compare anche nell'elenco Solo in aree specifiche quando decidi dove un singolo allarme deve raggiungerti, contrassegnata da un'icona di disegno. Così limiti un allarme a quella geofence senza attivarla per l'intero profilo.

Importazione & Esportazione GeoJSON

Puoi importare ed esportare geofence usando il formato standard GeoJSON, rendendo facile condividere confini o crearli in strumenti esterni come geojson.io.

  • Importa — Clicca l'icona di caricamento e incolla o carica un file GeoJSON. Ogni poligono nel file diventa una nuova geofence. Puoi revisionare e rinominare ciascuna prima di salvare.
  • Esporta — Clicca l'icona di download e seleziona quali geofence includere. Il file GeoJSON esportato contiene tutti i poligoni selezionati e può essere aperto in qualsiasi strumento GIS o editor di mappe.
💡
L'importazione GeoJSON è utile per migrare geofence da altri sistemi o disegnare confini complessi in uno strumento GIS desktop e poi importarli qui.

Invio per approvazione pubblica

Se pensi che la tua geofence possa essere utile per tutta la community, puoi inviarla per la revisione degli admin. Se approvata, diventa un'area pubblica che tutti possono selezionare. La tua geofence privata continua a funzionare mentre la revisione è in corso.

Badge di stato

  • Attiva — La tua geofence privata, funzionante solo per te.
  • In revisione — Inviata e in attesa di revisione da parte degli admin.
  • Approvata — Promossa ad area pubblica.
  • Rifiutata — Non approvata. Puoi vedere il feedback dell'admin e la geofence rimane attiva come zona privata.
ℹ️
Puoi avere fino a 10 geofence personalizzate, ciascuna con un massimo di 500 punti di confine.
", + "CONTENT_POKEMON": "\"Pagina

Gli allarmi Pokemon ti avvisano quando un Pokemon selvatico appare e corrisponde ai tuoi filtri.

Aggiungere un allarme Pokemon

\"Finestra
  1. Vai a Pokemon dalla barra laterale e clicca il pulsante +.
  2. Seleziona Pokemon — Cerca per nome o numero Pokedex, oppure usa i pulsanti filtro per generazione e tipo per sfogliare. Puoi selezionare più Pokemon contemporaneamente.
  3. Imposta i filtri — Scegli cosa rende uno spawn degno di notifica:
  • Intervallo IV — Percentuale IV minima e massima (0-100%)
  • Intervallo CP — Filtra per potenza di combattimento
  • Intervallo livello — Filtra per livello Pokemon (0-55)
  • Statistiche individuali — Filtra per valori ATK, DEF e STA (0-15 ciascuno)
  • Forma — Traccia forme specifiche (es. Alolan, Galarian) o tutte le forme
  • Genere — Maschio, femmina, senza genere, o tutti
  • Peso — Filtra per intervallo di peso
  • Taglia — Filtra per categoria di taglia: seleziona ALL (nessun filtro) per qualsiasi taglia, oppure scegli taglie specifiche da XXS a XXL (XXS, XS, Normal, XL, XXL)
  • Tempo minimo rimasto — Salta gli spawn che spariranno prima che tu arrivi. Si imposta in Altri filtri; la scheda mostra poi una pillola tipo "10 min rimasti"
ℹ️
I valori predefiniti dei filtri sono impostati in modo che tutti i Pokemon corrispondano quando nessun filtro è configurato esplicitamente. Ad esempio, IV predefinito 0-100%, livello 0-55 e taglia ALL. Devi modificare solo i filtri che ti interessano.

Filtri PVP

Ricevi una notifica quando un Pokemon ha ottimi IV per il PVP. Seleziona una lega (Grande, Ultra o Coppa Piccoli) e imposta l'intervallo di ranking che ti interessa (es. rank 1-50).

I pulsanti Livello massimo scelgono a quale tetto vengono letti i ranking. Lascia Tutti per usare il valore impostato dalla configurazione Poracle della tua community.

Megaevoluzione decide se la regola classifica la forma base o una mega: Base, Mega, Mega X o Mega Y. Le mega sono classificate a parte, quindi una regola mega non corrisponderà mai a uno spawn in forma base.

Allarme \"Tutti i Pokemon\"

💡
Seleziona \"Tutti i Pokemon\" (ID 0) per creare un unico allarme che copre ogni specie. Utile con un filtro IV alto come 96-100% per catturare qualsiasi spawn di valore.

Leggere le schede allarme

Ogni scheda allarme mostra pillole colorate che riassumono i tuoi filtri a colpo d'occhio:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Pagina

Allarmi Raid e Uova

Ricevi una notifica quando appare un boss raid o un uovo che ti interessa.

  • Per livello — Seleziona i livelli raid (1-6) o i livelli uovo per monitorare tutti i raid di quel livello.
  • Per boss — Seleziona specifici boss raid Pokemon che vuoi affrontare.
  • Filtro squadra — Avvisa solo per raid nelle palestre controllate da una squadra specifica (Mystic, Valor, Instinct).
  • Monitoraggio palestra — Monitora i raid in palestre specifiche per nome, così vieni avvisato solo per le tue palestre preferite.
  • Filtro mosse — Filtra i boss raid per le loro mosse veloci o caricate.
  • Notifiche RSVP — Ricevi una notifica quando altri allenatori confermano la partecipazione a un raid o uovo che stai monitorando.

Gli allarmi Raid e Uova sono gestiti in schede separate nella pagina Raid. Le Uova supportano anche il monitoraggio di palestre specifiche e le notifiche RSVP.

Allarmi Max Battle (Dynamax)

Ricevi notifiche sulle battaglie Dynamax e Gigantamax ai Power Spot.

  • Per livello — Seleziona i livelli di battaglia per monitorare qualsiasi Pokemon a quei livelli. I livelli vanno da 1 Stella a 5 Stelle (Leggendario) per Dynamax, più Gigantamax e Gigantamax Leggendario per le battaglie più grandi. Viene creato un allarme per ogni livello selezionato.
  • Per Pokemon — Seleziona Pokemon specifici che vuoi affrontare in tutti i livelli Max Battle. Se il database scanner è configurato, il selettore mostra solo i Pokemon apparsi nelle Max Battle.
  • Solo Gigantamax — Quando monitori per Pokemon, attiva questo per ricevere notifiche solo quando quel Pokemon appare nelle battaglie Gigantamax (le battaglie di livello più alto con mosse G-Max uniche). Per il monitoraggio per livello, il Gigantamax si gestisce selezionando direttamente i livelli Gigantamax o Gigantamax Leggendario.
  • Seleziona tutto — Seleziona rapidamente tutti i livelli disponibili (equivalente al comando !maxbattle everything del bot).

Allarmi Missioni

Ricevi notifiche sulle missioni di ricerca sul campo con ricompense specifiche.

  • Incontri Pokemon — Seleziona i Pokemon che vuoi come ricompensa delle missioni.
  • Strumenti — Monitora le missioni che ricompensano con strumenti specifici.
  • Mega Energia — Monitora le missioni che danno mega energia per Pokemon specifici.
  • Caramelle — Monitora le missioni che ricompensano con caramelle per Pokemon specifici.
  • Polvere di stelle — Monitora le missioni che ricompensano con polvere di stelle.

Le schede oggetti, mega energia e caramelle hanno ciascuna un campo Quantità minima, e quella della polvere di stelle una Polvere di stelle minima. Lascia 0 per accettare qualsiasi quantità. Le schede mostrano la quantità accanto alla ricompensa, ad esempio "3× Rare Candy".

Allarmi Invasioni

Ricevi notifiche sulle invasioni di Team Rocket.

  • Monitora tutto — Un allarme per ogni tipo di recluta e leader.
  • Per tipo — Seleziona tipi di reclute specifici (Coleottero, Drago, Fuoco, ecc.), Leader Rocket o Giovanni. I nomi dei tipi di recluta vengono normalizzati automaticamente (senza distinzione maiuscole/minuscole), quindi non devi preoccuparti della capitalizzazione esatta.
  • Genere — Filtra per genere della recluta.

Allarmi Esche

Ricevi una notifica quando viene piazzata un'esca di un tipo specifico. Scegli tra esche Normali, Glaciali, Muschiate, Magnetiche, Piovose e Dorate.

Allarmi Nidi

Monitora le specie Pokemon nei nidi. Imposta una soglia di spawn minimi per ora per essere avvisato solo dei nidi con attività sufficiente.

Allarmi Palestre

Monitora i cambi di squadra nelle palestre. Seleziona quali squadre (Neutrale, Mystic, Valor, Instinct) monitorare. Attiva il monitoraggio Cambi Posti per essere avvisato quando si liberano posti in palestra, o attiva il monitoraggio Cambi Battaglia per essere avvisato quando una palestra è sotto attacco.

Allarmi Modifiche Forte

Monitora le modifiche ai pokestop e alle palestre stesse — non le attività che vi si svolgono, ma le modifiche ai punti di interesse effettivi.

  • Tipo forte — Scegli se monitorare Pokestop, Palestre o Tutto.
  • Tipi di modifica — Seleziona quali modifiche monitorare: Nome cambiato, Descrizione cambiata, Posizione cambiata, Immagine cambiata, Rimosso o Nuovo forte.
  • Includi vuoti — Includi i forti senza nome impostato.
💡
Gli allarmi modifiche forte sono utili per monitorare gli aggiornamenti del database mappa — nuovi pokestop che appaiono, palestre che vengono spostate o POI rimossi dal gioco.

Puntare a una palestra specifica

Quando crei o modifichi un allarme Raid, Uovo o Palestra, puoi opzionalmente cercare e selezionare una palestra specifica. Questo è utile quando ti interessa solo l'attività alla tua palestra preferita — come quella sul percorso per pranzo o vicino a casa tua.

  • Come usarlo — Nella finestra di aggiunta o modifica, digita il nome di una palestra nel campo di ricerca. I risultati mostrano la foto della palestra, il nome e l'area così puoi identificare quella giusta.
  • Quando una palestra è selezionata — L'allarme scatta solo per eventi in quella palestra specifica. Il nome della palestra appare sulla scheda allarme nella tua lista così puoi vedere a colpo d'occhio quale palestra è il bersaglio.
  • Quando nessuna palestra è selezionata — Questo è il comportamento predefinito. L'allarme funziona normalmente per tutte le palestre nelle tue aree selezionate o entro il tuo raggio di distanza.
💡
Puoi combinare un allarme per palestra specifica con un allarme più ampio. Ad esempio, crea un allarme raid per la tua palestra locale per tutti i livelli e un secondo allarme per raid di livello 5 in tutte le tue aree.
", + "CONTENT_DELIVERY": "\"Schede

Ogni allarme ha impostazioni di consegna che controllano dove ricevi le notifiche.

Dove ti raggiunge un avviso

La scheda Consegna di ogni finestra di creazione e modifica chiede Dove deve raggiungerti questo avviso? e offre tre risposte:

  • Ovunque nelle mie aree — L'impostazione predefinita. L'allarme segue le aree selezionate sul tuo profilo, quindi cambiare le aree cambia anche questo allarme.
  • Vicino a un punto — Un raggio in chilometri, misurato dalla tua posizione o da un luogo salvato che scegli sotto Misurato da. Se non hai ancora una posizione, il selettore lo segnala e propone di impostarla.
  • Solo in aree specifiche — Un sottoinsieme di aree per questo singolo allarme, scelto tra le aree pubbliche e le geofence che hai disegnato tu.

Allarmi diversi possono rispondere in modo diverso: le aree per i Pokemon, un raggio dalla tua posizione per i raid, un luogo con un nome per le missioni.

Il chip sulla scheda

La maggior parte delle schede allarme porta un chip con la sua risposta — "Ovunque nelle mie aree", "Ovunque io riceva avvisi", "Entro 5 km dalla mia posizione", "Entro 2 km da Casa", "Solo in Terrigal, Erina". Cliccalo per cambiare quel singolo allarme senza aprire l'intera finestra di modifica.

Predefinito per i nuovi allarmi

I nuovi allarmi si aprono in modalità Aree. Per cambiarlo, apri il menu utente (il tuo avatar, in alto a destra) e scegli Impostazioni predefinite avvisi — decidi se i nuovi allarmi partono da Aree o da Distanza, imposta un raggio predefinito e scegli se quel raggio è misurato dalla tua posizione o da un luogo salvato. La preferenza è salvata nel tuo browser e precompila anche la finestra di Selezione Rapida. Vale solo per gli allarmi creati da qui in avanti; quelli esistenti non cambiano, e puoi comunque modificare dove ti raggiunge ogni singolo allarme.

Template di notifica

Se i template sono abilitati, puoi scegliere l'aspetto dei tuoi messaggi di notifica. Il selettore di template mostra un'anteprima dal vivo di come apparirà il tuo DM Discord, incluso il formato embed, i campi e le immagini.

Modalità Pulizia

Quando attivata, il bot elimina automaticamente la notifica da Discord dopo la scadenza dell'evento (es. un Pokemon scompare o un raid finisce). Questo mantiene i tuoi DM ordinati. Puoi attivare la modalità pulizia per singolo allarme o in blocco dalla pagina Pulizia.

Modifica sul posto e riepiloghi

Alcuni allarmi supportano modalità di consegna aggiuntive. Attiva Modifica messaggio sul posto per un'esca per aggiornare il messaggio Discord esistente quando l'esca cambia invece di inviarne uno nuovo, oppure Riepilogo giornaliero per una missione per raccogliere le missioni corrispondenti in un unico messaggio di riepilogo (richiede una pianificazione del riepilogo configurata sul bot). Raid e uova vengono modificati sul posto automaticamente quando scegli una modalità RSVP. Queste impostazioni vengono mantenute anche se le imposti dal bot.

Aggiornamenti RSVP (raid e uova)

Gli allarmi raid e uova aggiungono un'impostazione Notifiche RSVP nella finestra di aggiunta/modifica con tre scelte: Solo corrispondenze invia gli avvisi raid/uovo standard; Corrispondenze + aggiornamenti RSVP notifica di nuovo anche quando cambiano i conteggi RSVP (allenatori che confermano la partecipazione); e Solo aggiornamenti RSVP salta la corrispondenza iniziale e ti notifica solo le modifiche RSVP. Scegliendo una delle modalità RSVP il bot modifica sul posto il messaggio Discord esistente man mano che i conteggi cambiano, invece di inviarne di nuovi, e la scheda mostra una pillola "RSVP" o "Solo RSVP". Nota che Solo aggiornamenti RSVP resta silenziosa a meno che lo scanner della tua community non emetta eventi RSVP — scegliela solo se sai che gli RSVP vengono segnalati.

", + "CONTENT_QUEST_SUMMARY": "

Le missioni di Ricerca sul campo cambiano ogni giorno e possono corrispondere in gran numero, quindi un filtro missioni affollato può inondare i tuoi MP. Consegna del riepilogo delle missioni raccoglie le missioni corrispondenti in un unico riepilogo pianificato invece di tanti avvisi separati.

Due parti che lavorano insieme

  • Interruttore Riepilogo giornaliero — attivalo su un allarme missione (nella sua finestra di aggiunta/modifica) per contrassegnarne le corrispondenze per il riepilogo invece della consegna immediata.
  • Pianificazione della consegna — scegli quando vengono inviate le missioni raccolte.

Servono entrambe: l’interruttore indica quali missioni raccogliere, la pianificazione indica quando consegnarle.

Impostare la pianificazione

Apri la pagina Missioni, poi il menu nella barra degli strumenti e scegli Consegna del riepilogo delle missioni. Usa Modifica pianificazione per scegliere giorni e orari — lo stesso editor usato per le ore attive dei profili. Gli orari salvati appaiono come pillole ambra.

La pianificazione è per utente ed è condivisa tra tutti i tuoi profili — a differenza delle ore attive dei profili, che si impostano per profilo.

Invia riepilogo ora

Invia riepilogo ora consegna immediatamente tutto ciò che è stato raccolto dall’ultimo riepilogo. Se non è ancora stato raccolto nulla, non viene inviato nulla — le missioni vengono memorizzate nel buffer man mano che corrispondono, quindi dai tempo o attendi che la pianificazione si attivi.

Buono a sapersi

  • Il menu appare solo quando il bot del tuo server ha i riepiloghi delle missioni abilitati.
  • L’orario di consegna usa la posizione salvata per il fuso orario — imposta una posizione, altrimenti i riepiloghi potrebbero arrivare all’ora locale sbagliata (la finestra ti avvisa quando non è impostata alcuna posizione).
  • La rimozione della pianificazione mantiene l’interruttore per allarme; le missioni continuano a essere raccolte, ma tornano all’orario predefinito del bot.
", "CONTENT_TEST_ALERTS": "

Ogni scheda allarme ha un pulsante Test (icona aeroplanino di carta) che invia una notifica di esempio al tuo Discord o Telegram, usando i filtri esatti dell'allarme e il tuo template di consegna attuale.

Come funziona

  1. Trova qualsiasi scheda allarme nella tua lista (Pokemon, Raid, Missione, ecc.).
  2. Clicca l'icona invia nella riga azioni della scheda.
  3. Viene generato un evento fittizio corrispondente ai filtri del tuo allarme e inviato attraverso la pipeline di notifica. Riceverai un DM proprio come un avviso reale.

Cosa viene testato

Il test usa i valori dei filtri del tuo allarme (ID Pokemon, livello raid, ricompensa missione, ecc.) e la tua posizione salvata come coordinate dell'evento fittizio. La notifica viene formattata usando il template selezionato, così vedi esattamente come apparirebbe un avviso reale.

Tempo di attesa

Per prevenire lo spam, ogni allarme ha un tempo di attesa di 15 secondi tra un invio test e l'altro. Il pulsante è disabilitato durante l'attesa e una notifica mostra il feedback (successo, errore o tempo rimanente).

💡
Gli avvisi di prova sono ottimi per verificare che il tuo template sia corretto o confermare che la consegna via webhook funzioni prima di aspettare che un evento reale lo attivi.
", "CONTENT_POKEMON_AVAILABILITY": "

Quando aggiungi o modifichi allarmi Pokemon, il selettore Pokemon può mostrare indicatori di disponibilità — piccoli badge che ti dicono quali Pokemon stanno attualmente spawnando in natura.

Come funziona

Se la tua community ha uno scanner Golbat configurato, il selettore mostra punti colorati accanto ai nomi dei Pokemon:

  • Punto verde — Questo Pokemon è stato visto spawnare di recente.
  • Nessun punto — Non attualmente segnalato nei dati dello scanner.

Questo ti aiuta a evitare di creare allarmi per Pokemon che non stanno spawnando nella tua zona in questo momento (es. specie stagionali o esclusive di eventi).

Aggiornamento disponibilità

I dati si aggiornano automaticamente in background. Non devi fare nulla — cerca semplicemente i punti quando sfogli il selettore Pokemon.

ℹ️
Questa funzionalità è visibile solo se il tuo admin ha configurato l'integrazione dello scanner Golbat. Se non vedi i punti di disponibilità, la funzionalità non è abilitata per la tua community.
", "CONTENT_BULK": "\"Lista

Tutte le pagine allarmi supportano operazioni in blocco per gestire molti allarmi contemporaneamente.

Modalità selezione

Clicca l'icona checklist nella barra strumenti per entrare in modalità selezione. Poi clicca le singole schede allarme per selezionarle, oppure usa Seleziona tutto per prendere tutto ciò che è visibile.

Azioni in blocco

  • Aggiorna distanza — Cambia la modalità di consegna (aree o distanza) per tutti gli allarmi selezionati contemporaneamente.
  • Elimina — Rimuovi tutti gli allarmi selezionati con una sola conferma.
💡
In fondo a ogni lista allarmi troverai anche i pulsanti Aggiorna Tutta la Distanza e Elimina Tutto che si applicano a ogni allarme di quel tipo.
", - "CONTENT_QUICK_PICKS": "\"Pagina

Le Selezioni Rapide sono template di allarme precostituiti creati dagli admin della tua community. Ti permettono di configurare allarmi comuni con un clic invece di creare ogni allarme individualmente.

Applicare una Selezione Rapida

  1. Vai a Selezione Rapida dalla barra laterale.
  2. Sfoglia le selezioni disponibili, filtrando opzionalmente per categoria.
  3. Clicca Applica sulla Selezione Rapida che vuoi.
  4. Personalizza prima di applicare: scegli la modalità di consegna (aree o distanza), attiva la modalità pulizia e opzionalmente escludi Pokemon specifici.
  5. Conferma per creare tutti gli allarmi in una volta.

Rimuovere allarmi da Selezione Rapida

Se non vuoi più gli allarmi di una Selezione Rapida, clicca Rimuovi per eliminare tutti gli allarmi che ha creato.

", - "CONTENT_PROFILES": "

La pagina Profili è il tuo centro unificato per gestire i profili e visualizzare tutti gli allarmi di ogni profilo in un unico posto.

Perché usare i profili?

I profili ti permettono di mantenere configurazioni di allarme completamente separate. Ogni profilo ha il proprio set di allarmi, aree selezionate, posizione e attivazioni geofence personalizzate. Utile per situazioni diverse — ad esempio, un profilo \"Casa\" per il tuo quartiere e un profilo \"Lavoro\" per i dintorni del tuo ufficio.

Panoramica

La pagina mostra una barra statistiche con il conteggio totale degli allarmi per tipo, una barra di ricerca per filtrare tra tutti i profili e chip filtro per tipo per mostrare solo tipi di allarme specifici (Pokemon, Raid, Missioni, ecc.).

Ogni profilo appare come un pannello espandibile. Clicca per espandere e vedere tutti gli allarmi raggruppati per tipo, con immagini degli asset di gioco (sprite Pokemon, uova raid, icone esche) e pillole filtro che mostrano IV, CP, Livello, PVP e altre impostazioni a colpo d'occhio.

Gestire i profili

  • Crea — Clicca il pulsante + in alto a destra. I nomi dei profili devono essere unici (massimo 32 caratteri).
  • Cambia — Clicca Cambia dentro un pannello profilo per renderlo il tuo profilo attivo. Il profilo attivo è contrassegnato con un badge verde e un bordo sinistro.
  • Modifica — Clicca l'icona matita per rinominare un profilo.
  • Elimina — Clicca l'icona cestino per rimuovere un profilo e tutti i suoi allarmi. Non puoi eliminare il tuo profilo attivo.

Duplica

Clicca l'icona copia su qualsiasi profilo per creare una copia esatta con tutti i suoi allarmi. Ti verrà chiesto di dare un nome al nuovo profilo — viene suggerito un nome predefinito come \"Profilo (Copia)\". Il duplicato include tutti i filtri allarme ma riceve un nuovo set di selezioni aree.

Esporta e Importa

  • Esporta — Clicca l'icona download su un profilo per salvare un file di backup (JSON). Il file contiene tutti i filtri allarme, privati degli ID interni per renderlo portabile.
  • Importa — Clicca il pulsante Importa in alto a destra, seleziona un file di backup e scegli un nome per il nuovo profilo. Tutti gli allarmi dal backup vengono ripristinati. Se esiste già un profilo con lo stesso nome, viene aggiunto automaticamente un suffisso numerico.

Rilevamento duplicati

Se lo stesso allarme esiste su più profili (es. monitoraggio Pikachu sia su \"Casa\" che su \"Lavoro\"), quegli allarmi vengono evidenziati con un bordo arancione e un'icona copia. Quando esistono duplicati, appare un chip filtro Duplicati nella barra filtri — cliccalo per mostrare solo gli allarmi duplicati tra i profili.

⚠️
Attenzione: Eliminare un profilo rimuove permanentemente tutti gli allarmi in quel profilo. Non puoi eliminare il tuo profilo attualmente attivo. Considera di esportare un backup prima.
", - "CONTENT_CLEANING": "\"Pagina

La pagina Pulizia ti permette di controllare la modalità pulizia per tutti i tuoi tipi di allarme contemporaneamente.

Quando la modalità pulizia è attiva per un tipo di allarme, il bot elimina automaticamente le notifiche da Discord dopo la scadenza dell'evento:

  • Pokemon — Eliminata quando lo spawn scompare
  • Raid — Eliminata quando il raid finisce
  • Uova — Eliminata quando l'uovo si schiude
  • Missioni — Eliminata quando le missioni si resettano a mezzanotte
  • Invasioni — Eliminata quando la recluta se ne va
  • Esche — Eliminata quando l'esca scade
  • Nidi — Eliminata quando i nidi migrano
  • Palestre — Eliminata dopo i cambi palestra
  • Modifiche Forte — Eliminata dopo la scadenza della notifica modifica forte
  • Max Battle — Eliminata quando la battaglia finisce

Usa Attiva tutto o Disattiva tutto per cambiare tutto in una volta.

💡
Consigliato: Mantieni la modalità pulizia attiva per evitare che avvisi scaduti si accumulino nei tuoi DM.
", - "CONTENT_APPEARANCE": "

Modalità Scura / Chiara

Clicca l'icona sole/luna nella barra strumenti in alto per passare tra il tema scuro e quello chiaro. La tua scelta viene salvata automaticamente.

\"Barra

Colori accento

Apri il menu utente (il tuo avatar in alto a destra) e seleziona Tema Accento. Scegli tra:

  • Predefinito — Blu
  • Pokemon — Verde
  • Raid — Rosso
  • Mystic — Blu
  • Valor — Rosso
  • Instinct — Giallo

Il colore accento cambia il gradiente della barra strumenti, l'evidenziazione della navigazione attiva e altri accenti dell'interfaccia in tutto il sito.

\"Dashboard

Lingua

Se disponibile, usa il selettore lingua nella barra strumenti per cambiare la lingua dell'interfaccia. Sono supportate 18 lingue.

Scorciatoie da tastiera

?Mostra scorciatoie da tastiera
EscChiudi menu o finestre
[Comprimi barra laterale
]Espandi barra laterale
", - "CONTENT_ALERTS_LOGOUT": "\"Menu

Mettere in pausa gli avvisi

Apri il menu utente (il tuo avatar) e clicca Metti in Pausa. Apparirà un banner rosso in cima al sito che conferma che i tuoi avvisi sono in pausa. Non riceverai alcuna notifica finché sono in pausa.

Per riprendere, clicca Riprendi Avvisi dal menu utente o dal banner.

Disconnessione

Apri il menu utente e clicca Esci. Verrai riportato alla pagina di login.

", - "CONTENT_FAQ": "

\"Non riesco ad accedere\"

Devi registrarti con il bot Poracle su Discord o Telegram prima di poter accedere a questo sito. Se vedi \"Il tuo account non è registrato\", contatta l'admin della tua community per le istruzioni di registrazione.

\"Non ricevo le notifiche\"

Controlla queste cause comuni:

  1. Avvisi in pausa — Cerca un banner rosso in cima al sito. Riprendi gli avvisi dal menu utente.
  2. Nessuna posizione impostata — Se i tuoi allarmi usano la modalità distanza, hai bisogno di una posizione salvata.
  3. Nessuna area selezionata — Se i tuoi allarmi usano la modalità aree, assicurati di aver selezionato le aree nella pagina Aree.
  4. Profilo sbagliato — Potresti avere allarmi su un profilo diverso. Controlla quale profilo è attivo sulla Dashboard.
  5. Filtri troppo restrittivi — Prova ad allentare i filtri IV, CP o livello per vedere se le notifiche iniziano ad arrivare.

\"I miei allarmi sono scomparsi\"

Gli allarmi sono specifici per profilo. Se hai cambiato profilo, i tuoi allarmi dell'altro profilo sono ancora lì — basta tornare dalla Dashboard o dalla pagina Profili.

\"Non riesco a cliccare un'area piccola sulla mappa\"

Quando le aree si sovrappongono, ingrandisci per rendere l'area più piccola più facile da cliccare. Le aree più piccole sono sempre sopra quelle più grandi.

\"Cosa fa la modalità Pulizia?\"

La modalità pulizia dice al bot di eliminare automaticamente una notifica da Discord dopo la scadenza dell'evento (es. un Pokemon scompare). Senza di essa, i vecchi avvisi rimangono nei tuoi DM per sempre. Attivala nella pagina Pulizia o per singolo allarme nella scheda Consegna.

\"Qual è la differenza tra Aree e Distanza?\"

Ogni allarme usa una modalità di consegna. Aree ti avvisa degli eventi dentro zone geografiche specifiche. Distanza ti avvisa degli eventi entro un raggio dalla tua posizione salvata. Puoi usare entrambe le modalità su allarmi diversi.

" + "CONTENT_QUICK_PICKS": "\"Pagina

Le Selezioni Rapide sono template di allarme precostituiti creati dagli admin della tua community. Ti permettono di configurare allarmi comuni con un clic invece di creare ogni allarme individualmente.

Applicare una Selezione Rapida

  1. Vai a Selezione Rapida dalla barra laterale.
  2. Sfoglia le selezioni disponibili, filtrando opzionalmente per categoria.
  3. Clicca Applica sulla Selezione Rapida che vuoi.
  4. Personalizza prima di applicare: decidi dove gli avvisi devono raggiungerti — la scheda Consegna è lo stesso selettore a tre opzioni di un singolo allarme, quindi puoi puntarli su un luogo salvato o su un sottoinsieme di aree —, attiva la modalità pulizia e opzionalmente escludi Pokemon specifici.
  5. Conferma per creare tutti gli allarmi in una volta.

Rimuovere allarmi da Selezione Rapida

Se non vuoi più gli allarmi di una Selezione Rapida, clicca Rimuovi per eliminare tutti gli allarmi che ha creato.

", + "CONTENT_PROFILES": "

La pagina Profili è il tuo centro unificato per gestire i profili e visualizzare tutti gli allarmi di ogni profilo in un unico posto.

Perché usare i profili?

I profili ti permettono di mantenere configurazioni di allarme completamente separate. Ogni profilo ha il proprio set di allarmi, aree selezionate, posizione e attivazioni geofence personalizzate. Utile per situazioni diverse — ad esempio, un profilo \"Casa\" per il tuo quartiere e un profilo \"Lavoro\" per i dintorni del tuo ufficio.

Panoramica

La pagina mostra una barra statistiche con il conteggio totale degli allarmi per tipo, una barra di ricerca per filtrare tra tutti i profili e chip filtro per tipo per mostrare solo tipi di allarme specifici (Pokemon, Raid, Missioni, ecc.).

Ogni profilo appare come un pannello espandibile. Clicca per espandere e vedere tutti gli allarmi raggruppati per tipo, con immagini degli asset di gioco (sprite Pokemon, uova raid, icone esche) e pillole filtro che mostrano IV, CP, Livello, PVP e altre impostazioni a colpo d'occhio.

Gestire i profili

  • Crea — Clicca il pulsante + in alto a destra. I nomi dei profili devono essere unici (massimo 32 caratteri).
  • Cambia — Clicca Cambia dentro un pannello profilo per renderlo il tuo profilo attivo. Il profilo attivo è contrassegnato con un badge verde e un bordo sinistro.
  • Modifica — Clicca l'icona matita per rinominare un profilo.
  • Elimina — Clicca l'icona cestino per rimuovere un profilo e tutti i suoi allarmi. Non puoi eliminare il tuo profilo attivo.

Duplica

Clicca l'icona copia su qualsiasi profilo per creare una copia esatta con tutti i suoi allarmi. Ti verrà chiesto di dare un nome al nuovo profilo — viene suggerito un nome predefinito come \"Profilo (Copia)\". Il duplicato include tutti i filtri di allarme e ne copia anche aree, posizione e orari attivi dal profilo di origine.

Esporta e Importa

  • Esporta — Clicca l'icona download su un profilo per salvare un file di backup (JSON). Il file contiene tutti i filtri allarme, privati degli ID interni per renderlo portabile.
  • Importa — Clicca il pulsante Importa in alto a destra, seleziona un file di backup e scegli un nome per il nuovo profilo. Tutti gli allarmi dal backup vengono ripristinati. Se esiste già un profilo con lo stesso nome, viene aggiunto automaticamente un suffisso numerico.

Rilevamento duplicati

Se lo stesso allarme esiste su più profili (es. monitoraggio Pikachu sia su \"Casa\" che su \"Lavoro\"), quegli allarmi vengono evidenziati con un bordo arancione e un'icona copia. Quando esistono duplicati, appare un chip filtro Duplicati nella barra filtri — cliccalo per mostrare solo gli allarmi duplicati tra i profili.

⚠️
Attenzione: Eliminare un profilo rimuove permanentemente tutti gli allarmi in quel profilo. Non puoi eliminare il tuo profilo attualmente attivo. Considera di esportare un backup prima.
", + "CONTENT_CLEANING": "\"Pagina

La pagina Pulizia ti permette di controllare la modalità pulizia per tutti i tuoi tipi di allarme contemporaneamente.

Quando la modalità pulizia è attiva per un tipo di allarme, il bot elimina automaticamente le notifiche da Discord dopo la scadenza dell'evento:

  • Pokemon — Eliminata quando lo spawn scompare
  • Raid — Eliminata quando il raid finisce
  • Uova — Eliminata quando l'uovo si schiude
  • Missioni — Eliminata quando le missioni si resettano a mezzanotte
  • Invasioni — Eliminata quando la recluta se ne va
  • Esche — Eliminata quando l'esca scade
  • Nidi — Eliminata quando i nidi migrano
  • Palestre — Eliminata dopo i cambi palestra
  • Max Battle — Eliminata quando la battaglia finisce

Usa Attiva tutto o Disattiva tutto per cambiare tutto in una volta.

💡
Consigliato: Mantieni la modalità pulizia attiva per evitare che avvisi scaduti si accumulino nei tuoi DM.
", + "CONTENT_APPEARANCE": "

Modalità Scura / Chiara

Clicca l'icona sole/luna nella barra strumenti in alto per passare tra il tema scuro e quello chiaro. La tua scelta viene salvata automaticamente.

\"Barra

Colori accento

Apri il menu utente (il tuo avatar in alto a destra) e seleziona Tema Accento. Scegli tra:

  • Predefinito — Blu
  • Pokemon — Verde
  • Raid — Rosso
  • Mystic — Blu
  • Valor — Rosso
  • Instinct — Giallo

Il colore accento cambia il gradiente della barra strumenti, l'evidenziazione della navigazione attiva e altri accenti dell'interfaccia in tutto il sito.

\"Dashboard

Lingua dell'interfaccia

Apri il menu utente (il tuo avatar, in alto a destra) e scegli Lingua dell'interfaccia. Ci sono 11 lingue. Cambia il testo del sito e anche i nomi, i tipi e le forme dei Pokemon mostrati nei selettori e sulle tue schede di allarme. Se non ne hai mai scelta una, ricevi quella del tuo browser o quella impostata sul tuo server Poracle.

Lingua degli avvisi

Subito sotto c'è Lingua degli avvisi, un'impostazione separata. Determina la lingua in cui Poracle scrive i tuoi DM. Le due sono indipendenti: un sito in italiano con DM in inglese, o il contrario, è del tutto normale. Prima si trovava nella pagina Aree.

Scorciatoie da tastiera

?Mostra scorciatoie da tastiera
EscChiudi menu o finestre
[Comprimi barra laterale
]Espandi barra laterale
", + "CONTENT_ALERTS_LOGOUT": "\"Menu

Mettere in pausa gli avvisi

Apri il menu utente (il tuo avatar) e clicca Metti in Pausa. Apparirà un banner rosso in cima al sito che conferma che i tuoi avvisi sono in pausa. Non riceverai alcuna notifica finché sono in pausa.

Per riprendere, clicca Riprendi Avvisi dal menu utente o dal banner.

Disconnessione

Apri il menu utente e clicca Esci. Verrai riportato alla pagina di login.

Se hai effettuato l’accesso tramite un provider SSO che supporta il single logout, il menu offre anche Esci ovunque: termina la sessione anche presso il provider, non solo qui.

", + "CONTENT_FAQ": "

\"Non riesco ad accedere\"

Devi registrarti con il bot Poracle su Discord o Telegram prima di poter accedere a questo sito. Se vedi \"Il tuo account non è registrato\", contatta l'admin della tua community per le istruzioni di registrazione.

\"Non ricevo le notifiche\"

Controlla queste cause comuni:

  1. Avvisi in pausa — Cerca un banner rosso in cima al sito. Riprendi gli avvisi dal menu utente.
  2. Nessuna posizione impostata — Un allarme che ti raggiunge entro un raggio misura dalla tua posizione o da un luogo salvato. Impostane una nella pagina Aree e luoghi.
  3. Niente a portata — Guarda il chip sulla scheda dell'allarme. Dice dove l'allarme ti raggiunge, e può puntare ad aree che il tuo profilo non copre più.
  4. Profilo sbagliato — Potresti avere allarmi su un profilo diverso. Controlla quale profilo è attivo sulla Dashboard.
  5. Filtri troppo restrittivi — Prova ad allentare i filtri IV, CP o livello per vedere se le notifiche iniziano ad arrivare.

\"I miei allarmi sono scomparsi\"

Gli allarmi sono specifici per profilo. Se hai cambiato profilo, i tuoi allarmi dell'altro profilo sono ancora lì — basta tornare dalla Dashboard o dalla pagina Profili.

\"Non riesco a cliccare un'area piccola sulla mappa\"

Quando le aree si sovrappongono, ingrandisci per rendere l'area più piccola più facile da cliccare. Le aree più piccole sono sempre sopra quelle più grandi.

\"Cosa fa la modalità Pulizia?\"

La modalità pulizia dice al bot di eliminare automaticamente una notifica da Discord dopo la scadenza dell'evento (es. un Pokemon scompare). Senza di essa, i vecchi avvisi rimangono nei tuoi DM per sempre. Attivala nella pagina Pulizia o per singolo allarme nella scheda Consegna.

\"Dove mi raggiunge un avviso?\"

Ogni allarme risponde per conto suo, nella sua scheda Consegna. Ovunque nelle mie aree segue le aree selezionate sul tuo profilo. Vicino a un punto è un raggio dalla tua posizione o da un luogo salvato. Solo in aree specifiche limita quel singolo allarme a un sottoinsieme di aree. Il chip sulla scheda mostra sempre la risposta attuale, e un clic la cambia.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "Avvisi DM", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Accedi", "SIGN_IN_DESC": "Accedi per gestire i tuoi allarmi di notifica Pokemon GO.", "SIGN_IN_DISCORD": "Accedi con Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Accedi con Telegram", + "PROVIDER_DISABLED_BY_ADMIN": "Questo metodo di accesso è stato disattivato da un amministratore.", + "PROVIDER_DISABLED_HINT": "Questo metodo di accesso è disattivato per gli utenti non amministratori.", + "ERR_TELEGRAM_DISABLED": "L'accesso con Telegram è attualmente disattivato.", "OR": "oppure", "NO_METHODS": "Nessun metodo di accesso è attualmente abilitato. Contatta un amministratore.", "AUTHENTICATING": "Autenticazione...", "FOOTER": "Gestisci allarmi per Pokemon, Raid, Missioni e altro", "AUTH_FAILED": "Autenticazione Fallita", "BACK_TO_LOGIN": "Torna all'Accesso", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "L'accesso con Discord è attualmente disattivato.", + "ERR_DISCORD_FETCH": "Impossibile recuperare il tuo profilo Discord. Riprova.", + "ERR_MISSING_CODE": "L'accesso con Discord è stato annullato o non è riuscito.", + "ERR_MISSING_ROLE": "Non hai il ruolo Discord necessario per accedere a questo sito.", + "ERR_NOT_IN_GUILD": "Devi essere membro del server Discord per accedere a questo sito.", + "ERR_NOT_REGISTERED": "Il tuo account non è registrato. Registrati per iniziare.", + "ERR_ROLE_CHECK_FAILED": "Impossibile verificare i tuoi ruoli Discord. Riprova più tardi.", + "ERR_TELEGRAM_FAILED": "L'accesso con Telegram non è riuscito. Riprova.", + "ERR_TOKEN_EXCHANGE": "L'accesso con Discord non è riuscito. Riprova.", "ERR_GENERIC": "Errore di autenticazione: {{error}}", "ERR_NO_TOKEN": "Nessun token di autenticazione ricevuto.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "Registrati", + "SIGN_UP_DESC": "Non hai un account? Registrati per iniziare.", + "SIGN_IN_OIDC": "Accedi con {{provider}}", + "SIGNED_OUT_TITLE": "Disconnesso", + "SIGNED_OUT_DESC": "Sei stato disconnesso da DM Alerts.", + "ERR_OIDC_DISABLED": "L'accesso esterno è attualmente disabilitato.", + "ERR_OIDC_NO_IDENTITY": "Il tuo provider di accesso esterno non ha restituito un account che possiamo associare. Assicurati che il tuo account Discord sia collegato.", + "ERR_OIDC_TOKEN_EXCHANGE": "Accesso esterno non riuscito. Riprova.", + "ERR_OIDC_USERINFO": "Impossibile recuperare il tuo profilo dal provider di accesso esterno. Riprova.", + "SIGN_IN_AGAIN": "Accedi di nuovo" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Questa funzione è stata disattivata dall'amministratore." }, "ADMIN": { "USERS_TITLE": "Gestione Utenti", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Nome promosso", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Nome per la geofence promossa", "APPROVAL_PROMOTED_NAME_HINT": "Opzionale. Per impostazione predefinita usa il nome attuale.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Motivo del rifiuto", "APPROVAL_REJECT_PLACEHOLDER": "Spiega perché questa geofence viene rifiutata...", "USERS_DESC_FULL": "Gestisci gli utenti Discord registrati. Fermato = l'utente ha messo in pausa gli avvisi o ha raggiunto il limite. Bloccato = bloccato dall'amministratore.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Approvazione invio fallita", "SNACK_APPROVED": "\"{{name}}\" approvata", "SNACK_FAILED_REJECT": "Rifiuto invio fallito", - "SNACK_REJECTED": "\"{{name}}\" rifiutata" + "SNACK_REJECTED": "\"{{name}}\" rifiutata", + "APPROVAL_REGION_HINT": "Scegli la regione sotto cui apparirà questo geofence.", + "SERVER_TITLE": "Server Poracle", + "SERVER_REFRESH": "Controlla di nuovo", + "SERVER_VERSION": "Versione", + "SERVER_SCHEMA": "Schema del database", + "SERVER_CHECKED": "Ultimo controllo", + "SERVER_CAPABILITIES": "Funzionalità", + "SERVER_NO_CAPABILITIES": "Questo server non ne segnala nessuna.", + "SERVER_UNKNOWN": "Sconosciuta", + "SERVER_UNREACHABLE": "Poracle non ha risposto. Allarmi, profili e luoghi passano da lì e falliranno finché non torna.", + "SERVER_TOO_OLD": "Poracle {{version}} è precedente a {{minimum}}, richiesto da questa versione del sito. La consegna per allarme, il filtro mega PVP e quello sul tempo rimanente sembreranno salvati senza cambiare nulla.", + "UPDATE_AVAILABLE": "È in esecuzione {{name}} {{running}} ed è uscita la {{latest}}.", + "UPDATE_PRERELEASE": "{{name}} {{running}} è più recente di qualsiasi versione pubblicata: è una build di sviluppo.", + "VERSIONS_TITLE": "Versioni", + "VERSIONS_WEB": "Questo sito", + "VERSIONS_BUILD": "Build", + "UPDATE_CURRENT": "Aggiornato.", + "UPDATE_UNCOMPARABLE": "Canale di sviluppo. L’ultima versione pubblicata è {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Scegli un punto", "CANCEL": "Annulla", "CONFIRM": "Conferma", "DONT_ASK_AGAIN": "Non chiedere più per questa sessione", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Aggiorna Tutte le Distanze", "DISTANCE_DESC": "Imposta la modalità posizione per tutti gli allarmi di questo tipo.", "DISTANCE_UPDATE_ALL": "Aggiorna Tutto", + "DISTANCE_MUST_BE_POSITIVE": "La distanza deve essere maggiore di zero.", "LOCATION_SAVE_ERROR": "Aggiornamento posizione fallito", "LOCATION_SAVE_SUCCESS": "Posizione aggiornata con successo", "LOCATION_GEO_UNSUPPORTED": "La geolocalizzazione non è supportata dal tuo browser", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Troppi avvisi di prova. Attendi un momento.", "ERROR_NOT_FOUND": "Allarme non trovato — potrebbe essere stato eliminato.", "ERROR_GENERIC": "Invio avviso di prova fallito. Riprova più tardi.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Troppi avvisi di prova. Attendi un momento.", + "NOT_FOUND": "Avviso non trovato: potrebbe essere stato eliminato.", + "UNSUPPORTED": "Gli avvisi di prova non sono disponibili per questo tipo.", + "FAILED": "Impossibile inviare l'avviso di prova. Riprova più tardi." }, "COMMON": { "CANCEL": "Annulla", @@ -1296,6 +1448,7 @@ "EDIT": "Modifica", "ADD": "Aggiungi", "OK": "OK", + "UNDO": "Annulla", "CONFIRM": "Conferma", "DELETE_ALL": "Elimina Tutto", "CLOSE": "Chiudi", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Cerca una palestra (opzionale)", "SEARCH_HINT": "Scrivi nome palestra...", - "CLEAR_ARIA": "Cancella selezione palestra" + "CLEAR_ARIA": "Cancella selezione palestra", + "RATE_LIMITED": "Troppe richieste allo scanner: rallenta un po’." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Le notifiche verranno inviate per queste aree:", @@ -1392,12 +1546,9 @@ "GROUP_ALARM_TYPES": "Tipi di allarme", "GROUP_FEATURES": "Funzionalità", "GROUP_ADMINISTRATION": "Amministrazione", - "GROUP_COMMANDS": "Comandi", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Mappe e risorse", "GROUP_ANALYTICS_LINKS": "Analisi e link", - "GROUP_DEBUG": "Debug", "GROUP_ICON_REPO": "Repository icone", "GROUP_OTHER": "Altro", "CUSTOM_TITLE_LABEL": "Titolo del sito", @@ -1411,52 +1562,51 @@ "FAVICON_URL_PREVIEW": "Anteprima favicon (32×32)", "FAVICON_URL_CACHE_WARNING": "I browser memorizzano i favicon nella cache in modo aggressivo. Dopo il salvataggio, gli utenti devono svuotare la cache del browser o effettuare un aggiornamento forzato (Ctrl+F5 / Cmd+Shift+R) per vedere la nuova icona.", "FAVICON_URL_CSP_NOTE": "Se il tuo sito utilizza una Content Security Policy, l'origine dell'URL del favicon deve essere consentita dalla direttiva img-src; altrimenti il browser blocca il recupero e torna all'icona predefinita.", + "FORCED_BY_PORACLE": "Disattivato nella configurazione di Poracle. Poracle scarta questi webhook e il suo bot rifiuta il comando, quindi non è possibile attivarlo qui.", + "FORCED_BY_PORACLE_TOOLTIP": "Controllato dalla configurazione di Poracle, non da questa pagina.", "CUSTOM_PAGE_NAME_LABEL": "Etichetta del link di navigazione", "CUSTOM_PAGE_NAME_DESC": "Etichetta per il link di navigazione personalizzato (es. \"Torna alla mappa\").", "CUSTOM_PAGE_URL_LABEL": "URL del link di navigazione", "CUSTOM_PAGE_URL_DESC": "URL a cui punta il link di navigazione personalizzato.", "CUSTOM_PAGE_ICON_LABEL": "Icona del link di navigazione", "CUSTOM_PAGE_ICON_DESC": "Classe FontAwesome per l'icona del link di navigazione (es. \"fas fa-map\").", - "DISABLE_MONS_LABEL": "Disabilita Pokémon", - "DISABLE_MONS_DESC": "Nascondi la gestione degli allarmi Pokémon a tutti gli utenti.", - "DISABLE_RAIDS_LABEL": "Disabilita Raid", - "DISABLE_RAIDS_DESC": "Nascondi la gestione degli allarmi Raid a tutti gli utenti.", - "DISABLE_QUESTS_LABEL": "Disabilita Missioni", - "DISABLE_QUESTS_DESC": "Nascondi la gestione degli allarmi missione a tutti gli utenti.", - "DISABLE_INVASIONS_LABEL": "Disabilita Invasioni", - "DISABLE_INVASIONS_DESC": "Nascondi la gestione degli allarmi invasione a tutti gli utenti.", - "DISABLE_LURES_LABEL": "Disabilita Esche", - "DISABLE_LURES_DESC": "Nascondi la gestione degli allarmi esca a tutti gli utenti.", - "DISABLE_NESTS_LABEL": "Disabilita Nidi", - "DISABLE_NESTS_DESC": "Nascondi la gestione degli allarmi nido a tutti gli utenti.", - "DISABLE_GYMS_LABEL": "Disabilita Palestre", - "DISABLE_GYMS_DESC": "Nascondi la gestione degli allarmi palestra a tutti gli utenti.", - "DISABLE_FORT_CHANGES_LABEL": "Disabilita modifiche forte", - "DISABLE_FORT_CHANGES_DESC": "Nascondi la gestione degli allarmi di modifica forte a tutti gli utenti.", - "DISABLE_MAXBATTLES_LABEL": "Disabilita Lotte Dynamax", - "DISABLE_MAXBATTLES_DESC": "Nascondi la gestione degli allarmi Lotta Dynamax a tutti gli utenti.", - "DISABLE_AREAS_LABEL": "Disabilita aree", - "DISABLE_AREAS_DESC": "Impedisce agli utenti di gestire le sottoscrizioni alle aree.", - "DISABLE_PROFILES_LABEL": "Disabilita profili", - "DISABLE_PROFILES_DESC": "Impedisce agli utenti di creare e cambiare profili di allarme.", - "DISABLE_LOCATION_LABEL": "Disabilita posizione", - "DISABLE_LOCATION_DESC": "Impedisce agli utenti di impostare una posizione di casa.", - "DISABLE_NOMINATIM_LABEL": "Disabilita geocoding", - "DISABLE_NOMINATIM_DESC": "Disabilita la ricerca di indirizzi Nominatim per la scelta della posizione.", - "DISABLE_GEOMAP_LABEL": "Disabilita vista mappa", - "DISABLE_GEOMAP_DESC": "Nascondi completamente la mappa interattiva dei geofence.", - "DISABLE_GEOMAP_SELECT_LABEL": "Disabilita selezione aree dalla mappa", - "DISABLE_GEOMAP_SELECT_DESC": "Impedisce agli utenti di selezionare aree cliccando sulla mappa.", - "ENABLE_TEMPLATES_LABEL": "Abilita modelli", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Consenti agli utenti di gestire gli allarmi Pokémon.", + "DISABLE_RAIDS_LABEL": "Raid", + "DISABLE_RAIDS_DESC": "Consenti agli utenti di gestire gli allarmi Raid.", + "DISABLE_QUESTS_LABEL": "Missioni", + "DISABLE_QUESTS_DESC": "Consenti agli utenti di gestire gli allarmi missione.", + "DISABLE_INVASIONS_LABEL": "Invasioni", + "DISABLE_INVASIONS_DESC": "Consenti agli utenti di gestire gli allarmi invasione.", + "DISABLE_LURES_LABEL": "Esche", + "DISABLE_LURES_DESC": "Consenti agli utenti di gestire gli allarmi esca.", + "DISABLE_NESTS_LABEL": "Nidi", + "DISABLE_NESTS_DESC": "Consenti agli utenti di gestire gli allarmi nido.", + "DISABLE_GYMS_LABEL": "Palestre", + "DISABLE_GYMS_DESC": "Consenti agli utenti di gestire gli allarmi palestra.", + "DISABLE_FORT_CHANGES_LABEL": "Modifiche forte", + "DISABLE_FORT_CHANGES_DESC": "Consenti agli utenti di gestire gli allarmi di modifica forte.", + "DISABLE_MAXBATTLES_LABEL": "Lotte Dynamax", + "DISABLE_MAXBATTLES_DESC": "Consenti agli utenti di gestire gli allarmi Lotta Dynamax.", + "DISABLE_AREAS_LABEL": "Aree", + "DISABLE_AREAS_DESC": "Consenti agli utenti di gestire le sottoscrizioni alle aree.", + "DISABLE_PROFILES_LABEL": "Profili", + "DISABLE_PROFILES_DESC": "Consenti agli utenti di creare e cambiare profili di allarme.", + "DISABLE_LOCATION_LABEL": "Posizione", + "DISABLE_LOCATION_DESC": "Consenti agli utenti di impostare una posizione di casa.", + "DISABLE_NOMINATIM_LABEL": "Geocoding", + "DISABLE_NOMINATIM_DESC": "Consenti la ricerca di indirizzi Nominatim per la scelta della posizione.", + "DISABLE_USER_GEOFENCES_LABEL": "Geofence personalizzate", + "DISABLE_USER_GEOFENCES_DESC": "Consenti agli utenti di disegnare, importare e inviare le proprie geofence. Le geofence esistenti continuano a funzionare.", + "ENABLE_TEMPLATES_LABEL": "Modelli", "ENABLE_TEMPLATES_DESC": "Consenti agli utenti di scegliere modelli di messaggio di notifica.", "ALLOWED_LANGUAGES_LABEL": "Lingue UI consentite", "ALLOWED_LANGUAGES_DESC": "Codici lingua separati da virgole da mostrare nel selettore (es. \"en,de,fr,es\"). Lascia vuoto per mostrare tutte e 11 le lingue.", + "PORACLE_LOCALE_HINT": "Lingua predefinita per i nuovi utenti: {{locale}}, presa dalla configurazione di Poracle. Chi sceglie una lingua, o il cui browser ne richiede una presente qui, ottiene invece quella.", "ENABLE_ROLES_LABEL": "Abilita accesso basato sui ruoli", "ENABLE_ROLES_DESC": "Consenti l'accesso solo agli utenti con specifici ruoli Discord. Richiede Bot Token e Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "ID ruoli consentiti", - "ALLOWED_ROLE_IDS_DESC": "ID ruoli Discord separati da virgole che concedono l'accesso (es. \"123456789,987654321\"). Lascia vuoto per consentirli tutti.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Lingue consentite", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Elenco separato da virgole di codici lingua selezionabili dagli utenti (es. \"en,de,fr\").", + "ALLOWED_ROLE_IDS_DESC": "ID ruoli Discord separati da virgole, es. 123456789,987654321. Un utente deve avere almeno uno di questi ruoli per accedere. Lascia vuoto per consentirli tutti.", "REGISTER_COMMAND_LABEL": "Comando di registrazione", "REGISTER_COMMAND_DESC": "Comando del bot Poracle che gli utenti eseguono per registrarsi (es. \"$!register\").", "LOCATION_COMMAND_LABEL": "Comando di posizione", @@ -1464,9 +1614,31 @@ "ENABLE_TELEGRAM_LABEL": "Abilita accesso Telegram", "ENABLE_TELEGRAM_DESC": "Consenti l'accesso Telegram su questo sito. Richiede TELEGRAM_ENABLED=true, bot token e bot username in .env (riavvio del server necessario dopo modifiche a .env).", "TELEGRAM_BOT_LABEL": "Nome utente del bot", - "TELEGRAM_BOT_DESC": "Nome utente del bot Telegram (senza @).", + "TELEGRAM_BOT_DESC": "Nome utente del bot Telegram (senza @). Usato quando TELEGRAM_BOT_USERNAME non è configurato.", "ENABLE_DISCORD_LABEL": "Abilita accesso Discord", "ENABLE_DISCORD_DESC": "Consenti l'accesso Discord su questo sito. Richiede Discord Client ID e Client Secret in .env (riavvio del server necessario dopo modifiche a .env). Non influenza la consegna del bot PoracleNG.", + "GROUP_OIDC": "SSO Esterno", + "ENABLE_OIDC_LABEL": "Abilita accesso SSO Esterno", + "ENABLE_OIDC_DESC": "Consenti l'accesso tramite il provider OIDC/OAuth2 esterno configurato. Richiede le impostazioni OIDC_* (URL del provider, client ID e secret) in .env (riavvio del server necessario dopo modifiche a .env).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Tutti gli utenti vengono reindirizzati al provider SSO esterno. L'accesso locale viene ignorato.", + "AUTH_MODE_SWITCH_CONFIRM": "Passa a SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Passare all'accesso SSO?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Dopo il salvataggio, tutti gli utenti (inclusi gli amministratori) verranno reindirizzati a {{provider}} per accedere — la pagina di accesso locale Discord/Telegram viene ignorata. Se il provider non è raggiungibile potresti rimanere bloccato; per recuperare imposta AUTH_FORCE_LOCAL=true nell'ambiente del server.", + "AUTH_OIDC_NOT_CONFIGURED": "L'SSO non è disponibile finché il provider OIDC non viene configurato nell'ambiente del server (variabili OIDC_*).", + "AUTH_OIDC_HIDES_LOCAL": "Discord e Telegram sono nascosti quando l'SSO è la modalità di accesso attiva.", + "AUTH_SLO_LABEL": "Logout singolo", + "AUTH_SLO_DESC": "Se abilitato, \"Esci ovunque\" termina anche la sessione del provider (non solo questo sito). Richiede l'endpoint di fine sessione del provider (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Il logout singolo non è disponibile finché non viene configurato l'endpoint di fine sessione del provider (variabile OIDC_END_SESSION_URL).", + "OIDC_SERVER_CONFIG": "Configurazione provider OIDC", + "OIDC_PROVIDER_LABEL": "Nome del provider", + "OIDC_AUTHORIZATION_URL_LABEL": "Authorization URL", + "OIDC_TOKEN_URL_LABEL": "Token URL", + "OIDC_USERINFO_URL_LABEL": "UserInfo URL", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scope", + "OIDC_IDENTITY_CLAIM_LABEL": "Identity claim", + "OIDC_USE_PKCE_LABEL": "Usa PKCE", "PROVIDER_URL_LABEL": "URL tasselli mappa", "PROVIDER_URL_DESC": "Modello URL del fornitore di tasselli mappa (usato per mappe statiche).", "GANALYTICSID_LABEL": "ID Google Analytics", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "ID admin", "DISCORD_ADMIN_IDS_DESC": "ID utenti Discord con accesso admin (mascherato).", "DISCORD_GEOFENCE_FORUM_LABEL": "Canale forum geofence", - "DISCORD_GEOFENCE_FORUM_DESC": "Canale forum Discord per thread di invio geofence." + "DISCORD_GEOFENCE_FORUM_DESC": "Canale forum Discord per thread di invio geofence.", + "SEARCH_PLACEHOLDER": "Cerca impostazioni…", + "SEARCH_CLEAR": "Cancella ricerca", + "UNSAVED_CHANGES": "{{count}} non salvate", + "SAVE_CHANGES": "Salva modifiche", + "DISCARD_CHANGES": "Annulla", + "COLLAPSE_SECTION": "Comprimi sezione", + "EXPAND_SECTION": "Espandi sezione", + "SUMMARY_ENABLED": "{{count}} di {{total}} attive", + "GROUP_AUTH": "Autenticazione", + "AUTH_MODE_LABEL": "Modalità di accesso", + "AUTH_MODE_LOCAL": "Locale", + "AUTH_MODE_LOCAL_DESC": "Accedi direttamente con Discord o Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "L'accesso locale è imposto dalla configurazione del server.", + "DISABLE_UPDATE_CHECK_LABEL": "Non cercare aggiornamenti", + "DISABLE_UPDATE_CHECK_DESC": "Impedisce al sito di chiedere a GitHub se è uscita una versione più recente di PoracleWeb o Poracle. È l’unica richiesta che esce dalla tua rete e non invia nulla." }, "GEOFENCE_DETAIL": { "NAME": "Nome", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "La tua posizione", "SELECTED_COUNT": "{{count}} selezionati:", "AREAS_SELECTED": "{{count}} area/e selezionate" + }, + "ALERT_DEFAULTS": { + "TITLE": "Impostazioni predefinite avvisi", + "DESC": "Scegli come vengono recapitati i nuovi avvisi per impostazione predefinita. Potrai comunque modificarlo per ogni avviso al momento della creazione.", + "DEFAULT_DISTANCE": "Distanza predefinita", + "DEFAULT_DISTANCE_HINT": "Usata per precompilare il raggio dei nuovi avvisi basati sulla distanza.", + "FOOTNOTE": "Si applica solo agli avvisi appena creati: quelli esistenti restano invariati.", + "DISTANCE_TOO_SMALL": "Deve essere almeno 0,1 km.", + "DISTANCE_TOO_LARGE": "Deve essere al massimo 100 km." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Elementi per pagina:", + "RANGE": "{{start}} - {{end}} di {{total}}", + "RANGE_EMPTY": "0 di {{total}}", + "NEXT_PAGE": "Pagina successiva", + "PREVIOUS_PAGE": "Pagina precedente", + "FIRST_PAGE": "Prima pagina", + "LAST_PAGE": "Ultima pagina" + }, + "WHERE": { + "SET_PIN": "Imposta la posizione", + "PIN_MISSING_WARNING": "Non hai ancora impostato una posizione, quindi questo avviso non avrebbe da dove misurare.", + "PLACES_EMPTY_TITLE": "Nessun luogo per ora", + "PIN_UNSET": "Non impostata", + "PLACES_PAGE_DESC": "Punti con un nome a cui puntare i tuoi avvisi, al posto della tua posizione.", + "ADD_PLACE": "Aggiungi un luogo", + "AREAS_LABEL": "Aree", + "AREA_LIST_MORE": "{{areas}} e altre {{count}}", + "MEASURED_FROM": "Misurato da", + "MY_PIN": "La mia posizione", + "NAME_PLACE_MESSAGE": "Come vuoi chiamare questo luogo?", + "NAME_PLACE_TITLE": "Dai un nome a questo luogo", + "NEAR_PIN": "Entro {{distance}} km dalla mia posizione", + "NEAR_PLACE": "Entro {{distance}} km da {{place}}", + "NO_PLACES": "Nessun luogo per ora. Aggiungine uno qui sotto per puntare questo avviso altrove.", + "ONLY_IN": "Solo in {{areas}}", + "OPTION_AREAS": "Solo in aree specifiche", + "OPTION_NEAR": "Vicino a un punto", + "OPTION_PLACE": "Vicino a un luogo", + "OPTION_PROFILE": "Ovunque nelle mie aree", + "PIN_NOTE": "Il valore predefinito per ogni avviso senza una destinazione propria.", + "PIN_TITLE": "La mia posizione", + "PLACES_EMPTY": "Aggiungine uno per ricevere avvisi altrove: il lavoro, la palestra, casa dei tuoi.", + "PLACES_TITLE": "Luoghi", + "PLACE_DELETED": "{{place}} eliminato.", + "PLACE_DELETE_CONFIRM": "Gli avvisi diretti a {{place}} torneranno alla tua posizione.", + "PLACE_DELETE_ERROR": "Impossibile eliminare quel luogo.", + "PLACE_DELETE_TITLE": "Eliminare questo luogo?", + "PLACE_IN_USE": "{{place}} è usato da {{count}} avviso/i. Reindirizzali prima.", + "PLACE_LABEL": "Luogo", + "PLACE_NAME": "Nome", + "PLACE_SAVED": "{{place}} salvato.", + "PLACE_SAVE_ERROR": "Impossibile salvare quel luogo.", + "PROFILE_ANYWHERE": "Ovunque io riceva avvisi", + "PROFILE_AREAS": "Ovunque nelle mie aree", + "RADIUS_KM": "Raggio (km)", + "SAVE": "Imposta destinazione", + "SCOPE_SAVED": "Destinazione aggiornata.", + "SCOPE_SAVE_ERROR": "Impossibile aggiornare dove ti arriva questo avviso.", + "SHEET_TITLE": "Dove deve raggiungerti questo avviso?", + "USE_THIS_POINT": "Usa questo punto" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json index 7687dfb6..8b09699a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -16,7 +16,7 @@ "GYMS": "Gyms", "FORT_CHANGES": "Fortwijzigingen", "PROFILES": "Profielen", - "AREAS": "Gebieden", + "AREAS": "Gebieden en plaatsen", "MY_GEOFENCES": "Mijn Geofences", "CLEANING": "Opschonen", "HELP": "Hulp", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Bekijken als", - "BACK_TO_ADMIN": "Terug naar Beheer", + "EXIT_IMPERSONATION": "Terug naar je account", "DISABLED_ACCOUNT": "Je account is uitgeschakeld. Dit kan komen door snelheidsbeperking of een beheerdersactie.", + "DISABLED_ACCOUNT_INSPECTED": "Dit account is door een beheerder uitgeschakeld en ontvangt geen meldingen.", "DISABLED_SUPPORT": "Voor hulp, vraag het in", "PAUSED_ALERTS": "Je meldingen zijn gepauzeerd. Je ontvangt geen notificaties.", "RESUME": "Hervatten" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Verandert alleen de tekst van deze site.", "PROFILE_PREFIX": "Profiel #", "PAUSE_ALERTS": "Meldingen Pauzeren", "RESUME_ALERTS": "Meldingen Hervatten", "SWITCH_PROFILE": "Profiel Wisselen", - "AREAS_LOCATION": "Gebieden & Locatie", "CLEANING": "Opschonen", "ACCENT_THEME": "Accentthema", - "LANGUAGE": "Taal", + "DISPLAY_LANGUAGE": "Weergavetaal", + "ALERT_LANGUAGE": "Meldingstaal", + "ALERT_LANGUAGE_HINT": "Gebruikt voor meldingstekst en Pokemon-namen.", "LOGOUT": "Uitloggen", + "LOGOUT_EVERYWHERE": "Overal uitloggen", "ACCENT_DEFAULT": "Standaard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Standaardinstellingen meldingen" }, "SHORTCUTS": { "TITLE": "Sneltoetsen", @@ -77,6 +82,7 @@ "NETWORK": "Kan de server niet bereiken. Controleer je verbinding.", "BAD_REQUEST": "Ongeldig verzoek. Controleer je invoer.", "UNAUTHORIZED": "Je sessie is verlopen. Log opnieuw in.", + "INSPECTION_ENDED": "Inspectie beëindigd — je bent terug in je eigen sessie.", "FORBIDDEN": "Je hebt geen toestemming voor deze actie.", "NOT_FOUND": "De gevraagde bron is niet gevonden.", "CONFLICT": "Er is een conflict opgetreden. Het item is mogelijk gewijzigd.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Welkom onboarding" }, "POKEMON": { + "PVP_EVOLUTION": "Mega-evolutie", + "PVP_EVOLUTION_HINT": "Rangschik de basisvormen of een mega. Mega's worden apart gerangschikt, dus een mega-regel matcht geen basisvorm.", + "PVP_EVO_BASE": "Basis", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Pokemon Alarmen", "PAGE_DESC": "Volg wilde Pokemon spawns met aangepaste IV, CP, level en PVP filters.", "SEARCH_PLACEHOLDER": "Zoek op naam of #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Vorm & Geslacht", "LABEL_FORM": "Vorm", "ALL_FORMS": "Alle Vormen", + "FORM_MULTI_HINT": "Laat leeg om alle vormen op te nemen", "LABEL_GENDER": "Geslacht", "GENDER_ALL": "Alle", "GENDER_MALE": "Mannelijk", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Alleen melden als de geëvolueerde CP aan dit minimum voldoet", "PVP_DISABLED_HINT": "Selecteer een league om op PVP rang te filteren.", "SNACK_CREATED": "{{count}} Pokemon alarm(en) aangemaakt", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} Pokemon-alarm(en) aangemaakt, {{duplicates}} al gevolgd", "SNACK_UPDATED": "Pokemon alarm bijgewerkt", "SNACK_DELETED": "Pokemon alarm verwijderd", "SNACK_DELETED_ALL": "Alle Pokemon alarmen verwijderd", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normaal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Levellimiet", + "PVP_CAP_ALL": "Alle", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Standaard — uit de Poracle-configuratie", + "FILTER_TIME_LEFT": "Resterende Tijd", + "LABEL_MIN_TIME": "Minimale resterende tijd", + "MIN_TIME_HINT": "Slaat spawns over die weg zijn voordat je er bent.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "nog {{count}} min", + "PILL_TIME_LEFT_SECONDS": "nog {{count}} s", + "MIN_TIME_ANY": "Elke" }, "ALARM": { "LOCATION_MODE": "Locatiemodus", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Verwijdert de melding automatisch uit Discord nadat het lokmiddel verloopt", "CLEAN_HINT_NEST": "Verwijdert de melding automatisch uit Discord wanneer nesten roteren", "CLEAN_HINT_GYM": "Verwijdert de melding automatisch uit Discord wanneer gymactiviteit verandert", - "CLEAN_HINT_FORT": "Verwijdert de melding automatisch uit Discord nadat het verloopt", "CLEAN_HINT_MAX_BATTLE": "Verwijdert de melding automatisch uit Discord nadat het max gevecht eindigt", "SAVING": "Opslaan...", "SAVE": "Opslaan", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Afkoelperiode actief", "TEST_SEND": "Testmelding versturen", "TAB_DELIVERY": "Bezorging", - "COMMON_SETTINGS": "Gemeenschappelijke Instellingen" + "COMMON_SETTINGS": "Gemeenschappelijke Instellingen", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} aangemaakt, {{duplicates}} al gevolgd" }, "RAIDS": { + "RSVP_LABEL": "RSVP-meldingen", + "RSVP_OFF": "Alleen overeenkomsten", + "RSVP_INCLUDE": "Overeenkomsten + RSVP-updates", + "RSVP_ONLY": "Alleen RSVP-updates", + "RSVP_OFF_DESC": "Alleen standaard raid-/ei-meldingen.", + "RSVP_INCLUDE_DESC": "Ook opnieuw melden wanneer RSVP-aantallen wijzigen.", + "RSVP_ONLY_DESC": "Sla initiële matches over; meld alleen RSVP-wijzigingen. Zonder een scanner die RSVP verstuurt blijft dit alarm stil.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Alleen RSVP", "PAGE_TITLE": "Raid & Ei Alarmen", "PAGE_DESC": "Ontvang meldingen over raidbazen en uitkomende eieren bij nabije gyms.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Weet je zeker dat je ALLE raid en ei alarmen wilt verwijderen? Dit kan niet ongedaan worden gemaakt.", "CONFIRM_BULK_DELETE_TITLE": "Geselecteerde Alarmen Verwijderen", "CONFIRM_BULK_DELETE_MSG": "Weet je zeker dat je {{count}} alarmen wilt verwijderen?", - "CONFIRM_DELETE_SELECTED": "Geselecteerde Verwijderen" + "CONFIRM_DELETE_SELECTED": "Geselecteerde Verwijderen", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standaard", + "SECTION_SPECIAL": "Speciaal", + "SECTION_CUSTOM": "Eigen", + "ADD": "Level toevoegen", + "ADD_PLACEHOLDER": "bijv. 42", + "ADD_HELP": "Elk positief geheel getal dat je server gebruikt. 9000 betekent 'elk level'.", + "INVALID": "Level moet minimaal 1 zijn.", + "DUPLICATE": "Level {{value}} staat al in de lijst.", + "SR_REMOVE": "Eigen level {{value}} verwijderen", + "REMOVED": "Level {{value}} verwijderd", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest Alarmen", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Mega Energie", "TAB_CANDY": "Snoep", "ITEM_REWARD": "Itembeloning", - "ANY_ITEM": "Elk Item", + "ANY_ITEM": "Elk voorwerp", "QUEST_TYPE_LABEL": "Questtype:", "SNACK_CREATED": "Quest alarm aangemaakt", "SNACK_UPDATED": "Quest alarm bijgewerkt", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Alle quest alarmen verwijderd", "SNACK_FAILED_DELETE_ALL": "Alarmen verwijderen mislukt", "SNACK_FAILED_DISTANCE": "Afstanden bijwerken mislukt", - "CONFIRM_DELETE_SELECTED": "Geselecteerde Verwijderen" + "CONFIRM_DELETE_SELECTED": "Geselecteerde Verwijderen", + "SUMMARY_MODE": "Dagelijkse samenvatting", + "SUMMARY_HINT": "Bundel overeenkomende quests in één samenvattingsbericht in plaats van een melding per stuk. Vereist een geconfigureerd samenvattingsschema op de bot.", + "SUMMARY_BADGE": "Samenvatting", + "SUMMARY_SCHEDULE": "Bezorging van questsamenvatting", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Questsamenvatting", + "SUMMARY_SCHEDULE_EMPTY": "Geen samenvattingsschema ingesteld. Quests worden afzonderlijk bezorgd.", + "SUMMARY_SCHEDULE_EDIT": "Schema bewerken", + "SUMMARY_SCHEDULE_CLEAR": "Schema verwijderen", + "SUMMARY_SCHEDULE_SEND_NOW": "Samenvatting nu verzenden", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Levert de questmatches die sinds je laatste samenvatting zijn verzameld. Staat er nog niets in de buffer, dan wordt er niets verzonden.", + "SUMMARY_SCHEDULE_SAVED": "Samenvattingsschema opgeslagen", + "SUMMARY_SCHEDULE_CLEARED": "Samenvattingsschema verwijderd", + "SUMMARY_SCHEDULE_SENT": "Samenvatting verzonden", + "SUMMARY_SCHEDULE_FAILED": "Kan het samenvattingsschema niet bijwerken", + "SUMMARY_SCHEDULE_UNAVAILABLE": "Bezorging van samenvattingen is tijdelijk niet beschikbaar. Probeer het later opnieuw.", + "SUMMARY_DISABLED_HINT": "Het plannen van samenvattingen is niet beschikbaar op deze server.", + "TAB_STARDUST": "Sterrenstof", + "MIN_AMOUNT": "Minimaal aantal", + "MIN_AMOUNT_HINT": "0 = elk aantal", + "MIN_STARDUST": "Minimaal sterrenstof", + "MIN_STARDUST_HINT": "0 = elke sterrenstof-opdracht", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Invasie Alarmen", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnetisch", "TYPE_RAINY": "Regenachtig", "TYPE_GOLDEN": "Gouden", - "TYPE_UNKNOWN": "Lokmodule #{{id}}" + "TYPE_UNKNOWN": "Lokmodule #{{id}}", + "EDIT_MODE": "Bericht ter plekke bewerken", + "EDIT_HINT": "Werk het bestaande Discord-bericht bij wanneer de lokmodule verandert in plaats van een nieuw bericht te sturen.", + "EDIT_BADGE": "Bewerken", + "CONFIRM_DELETE_TITLE": "Lokmodule-alarm verwijderen?", + "SNACK_FAILED_DISTANCE": "De afstand kon niet worden bijgewerkt." }, "NESTS": { "PAGE_TITLE": "Nest Alarmen", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Nest alarm verwijderd", "SNACK_FAILED_CREATE": "Alarm aanmaken mislukt", "SNACK_FAILED_UPDATE": "Alarm bijwerken mislukt", - "SNACK_FAILED_DELETE": "Alarm verwijderen mislukt" + "SNACK_FAILED_DELETE": "Alarm verwijderen mislukt", + "CONFIRM_DELETE_TITLE": "Nest-alarm verwijderen?", + "SNACK_FAILED_DISTANCE": "De afstand kon niet worden bijgewerkt." }, "GYMS": { "PAGE_TITLE": "Gym Alarmen", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Mystic", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinct", - "TEAM_UNKNOWN": "Team {{id}}" + "TEAM_UNKNOWN": "Team {{id}}", + "CONFIRM_DELETE_TITLE": "Gym-alarm verwijderen?", + "SNACK_FAILED_DISTANCE": "De afstand kon niet worden bijgewerkt." }, "FORT_CHANGES": { "PAGE_TITLE": "Fortwijziging Alarmen", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Verwijderd", "CHANGE_NEW": "Nieuw fort", "INCLUDE_EMPTY": "Forten zonder naam opnemen", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Melding kon niet worden aangemaakt", + "CREATE_SUCCESS": "Melding voor gymwijzigingen aangemaakt", + "UPDATE_FAILED": "Melding kon niet worden bijgewerkt", + "UPDATE_SUCCESS": "Melding voor gymwijzigingen bijgewerkt", "ALL_CHANGES": "Alle wijzigingen", "LABEL_NAME": "Naam", "LABEL_LOCATION": "Locatie", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Alarm voor {{type}}-wijziging verwijderen?", "SNACK_DELETED": "Fort-wijzigingsalarm verwijderd", "SNACK_FAILED_DISTANCE": "Kan afstanden niet bijwerken", - "SNACK_ALL_DISTANCE": "Alle afstanden bijgewerkt" + "SNACK_ALL_DISTANCE": "Alle afstanden bijgewerkt", + "FORT_TYPE_LABEL": "Forttype", + "CHANGE_TYPES_LABEL": "Soorten wijziging", + "TRACKING_SUBTITLE": "Fortwijzigingen volgen", + "CHANGE_DESCRIPTION": "Beschrijving gewijzigd" }, "MAX_BATTLES": { "PAGE_TITLE": "Max Gevecht Alarmen", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Meldingen konden niet worden aangemaakt", + "CREATE_SUCCESS": "{{count}} melding(en) aangemaakt", "ANY_POKEMON": "Elk Pokémon", "ANY_LEVEL": "Elk niveau", "STAR_LABEL": "{{stars}} sterren", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Kan afstanden niet bijwerken", "SNACK_ALL_DISTANCE": "Alle afstanden bijgewerkt", "SNACK_FAILED_UPDATE": "Kan alarm niet bijwerken", - "SNACK_UPDATED": "Max Battle-alarm bijgewerkt" + "SNACK_UPDATED": "Max Battle-alarm bijgewerkt", + "HINT_BY_LEVEL": "Volgt elke Pokemon op deze gevechtsniveaus. Elk gekozen niveau wordt een eigen alarm.", + "HINT_BY_POKEMON": "Volgt specifieke Pokemon in Max Gevechten, ongeacht het niveau.", + "HINT_GMAX_ONLY_ADD": "Meldt alleen Gigantamax-gevechten voor de gekozen Pokemon.", + "HINT_GMAX_ONLY_EDIT": "Meldt alleen Gigantamax-gevechten voor deze Pokemon.", + "HINT_ALL_LEVELS": "Dit alarm volgt één Pokemon op alle Max Gevecht-niveaus.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Gebieden & Locatie", + "MANAGE_PLACES": "Plaatsen beheren", + "PAGE_TITLE": "Gebieden en plaatsen", "PAGE_DESC": "Bepaal waar je meldingen ontvangt.", "METHOD_AREAS": "Gebieden", "METHOD_AREAS_ACTIVE": "{{count}} gebied(en) actief", "METHOD_NOT_CONFIGURED": "Niet geconfigureerd", "METHOD_AREAS_DESC": "Ontvang meldingen over alles dat binnen je geselecteerde geofence-zones gebeurt.", "METHOD_AREAS_TIP": "Beste voor: hele steden, wijken of parken dekken", - "METHOD_LOCATION": "Locatie", - "METHOD_LOCATION_NOT_SET": "Niet ingesteld", - "METHOD_LOCATION_DESC": "Ontvang meldingen over alles binnen een bepaalde afstand van je vastgezette locatie.", + "METHOD_LOCATION": "Mijn locatie", + "METHOD_LOCATION_NOT_SET": "Geen locatie ingesteld", + "METHOD_LOCATION_DESC": "Krijg meldingen over alles binnen een ingestelde afstand van je locatie.", "METHOD_LOCATION_TIP": "Beste voor: meldingen bij je huis, werk of een specifieke plek", "CLEAR_LOCATION": "Wissen", "CHANGE_LOCATION": "Wijzigen", "SET_LOCATION": "Instellen", "METHOD_NOTE": "Elk alarm kiest één methode in het Bezorging tabblad.", + "NOTIFICATION_LANGUAGE": "Meldingstaal", + "NOTIFICATION_LANGUAGE_DESC": "De taal die Poracle gebruikt voor je meldingsteksten en Pokémon-namen. Dit staat los van de weergavetaal in het bovenste menu.", + "SNACK_LANGUAGE_UPDATED": "Meldingstaal bijgewerkt", + "SNACK_LANGUAGE_FAILED": "Bijwerken van meldingstaal mislukt", "SELECT_AREAS": "Gebieden Selecteren", "MAP_VIEW": "Kaart", "LIST_VIEW": "Lijst", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Locatie bijwerken mislukt", "SEARCH_AREAS": "Gebieden zoeken", "MANUAL_ADD_PLACEHOLDER": "Typ een gebiedsnaam en druk op Enter", - "FILTER_PLACEHOLDER": "Filter op naam..." + "FILTER_PLACEHOLDER": "Filter op naam...", + "SNACK_LOAD_SELECTED_FAILED": "Je huidige gebieden konden niet worden geladen. Herlaad voordat je ze wijzigt.", + "SELECTION_UNKNOWN": "Je huidige gebieden konden niet worden geladen — herlaad de pagina voordat je opslaat." }, "PROFILES": { "PAGE_TITLE": "Profielen", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Regio Selecteren", "SEARCH_REGIONS": "Regio's zoeken...", "TOGGLE_TOOLTIP": "Meldingen voor deze geofence op het huidige profiel in-/uitschakelen", - "CREATED_PREFIX": "Gemaakt" + "CREATED_PREFIX": "Gemaakt", + "REGION_OPTIONAL_HINT": "Optioneel. Kies een regio als je geofence bij een regio hoort." }, "CLEANING": { "PAGE_TITLE": "Opschoonmodus", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Some help content may not be available in your language yet.", "TRANSLATION_CTA_LINK": "Help translate", "FALLBACK_CHIP": "English", + "IMAGE_ENLARGE": "Klik om te vergroten", "SECTION_GETTING_STARTED": "Getting Started", "SECTION_GETTING_STARTED_SUB": "Login, onboarding wizard, and initial setup", "SECTION_DASHBOARD": "Dashboard", "SECTION_DASHBOARD_SUB": "Your overview of alarms, areas, and status", - "SECTION_LOCATION": "Setting Your Location", + "SECTION_LOCATION": "Je locatie instellen", "SECTION_LOCATION_SUB": "GPS, address search, and coordinates", - "SECTION_AREAS": "Choosing Your Areas", - "SECTION_AREAS_SUB": "Map view, list view, and region filtering", + "SECTION_AREAS": "Gebieden en plaatsen", + "SECTION_AREAS_SUB": "Kaartweergave, lijstweergave, regiofilter en plaatsen", "SECTION_GEOFENCES": "Custom Geofences", "SECTION_GEOFENCES_SUB": "Draw boundaries, submit for public approval", "SECTION_POKEMON": "Pokemon Alarms", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Other Alarm Types", "SECTION_OTHER_ALARMS_SUB": "Raids, eggs, quests, rockets, lures, nests, gyms, fort changes", "SECTION_DELIVERY": "Delivery Settings", - "SECTION_DELIVERY_SUB": "Areas vs distance, templates, and clean mode", + "SECTION_DELIVERY_SUB": "Bezorgbereik, sjablonen en opschoningsmodus", + "SECTION_QUEST_SUMMARY": "Bezorging van questsamenvatting", + "SECTION_QUEST_SUMMARY_SUB": "Bundel drukke quests in één gepland overzicht", "SECTION_TEST_ALERTS": "Test Alerts", "SECTION_TEST_ALERTS_SUB": "Send sample notifications to preview your alarms", "SECTION_POKEMON_AVAILABILITY": "Pokemon Availability", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Common issues and how to fix them", "CONTENT_GETTING_STARTED": "

De DM Alerts site laat je precies instellen welke Pokemon GO meldingen je als directe berichten ontvangt. In plaats van elke melding te krijgen, kies je wat belangrijk voor je is — specifieke Pokemon, raids, quests en meer — en je wordt alleen daarover gewaarschuwd.

ℹ️
Voordat je de site kunt gebruiken, moet je je eerst registreren bij de Poracle bot op Discord of Telegram. Na registratie kom je hier terug en log je in.

Inloggen

  • Discord — Klik op \"Inloggen met Discord\" op de loginpagina. Je wordt naar Discord gebracht om de app te autoriseren en daarna automatisch teruggeleid.
  • Telegram — Indien ingeschakeld, gebruik de Telegram login widget op de loginpagina. Bevestig de login in je Telegram app.
\"Loginpagina

Eerste configuratie

Wanneer je voor het eerst inlogt, leidt een welkomstassistent je door drie stappen:

  1. Stel je locatie in — Wordt gebruikt om afstanden te berekenen voor meldingen in de buurt.
  2. Kies je gebieden — Selecteer de geografische zones waarvan je meldingen wilt ontvangen.
  3. Voeg je eerste alarm toe — Maak een Pokemon, Raid of Quest alarm aan om meldingen te ontvangen.
\"Welkomstassistent

Je kunt elke stap overslaan en later terugkomen. De assistent verschijnt niet meer nadat je hem hebt gesloten of alle stappen hebt voltooid.

", "CONTENT_DASHBOARD": "\"Dashboard

Het Dashboard is je thuisbasis. Het toont een overzicht van je huidige configuratie in één oogopslag.

Statuskaarten

  • Locatie — Toont je opgeslagen coördinaten of adres. Klik om je locatie in te stellen of bij te werken.
  • Actieve gebieden — Toont hoeveel gebieden je volgt. Klik om je gebieden te beheren.
  • Profiel — Toont je actieve profiel. Als je meerdere profielen hebt, klik om te wisselen.

Actieve filters

Een raster van kaarten toont hoeveel alarmen je hebt per type (Pokemon, Raids, Quests, enz.). Klik op een kaart om naar die alarmlijst te gaan.

Weer

Als je een locatie hebt ingesteld, toont het dashboard het huidige in-game weer op je coördinaten samen met de laatste updatetijd. Gebiedsweer wordt ook getoond voor elk van je geselecteerde gebieden, zodat je de weersomstandigheden in al je gevolgde zones kunt zien.

Snelle acties

Snelkoppelingsknoppen om Pokemon, Raid of Quest alarmen toe te voegen, gebieden te beheren of opschoning te configureren — allemaal zonder door de zijbalk te navigeren.

Tips

Handige herinneringen verschijnen wanneer je configuratie onvolledig is — zoals ontbrekende locatie, geen gebieden geselecteerd of geen alarmen geconfigureerd. Elke tip heeft een actieknop om het op te lossen. Je kunt tips die je niet nodig hebt wegklikken.

Navigatie

Gebruik de zijbalk om tussen secties te navigeren. Alarmtypes staan bovenaan, gevolgd door instellingen zoals Gebieden, Geofences, Profielen en Opschoning. Hulp staat altijd onderaan.

\"Zijbalknavigatie", - "CONTENT_LOCATION": "\"Dashboard

Je locatie wordt gebruikt voor afstandsgebaseerde meldingen. Wanneer een alarm de modus \"Stel Afstand in\" gebruikt, word je gewaarschuwd over gebeurtenissen binnen een straal van deze locatie.

Je locatie instellen

Open het locatievenster vanuit het Dashboard of de pagina Gebieden. Je hebt vier manieren om het in te stellen:

  • Zoek op adres — Typ een adres, stad of herkenningspunt. Selecteer uit de suggesties die verschijnen.
  • Voer coördinaten in — Typ breedtegraad en lengtegraad direct in als je ze kent.
  • Gebruik je GPS — Klik op \"Gebruik mijn locatie\" om de huidige locatie van je apparaat te gebruiken. Je browser vraagt om toestemming.
  • Klik op de kaart — Klik ergens op de minikaart om dat punt als je locatie in te stellen.

Na het selecteren van een locatie wordt het adres automatisch getoond. Klik op Opslaan om te bevestigen.

💡
Je kunt je locatie wissen vanaf de pagina Gebieden als je alleen gebiedsgebaseerde meldingen wilt.
", - "CONTENT_AREAS": "\"Pagina

Gebieden zijn vooraf gedefinieerde geografische zones die door je community zijn ingesteld. Wanneer een alarm de modus \"Gebruik Gebieden\" gebruikt, word je gewaarschuwd over gebeurtenissen die plaatsvinden in je geselecteerde gebieden.

Gebieden selecteren

Ga naar Gebieden & Locatie vanuit de zijbalk. Je kunt gebieden op twee manieren selecteren:

  • Kaartweergave — Klik op gekleurde polygonen op de kaart om gebieden te selecteren of deselecteren. Geselecteerde gebieden worden groen. Beweeg over een gebied om de naam te zien.
  • Lijstweergave — Gebruik selectievakjes om gebieden te kiezen uit een doorzoekbare lijst.

Regiofilter

Als je community veel gebieden heeft in verschillende regio's, gebruik het regiodropdownmenu om in te zoomen op een specifieke regio. Dit maakt het makkelijker om gebieden bij je in de buurt te vinden.

Geneste gebieden

Sommige gebieden overlappen — een kleinere zone binnen een grotere. Beide zijn klikbaar. Zoom in om het makkelijker te maken op het kleinere gebied te klikken.

Opslaan

Een opslagbalk verschijnt onderaan wanneer je wijzigingen hebt gemaakt. Klik op Opslaan om je selecties te bevestigen, of Annuleren om terug te keren.

ℹ️
Gebieden zijn per profiel. Elk profiel heeft zijn eigen set geselecteerde gebieden. Bij het wisselen van profiel zie je andere gebiedsselecties. Aangepaste geofences kunnen ook per profiel worden in- of uitgeschakeld vanaf de Geofences pagina.
", - "CONTENT_GEOFENCES": "\"Pagina

Als de vooraf gedefinieerde gebieden niet dekken waar je meldingen wilt, kun je je eigen aangepaste geofence-grenzen op de kaart tekenen.

Een geofence tekenen

  1. Ga naar Mijn Geofences vanuit de zijbalk.
  2. Klik op Geofence Tekenen.
  3. Klik op de kaart om punten van je polygoongrens te plaatsen. Klik opnieuw op het eerste punt om de vorm te sluiten (minimaal 3 punten).
  4. Geef je geofence een naam en selecteer bij welke regio het hoort. De regio wordt meestal automatisch gedetecteerd.
  5. Klik op Opslaan.

Geofences beheren

  • Bewerken — Hernoem je geofence of wijzig de regio.
  • Verwijderen — Verwijder een geofence die je niet meer nodig hebt. De geofence wordt automatisch uit alle profielen verwijderd.

Profielschakelaar

Elke geofencekaart heeft een schuifschakelaar om het te activeren of deactiveren voor je huidige profiel. Wanneer je een geofence aanmaakt, wordt het automatisch geactiveerd op het profiel dat je gebruikt. Schakel naar een ander profiel en de schakelaar toont \"Inactief\" — zet hem aan om ook op dat profiel meldingen voor die geofence te ontvangen. Zo kun je bepalen welke profielen meldingen krijgen voor elke geofence zonder hem opnieuw aan te maken.

ℹ️
Goedgekeurde geofences (gepromoveerd tot openbare gebieden) tonen de schakelaar niet — beheer ze vanaf de pagina Gebieden.

GeoJSON Importeren & Exporteren

Je kunt geofences importeren en exporteren met het standaard GeoJSON formaat, waardoor het makkelijk is om grenzen te delen of ze in externe tools te maken zoals geojson.io.

  • Importeren — Klik op het uploadpictogram en plak of upload een GeoJSON bestand. Elke polygoon in het bestand wordt een nieuwe geofence. Je kunt ze allemaal bekijken en hernoemen voordat je opslaat.
  • Exporteren — Klik op het downloadpictogram en selecteer welke geofences je wilt opnemen. Het geëxporteerde GeoJSON bestand bevat alle geselecteerde polygonen en kan in elke GIS-tool of kaarteditor worden geopend.
💡
GeoJSON import is handig voor het migreren van geofences uit andere systemen of het tekenen van complexe grenzen in een desktop GIS-tool en ze vervolgens hier te importeren.

Indienen voor openbare goedkeuring

Als je denkt dat je geofence nuttig zou zijn voor de hele community, kun je het indienen voor beheerdersbeoordeling. Als het wordt goedgekeurd, wordt het een openbaar gebied dat iedereen kan selecteren. Je privégeofence blijft werken terwijl de beoordeling loopt.

Statusbadges

  • Actief — Je privégeofence, alleen werkend voor jou.
  • In beoordeling — Ingediend en wachtend op beheerdersbeoordeling.
  • Goedgekeurd — Gepromoveerd tot een openbaar gebied.
  • Afgewezen — Niet goedgekeurd. Je kunt de feedback van de beheerder bekijken en de geofence blijft actief als privézone.
ℹ️
Je kunt maximaal 10 aangepaste geofences hebben, elk met maximaal 500 grenspunten.
", - "CONTENT_POKEMON": "\"Pokemon

Pokemon alarmen waarschuwen je wanneer een wilde Pokemon verschijnt die aan je filters voldoet.

Een Pokemon alarm toevoegen

\"Venster
  1. Ga naar Pokemon vanuit de zijbalk en klik op de + knop.
  2. Selecteer Pokemon — Zoek op naam of Pokedex nummer, of gebruik de generatie- en typefilterknoppen om te bladeren. Je kunt meerdere Pokemon tegelijk selecteren.
  3. Stel filters in — Kies wat een spawn de moeite waard maakt om over gewaarschuwd te worden:
  • IV bereik — Minimum en maximum IV percentage (0-100%)
  • CP bereik — Filter op gevechtskracht
  • Niveaubereik — Filter op Pokemon niveau (0-55)
  • Individuele stats — Filter op ATK, DEF en STA waarden (0-15 elk)
  • Vorm — Volg specifieke vormen (bijv. Alolan, Galarian) of alle vormen
  • Geslacht — Mannelijk, vrouwelijk, geslachtloos of alle
  • Gewicht — Filter op gewichtsbereik
  • Grootte — Filter op groottecategorie: selecteer ALL (geen filter) om elke grootte te matchen, of kies specifieke groottes van XXS tot XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Standaard filterwaarden zijn zo ingesteld dat alle Pokemon overeenkomen wanneer er geen filters expliciet zijn geconfigureerd. Bijvoorbeeld, IV standaard 0-100%, niveau 0-55 en grootte ALL. Je hoeft alleen de filters aan te passen die je belangrijk vindt.

PVP Filters

Ontvang een melding wanneer een Pokemon geweldige PVP IV's heeft. Selecteer een competitie (Great, Ultra of Little Cup) en stel het rankbereik in dat je belangrijk vindt (bijv. rank 1-50).

Alarm \"Alle Pokemon\"

💡
Selecteer \"Alle Pokemon\" (ID 0) om één alarm te maken dat elke soort dekt. Handig met een hoog IV filter zoals 96-100% om elke waardevolle spawn te vangen.

Alarmkaarten lezen

Elke alarmkaart toont gekleurde pillen die je filters in één oogopslag samenvatten:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Raidpagina

Raid & Ei alarmen

Ontvang een melding wanneer een raidboss of ei verschijnt dat je interesseert.

  • Op niveau — Selecteer raidniveaus (1-6) of einiveaus om alle raids van dat niveau te volgen.
  • Op boss — Selecteer specifieke Pokemon raidbosses die je wilt bestrijden.
  • Teamfilter — Waarschuw alleen voor raids bij gyms die door een specifiek team worden beheerst (Mystic, Valor, Instinct).
  • Gymtracking — Volg raids bij specifieke gyms op naam, zodat je alleen wordt gewaarschuwd over je favoriete gyms.
  • Movefilter — Filter raidbosses op hun snelle of geladen moves.
  • RSVP meldingen — Ontvang een melding wanneer andere trainers zich aanmelden voor een raid of ei dat je volgt.

Raid en Ei alarmen worden beheerd op aparte tabs binnen de Raids pagina. Eieren ondersteunen ook gym-specifieke tracking en RSVP meldingen.

Max Battle (Dynamax) alarmen

Ontvang meldingen over Dynamax en Gigantamax gevechten bij Power Spots.

  • Op niveau — Selecteer gevechtsniveaus om elke Pokemon op die niveaus te volgen. Niveaus lopen van 1 Ster tot 5 Sterren (Legendarisch) voor Dynamax, plus Gigantamax en Legendarisch Gigantamax voor de grootste gevechten. Er wordt één alarm aangemaakt per geselecteerd niveau.
  • Op Pokemon — Selecteer specifieke Pokemon die je wilt bevechten op alle Max Battle niveaus. Als de scannerdatabase is geconfigureerd, toont de selector alleen Pokemon die in Max Battles zijn verschenen.
  • Alleen Gigantamax — Bij tracking op Pokemon, schakel dit in om alleen meldingen te ontvangen wanneer die Pokemon in Gigantamax gevechten verschijnt (de gevechten van het hoogste niveau met unieke G-Max moves). Bij tracking op niveau wordt Gigantamax beheerd door direct de Gigantamax of Legendarisch Gigantamax niveaus te selecteren.
  • Alles selecteren — Selecteer snel alle beschikbare niveaus tegelijk (equivalent aan het !maxbattle everything commando van de bot).

Quest alarmen

Ontvang meldingen over veldonderzoekstaken met specifieke beloningen.

  • Pokemon ontmoetingen — Selecteer Pokemon die je als questbeloning wilt.
  • Items — Volg quests die specifieke items belonen.
  • Mega Energie — Volg quests die mega-energie geven voor specifieke Pokemon.
  • Snoepjes — Volg quests die snoepjes belonen voor specifieke Pokemon.

Invasie alarmen

Ontvang meldingen over Team Rocket invasies.

  • Alles volgen — Eén alarm voor elk type grunt en leider.
  • Op type — Selecteer specifieke grunttypes (Bug, Dragon, Fire, enz.), Rocket Leaders of Giovanni. Grunttypenamen worden automatisch genormaliseerd (niet hoofdlettergevoelig), dus je hoeft je geen zorgen te maken over exacte hoofdletters.
  • Geslacht — Filter op gruntgeslacht.

Lokmiddel alarmen

Ontvang een melding wanneer een specifiek type lokmiddel wordt geplaatst. Kies uit Normal, Glacial, Mossy, Magnetic, Rainy en Golden lokmiddelen.

Nest alarmen

Volg nestende Pokemon soorten. Stel een drempel in voor minimum spawns per uur zodat je alleen wordt gewaarschuwd over nesten met voldoende activiteit.

Gym alarmen

Volg gymteamwisselingen. Selecteer welke teams (Neutraal, Mystic, Valor, Instinct) je wilt monitoren. Schakel Plekwijzigingen tracking in om gewaarschuwd te worden wanneer gymplekken vrijkomen, of schakel Gevechtswijzigingen tracking in om gewaarschuwd te worden wanneer een gym wordt aangevallen.

Fortwijziging alarmen

Volg wijzigingen aan pokestops en gyms zelf — niet de activiteiten erbij, maar wijzigingen aan de daadwerkelijke interessepunten.

  • Forttype — Kies om Pokestops, Gyms of Alles te volgen.
  • Wijzigingstypen — Selecteer welke wijzigingen je wilt monitoren: Naam gewijzigd, Locatie gewijzigd, Afbeelding gewijzigd, Verwijdering of Nieuw fort toegevoegd.
  • Lege opnemen — Neem forten zonder naam op.
💡
Fortwijziging alarmen zijn handig voor het volgen van kaartdatabase-updates — nieuwe pokestops die verschijnen, gyms die worden verplaatst of POI's die uit het spel worden verwijderd.

Een specifieke gym targeten

Bij het aanmaken of bewerken van een Raid, Ei of Gym alarm kun je optioneel een specifieke gym zoeken en selecteren. Dit is handig wanneer je alleen geeft om activiteit bij je favoriete gym — zoals die op je lunchroute of bij je huis.

  • Hoe te gebruiken — In het toevoeg- of bewerkingsvenster, typ een gymnaam in het gymzoekveld. Resultaten tonen de foto, naam en het gebied van de gym zodat je de juiste kunt identificeren.
  • Wanneer een gym is geselecteerd — Het alarm gaat alleen af voor gebeurtenissen bij die specifieke gym. De gymnaam verschijnt op de alarmkaart in je lijst zodat je in één oogopslag kunt zien welke gym het doel is.
  • Wanneer geen gym is geselecteerd — Dit is de standaard. Het alarm werkt normaal voor alle gyms in je geselecteerde gebieden of binnen je afstandsstraal.
💡
Je kunt een gym-specifiek alarm combineren met een breder alarm. Maak bijvoorbeeld één raidalarm gericht op je lokale gym voor alle niveaus, en een tweede alarm voor niveau 5 raids in al je gebieden.
", - "CONTENT_DELIVERY": "\"Pokemon

Elk alarm heeft bezorginstellingen die bepalen waar je meldingen ontvangt.

Gebieden vs Afstand

Elk alarm gebruikt een van twee bezorgmodi:

🗺
Gebruik GebiedenJe wordt gewaarschuwd wanneer gebeurtenissen plaatsvinden in je geselecteerde gebieden. Goed voor het volgen van specifieke wijken.
📏
Stel Afstand inJe wordt gewaarschuwd binnen een straal (km) van je opgeslagen locatie. Goed voor het volgen van alles in je buurt.

Je kunt verschillende modi gebruiken voor verschillende alarmen — bijvoorbeeld gebieden voor Pokemon en afstand voor raids.

Meldingstemplates

Als templates zijn ingeschakeld, kun je kiezen hoe je meldingsberichten eruitzien. De templateselector toont een live voorbeeld van hoe je Discord DM eruit zal zien, inclusief het embed-formaat, velden en afbeeldingen.

Opschoningsmodus

Wanneer ingeschakeld, verwijdert de bot automatisch de melding uit Discord nadat de gebeurtenis is verlopen (bijv. een Pokemon verdwijnt of een raid eindigt). Dit houdt je DM's opgeruimd. Je kunt de opschoningsmodus per alarm of in bulk inschakelen vanaf de pagina Opschoning.

Ping / Rolmeldingen

Als je webhooks gebruikt, kun je een Discord rol instellen om te vermelden in de melding (bijv. @Pokemon). Dit is alleen relevant voor webhook-configuraties.

", + "CONTENT_LOCATION": "\"Dashboard

Je locatie is het punt waarvandaan je meldingen worden gemeten. Een alarm dat je binnen een straal bereikt, gaat uit van die locatie, tenzij je dat alarm op een opgeslagen plaats richt.

Je locatie instellen

Open het locatievenster vanuit het Dashboard of de pagina Gebieden en plaatsen. Je hebt vier manieren om het in te stellen:

  • Zoek op adres — Typ een adres, stad of herkenningspunt. Selecteer uit de suggesties die verschijnen.
  • Voer coördinaten in — Typ breedtegraad en lengtegraad direct in als je ze kent.
  • Gebruik je GPS — Klik op \"Gebruik mijn locatie\" om de huidige locatie van je apparaat te gebruiken. Je browser vraagt om toestemming.
  • Klik op de kaart — Klik ergens op de minikaart om dat punt als je locatie in te stellen.

Na het kiezen van een punt wordt het adres automatisch getoond. Klik op Opslaan om te bevestigen.

Hetzelfde venster wordt hergebruikt wanneer je een plaats toevoegt of een punt kiest voor één alarm. Het heet dan Kies een punt en je bevestigt met Dit punt gebruiken; je eigen locatie blijft ongemoeid.

💡
Je kunt je locatie wissen vanaf de pagina Gebieden en plaatsen als je alleen gebiedsgebaseerde meldingen wilt.
", + "CONTENT_AREAS": "\"Pagina

Gebieden zijn vooraf gedefinieerde geografische zones die door je community zijn ingesteld. Wat je hier kiest, volgt elk alarm standaard: een alarm op Overal in mijn gebieden gaat af bij gebeurtenissen daarbinnen.

Gebieden selecteren

Ga naar Gebieden en plaatsen vanuit de zijbalk. Je kunt gebieden op twee manieren selecteren:

  • Kaartweergave — Klik op gekleurde polygonen op de kaart om gebieden te selecteren of deselecteren. Geselecteerde gebieden worden groen. Beweeg over een gebied om de naam te zien.
  • Lijstweergave — Gebruik selectievakjes om gebieden te kiezen uit een doorzoekbare lijst.

Plaatsen

Een plaats is een benoemd punt — je werk, de sportschool, het huis van je ouders — waarvandaan een alarm zijn straal kan meten in plaats van vanaf je locatie. Voeg er een toe in het onderdeel Plaatsen op dezelfde pagina en kies die daarna onder Gemeten vanaf wanneer je bepaalt waar een alarm je moet bereiken. Een plaats kan niet worden verwijderd zolang er alarmen naar wijzen; de melding zegt hoeveel.

Regiofilter

Als je community veel gebieden heeft in verschillende regio's, gebruik het regiodropdownmenu om in te zoomen op een specifieke regio. Dit maakt het makkelijker om gebieden bij je in de buurt te vinden.

Geneste gebieden

Sommige gebieden overlappen — een kleinere zone binnen een grotere. Beide zijn klikbaar. Zoom in om het makkelijker te maken op het kleinere gebied te klikken.

Opslaan

Een opslagbalk verschijnt onderaan wanneer je wijzigingen hebt gemaakt. Klik op Opslaan om je selecties te bevestigen, of Annuleren om terug te keren.

ℹ️
Gebieden zijn per profiel. Elk profiel heeft zijn eigen set geselecteerde gebieden. Bij het wisselen van profiel zie je andere gebiedsselecties. Aangepaste geofences kunnen ook per profiel worden in- of uitgeschakeld vanaf de Geofences pagina.
", + "CONTENT_GEOFENCES": "\"Pagina

Als de vooraf gedefinieerde gebieden niet dekken waar je meldingen wilt, kun je je eigen aangepaste geofence-grenzen op de kaart tekenen.

Een geofence tekenen

  1. Ga naar Mijn Geofences vanuit de zijbalk.
  2. Klik op Geofence Tekenen.
  3. Klik op de kaart om punten van je polygoongrens te plaatsen. Klik opnieuw op het eerste punt om de vorm te sluiten (minimaal 3 punten).
  4. Geef je geofence een naam en selecteer bij welke regio het hoort. De regio wordt meestal automatisch gedetecteerd.
  5. Klik op Opslaan.

Geofences beheren

  • Bewerken — Hernoem je geofence of wijzig de regio.
  • Verwijderen — Verwijder een geofence die je niet meer nodig hebt. De geofence wordt automatisch uit alle profielen verwijderd.

Profielschakelaar

Elke geofencekaart heeft een schuifschakelaar om het te activeren of deactiveren voor je huidige profiel. Wanneer je een geofence aanmaakt, wordt het automatisch geactiveerd op het profiel dat je gebruikt. Schakel naar een ander profiel en de schakelaar toont \"Inactief\" — zet hem aan om ook op dat profiel meldingen voor die geofence te ontvangen. Zo kun je bepalen welke profielen meldingen krijgen voor elke geofence zonder hem opnieuw aan te maken.

ℹ️
Goedgekeurde geofences (gepromoveerd tot openbare gebieden) tonen de schakelaar niet — beheer ze vanaf de pagina Gebieden.

Een geofence voor één alarm gebruiken

Een geofence die je zelf hebt getekend, staat ook in de lijst Alleen in bepaalde gebieden wanneer je bepaalt waar één alarm je moet bereiken; hij is gemarkeerd met een tekenicoon. Zo beperk je één alarm ertoe zonder de geofence voor het hele profiel aan te zetten.

GeoJSON Importeren & Exporteren

Je kunt geofences importeren en exporteren met het standaard GeoJSON formaat, waardoor het makkelijk is om grenzen te delen of ze in externe tools te maken zoals geojson.io.

  • Importeren — Klik op het uploadpictogram en plak of upload een GeoJSON bestand. Elke polygoon in het bestand wordt een nieuwe geofence. Je kunt ze allemaal bekijken en hernoemen voordat je opslaat.
  • Exporteren — Klik op het downloadpictogram en selecteer welke geofences je wilt opnemen. Het geëxporteerde GeoJSON bestand bevat alle geselecteerde polygonen en kan in elke GIS-tool of kaarteditor worden geopend.
💡
GeoJSON import is handig voor het migreren van geofences uit andere systemen of het tekenen van complexe grenzen in een desktop GIS-tool en ze vervolgens hier te importeren.

Indienen voor openbare goedkeuring

Als je denkt dat je geofence nuttig zou zijn voor de hele community, kun je het indienen voor beheerdersbeoordeling. Als het wordt goedgekeurd, wordt het een openbaar gebied dat iedereen kan selecteren. Je privégeofence blijft werken terwijl de beoordeling loopt.

Statusbadges

  • Actief — Je privégeofence, alleen werkend voor jou.
  • In beoordeling — Ingediend en wachtend op beheerdersbeoordeling.
  • Goedgekeurd — Gepromoveerd tot een openbaar gebied.
  • Afgewezen — Niet goedgekeurd. Je kunt de feedback van de beheerder bekijken en de geofence blijft actief als privézone.
ℹ️
Je kunt maximaal 10 aangepaste geofences hebben, elk met maximaal 500 grenspunten.
", + "CONTENT_POKEMON": "\"Pokemon

Pokemon alarmen waarschuwen je wanneer een wilde Pokemon verschijnt die aan je filters voldoet.

Een Pokemon alarm toevoegen

\"Venster
  1. Ga naar Pokemon vanuit de zijbalk en klik op de + knop.
  2. Selecteer Pokemon — Zoek op naam of Pokedex nummer, of gebruik de generatie- en typefilterknoppen om te bladeren. Je kunt meerdere Pokemon tegelijk selecteren.
  3. Stel filters in — Kies wat een spawn de moeite waard maakt om over gewaarschuwd te worden:
  • IV bereik — Minimum en maximum IV percentage (0-100%)
  • CP bereik — Filter op gevechtskracht
  • Niveaubereik — Filter op Pokemon niveau (0-55)
  • Individuele stats — Filter op ATK, DEF en STA waarden (0-15 elk)
  • Vorm — Volg specifieke vormen (bijv. Alolan, Galarian) of alle vormen
  • Geslacht — Mannelijk, vrouwelijk, geslachtloos of alle
  • Gewicht — Filter op gewichtsbereik
  • Grootte — Filter op groottecategorie: selecteer ALL (geen filter) om elke grootte te matchen, of kies specifieke groottes van XXS tot XXL (XXS, XS, Normal, XL, XXL)
  • Minimale resterende tijd — Sla spawns over die weg zijn voor je er bent. In te stellen onder Meer filters; de kaart toont dan een label als "nog 10 min"
ℹ️
Standaard filterwaarden zijn zo ingesteld dat alle Pokemon overeenkomen wanneer er geen filters expliciet zijn geconfigureerd. Bijvoorbeeld, IV standaard 0-100%, niveau 0-55 en grootte ALL. Je hoeft alleen de filters aan te passen die je belangrijk vindt.

PVP Filters

Ontvang een melding wanneer een Pokemon geweldige PVP IV's heeft. Selecteer een competitie (Great, Ultra of Little Cup) en stel het rankbereik in dat je belangrijk vindt (bijv. rank 1-50).

De knoppen Level Cap kiezen bij welke cap de ranks worden gelezen. Laat Alle staan om de waarde uit de Poracle-configuratie van je community te gebruiken.

Mega-evolutie bepaalt of de regel de basisvorm of een mega rangschikt: Base, Mega, Mega X of Mega Y. Mega's worden apart gerangschikt, dus een mega-regel matcht nooit een spawn in basisvorm.

Alarm \"Alle Pokemon\"

💡
Selecteer \"Alle Pokemon\" (ID 0) om één alarm te maken dat elke soort dekt. Handig met een hoog IV filter zoals 96-100% om elke waardevolle spawn te vangen.

Alarmkaarten lezen

Elke alarmkaart toont gekleurde pillen die je filters in één oogopslag samenvatten:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Raidpagina

Raid & Ei alarmen

Ontvang een melding wanneer een raidboss of ei verschijnt dat je interesseert.

  • Op niveau — Selecteer raidniveaus (1-6) of einiveaus om alle raids van dat niveau te volgen.
  • Op boss — Selecteer specifieke Pokemon raidbosses die je wilt bestrijden.
  • Teamfilter — Waarschuw alleen voor raids bij gyms die door een specifiek team worden beheerst (Mystic, Valor, Instinct).
  • Gymtracking — Volg raids bij specifieke gyms op naam, zodat je alleen wordt gewaarschuwd over je favoriete gyms.
  • Movefilter — Filter raidbosses op hun snelle of geladen moves.
  • RSVP meldingen — Ontvang een melding wanneer andere trainers zich aanmelden voor een raid of ei dat je volgt.

Raid en Ei alarmen worden beheerd op aparte tabs binnen de Raids pagina. Eieren ondersteunen ook gym-specifieke tracking en RSVP meldingen.

Max Battle (Dynamax) alarmen

Ontvang meldingen over Dynamax en Gigantamax gevechten bij Power Spots.

  • Op niveau — Selecteer gevechtsniveaus om elke Pokemon op die niveaus te volgen. Niveaus lopen van 1 Ster tot 5 Sterren (Legendarisch) voor Dynamax, plus Gigantamax en Legendarisch Gigantamax voor de grootste gevechten. Er wordt één alarm aangemaakt per geselecteerd niveau.
  • Op Pokemon — Selecteer specifieke Pokemon die je wilt bevechten op alle Max Battle niveaus. Als de scannerdatabase is geconfigureerd, toont de selector alleen Pokemon die in Max Battles zijn verschenen.
  • Alleen Gigantamax — Bij tracking op Pokemon, schakel dit in om alleen meldingen te ontvangen wanneer die Pokemon in Gigantamax gevechten verschijnt (de gevechten van het hoogste niveau met unieke G-Max moves). Bij tracking op niveau wordt Gigantamax beheerd door direct de Gigantamax of Legendarisch Gigantamax niveaus te selecteren.
  • Alles selecteren — Selecteer snel alle beschikbare niveaus tegelijk (equivalent aan het !maxbattle everything commando van de bot).

Quest alarmen

Ontvang meldingen over veldonderzoekstaken met specifieke beloningen.

  • Pokemon ontmoetingen — Selecteer Pokemon die je als questbeloning wilt.
  • Items — Volg quests die specifieke items belonen.
  • Mega Energie — Volg quests die mega-energie geven voor specifieke Pokemon.
  • Snoepjes — Volg quests die snoepjes belonen voor specifieke Pokemon.
  • Stardust — Volg quests die stardust belonen.

De tabbladen voor items, mega-energie en snoepjes hebben elk een veld Minimum aantal, en het stardust-tabblad een Minimum stardust. Laat 0 staan om elk aantal te accepteren. De kaarten tonen het aantal naast de beloning, bijvoorbeeld "3× Rare Candy".

Invasie alarmen

Ontvang meldingen over Team Rocket invasies.

  • Alles volgen — Eén alarm voor elk type grunt en leider.
  • Op type — Selecteer specifieke grunttypes (Bug, Dragon, Fire, enz.), Rocket Leaders of Giovanni. Grunttypenamen worden automatisch genormaliseerd (niet hoofdlettergevoelig), dus je hoeft je geen zorgen te maken over exacte hoofdletters.
  • Geslacht — Filter op gruntgeslacht.

Lokmiddel alarmen

Ontvang een melding wanneer een specifiek type lokmiddel wordt geplaatst. Kies uit Normal, Glacial, Mossy, Magnetic, Rainy en Golden lokmiddelen.

Nest alarmen

Volg nestende Pokemon soorten. Stel een drempel in voor minimum spawns per uur zodat je alleen wordt gewaarschuwd over nesten met voldoende activiteit.

Gym alarmen

Volg gymteamwisselingen. Selecteer welke teams (Neutraal, Mystic, Valor, Instinct) je wilt monitoren. Schakel Plekwijzigingen tracking in om gewaarschuwd te worden wanneer gymplekken vrijkomen, of schakel Gevechtswijzigingen tracking in om gewaarschuwd te worden wanneer een gym wordt aangevallen.

Fortwijziging alarmen

Volg wijzigingen aan pokestops en gyms zelf — niet de activiteiten erbij, maar wijzigingen aan de daadwerkelijke interessepunten.

  • Forttype — Kies om Pokestops, Gyms of Alles te volgen.
  • Wijzigingstypen — Selecteer welke wijzigingen je wilt monitoren: Naam gewijzigd, Beschrijving gewijzigd, Locatie gewijzigd, Afbeelding gewijzigd, Verwijderd of Nieuw fort.
  • Lege opnemen — Neem forten zonder naam op.
💡
Fortwijziging alarmen zijn handig voor het volgen van kaartdatabase-updates — nieuwe pokestops die verschijnen, gyms die worden verplaatst of POI's die uit het spel worden verwijderd.

Een specifieke gym targeten

Bij het aanmaken of bewerken van een Raid, Ei of Gym alarm kun je optioneel een specifieke gym zoeken en selecteren. Dit is handig wanneer je alleen geeft om activiteit bij je favoriete gym — zoals die op je lunchroute of bij je huis.

  • Hoe te gebruiken — In het toevoeg- of bewerkingsvenster, typ een gymnaam in het gymzoekveld. Resultaten tonen de foto, naam en het gebied van de gym zodat je de juiste kunt identificeren.
  • Wanneer een gym is geselecteerd — Het alarm gaat alleen af voor gebeurtenissen bij die specifieke gym. De gymnaam verschijnt op de alarmkaart in je lijst zodat je in één oogopslag kunt zien welke gym het doel is.
  • Wanneer geen gym is geselecteerd — Dit is de standaard. Het alarm werkt normaal voor alle gyms in je geselecteerde gebieden of binnen je afstandsstraal.
💡
Je kunt een gym-specifiek alarm combineren met een breder alarm. Maak bijvoorbeeld één raidalarm gericht op je lokale gym voor alle niveaus, en een tweede alarm voor niveau 5 raids in al je gebieden.
", + "CONTENT_DELIVERY": "\"Pokemon

Elk alarm heeft bezorginstellingen die bepalen waar je meldingen ontvangt.

Waar een melding je bereikt

Het tabblad Bezorging van elk toevoeg- en bewerkvenster vraagt Waar moet deze melding je bereiken? en biedt drie antwoorden:

  • Overal in mijn gebieden — De standaard. Het alarm volgt de gebieden die je profiel heeft geselecteerd, dus je gebieden wijzigen wijzigt ook dit alarm.
  • Bij een punt — Een straal in kilometers, gemeten vanaf je locatie of vanaf een opgeslagen plaats die je kiest onder Gemeten vanaf. Heb je nog geen locatie, dan meldt de kiezer dat en biedt aan er een in te stellen.
  • Alleen in bepaalde gebieden — Een deelverzameling gebieden voor dit ene alarm, gekozen uit de openbare gebieden en de geofences die je zelf hebt getekend.

Alarmen mogen verschillend antwoorden: gebieden voor Pokemon, een straal vanaf je locatie voor raids, één benoemde plaats voor quests.

De chip op de kaart

De meeste alarmkaarten dragen een chip met het antwoord — "Overal in mijn gebieden", "Overal waar ik meldingen krijg", "Binnen 5 km van mijn locatie", "Binnen 2 km van Thuis", "Alleen in Terrigal, Erina". Klik erop om dat ene alarm te wijzigen zonder het volledige bewerkvenster te openen.

Standaard voor nieuwe alarmen

Nieuwe alarmen openen standaard in de modus Gebieden. Wil je dat veranderen, open dan het gebruikersmenu (je avatar, rechtsboven) en kies Standaardinstellingen meldingen — bepaal of nieuwe alarmen in Gebieden of Afstand starten, stel een standaardstraal in en kies of die straal vanaf je locatie of vanaf een opgeslagen plaats wordt gemeten. De voorkeur staat in je browser en vult ook het Quick Pick-venster voor. Ze geldt alleen voor nieuw aangemaakte alarmen; bestaande veranderen niet, en je kunt nog steeds per alarm wijzigen waar het je bereikt.

Meldingstemplates

Als templates zijn ingeschakeld, kun je kiezen hoe je meldingsberichten eruitzien. De templateselector toont een live voorbeeld van hoe je Discord DM eruit zal zien, inclusief het embed-formaat, velden en afbeeldingen.

Opschoningsmodus

Wanneer ingeschakeld, verwijdert de bot automatisch de melding uit Discord nadat de gebeurtenis is verlopen (bijv. een Pokemon verdwijnt of een raid eindigt). Dit houdt je DM's opgeruimd. Je kunt de opschoningsmodus per alarm of in bulk inschakelen vanaf de pagina Opschoning.

Ter plekke bewerken & samenvattingen

Sommige alarmen ondersteunen extra bezorgmodi. Schakel Bericht ter plekke bewerken in voor een lokmodule om het bestaande Discord-bericht bij te werken wanneer de lokmodule verandert in plaats van een nieuw bericht te sturen, of Dagelijkse samenvatting voor een quest om overeenkomende quests in één samenvattingsbericht te bundelen (vereist een samenvattingsschema op de bot). Raids en eieren worden automatisch ter plekke bewerkt wanneer je een RSVP-modus kiest. Deze instellingen blijven behouden, ook als je ze via de bot instelt.

RSVP-updates (raids & eieren)

Raid- en ei-alarmen voegen een RSVP-meldingen instelling toe in het toevoeg-/bewerkingsvenster met drie keuzes: Alleen overeenkomsten stuurt de standaard raid-/ei-meldingen; Overeenkomsten + RSVP-updates meldt ook opnieuw wanneer de RSVP-aantallen wijzigen (trainers die zich aanmelden); en Alleen RSVP-updates slaat de initiële overeenkomst over en meldt je alleen RSVP-wijzigingen. Bij het kiezen van een van beide RSVP-modi bewerkt de bot het bestaande Discord-bericht ter plekke naarmate de aantallen veranderen, in plaats van nieuwe te sturen, en de kaart toont een "RSVP" of "Alleen RSVP" pil. Let op dat Alleen RSVP-updates stil blijft tenzij de scanner van je community RSVP-gebeurtenissen verstuurt — kies dit alleen als je weet dat RSVP’s worden gerapporteerd.

", + "CONTENT_QUEST_SUMMARY": "

Field Research-quests wisselen dagelijks en kunnen in grote aantallen overeenkomen, dus een druk questfilter kan je DM’s overspoelen. Bezorging van questsamenvatting bundelt overeenkomende quests in één gepland overzicht in plaats van veel losse meldingen.

Twee delen die samenwerken

  • Schakelaar Dagelijkse samenvatting — zet deze aan voor een questalarm (in het toevoegen/bewerken-venster) om de overeenkomsten te markeren voor het overzicht in plaats van directe bezorging.
  • Bezorgschema — kies wanneer de verzamelde quests worden verzonden.

Beide zijn nodig: de schakelaar bepaalt welke quests worden verzameld, het schema bepaalt wanneer ze worden bezorgd.

Je schema instellen

Open de pagina Quests, daarna het menu in de werkbalk en kies Bezorging van questsamenvatting. Gebruik Schema bewerken om dagen en tijden te kiezen — dezelfde editor als voor de actieve uren van profielen. Opgeslagen tijden verschijnen als amberkleurige pillen.

Het schema is per gebruiker en wordt gedeeld over al je profielen — in tegenstelling tot de actieve uren van profielen, die per profiel worden ingesteld.

Samenvatting nu verzenden

Samenvatting nu verzenden bezorgt meteen alles wat sinds je laatste samenvatting is verzameld. Als er nog niets is verzameld, wordt er niets verzonden — quests worden gebufferd zodra ze overeenkomen, dus geef het tijd of wacht tot het schema wordt geactiveerd.

Goed om te weten

  • Het menu verschijnt alleen wanneer de bot van je server questsamenvattingen heeft ingeschakeld.
  • De bezorgtijd gebruikt je opgeslagen locatie voor de tijdzone — stel een locatie in, anders kunnen samenvattingen op de verkeerde lokale tijd aankomen (het venster waarschuwt je wanneer er geen locatie is ingesteld).
  • Het verwijderen van het schema behoudt de schakelaar per alarm; quests worden nog steeds verzameld, maar vallen terug op de standaardtijd van de bot.
", "CONTENT_TEST_ALERTS": "

Elke alarmkaart heeft een Test knop (papiervliegtuigpictogram) die een voorbeeldmelding stuurt naar je Discord of Telegram, met de exacte filters van het alarm en je huidige bezorgtemplate.

Hoe het werkt

  1. Zoek een alarmkaart in je lijst (Pokemon, Raid, Quest, enz.).
  2. Klik op het verzend pictogram in de actierij van de kaart.
  3. Er wordt een namaakgebeurtenis gegenereerd die overeenkomt met de filters van je alarm en door de meldingspipeline gestuurd. Je ontvangt een DM net als een echt alarm.

Wat wordt getest

De test gebruikt de filterwaarden van je alarm (Pokemon ID, raidniveau, questbeloning, enz.) en je opgeslagen locatie als de namaakgebeurteniscoördinaten. De melding wordt opgemaakt met je geselecteerde template, zodat je precies ziet hoe een echt alarm eruit zou zien.

Afkoeltijd

Om spam te voorkomen heeft elk alarm een afkoeltijd van 15 seconden tussen testverzendingen. De knop is uitgeschakeld tijdens de afkoeltijd en een snackbar toont feedback (succes, fout of resterende afkoeltijd).

💡
Testmeldingen zijn geweldig om te controleren of je template er goed uitziet of om te bevestigen dat je webhookbezorging werkt voordat je wacht op een echte gebeurtenis.
", "CONTENT_POKEMON_AVAILABILITY": "

Bij het toevoegen of bewerken van Pokemon alarmen kan de Pokemon selector beschikbaarheidsindicatoren tonen — kleine badges die aangeven welke Pokemon momenteel in het wild spawnen.

Hoe het werkt

Als je community een Golbat scanner geconfigureerd heeft, toont de selector gekleurde stippen naast Pokemon namen:

  • Groene stip — Deze Pokemon is recent gezien bij het spawnen.
  • Geen stip — Momenteel niet gerapporteerd in de scannerdata.

Dit helpt je om te voorkomen dat je alarmen maakt voor Pokemon die momenteel niet spawnen in je gebied (bijv. seizoensgebonden of evenement-exclusieve soorten).

Beschikbaarheid vernieuwen

De gegevens worden automatisch op de achtergrond vernieuwd. Je hoeft niets te doen — zoek gewoon naar de stippen wanneer je door de Pokemon selector bladert.

ℹ️
Deze functie is alleen zichtbaar als je beheerder de Golbat scanner-integratie heeft geconfigureerd. Als je geen beschikbaarheidsstippen ziet, is de functie niet ingeschakeld voor je community.
", "CONTENT_BULK": "\"Pokemon

Alle alarmpagina's ondersteunen bulkbewerkingen zodat je veel alarmen tegelijk kunt beheren.

Selectiemodus

Klik op het checklistpictogram in de werkbalk om de selectiemodus te activeren. Klik vervolgens op individuele alarmkaarten om ze te selecteren, of gebruik Alles selecteren om alles dat zichtbaar is te pakken.

Bulkacties

  • Afstand bijwerken — Wijzig de bezorgmodus (gebieden of afstand) voor alle geselecteerde alarmen tegelijk.
  • Verwijderen — Verwijder alle geselecteerde alarmen met één bevestiging.
💡
Onderaan elke alarmlijst vind je ook de knoppen Alle Afstand Bijwerken en Alles Verwijderen die van toepassing zijn op elk alarm van dat type.
", - "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks zijn voorgemaakte alarmtemplates gemaakt door de beheerders van je community. Ze laten je veelgebruikte alarmconfiguraties instellen met één klik in plaats van elk alarm afzonderlijk te maken.

Een Quick Pick toepassen

  1. Ga naar Quick Picks vanuit de zijbalk.
  2. Blader door de beschikbare picks, eventueel gefilterd op categorie.
  3. Klik op Toepassen bij een Quick Pick die je wilt.
  4. Pas aan voor het toepassen: kies je bezorgmodus (gebieden of afstand), schakel opschoningsmodus in en sluit optioneel specifieke Pokemon uit.
  5. Bevestig om alle alarmen in één keer aan te maken.

Quick Pick alarmen verwijderen

Als je de alarmen van een Quick Pick niet meer wilt, klik dan op Verwijderen om alle alarmen te verwijderen die het heeft aangemaakt.

", - "CONTENT_PROFILES": "

De Profielen pagina is je centrale plek voor het beheren van profielen en het bekijken van alle alarmen over elk profiel op één plek.

Waarom profielen gebruiken?

Profielen laten je volledig aparte alarmconfiguraties onderhouden. Elk profiel heeft zijn eigen set alarmen, geselecteerde gebieden, locatie en aangepaste geofence-activeringen. Handig voor verschillende situaties — bijvoorbeeld een \"Thuis\" profiel voor je buurt en een \"Werk\" profiel voor rond je kantoor.

Overzicht

De pagina toont een statistiekbalk met totale alarmtellingen per type, een zoekbalk om over alle profielen te filteren en typefilterchips om alleen specifieke alarmtypen te tonen (Pokemon, Raids, Quests, enz.).

Elk profiel verschijnt als een uitklapbaar paneel. Klik om uit te klappen en alle alarmen gegroepeerd per type te zien, met game-afbeeldingen (Pokemon sprites, raideieren, lokmiddelpictogrammen) en filterpillen die IV, CP, Niveau, PVP en andere instellingen in één oogopslag tonen.

Profielen beheren

  • Aanmaken — Klik op de + knop rechtsboven. Profielnamen moeten uniek zijn (maximaal 32 tekens).
  • Wisselen — Klik op Wisselen in een profielpaneel om het je actieve profiel te maken. Je actieve profiel is gemarkeerd met een groene badge en linkerrand.
  • Bewerken — Klik op het potloodpictogram om een profiel te hernoemen.
  • Verwijderen — Klik op het prullenbakpictogram om een profiel en al zijn alarmen te verwijderen. Je kunt je actieve profiel niet verwijderen.

Dupliceren

Klik op het kopieerpictogram op een profiel om een exacte kopie met al zijn alarmen te maken. Je wordt gevraagd het nieuwe profiel een naam te geven — een standaardnaam zoals \"Profiel (Kopie)\" wordt voorgesteld. Het duplicaat bevat alle alarmfilters maar krijgt een nieuwe set gebiedsselecties.

Exporteren & Importeren

  • Exporteren — Klik op het downloadpictogram op een profiel om een backupbestand (JSON) op te slaan. Het bestand bevat alle alarmfilters, ontdaan van interne ID's zodat het overdraagbaar is.
  • Importeren — Klik op de knop Importeren rechtsboven, selecteer een backupbestand en kies een naam voor het nieuwe profiel. Alle alarmen uit de backup worden hersteld. Als er al een profiel met dezelfde naam bestaat, wordt automatisch een nummersuffix toegevoegd.

Duplicaatdetectie

Als hetzelfde alarm op meerdere profielen bestaat (bijv. Pikachu volgen op zowel \"Thuis\" als \"Werk\"), worden die alarmen gemarkeerd met een oranje rand en een kopieerpictogram. Wanneer er duplicaten zijn, verschijnt een Duplicaten filterchip in de filterbalk — klik erop om alleen gedupliceerde alarmen over profielen te tonen.

⚠️
Waarschuwing: Het verwijderen van een profiel verwijdert permanent alle alarmen in dat profiel. Je kunt je huidige actieve profiel niet verwijderen. Overweeg eerst een backup te exporteren.
", - "CONTENT_CLEANING": "\"Opschoningspagina

De Opschoningspagina laat je de opschoningsmodus voor al je alarmtypen tegelijk beheren.

Wanneer de opschoningsmodus aan staat voor een alarmtype, verwijdert de bot automatisch meldingen uit Discord nadat de gebeurtenis is verlopen:

  • Pokemon — Verwijderd wanneer de spawn verdwijnt
  • Raids — Verwijderd wanneer de raid eindigt
  • Eieren — Verwijderd wanneer het ei uitkomt
  • Quests — Verwijderd wanneer quests om middernacht resetten
  • Invasies — Verwijderd wanneer de grunt vertrekt
  • Lokmiddelen — Verwijderd wanneer het lokmiddel verloopt
  • Nesten — Verwijderd wanneer nesten migreren
  • Gyms — Verwijderd na gymwijzigingen
  • Fortwijzigingen — Verwijderd nadat de fortwijzigingsmelding verloopt
  • Max Battles — Verwijderd wanneer het gevecht eindigt

Gebruik Alles Inschakelen of Alles Uitschakelen om alles tegelijk te wisselen.

💡
Aanbevolen: Houd de opschoningsmodus ingeschakeld om te voorkomen dat verlopen meldingen zich opstapelen in je DM's.
", - "CONTENT_APPEARANCE": "

Donkere / Lichte modus

Klik op het zon/maan pictogram in de bovenste werkbalk om te wisselen tussen donkere en lichte thema's. Je keuze wordt automatisch opgeslagen.

\"Werkbalk

Accentkleuren

Open het gebruikersmenu (je avatar rechtsboven) en selecteer Accentthema. Kies uit:

  • Standaard — Blauw
  • Pokemon — Groen
  • Raids — Rood
  • Mystic — Blauw
  • Valor — Rood
  • Instinct — Geel

De accentkleur wijzigt het werkbalkgradiënt, de actieve navigatiemarkering en andere UI-accenten door de hele site.

\"Dashboard

Taal

Indien beschikbaar, gebruik de taalselector in de werkbalk om de interfacetaal te wijzigen. 18 talen worden ondersteund.

Sneltoetsen

?Toon sneltoetsen
EscSluit menu's of vensters
[Klap zijbalk in
]Klap zijbalk uit
", - "CONTENT_ALERTS_LOGOUT": "\"Gebruikersmenu

Meldingen pauzeren

Open het gebruikersmenu (je avatar) en klik op Meldingen Pauzeren. Er verschijnt een rode banner bovenaan de site die bevestigt dat je meldingen zijn gepauzeerd. Je ontvangt geen meldingen zolang ze gepauzeerd zijn.

Om te hervatten, klik op Meldingen Hervatten vanuit het gebruikersmenu of de banner.

Uitloggen

Open het gebruikersmenu en klik op Uitloggen. Je wordt teruggebracht naar de loginpagina.

", - "CONTENT_FAQ": "

\"Ik kan niet inloggen\"

Je moet je registreren bij de Poracle bot op Discord of Telegram voordat je kunt inloggen op deze site. Als je \"Je account is niet geregistreerd\" ziet, neem contact op met je communitybeheerder voor registratie-instructies.

\"Ik ontvang geen meldingen\"

Controleer deze veelvoorkomende oorzaken:

  1. Meldingen gepauzeerd — Zoek naar een rode banner bovenaan de site. Hervat meldingen vanuit het gebruikersmenu.
  2. Geen locatie ingesteld — Als je alarmen de afstandsmodus gebruiken, heb je een opgeslagen locatie nodig.
  3. Geen gebieden geselecteerd — Als je alarmen de gebiedenmodus gebruiken, zorg dat je gebieden hebt geselecteerd op de pagina Gebieden.
  4. Verkeerd profiel — Je hebt misschien alarmen op een ander profiel. Controleer welk profiel actief is op het Dashboard.
  5. Filters te streng — Probeer je IV, CP of niveaufilters te versoepelen om te zien of meldingen beginnen door te komen.

\"Mijn alarmen zijn verdwenen\"

Alarmen zijn profielspecifiek. Als je van profiel bent gewisseld, zijn je alarmen van het andere profiel er nog steeds — wissel gewoon terug vanuit het Dashboard of de Profielen pagina.

\"Ik kan niet op een klein gebied op de kaart klikken\"

Wanneer gebieden overlappen, zoom in om het kleinere gebied makkelijker klikbaar te maken. Kleinere gebieden staan altijd boven grotere.

\"Wat doet de opschoningsmodus?\"

De opschoningsmodus vertelt de bot om automatisch een melding uit Discord te verwijderen nadat de gebeurtenis is verlopen (bijv. een Pokemon verdwijnt). Zonder dit blijven oude meldingen voor altijd in je DM's staan. Schakel het in op de Opschoningspagina of per alarm in het tabblad Bezorging.

\"Wat is het verschil tussen Gebieden en Afstand?\"

Elk alarm gebruikt één bezorgmodus. Gebieden waarschuwt je over gebeurtenissen binnen specifieke geografische zones. Afstand waarschuwt je over gebeurtenissen binnen een straal van je opgeslagen locatie. Je kunt beide modi combineren over verschillende alarmen.

" + "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks zijn voorgemaakte alarmtemplates gemaakt door de beheerders van je community. Ze laten je veelgebruikte alarmconfiguraties instellen met één klik in plaats van elk alarm afzonderlijk te maken.

Een Quick Pick toepassen

  1. Ga naar Quick Picks vanuit de zijbalk.
  2. Blader door de beschikbare picks, eventueel gefilterd op categorie.
  3. Klik op Toepassen bij een Quick Pick die je wilt.
  4. Pas aan voor het toepassen: bepaal waar de meldingen je moeten bereiken — het tabblad Bezorging is dezelfde kiezer met drie opties als bij een los alarm, dus je kunt ze op een opgeslagen plaats of een deelverzameling gebieden richten — schakel opschoningsmodus in en sluit optioneel specifieke Pokemon uit.
  5. Bevestig om alle alarmen in één keer aan te maken.

Quick Pick alarmen verwijderen

Als je de alarmen van een Quick Pick niet meer wilt, klik dan op Verwijderen om alle alarmen te verwijderen die het heeft aangemaakt.

", + "CONTENT_PROFILES": "

De Profielen pagina is je centrale plek voor het beheren van profielen en het bekijken van alle alarmen over elk profiel op één plek.

Waarom profielen gebruiken?

Profielen laten je volledig aparte alarmconfiguraties onderhouden. Elk profiel heeft zijn eigen set alarmen, geselecteerde gebieden, locatie en aangepaste geofence-activeringen. Handig voor verschillende situaties — bijvoorbeeld een \"Thuis\" profiel voor je buurt en een \"Werk\" profiel voor rond je kantoor.

Overzicht

De pagina toont een statistiekbalk met totale alarmtellingen per type, een zoekbalk om over alle profielen te filteren en typefilterchips om alleen specifieke alarmtypen te tonen (Pokemon, Raids, Quests, enz.).

Elk profiel verschijnt als een uitklapbaar paneel. Klik om uit te klappen en alle alarmen gegroepeerd per type te zien, met game-afbeeldingen (Pokemon sprites, raideieren, lokmiddelpictogrammen) en filterpillen die IV, CP, Niveau, PVP en andere instellingen in één oogopslag tonen.

Profielen beheren

  • Aanmaken — Klik op de + knop rechtsboven. Profielnamen moeten uniek zijn (maximaal 32 tekens).
  • Wisselen — Klik op Wisselen in een profielpaneel om het je actieve profiel te maken. Je actieve profiel is gemarkeerd met een groene badge en linkerrand.
  • Bewerken — Klik op het potloodpictogram om een profiel te hernoemen.
  • Verwijderen — Klik op het prullenbakpictogram om een profiel en al zijn alarmen te verwijderen. Je kunt je actieve profiel niet verwijderen.

Dupliceren

Klik op het kopieerpictogram op een profiel om een exacte kopie met al zijn alarmen te maken. Je wordt gevraagd het nieuwe profiel een naam te geven — een standaardnaam zoals \"Profiel (Kopie)\" wordt voorgesteld. Het duplicaat bevat alle alarmfilters, en ook de gebieden, locatie en actieve uren worden van het bronprofiel gekopieerd.

Exporteren & Importeren

  • Exporteren — Klik op het downloadpictogram op een profiel om een backupbestand (JSON) op te slaan. Het bestand bevat alle alarmfilters, ontdaan van interne ID's zodat het overdraagbaar is.
  • Importeren — Klik op de knop Importeren rechtsboven, selecteer een backupbestand en kies een naam voor het nieuwe profiel. Alle alarmen uit de backup worden hersteld. Als er al een profiel met dezelfde naam bestaat, wordt automatisch een nummersuffix toegevoegd.

Duplicaatdetectie

Als hetzelfde alarm op meerdere profielen bestaat (bijv. Pikachu volgen op zowel \"Thuis\" als \"Werk\"), worden die alarmen gemarkeerd met een oranje rand en een kopieerpictogram. Wanneer er duplicaten zijn, verschijnt een Duplicaten filterchip in de filterbalk — klik erop om alleen gedupliceerde alarmen over profielen te tonen.

⚠️
Waarschuwing: Het verwijderen van een profiel verwijdert permanent alle alarmen in dat profiel. Je kunt je huidige actieve profiel niet verwijderen. Overweeg eerst een backup te exporteren.
", + "CONTENT_CLEANING": "\"Opschoningspagina

De Opschoningspagina laat je de opschoningsmodus voor al je alarmtypen tegelijk beheren.

Wanneer de opschoningsmodus aan staat voor een alarmtype, verwijdert de bot automatisch meldingen uit Discord nadat de gebeurtenis is verlopen:

  • Pokemon — Verwijderd wanneer de spawn verdwijnt
  • Raids — Verwijderd wanneer de raid eindigt
  • Eieren — Verwijderd wanneer het ei uitkomt
  • Quests — Verwijderd wanneer quests om middernacht resetten
  • Invasies — Verwijderd wanneer de grunt vertrekt
  • Lokmiddelen — Verwijderd wanneer het lokmiddel verloopt
  • Nesten — Verwijderd wanneer nesten migreren
  • Gyms — Verwijderd na gymwijzigingen
  • Max Battles — Verwijderd wanneer het gevecht eindigt

Gebruik Alles Inschakelen of Alles Uitschakelen om alles tegelijk te wisselen.

💡
Aanbevolen: Houd de opschoningsmodus ingeschakeld om te voorkomen dat verlopen meldingen zich opstapelen in je DM's.
", + "CONTENT_APPEARANCE": "

Donkere / Lichte modus

Klik op het zon/maan pictogram in de bovenste werkbalk om te wisselen tussen donkere en lichte thema's. Je keuze wordt automatisch opgeslagen.

\"Werkbalk

Accentkleuren

Open het gebruikersmenu (je avatar rechtsboven) en selecteer Accentthema. Kies uit:

  • Standaard — Blauw
  • Pokemon — Groen
  • Raids — Rood
  • Mystic — Blauw
  • Valor — Rood
  • Instinct — Geel

De accentkleur wijzigt het werkbalkgradiënt, de actieve navigatiemarkering en andere UI-accenten door de hele site.

\"Dashboard

Weergavetaal

Open het gebruikersmenu (je avatar, rechtsboven) en kies Weergavetaal. Er zijn 11 talen. Het wijzigt de tekst van deze site en ook de Pokemon-namen, -typen en -vormen in de keuzelijsten en op je alarmkaarten. Heb je er nooit een gekozen, dan krijg je die van je browser, of die van je Poracle-server.

Meldingstaal

Direct eronder staat Meldingstaal, een aparte instelling. Die bepaalt in welke taal Poracle je DM's schrijft. De twee staan los van elkaar: een Nederlandse site met Engelse DM's, of andersom, is volkomen normaal. Vroeger stond hij op de Gebieden-pagina.

Sneltoetsen

?Toon sneltoetsen
EscSluit menu's of vensters
[Klap zijbalk in
]Klap zijbalk uit
", + "CONTENT_ALERTS_LOGOUT": "\"Gebruikersmenu

Meldingen pauzeren

Open het gebruikersmenu (je avatar) en klik op Meldingen Pauzeren. Er verschijnt een rode banner bovenaan de site die bevestigt dat je meldingen zijn gepauzeerd. Je ontvangt geen meldingen zolang ze gepauzeerd zijn.

Om te hervatten, klik op Meldingen Hervatten vanuit het gebruikersmenu of de banner.

Uitloggen

Open het gebruikersmenu en klik op Uitloggen. Je wordt teruggebracht naar de loginpagina.

Als je bent ingelogd via een SSO-provider die single logout ondersteunt, biedt het menu ook Overal uitloggen — daarmee wordt ook je sessie bij de provider beëindigd, niet alleen hier.

", + "CONTENT_FAQ": "

\"Ik kan niet inloggen\"

Je moet je registreren bij de Poracle bot op Discord of Telegram voordat je kunt inloggen op deze site. Als je \"Je account is niet geregistreerd\" ziet, neem contact op met je communitybeheerder voor registratie-instructies.

\"Ik ontvang geen meldingen\"

Controleer deze veelvoorkomende oorzaken:

  1. Meldingen gepauzeerd — Zoek naar een rode banner bovenaan de site. Hervat meldingen vanuit het gebruikersmenu.
  2. Geen locatie ingesteld — Een alarm dat je binnen een straal bereikt, meet vanaf je locatie of vanaf een opgeslagen plaats. Stel er een in op de pagina Gebieden en plaatsen.
  3. Niets binnen bereik — Kijk naar de chip op de alarmkaart. Die zegt waar het alarm je bereikt, en hij kan wijzen naar gebieden die je profiel niet meer dekt.
  4. Verkeerd profiel — Je hebt misschien alarmen op een ander profiel. Controleer welk profiel actief is op het Dashboard.
  5. Filters te streng — Probeer je IV, CP of niveaufilters te versoepelen om te zien of meldingen beginnen door te komen.

\"Mijn alarmen zijn verdwenen\"

Alarmen zijn profielspecifiek. Als je van profiel bent gewisseld, zijn je alarmen van het andere profiel er nog steeds — wissel gewoon terug vanuit het Dashboard of de Profielen pagina.

\"Ik kan niet op een klein gebied op de kaart klikken\"

Wanneer gebieden overlappen, zoom in om het kleinere gebied makkelijker klikbaar te maken. Kleinere gebieden staan altijd boven grotere.

\"Wat doet de opschoningsmodus?\"

De opschoningsmodus vertelt de bot om automatisch een melding uit Discord te verwijderen nadat de gebeurtenis is verlopen (bijv. een Pokemon verdwijnt). Zonder dit blijven oude meldingen voor altijd in je DM's staan. Schakel het in op de Opschoningspagina of per alarm in het tabblad Bezorging.

\"Waar bereikt een melding mij?\"

Elk alarm beantwoordt dat zelf, op zijn tabblad Bezorging. Overal in mijn gebieden volgt de gebieden die je profiel heeft geselecteerd. Bij een punt is een straal vanaf je locatie of vanaf een opgeslagen plaats. Alleen in bepaalde gebieden beperkt dat ene alarm tot een deelverzameling gebieden. De chip op de kaart toont altijd het huidige antwoord, en een klik verandert het.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "DM Meldingen", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Inloggen", "SIGN_IN_DESC": "Log in om je Pokemon GO meldingsalarmen te beheren.", "SIGN_IN_DISCORD": "Inloggen met Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Inloggen met Telegram", + "PROVIDER_DISABLED_BY_ADMIN": "Deze inlogmethode is uitgeschakeld door een beheerder.", + "PROVIDER_DISABLED_HINT": "Deze inlogmethode is uitgeschakeld voor niet-beheerders.", + "ERR_TELEGRAM_DISABLED": "Inloggen met Telegram is momenteel uitgeschakeld.", "OR": "of", "NO_METHODS": "Er zijn momenteel geen inlogmethoden ingeschakeld. Neem contact op met een beheerder.", "AUTHENTICATING": "Authenticeren...", "FOOTER": "Beheer alarmen voor Pokemon, Raids, Quests en meer", "AUTH_FAILED": "Authenticatie Mislukt", "BACK_TO_LOGIN": "Terug naar Inloggen", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "Inloggen met Discord is momenteel uitgeschakeld.", + "ERR_DISCORD_FETCH": "Je Discord-profiel kon niet worden opgehaald. Probeer het opnieuw.", + "ERR_MISSING_CODE": "Het inloggen met Discord is geannuleerd of mislukt.", + "ERR_MISSING_ROLE": "Je hebt niet de vereiste Discord-rol voor deze site.", + "ERR_NOT_IN_GUILD": "Je moet lid zijn van de Discord-server om deze site te gebruiken.", + "ERR_NOT_REGISTERED": "Je account is niet geregistreerd. Meld je aan om te beginnen.", + "ERR_ROLE_CHECK_FAILED": "Je Discord-rollen konden niet worden gecontroleerd. Probeer het later.", + "ERR_TELEGRAM_FAILED": "Inloggen met Telegram is mislukt. Probeer het opnieuw.", + "ERR_TOKEN_EXCHANGE": "Inloggen met Discord is mislukt. Probeer het opnieuw.", "ERR_GENERIC": "Authenticatiefout: {{error}}", "ERR_NO_TOKEN": "Geen authenticatietoken ontvangen.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "Aanmelden", + "SIGN_UP_DESC": "Nog geen account? Meld je aan om te beginnen.", + "SIGN_IN_OIDC": "Inloggen met {{provider}}", + "SIGNED_OUT_TITLE": "Uitgelogd", + "SIGNED_OUT_DESC": "Je bent uitgelogd bij DM Alerts.", + "ERR_OIDC_DISABLED": "Externe login is momenteel uitgeschakeld.", + "ERR_OIDC_NO_IDENTITY": "Je externe loginprovider heeft geen account teruggegeven dat we kunnen koppelen. Zorg ervoor dat je Discord-account is gekoppeld.", + "ERR_OIDC_TOKEN_EXCHANGE": "Externe login mislukt. Probeer het opnieuw.", + "ERR_OIDC_USERINFO": "Kon je profiel niet ophalen van de externe loginprovider. Probeer het opnieuw.", + "SIGN_IN_AGAIN": "Opnieuw aanmelden" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Deze functie is uitgeschakeld door de beheerder." }, "ADMIN": { "USERS_TITLE": "Gebruikersbeheer", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Gepromoveerde naam", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Naam voor de gepromoveerde geofence", "APPROVAL_PROMOTED_NAME_HINT": "Optioneel. Standaard de huidige weergavenaam.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Reden voor afwijzing", "APPROVAL_REJECT_PLACEHOLDER": "Leg uit waarom deze geofence wordt afgewezen...", "USERS_DESC_FULL": "Beheer geregistreerde Discord gebruikers. Gestopt = gebruiker heeft meldingen gepauzeerd of snelheidslimiet bereikt. Geblokkeerd = geblokkeerd door beheerder.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Inzending goedkeuren mislukt", "SNACK_APPROVED": "\"{{name}}\" goedgekeurd", "SNACK_FAILED_REJECT": "Inzending afwijzen mislukt", - "SNACK_REJECTED": "\"{{name}}\" afgewezen" + "SNACK_REJECTED": "\"{{name}}\" afgewezen", + "APPROVAL_REGION_HINT": "Kies de regio waaronder deze geofence verschijnt.", + "SERVER_TITLE": "Poracle-server", + "SERVER_REFRESH": "Opnieuw controleren", + "SERVER_VERSION": "Versie", + "SERVER_SCHEMA": "Databaseschema", + "SERVER_CHECKED": "Laatst gecontroleerd", + "SERVER_CAPABILITIES": "Functies", + "SERVER_NO_CAPABILITIES": "Deze server meldt er geen.", + "SERVER_UNKNOWN": "Onbekend", + "SERVER_UNREACHABLE": "Poracle antwoordde niet. Alarmen, profielen en locaties lopen erlangs en mislukken tot het weer reageert.", + "SERVER_TOO_OLD": "Poracle {{version}} is ouder dan {{minimum}}, dat deze versie van de site nodig heeft. Bereik per alarm, het PVP-megafilter en het filter voor resterende tijd lijken op te slaan maar veranderen niets.", + "UPDATE_AVAILABLE": "{{name}} {{running}} draait, en {{latest}} is uit.", + "UPDATE_PRERELEASE": "{{name}} {{running}} is nieuwer dan elke uitgave — dit is een ontwikkelversie.", + "VERSIONS_TITLE": "Versies", + "VERSIONS_WEB": "Deze site", + "VERSIONS_BUILD": "Build", + "UPDATE_CURRENT": "Actueel.", + "UPDATE_UNCOMPARABLE": "Ontwikkelkanaal. Nieuwste uitgave is {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Kies een punt", "CANCEL": "Annuleren", "CONFIRM": "Bevestigen", "DONT_ASK_AGAIN": "Niet meer vragen deze sessie", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Alle Afstanden Bijwerken", "DISTANCE_DESC": "Stel de locatiemodus in voor alle alarmen van dit type.", "DISTANCE_UPDATE_ALL": "Alles Bijwerken", + "DISTANCE_MUST_BE_POSITIVE": "De afstand moet groter zijn dan nul.", "LOCATION_SAVE_ERROR": "Locatie bijwerken mislukt", "LOCATION_SAVE_SUCCESS": "Locatie succesvol bijgewerkt", "LOCATION_GEO_UNSUPPORTED": "Geolocatie wordt niet ondersteund door je browser", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Te veel testmeldingen. Wacht even.", "ERROR_NOT_FOUND": "Alarm niet gevonden — het is mogelijk verwijderd.", "ERROR_GENERIC": "Testmelding verzenden mislukt. Probeer het later opnieuw.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Te veel testmeldingen. Wacht even.", + "NOT_FOUND": "Melding niet gevonden — mogelijk is deze verwijderd.", + "UNSUPPORTED": "Testmeldingen zijn niet beschikbaar voor dit type.", + "FAILED": "Testmelding kon niet worden verzonden. Probeer het later." }, "COMMON": { "CANCEL": "Annuleren", @@ -1296,6 +1448,7 @@ "EDIT": "Bewerken", "ADD": "Toevoegen", "OK": "OK", + "UNDO": "Ongedaan maken", "CONFIRM": "Bevestigen", "DELETE_ALL": "Alles Verwijderen", "CLOSE": "Sluiten", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Zoek een gym (optioneel)", "SEARCH_HINT": "Typ gym naam...", - "CLEAR_ARIA": "Gymselectie wissen" + "CLEAR_ARIA": "Gymselectie wissen", + "RATE_LIMITED": "Te veel scannerverzoeken — doe het wat rustiger aan." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Meldingen worden verstuurd voor deze gebieden:", @@ -1392,14 +1546,12 @@ "GROUP_ALARM_TYPES": "Alarmsoorten", "GROUP_FEATURES": "Functies", "GROUP_ADMINISTRATION": "Administratie", - "GROUP_COMMANDS": "Commando's", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Kaarten & assets", "GROUP_ANALYTICS_LINKS": "Analytics & links", - "GROUP_DEBUG": "Debug", "GROUP_ICON_REPO": "Iconenrepository", "GROUP_OTHER": "Overig", + "GROUP_OIDC": "Externe SSO", "CUSTOM_TITLE_LABEL": "Sitetitel", "CUSTOM_TITLE_DESC": "Naam die in het browsertabblad en de pagina-header wordt weergegeven.", "HEADER_LOGO_URL_LABEL": "Header-logo-URL", @@ -1411,52 +1563,51 @@ "FAVICON_URL_PREVIEW": "Favicon-voorbeeld (32×32)", "FAVICON_URL_CACHE_WARNING": "Browsers cachen favicons agressief. Na het opslaan moeten gebruikers hun browsercache wissen of een hard refresh uitvoeren (Ctrl+F5 / Cmd+Shift+R) om het nieuwe pictogram te zien.", "FAVICON_URL_CSP_NOTE": "Als uw site een Content Security Policy gebruikt, moet de oorsprong van de favicon-URL toegestaan zijn door uw img-src-richtlijn; anders blokkeert de browser het ophalen en valt terug op het standaardpictogram.", + "FORCED_BY_PORACLE": "Uitgeschakeld in de configuratie van Poracle zelf. Poracle negeert deze webhooks en de bot weigert het commando, dus dit kan hier niet worden ingeschakeld.", + "FORCED_BY_PORACLE_TOOLTIP": "Wordt bepaald door de configuratie van Poracle, niet door deze pagina.", "CUSTOM_PAGE_NAME_LABEL": "Label van navigatielink", "CUSTOM_PAGE_NAME_DESC": "Label voor de aangepaste navigatielink (bijv. \"Terug naar kaart\").", "CUSTOM_PAGE_URL_LABEL": "URL van navigatielink", "CUSTOM_PAGE_URL_DESC": "URL waar de aangepaste navigatielink naartoe wijst.", "CUSTOM_PAGE_ICON_LABEL": "Pictogram van navigatielink", "CUSTOM_PAGE_ICON_DESC": "FontAwesome-klasse voor het pictogram van de navigatielink (bijv. \"fas fa-map\").", - "DISABLE_MONS_LABEL": "Pokémon uitschakelen", - "DISABLE_MONS_DESC": "Verberg het beheer van Pokémon-alarmen voor alle gebruikers.", - "DISABLE_RAIDS_LABEL": "Raids uitschakelen", - "DISABLE_RAIDS_DESC": "Verberg het beheer van Raid-alarmen voor alle gebruikers.", - "DISABLE_QUESTS_LABEL": "Taken uitschakelen", - "DISABLE_QUESTS_DESC": "Verberg het beheer van takenalarmen voor alle gebruikers.", - "DISABLE_INVASIONS_LABEL": "Invasies uitschakelen", - "DISABLE_INVASIONS_DESC": "Verberg het beheer van invasiealarmen voor alle gebruikers.", - "DISABLE_LURES_LABEL": "Lokmodules uitschakelen", - "DISABLE_LURES_DESC": "Verberg het beheer van lokmodule-alarmen voor alle gebruikers.", - "DISABLE_NESTS_LABEL": "Nesten uitschakelen", - "DISABLE_NESTS_DESC": "Verberg het beheer van nestalarmen voor alle gebruikers.", - "DISABLE_GYMS_LABEL": "Gyms uitschakelen", - "DISABLE_GYMS_DESC": "Verberg het beheer van Gym-alarmen voor alle gebruikers.", - "DISABLE_FORT_CHANGES_LABEL": "Fort-wijzigingen uitschakelen", - "DISABLE_FORT_CHANGES_DESC": "Verberg het beheer van fort-wijzigingsalarmen voor alle gebruikers.", - "DISABLE_MAXBATTLES_LABEL": "Max Battles uitschakelen", - "DISABLE_MAXBATTLES_DESC": "Verberg het beheer van Max Battle-alarmen voor alle gebruikers.", - "DISABLE_AREAS_LABEL": "Gebieden uitschakelen", - "DISABLE_AREAS_DESC": "Voorkom dat gebruikers hun gebiedsabonnementen beheren.", - "DISABLE_PROFILES_LABEL": "Profielen uitschakelen", - "DISABLE_PROFILES_DESC": "Voorkom dat gebruikers alarmprofielen maken en wisselen.", - "DISABLE_LOCATION_LABEL": "Locatie uitschakelen", - "DISABLE_LOCATION_DESC": "Voorkom dat gebruikers een thuislocatie instellen.", - "DISABLE_NOMINATIM_LABEL": "Geocoding uitschakelen", - "DISABLE_NOMINATIM_DESC": "Schakel Nominatim-adreszoekopdracht voor locatieselectie uit.", - "DISABLE_GEOMAP_LABEL": "Kaartweergave uitschakelen", - "DISABLE_GEOMAP_DESC": "Verberg de interactieve geofence-kaart volledig.", - "DISABLE_GEOMAP_SELECT_LABEL": "Gebiedsselectie op kaart uitschakelen", - "DISABLE_GEOMAP_SELECT_DESC": "Voorkom dat gebruikers gebieden selecteren door op de kaart te klikken.", - "ENABLE_TEMPLATES_LABEL": "Templates inschakelen", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Laat gebruikers Pokémon-alarmen beheren.", + "DISABLE_RAIDS_LABEL": "Raids", + "DISABLE_RAIDS_DESC": "Laat gebruikers raid-alarmen beheren.", + "DISABLE_QUESTS_LABEL": "Quests", + "DISABLE_QUESTS_DESC": "Laat gebruikers quest-alarmen beheren.", + "DISABLE_INVASIONS_LABEL": "Invasies", + "DISABLE_INVASIONS_DESC": "Laat gebruikers invasiealarmen beheren.", + "DISABLE_LURES_LABEL": "Lokmodules", + "DISABLE_LURES_DESC": "Laat gebruikers lokmodule-alarmen beheren.", + "DISABLE_NESTS_LABEL": "Nesten", + "DISABLE_NESTS_DESC": "Laat gebruikers nestalarmen beheren.", + "DISABLE_GYMS_LABEL": "Gyms", + "DISABLE_GYMS_DESC": "Laat gebruikers Gym-alarmen beheren.", + "DISABLE_FORT_CHANGES_LABEL": "Fort-wijzigingen", + "DISABLE_FORT_CHANGES_DESC": "Laat gebruikers fort-wijzigingsalarmen beheren.", + "DISABLE_MAXBATTLES_LABEL": "Max Battles", + "DISABLE_MAXBATTLES_DESC": "Laat gebruikers Max Battle-alarmen beheren.", + "DISABLE_AREAS_LABEL": "Gebieden", + "DISABLE_AREAS_DESC": "Laat gebruikers hun gebiedsabonnementen beheren.", + "DISABLE_PROFILES_LABEL": "Profielen", + "DISABLE_PROFILES_DESC": "Laat gebruikers alarmprofielen maken en wisselen.", + "DISABLE_LOCATION_LABEL": "Locatie", + "DISABLE_LOCATION_DESC": "Laat gebruikers een thuislocatie instellen.", + "DISABLE_NOMINATIM_LABEL": "Geocoding", + "DISABLE_NOMINATIM_DESC": "Sta Nominatim-adreszoekopdrachten toe voor locatieselectie.", + "DISABLE_USER_GEOFENCES_LABEL": "Eigen geofences", + "DISABLE_USER_GEOFENCES_DESC": "Laat gebruikers eigen geofences tekenen, importeren en indienen. Bestaande geofences blijven werken.", + "ENABLE_TEMPLATES_LABEL": "Templates", "ENABLE_TEMPLATES_DESC": "Laat gebruikers meldingsberichttemplates kiezen.", "ALLOWED_LANGUAGES_LABEL": "Toegestane UI-talen", "ALLOWED_LANGUAGES_DESC": "Door komma's gescheiden taalcodes die in de taalkiezer worden getoond (bijv. \"en,de,fr,es\"). Laat leeg om alle 11 talen te tonen.", + "PORACLE_LOCALE_HINT": "Standaardtaal voor nieuwe gebruikers: {{locale}}, uit de configuratie van Poracle zelf. Wie een taal kiest, of van wie de browser er een vraagt die deze site heeft, krijgt die in plaats daarvan.", "ENABLE_ROLES_LABEL": "Op rollen gebaseerde toegang inschakelen", "ENABLE_ROLES_DESC": "Laat alleen gebruikers met specifieke Discord-rollen inloggen. Vereist Bot Token en Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "Toegestane rol-ID's", - "ALLOWED_ROLE_IDS_DESC": "Door komma's gescheiden Discord-rol-ID's die toegang verlenen (bijv. \"123456789,987654321\"). Laat leeg om alle toe te staan.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Toegestane talen", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Door komma's gescheiden lijst met taalcodes die gebruikers kunnen selecteren (bijv. \"en,de,fr\").", + "ALLOWED_ROLE_IDS_DESC": "Door komma's gescheiden Discord-rol-ID's, bijv. 123456789,987654321. Een gebruiker heeft minstens één van deze rollen nodig om in te loggen. Laat leeg om alle toe te staan.", "REGISTER_COMMAND_LABEL": "Registratiecommando", "REGISTER_COMMAND_DESC": "Poracle-bot-commando dat gebruikers uitvoeren om zich te registreren (bijv. \"$!register\").", "LOCATION_COMMAND_LABEL": "Locatiecommando", @@ -1464,7 +1615,7 @@ "ENABLE_TELEGRAM_LABEL": "Telegram-login inschakelen", "ENABLE_TELEGRAM_DESC": "Sta Telegram-login toe op deze site. Vereist TELEGRAM_ENABLED=true, bot token en bot username in .env (serverherstart vereist bij .env-wijzigingen).", "TELEGRAM_BOT_LABEL": "Bot-gebruikersnaam", - "TELEGRAM_BOT_DESC": "Gebruikersnaam van de Telegram-bot (zonder @).", + "TELEGRAM_BOT_DESC": "Gebruikersnaam van de Telegram-bot (zonder @). Wordt gebruikt als TELEGRAM_BOT_USERNAME niet is ingesteld.", "ENABLE_DISCORD_LABEL": "Discord-login inschakelen", "ENABLE_DISCORD_DESC": "Sta Discord-login toe op deze site. Vereist Discord Client ID en Client Secret in .env (serverherstart vereist bij .env-wijzigingen). Beïnvloedt de PoracleNG-botlevering niet.", "PROVIDER_URL_LABEL": "Map-tile-URL", @@ -1498,7 +1649,43 @@ "DISCORD_ADMIN_IDS_LABEL": "Admin-ID's", "DISCORD_ADMIN_IDS_DESC": "Discord-gebruikers-ID's met admin-toegang (gemaskeerd).", "DISCORD_GEOFENCE_FORUM_LABEL": "Geofence-forumkanaal", - "DISCORD_GEOFENCE_FORUM_DESC": "Discord-forumkanaal voor geofence-inzendingsthreads." + "DISCORD_GEOFENCE_FORUM_DESC": "Discord-forumkanaal voor geofence-inzendingsthreads.", + "ENABLE_OIDC_LABEL": "Externe SSO-login inschakelen", + "ENABLE_OIDC_DESC": "Sta login toe via de geconfigureerde externe OIDC/OAuth2-provider. Vereist OIDC_*-instellingen (provider-URL's, client-ID en secret) in .env (serverherstart vereist bij .env-wijzigingen).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Alle gebruikers worden doorgestuurd naar de externe SSO-provider. Lokale aanmelding wordt overgeslagen.", + "AUTH_MODE_SWITCH_CONFIRM": "Overschakelen naar SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Overschakelen naar SSO-aanmelding?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Na het opslaan worden alle gebruikers (inclusief beheerders) doorgestuurd naar {{provider}} om in te loggen — de lokale Discord/Telegram-loginpagina wordt overgeslagen. Als de provider onbereikbaar is, kun je buitengesloten raken; herstel dit door AUTH_FORCE_LOCAL=true in te stellen in de serveromgeving.", + "AUTH_OIDC_NOT_CONFIGURED": "SSO is niet beschikbaar totdat de OIDC-provider is geconfigureerd in de serveromgeving (OIDC_*-omgevingsvariabelen).", + "AUTH_OIDC_HIDES_LOCAL": "Discord en Telegram worden verborgen zolang SSO de actieve aanmeldmodus is.", + "AUTH_SLO_LABEL": "Single logout", + "AUTH_SLO_DESC": "Indien ingeschakeld beëindigt \"Overal uitloggen\" ook de providersessie (niet alleen deze site). Vereist het end-session-eindpunt van de provider (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Single logout is niet beschikbaar totdat het end-session-eindpunt van de provider is geconfigureerd (OIDC_END_SESSION_URL-omgevingsvariabele).", + "OIDC_SERVER_CONFIG": "OIDC-providerconfiguratie", + "OIDC_PROVIDER_LABEL": "Providernaam", + "OIDC_AUTHORIZATION_URL_LABEL": "Authorization URL", + "OIDC_TOKEN_URL_LABEL": "Token URL", + "OIDC_USERINFO_URL_LABEL": "UserInfo URL", + "OIDC_CLIENT_ID_LABEL": "Client-ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Identity claim", + "OIDC_USE_PKCE_LABEL": "PKCE gebruiken", + "SEARCH_PLACEHOLDER": "Instellingen zoeken…", + "SEARCH_CLEAR": "Zoekopdracht wissen", + "UNSAVED_CHANGES": "{{count}} niet opgeslagen", + "SAVE_CHANGES": "Wijzigingen opslaan", + "DISCARD_CHANGES": "Verwerpen", + "COLLAPSE_SECTION": "Sectie inklappen", + "EXPAND_SECTION": "Sectie uitklappen", + "SUMMARY_ENABLED": "{{count}} van {{total}} ingeschakeld", + "GROUP_AUTH": "Authenticatie", + "AUTH_MODE_LABEL": "Aanmeldmodus", + "AUTH_MODE_LOCAL": "Lokaal", + "AUTH_MODE_LOCAL_DESC": "Meld je rechtstreeks aan met Discord of Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "Lokaal aanmelden wordt afgedwongen door de serverconfiguratie.", + "DISABLE_UPDATE_CHECK_LABEL": "Niet op updates controleren", + "DISABLE_UPDATE_CHECK_DESC": "Voorkomt dat de site aan GitHub vraagt of er een nieuwere PoracleWeb of Poracle is uitgebracht. Dit is het enige verzoek buiten je eigen netwerk en er wordt niets meegestuurd." }, "GEOFENCE_DETAIL": { "NAME": "Naam", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Jouw locatie", "SELECTED_COUNT": "{{count}} geselecteerd:", "AREAS_SELECTED": "{{count}} gebied(en) geselecteerd" + }, + "ALERT_DEFAULTS": { + "TITLE": "Standaardinstellingen meldingen", + "DESC": "Kies hoe nieuwe meldingen standaard worden bezorgd. Je kunt dit nog steeds per melding aanpassen bij het aanmaken.", + "DEFAULT_DISTANCE": "Standaardafstand", + "DEFAULT_DISTANCE_HINT": "Wordt gebruikt om de straal voor nieuwe afstandsmeldingen vooraf in te vullen.", + "FOOTNOTE": "Geldt alleen voor nieuw aangemaakte meldingen — bestaande meldingen blijven ongewijzigd.", + "DISTANCE_TOO_SMALL": "Moet minimaal 0,1 km zijn.", + "DISTANCE_TOO_LARGE": "Mag maximaal 100 km zijn." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Items per pagina:", + "RANGE": "{{start}} - {{end}} van {{total}}", + "RANGE_EMPTY": "0 van {{total}}", + "NEXT_PAGE": "Volgende pagina", + "PREVIOUS_PAGE": "Vorige pagina", + "FIRST_PAGE": "Eerste pagina", + "LAST_PAGE": "Laatste pagina" + }, + "WHERE": { + "SET_PIN": "Locatie instellen", + "PIN_MISSING_WARNING": "Je hebt nog geen locatie ingesteld, dus deze melding heeft niets om vanaf te meten.", + "PLACES_EMPTY_TITLE": "Nog geen plaatsen", + "PIN_UNSET": "Niet ingesteld", + "PLACES_PAGE_DESC": "Benoemde punten waar je meldingen op gericht kunnen worden, in plaats van je locatie.", + "ADD_PLACE": "Plaats toevoegen", + "AREAS_LABEL": "Gebieden", + "AREA_LIST_MORE": "{{areas}} en nog {{count}}", + "MEASURED_FROM": "Gemeten vanaf", + "MY_PIN": "Mijn locatie", + "NAME_PLACE_MESSAGE": "Hoe moet deze plaats heten?", + "NAME_PLACE_TITLE": "Deze plaats een naam geven", + "NEAR_PIN": "Binnen {{distance}} km van mijn locatie", + "NEAR_PLACE": "Binnen {{distance}} km van {{place}}", + "NO_PLACES": "Nog geen plaatsen. Voeg er hieronder een toe om deze melding ergens anders op te richten dan je locatie.", + "ONLY_IN": "Alleen in {{areas}}", + "OPTION_AREAS": "Alleen in bepaalde gebieden", + "OPTION_NEAR": "Bij een punt", + "OPTION_PLACE": "Bij een plaats", + "OPTION_PROFILE": "Overal in mijn gebieden", + "PIN_NOTE": "De terugval voor elke melding zonder eigen bestemming.", + "PIN_TITLE": "Mijn locatie", + "PLACES_EMPTY": "Voeg er een toe om meldingen ergens anders dan bij je locatie te krijgen: werk, de sportschool, bij je ouders.", + "PLACES_TITLE": "Plaatsen", + "PLACE_DELETED": "{{place}} verwijderd.", + "PLACE_DELETE_CONFIRM": "Meldingen gericht op {{place}} vallen terug op je locatie.", + "PLACE_DELETE_ERROR": "Kon die plaats niet verwijderen.", + "PLACE_DELETE_TITLE": "Deze plaats verwijderen?", + "PLACE_IN_USE": "{{place}} wordt gebruikt door {{count}} melding(en). Verwijs die eerst om.", + "PLACE_LABEL": "Plaats", + "PLACE_NAME": "Naam", + "PLACE_SAVED": "{{place}} opgeslagen.", + "PLACE_SAVE_ERROR": "Kon die plaats niet opslaan.", + "PROFILE_ANYWHERE": "Overal waar ik meldingen krijg", + "PROFILE_AREAS": "Overal in mijn gebieden", + "RADIUS_KM": "Straal (km)", + "SAVE": "Bestemming instellen", + "SCOPE_SAVED": "Bestemming bijgewerkt.", + "SCOPE_SAVE_ERROR": "Kon niet bijwerken waar die melding je bereikt.", + "SHEET_TITLE": "Waar moet deze melding je bereiken?", + "USE_THIS_POINT": "Dit punt gebruiken" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json index fc6c624b..2e6ccb95 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -16,7 +16,7 @@ "GYMS": "Areny", "FORT_CHANGES": "Zmiany fortów", "PROFILES": "Profile", - "AREAS": "Obszary", + "AREAS": "Obszary i miejsca", "MY_GEOFENCES": "Moje geofence", "CLEANING": "Czyszczenie", "HELP": "Pomoc", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Przeglądasz jako", - "BACK_TO_ADMIN": "Powrót do Admina", + "EXIT_IMPERSONATION": "Powrót do swojego konta", "DISABLED_ACCOUNT": "Twoje konto zostało wyłączone. Może to być spowodowane limitem zapytań lub działaniem administratora.", + "DISABLED_ACCOUNT_INSPECTED": "To konto zostało wyłączone przez administratora i nie otrzymuje powiadomień.", "DISABLED_SUPPORT": "Aby uzyskać pomoc, zapytaj na", "PAUSED_ALERTS": "Twoje alerty są wstrzymane. Nie będziesz otrzymywać powiadomień.", "RESUME": "Wznów" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Zmienia tylko tekst tej strony.", "PROFILE_PREFIX": "Profil #", "PAUSE_ALERTS": "Wstrzymaj alerty", "RESUME_ALERTS": "Wznów alerty", "SWITCH_PROFILE": "Zmień profil", - "AREAS_LOCATION": "Obszary i lokalizacja", "CLEANING": "Czyszczenie", "ACCENT_THEME": "Motyw akcentu", - "LANGUAGE": "Język", + "DISPLAY_LANGUAGE": "Język interfejsu", + "ALERT_LANGUAGE": "Język alertów", + "ALERT_LANGUAGE_HINT": "Używany w treści alertów i nazwach Pokemonów.", "LOGOUT": "Wyloguj", + "LOGOUT_EVERYWHERE": "Wyloguj się wszędzie", "ACCENT_DEFAULT": "Domyślny", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Rajdy", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Domyślne ustawienia alertów" }, "SHORTCUTS": { "TITLE": "Skróty klawiszowe", @@ -77,6 +82,7 @@ "NETWORK": "Nie można połączyć się z serwerem. Sprawdź swoje połączenie.", "BAD_REQUEST": "Nieprawidłowe żądanie. Sprawdź wprowadzone dane.", "UNAUTHORIZED": "Twoja sesja wygasła. Zaloguj się ponownie.", + "INSPECTION_ENDED": "Zakończono podgląd — wróciłeś do własnej sesji.", "FORBIDDEN": "Nie masz uprawnień do wykonania tej akcji.", "NOT_FOUND": "Żądany zasób nie został znaleziony.", "CONFLICT": "Wystąpił konflikt. Element mógł zostać zmodyfikowany.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Powitanie i konfiguracja" }, "POKEMON": { + "PVP_EVOLUTION": "Megaewolucja", + "PVP_EVOLUTION_HINT": "Oceniaj formy podstawowe albo megę. Megi mają osobny ranking, więc reguła megi nie dopasuje formy podstawowej.", + "PVP_EVO_BASE": "Podstawowa", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Alarmy Pokemon", "PAGE_DESC": "Śledź dzikie spawny Pokemon z niestandardowymi filtrami IV, CP, poziomu i PVP.", "SEARCH_PLACEHOLDER": "Szukaj po nazwie lub #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Forma i płeć", "LABEL_FORM": "Forma", "ALL_FORMS": "Wszystkie formy", + "FORM_MULTI_HINT": "Pozostaw puste, aby uwzględnić wszystkie formy", "LABEL_GENDER": "Płeć", "GENDER_ALL": "Wszystkie", "GENDER_MALE": "Samiec", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Powiadom tylko, jeśli CP po ewolucji spełnia to minimum", "PVP_DISABLED_HINT": "Wybierz ligę, aby filtrować według rangi PVP.", "SNACK_CREATED": "Utworzono {{count}} alarm(ów) Pokemon", + "SNACK_CREATED_WITH_DUPLICATES": "Utworzono alarmy Pokemon: {{count}}, juz sledzone: {{duplicates}}", "SNACK_UPDATED": "Alarm Pokemon zaktualizowany", "SNACK_DELETED": "Alarm Pokemon usunięty", "SNACK_DELETED_ALL": "Wszystkie alarmy Pokemon usunięte", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normalny", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Limit poziomu", + "PVP_CAP_ALL": "Wszystkie", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Domyślnie — z konfiguracji Poracle", + "FILTER_TIME_LEFT": "Pozostały Czas", + "LABEL_MIN_TIME": "Minimalny pozostały czas", + "MIN_TIME_HINT": "Pomija spawny, które znikną, zanim zdążysz dotrzeć.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "pozostało {{count}} min", + "PILL_TIME_LEFT_SECONDS": "pozostało {{count}} s", + "MIN_TIME_ANY": "Dowolny" }, "ALARM": { "LOCATION_MODE": "Tryb lokalizacji", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Automatycznie usuwa powiadomienie z Discord po wygaśnięciu przynęty", "CLEAN_HINT_NEST": "Automatycznie usuwa powiadomienie z Discord przy migracji gniazd", "CLEAN_HINT_GYM": "Automatycznie usuwa powiadomienie z Discord po zmianie aktywności areny", - "CLEAN_HINT_FORT": "Automatycznie usuwa powiadomienie z Discord po wygaśnięciu", "CLEAN_HINT_MAX_BATTLE": "Automatycznie usuwa powiadomienie z Discord po zakończeniu bitwy max", "SAVING": "Zapisywanie...", "SAVE": "Zapisz", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Przerwa aktywna", "TEST_SEND": "Wyślij testowe powiadomienie", "TAB_DELIVERY": "Dostarczanie", - "COMMON_SETTINGS": "Wspólne ustawienia" + "COMMON_SETTINGS": "Wspólne ustawienia", + "SNACK_CREATED_WITH_DUPLICATES": "Utworzono: {{count}}, juz sledzone: {{duplicates}}" }, "RAIDS": { + "RSVP_LABEL": "Powiadomienia RSVP", + "RSVP_OFF": "Tylko dopasowania", + "RSVP_INCLUDE": "Dopasowania + aktualizacje RSVP", + "RSVP_ONLY": "Tylko aktualizacje RSVP", + "RSVP_OFF_DESC": "Tylko standardowe alerty rajdów/jaj.", + "RSVP_INCLUDE_DESC": "Powiadamiaj także, gdy zmienią się liczby RSVP.", + "RSVP_ONLY_DESC": "Pomiń początkowe dopasowania; powiadamiaj tylko o zmianach RSVP. Bez skanera emitującego RSVP alarm zostanie wyciszony.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Tylko RSVP", "PAGE_TITLE": "Alarmy rajdów i jajek", "PAGE_DESC": "Otrzymuj powiadomienia o bossach rajdowych i wylęgach jajek w pobliskich arenach.", "TAB_RAIDS": "Rajdy ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Czy na pewno chcesz usunąć WSZYSTKIE alarmy rajdów i jajek? Tej akcji nie można cofnąć.", "CONFIRM_BULK_DELETE_TITLE": "Usuń zaznaczone alarmy", "CONFIRM_BULK_DELETE_MSG": "Czy na pewno chcesz usunąć {{count}} alarmów?", - "CONFIRM_DELETE_SELECTED": "Usuń zaznaczone" + "CONFIRM_DELETE_SELECTED": "Usuń zaznaczone", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Poziom", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standardowe", + "SECTION_SPECIAL": "Specjalne", + "SECTION_CUSTOM": "Własne", + "ADD": "Dodaj poziom", + "ADD_PLACEHOLDER": "np. 42", + "ADD_HELP": "Dowolna dodatnia liczba całkowita używana przez twój serwer. 9000 oznacza „dowolny poziom”.", + "INVALID": "Poziom musi wynosić co najmniej 1.", + "DUPLICATE": "Poziom {{value}} jest już na liście.", + "SR_REMOVE": "Usuń własny poziom {{value}}", + "REMOVED": "Usunięto poziom {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmy zadań", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Wszystkie alarmy zadań usunięte", "SNACK_FAILED_DELETE_ALL": "Nie udało się usunąć alarmów", "SNACK_FAILED_DISTANCE": "Nie udało się zaktualizować odległości", - "CONFIRM_DELETE_SELECTED": "Usuń zaznaczone" + "CONFIRM_DELETE_SELECTED": "Usuń zaznaczone", + "SUMMARY_MODE": "Codzienne podsumowanie", + "SUMMARY_HINT": "Łączy pasujące zadania w jedną wiadomość podsumowującą zamiast osobnego powiadomienia dla każdego. Wymaga skonfigurowanego harmonogramu podsumowań w bocie.", + "SUMMARY_BADGE": "Podsumowanie", + "SUMMARY_SCHEDULE": "Dostarczanie podsumowania zadań", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Podsumowanie zadań", + "SUMMARY_SCHEDULE_EMPTY": "Nie ustawiono harmonogramu podsumowania. Zadania są dostarczane pojedynczo.", + "SUMMARY_SCHEDULE_EDIT": "Edytuj harmonogram", + "SUMMARY_SCHEDULE_CLEAR": "Usuń harmonogram", + "SUMMARY_SCHEDULE_SEND_NOW": "Wyślij podsumowanie teraz", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Wysyła dopasowania questów zebrane od ostatniego podsumowania. Jeśli nic nie jest jeszcze w buforze, nic nie zostanie wysłane.", + "SUMMARY_SCHEDULE_SAVED": "Harmonogram podsumowania zapisany", + "SUMMARY_SCHEDULE_CLEARED": "Harmonogram podsumowania usunięty", + "SUMMARY_SCHEDULE_SENT": "Podsumowanie wysłane", + "SUMMARY_SCHEDULE_FAILED": "Nie udało się zaktualizować harmonogramu podsumowania", + "SUMMARY_SCHEDULE_UNAVAILABLE": "Dostarczanie podsumowań jest chwilowo niedostępne. Spróbuj ponownie później.", + "SUMMARY_DISABLED_HINT": "Planowanie podsumowań nie jest dostępne na tym serwerze.", + "TAB_STARDUST": "Gwiezdny Pył", + "MIN_AMOUNT": "Minimalna liczba", + "MIN_AMOUNT_HINT": "0 = dowolna liczba", + "MIN_STARDUST": "Minimalny gwiezdny pył", + "MIN_STARDUST_HINT": "0 = każde zadanie z gwiezdnym pyłem", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Alarmy inwazji", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnetyczny", "TYPE_RAINY": "Deszczowy", "TYPE_GOLDEN": "Złoty", - "TYPE_UNKNOWN": "Wabik #{{id}}" + "TYPE_UNKNOWN": "Wabik #{{id}}", + "EDIT_MODE": "Edytuj wiadomość w miejscu", + "EDIT_HINT": "Aktualizuje istniejącą wiadomość na Discordzie przy zmianie wabika zamiast wysyłać nową.", + "EDIT_BADGE": "Edycja", + "CONFIRM_DELETE_TITLE": "Usunąć alarm wabika?", + "SNACK_FAILED_DISTANCE": "Nie udało się zaktualizować odległości." }, "NESTS": { "PAGE_TITLE": "Alarmy gniazd", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Alarm gniazda usunięty", "SNACK_FAILED_CREATE": "Nie udało się utworzyć alarmu", "SNACK_FAILED_UPDATE": "Nie udało się zaktualizować alarmu", - "SNACK_FAILED_DELETE": "Nie udało się usunąć alarmu" + "SNACK_FAILED_DELETE": "Nie udało się usunąć alarmu", + "CONFIRM_DELETE_TITLE": "Usunąć alarm gniazda?", + "SNACK_FAILED_DISTANCE": "Nie udało się zaktualizować odległości." }, "GYMS": { "PAGE_TITLE": "Alarmy aren", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Mystic", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinct", - "TEAM_UNKNOWN": "Drużyna {{id}}" + "TEAM_UNKNOWN": "Drużyna {{id}}", + "CONFIRM_DELETE_TITLE": "Usunąć alarm areny?", + "SNACK_FAILED_DISTANCE": "Nie udało się zaktualizować odległości." }, "FORT_CHANGES": { "PAGE_TITLE": "Alarmy zmian fortów", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Usunięty", "CHANGE_NEW": "Nowy fort", "INCLUDE_EMPTY": "Uwzględnij forty bez nazwy", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Nie udało się utworzyć alertu", + "CREATE_SUCCESS": "Utworzono alert o zmianach areny", + "UPDATE_FAILED": "Nie udało się zaktualizować alertu", + "UPDATE_SUCCESS": "Zaktualizowano alert o zmianach areny", "ALL_CHANGES": "Wszystkie zmiany", "LABEL_NAME": "Nazwa", "LABEL_LOCATION": "Lokalizacja", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Usunąć alarm zmiany {{type}}?", "SNACK_DELETED": "Usunięto alarm zmiany", "SNACK_FAILED_DISTANCE": "Nie udało się zaktualizować odległości", - "SNACK_ALL_DISTANCE": "Zaktualizowano wszystkie odległości" + "SNACK_ALL_DISTANCE": "Zaktualizowano wszystkie odległości", + "FORT_TYPE_LABEL": "Typ fortu", + "CHANGE_TYPES_LABEL": "Rodzaje zmian", + "TRACKING_SUBTITLE": "Śledzenie zmian fortów", + "CHANGE_DESCRIPTION": "Zmiana opisu" }, "MAX_BATTLES": { "PAGE_TITLE": "Alarmy bitew Max", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Nie udało się utworzyć alertów", + "CREATE_SUCCESS": "Utworzono alerty: {{count}}", "ANY_POKEMON": "Dowolny Pokémon", "ANY_LEVEL": "Dowolny poziom", "STAR_LABEL": "{{stars}} gwiazdek", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Nie udało się zaktualizować odległości", "SNACK_ALL_DISTANCE": "Zaktualizowano wszystkie odległości", "SNACK_FAILED_UPDATE": "Nie udało się zaktualizować alarmu", - "SNACK_UPDATED": "Zaktualizowano alarm Max Battle" + "SNACK_UPDATED": "Zaktualizowano alarm Max Battle", + "HINT_BY_LEVEL": "Śledzi dowolnego Pokemona na tych poziomach bitwy. Każdy wybrany poziom to osobny alarm.", + "HINT_BY_POKEMON": "Śledzi konkretne Pokemony w bitwach Max, niezależnie od poziomu.", + "HINT_GMAX_ONLY_ADD": "Powiadamia tylko o bitwach Gigantamax dla wybranych Pokemonów.", + "HINT_GMAX_ONLY_EDIT": "Powiadamia tylko o bitwach Gigantamax dla tego Pokemona.", + "HINT_ALL_LEVELS": "Ten alarm śledzi jednego Pokemona na wszystkich poziomach bitew Max.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Obszary i lokalizacja", + "MANAGE_PLACES": "Zarządzaj miejscami", + "PAGE_TITLE": "Obszary i miejsca", "PAGE_DESC": "Kontroluj, gdzie otrzymujesz powiadomienia.", "METHOD_AREAS": "Obszary", "METHOD_AREAS_ACTIVE": "{{count}} aktywnych obszarów", "METHOD_NOT_CONFIGURED": "Nie skonfigurowano", "METHOD_AREAS_DESC": "Otrzymuj powiadomienia o wszystkim, co dzieje się w wybranych strefach geofence.", "METHOD_AREAS_TIP": "Najlepsze do: pokrywania całych miast, dzielnic lub parków", - "METHOD_LOCATION": "Lokalizacja", - "METHOD_LOCATION_NOT_SET": "Nie ustawiona", - "METHOD_LOCATION_DESC": "Otrzymuj powiadomienia o wszystkim w określonej odległości od twojej przypiętej lokalizacji.", + "METHOD_LOCATION": "Moja lokalizacja", + "METHOD_LOCATION_NOT_SET": "Nie ustawiono lokalizacji", + "METHOD_LOCATION_DESC": "Otrzymuj alerty o wszystkim w ustalonej odległości od twojej lokalizacji.", "METHOD_LOCATION_TIP": "Najlepsze do: alertów w pobliżu domu, pracy lub konkretnego miejsca", "CLEAR_LOCATION": "Wyczyść", "CHANGE_LOCATION": "Zmień", "SET_LOCATION": "Ustaw", "METHOD_NOTE": "Każdy alarm wybiera jedną metodę w zakładce Dostarczanie.", + "NOTIFICATION_LANGUAGE": "Język powiadomień", + "NOTIFICATION_LANGUAGE_DESC": "Język, którego Poracle używa w treści powiadomień i nazwach Pokémonów. Jest niezależny od języka interfejsu w górnym menu.", + "SNACK_LANGUAGE_UPDATED": "Zaktualizowano język powiadomień", + "SNACK_LANGUAGE_FAILED": "Nie udało się zaktualizować języka powiadomień", "SELECT_AREAS": "Wybierz obszary", "MAP_VIEW": "Mapa", "LIST_VIEW": "Lista", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Nie udało się zaktualizować lokalizacji", "SEARCH_AREAS": "Szukaj obszarów", "MANUAL_ADD_PLACEHOLDER": "Wpisz nazwę obszaru i naciśnij Enter", - "FILTER_PLACEHOLDER": "Filtruj według nazwy..." + "FILTER_PLACEHOLDER": "Filtruj według nazwy...", + "SNACK_LOAD_SELECTED_FAILED": "Nie udało się wczytać twoich obszarów. Odśwież przed ich zmianą.", + "SELECTION_UNKNOWN": "Nie udało się wczytać twoich obszarów — odśwież stronę przed zapisaniem." }, "PROFILES": { "PAGE_TITLE": "Profile", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Wybierz region", "SEARCH_REGIONS": "Szukaj regionów...", "TOGGLE_TOOLTIP": "Włącz/wyłącz powiadomienia dla tego geofence w bieżącym profilu", - "CREATED_PREFIX": "Utworzono" + "CREATED_PREFIX": "Utworzono", + "REGION_OPTIONAL_HINT": "Opcjonalne. Wybierz region, jeśli twój geofence do niego należy." }, "CLEANING": { "PAGE_TITLE": "Tryb czyszczenia", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Some help content may not be available in your language yet.", "TRANSLATION_CTA_LINK": "Help translate", "FALLBACK_CHIP": "English", + "IMAGE_ENLARGE": "Kliknij, aby powiększyć", "SECTION_GETTING_STARTED": "Getting Started", "SECTION_GETTING_STARTED_SUB": "Login, onboarding wizard, and initial setup", "SECTION_DASHBOARD": "Dashboard", "SECTION_DASHBOARD_SUB": "Your overview of alarms, areas, and status", - "SECTION_LOCATION": "Setting Your Location", + "SECTION_LOCATION": "Ustawianie lokalizacji", "SECTION_LOCATION_SUB": "GPS, address search, and coordinates", - "SECTION_AREAS": "Choosing Your Areas", - "SECTION_AREAS_SUB": "Map view, list view, and region filtering", + "SECTION_AREAS": "Obszary i miejsca", + "SECTION_AREAS_SUB": "Widok mapy, widok listy, filtrowanie regionów i miejsca", "SECTION_GEOFENCES": "Custom Geofences", "SECTION_GEOFENCES_SUB": "Draw boundaries, submit for public approval", "SECTION_POKEMON": "Pokemon Alarms", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Other Alarm Types", "SECTION_OTHER_ALARMS_SUB": "Raids, eggs, quests, rockets, lures, nests, gyms, fort changes", "SECTION_DELIVERY": "Delivery Settings", - "SECTION_DELIVERY_SUB": "Areas vs distance, templates, and clean mode", + "SECTION_DELIVERY_SUB": "Zasięg dostawy, szablony i tryb czyszczenia", + "SECTION_QUEST_SUMMARY": "Dostarczanie podsumowania zadań", + "SECTION_QUEST_SUMMARY_SUB": "Połącz hałaśliwe zadania w jedno zaplanowane podsumowanie", "SECTION_TEST_ALERTS": "Test Alerts", "SECTION_TEST_ALERTS_SUB": "Send sample notifications to preview your alarms", "SECTION_POKEMON_AVAILABILITY": "Pokemon Availability", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Common issues and how to fix them", "CONTENT_GETTING_STARTED": "

Strona DM Alerts pozwala ci dokładnie wybrać, które powiadomienia z Pokemon GO otrzymujesz jako wiadomości prywatne. Zamiast dostawać każdy alert, wybierasz to, co cię interesuje — konkretne Pokemon, rajdy, zadania i więcej — i dostajesz powiadomienia tylko o nich.

ℹ️
Zanim zaczniesz korzystać ze strony, musisz najpierw zarejestrować się u bota Poracle na Discord lub Telegram. Po rejestracji wróć tutaj i zaloguj się.

Logowanie

  • Discord — Kliknij \"Sign in with Discord\" na stronie logowania. Zostaniesz przekierowany do Discord, aby autoryzować aplikację, a potem automatycznie wrócisz.
  • Telegram — Jeśli jest włączony, użyj widżetu logowania Telegram na stronie logowania. Potwierdź logowanie w aplikacji Telegram.
\"Strona

Pierwsza konfiguracja

Gdy logujesz się po raz pierwszy, kreator powitalny przeprowadzi cię przez trzy kroki:

  1. Ustaw swoją lokalizację — Używana do obliczania odległości dla powiadomień o pobliskich wydarzeniach.
  2. Wybierz swoje obszary — Zaznacz strefy geograficzne, z których chcesz otrzymywać alerty.
  3. Dodaj swój pierwszy alarm — Utwórz alarm Pokemon, Rajdu lub Zadania, aby zacząć otrzymywać powiadomienia.
\"Kreator

Możesz pominąć dowolny krok i wrócić do niego później. Kreator nie pojawi się ponownie, gdy go zamkniesz lub ukończysz wszystkie kroki.

", "CONTENT_DASHBOARD": "\"Panel

Panel główny to twoja baza. Pokazuje przegląd twojej aktualnej konfiguracji.

Karty statusu

  • Lokalizacja — Pokazuje twoje zapisane współrzędne lub adres. Kliknij, aby ustawić lub zaktualizować lokalizację.
  • Aktywne obszary — Pokazuje ile obszarów śledzisz. Kliknij, aby zarządzać obszarami.
  • Profil — Pokazuje twój aktywny profil. Jeśli masz wiele profili, kliknij, aby przełączać między nimi.

Aktywne filtry

Siatka kart pokazuje ile alarmów masz dla każdego typu (Pokemon, Rajdy, Zadania itp.). Kliknij dowolną kartę, aby przejść do listy alarmów.

Pogoda

Jeśli masz ustawioną lokalizację, panel pokazuje aktualną pogodę w grze dla twoich współrzędnych wraz z czasem ostatniej aktualizacji. Pogoda dla obszarów jest również wyświetlana dla każdego z wybranych obszarów, dzięki czemu widzisz warunki pogodowe we wszystkich śledzonych strefach.

Szybkie akcje

Przyciski skrótów do dodawania alarmów Pokemon, Rajdów lub Zadań, zarządzania obszarami lub konfiguracji czyszczenia — wszystko bez nawigowania przez panel boczny.

Wskazówki

Pomocne przypomnienia pojawiają się, gdy twoja konfiguracja jest niekompletna — np. brak lokalizacji, brak wybranych obszarów lub brak skonfigurowanych alarmów. Każda wskazówka ma przycisk akcji, aby to naprawić. Możesz odrzucić wskazówki, których nie potrzebujesz.

Nawigacja

Użyj panelu bocznego do nawigacji między sekcjami. Typy alarmów są wymienione na górze, a następnie ustawienia takie jak Obszary, Geofence, Profile i Czyszczenie. Pomoc jest zawsze na dole.

\"Panel", - "CONTENT_LOCATION": "\"Panel

Twoja lokalizacja jest używana do powiadomień opartych na odległości. Gdy alarm używa trybu \"Ustaw odległość\", będziesz otrzymywać powiadomienia o wydarzeniach w promieniu od tej lokalizacji.

Ustawianie lokalizacji

Otwórz okno lokalizacji z Panelu głównego lub strony Obszary. Masz cztery sposoby na jej ustawienie:

  • Szukaj po adresie — Wpisz adres, miasto lub nazwę punktu orientacyjnego. Wybierz z pojawiających się sugestii.
  • Wpisz współrzędne — Wpisz bezpośrednio szerokość i długość geograficzną, jeśli je znasz.
  • Użyj GPS — Kliknij \"Use My Location\", aby użyć aktualnej lokalizacji twojego urządzenia. Przeglądarka poprosi o pozwolenie.
  • Kliknij na mapie — Kliknij dowolne miejsce na mini-mapie, aby ustawić ten punkt jako swoją lokalizację.

Po wybraniu lokalizacji adres wyświetla się automatycznie. Kliknij Zapisz, aby potwierdzić.

💡
Możesz wyczyścić swoją lokalizację na stronie Obszary, jeśli chcesz otrzymywać alerty tylko na podstawie obszarów.
", - "CONTENT_AREAS": "\"Strona

Obszary to predefiniowane strefy geograficzne skonfigurowane przez twoją społeczność. Gdy alarm używa trybu \"Użyj obszarów\", dostajesz powiadomienia o wydarzeniach, które mają miejsce w twoich wybranych obszarach.

Wybieranie obszarów

Przejdź do Obszary i Lokalizacja w panelu bocznym. Możesz wybierać obszary na dwa sposoby:

  • Widok mapy — Kliknij kolorowe wielokąty na mapie, aby zaznaczyć lub odznaczyć obszary. Zaznaczone obszary zmieniają kolor na zielony. Najedź na dowolny obszar, aby zobaczyć jego nazwę.
  • Widok listy — Użyj pól wyboru, aby wybrać obszary z przeszukiwalnej listy.

Filtrowanie regionów

Jeśli twoja społeczność ma wiele obszarów w różnych regionach, użyj rozwijanej listy regionów, aby przybliżyć konkretny region. Ułatwia to znalezienie obszarów w twojej okolicy.

Zagnieżdżone obszary

Niektóre obszary nakładają się — mniejsza strefa wewnątrz większej. Oba są klikalne. Przybliż mapę, aby łatwiej kliknąć mniejszy obszar.

Zapisywanie

Pasek zapisu pojawia się na dole, gdy dokonasz zmian. Kliknij Zapisz, aby potwierdzić wybór, lub Anuluj, aby cofnąć.

ℹ️
Obszary są przypisane do profilu. Każdy profil ma własny zestaw wybranych obszarów. Przełączanie profili pokaże inne zaznaczenia obszarów. Niestandardowe geofence można również włączać lub wyłączać dla każdego profilu na stronie Geofence.
", - "CONTENT_GEOFENCES": "\"Strona

Jeśli predefiniowane obszary nie obejmują miejsca, z którego chcesz alerty, możesz narysować własne niestandardowe granice geofence na mapie.

Rysowanie geofence

  1. Przejdź do Moje Geofence w panelu bocznym.
  2. Kliknij Rysuj Geofence.
  3. Kliknij na mapie, aby umieszczać punkty granicy wielokąta. Kliknij ponownie pierwszy punkt, aby zamknąć kształt (minimum 3 punkty).
  4. Nadaj geofence nazwę i wybierz region, do którego należy. Region jest zwykle wykrywany automatycznie.
  5. Kliknij Zapisz.

Zarządzanie geofence

  • Edytuj — Zmień nazwę geofence lub zmień jego region.
  • Usuń — Usuń geofence, którego już nie potrzebujesz. Geofence jest usuwany ze wszystkich profili automatycznie.

Przełącznik profilu

Każda karta geofence ma suwak do aktywacji lub dezaktywacji dla twojego aktualnego profilu. Gdy tworzysz geofence, jest automatycznie aktywowany w profilu, którego używasz. Przełącz się na inny profil, a suwak pokaże \"Inactive\" — włącz go, aby otrzymywać alerty z tego geofence również w tym profilu. Pozwala to kontrolować, które profile otrzymują powiadomienia z każdego geofence bez jego ponownego tworzenia.

ℹ️
Zatwierdzone geofence (awansowane do publicznych obszarów) nie pokazują suwaka — zarządzaj nimi ze strony Obszary.

GeoJSON Import & Export

Możesz importować i eksportować geofence w standardowym formacie GeoJSON, co ułatwia udostępnianie granic lub tworzenie ich w zewnętrznych narzędziach, takich jak geojson.io.

  • Import — Kliknij ikonę przesyłania i wklej lub prześlij plik GeoJSON. Każdy wielokąt w pliku staje się nowym geofence. Możesz przejrzeć i zmienić nazwę każdego z nich przed zapisaniem.
  • Eksport — Kliknij ikonę pobierania i wybierz, które geofence chcesz uwzględnić. Wyeksportowany plik GeoJSON zawiera wszystkie wybrane wielokąty i można go otworzyć w dowolnym narzędziu GIS lub edytorze map.
💡
Import GeoJSON jest przydatny do migracji geofence z innych systemów lub rysowania złożonych granic w narzędziu GIS na komputerze, a następnie importowania ich tutaj.

Zgłaszanie do publicznego zatwierdzenia

Jeśli uważasz, że twój geofence byłby przydatny dla całej społeczności, możesz go zgłosić do przeglądu przez administratora. Jeśli zostanie zatwierdzony, stanie się publicznym obszarem, który każdy może wybrać. Twój prywatny geofence nadal działa, dopóki trwa przegląd.

Odznaki statusu

  • Aktywny — Twój prywatny geofence, działający tylko dla ciebie.
  • Oczekuje na przegląd — Zgłoszony i czekający na przegląd administratora.
  • Zatwierdzony — Awansowany do publicznego obszaru.
  • Odrzucony — Nie zatwierdzony. Możesz zobaczyć opinię administratora, a geofence pozostaje aktywny jako prywatna strefa.
ℹ️
Możesz mieć maksymalnie 10 niestandardowych geofence, każdy z maksymalnie 500 punktami granicznymi.
", - "CONTENT_POKEMON": "\"Strona

Alarmy Pokemon powiadamiają cię, gdy dziki Pokemon pojawi się i pasuje do twoich filtrów.

Dodawanie alarmu Pokemon

\"Okno
  1. Przejdź do Pokemon w panelu bocznym i kliknij przycisk +.
  2. Wybierz Pokemon — Szukaj po nazwie lub numerze Pokedex, albo użyj przycisków filtrów generacji i typów do przeglądania. Możesz wybrać wiele Pokemon naraz.
  3. Ustaw filtry — Wybierz, co sprawia, że spawn jest wart powiadomienia:
  • Zakres IV — Minimalny i maksymalny procent IV (0-100%)
  • Zakres CP — Filtruj po sile bojowej
  • Zakres poziomu — Filtruj po poziomie Pokemon (0-55)
  • Indywidualne statystyki — Filtruj po wartościach ATK, DEF i STA (0-15 każda)
  • Forma — Śledź konkretne formy (np. Alolan, Galarian) lub wszystkie formy
  • Płeć — Samiec, samica, bezpłciowy lub wszystkie
  • Waga — Filtruj po zakresie wagi
  • Rozmiar — Filtruj po kategorii rozmiaru: wybierz ALL (brak filtra), aby pasował każdy rozmiar, lub wybierz konkretne rozmiary od XXS do XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Domyślne wartości filtrów są ustawione tak, aby wszystkie Pokemon pasowały, gdy żadne filtry nie są jawnie skonfigurowane. Na przykład IV domyślnie to 0-100%, poziom to 0-55, a rozmiar to ALL. Musisz dostosować tylko te filtry, które cię interesują.

Filtry PVP

Otrzymuj powiadomienia, gdy Pokemon ma świetne IV do PVP. Wybierz ligę (Great, Ultra lub Little Cup) i ustaw zakres rangi, który cię interesuje (np. ranga 1-50).

Alarm \"Wszystkie Pokemon\"

💡
Wybierz \"All Pokemon\" (ID 0), aby utworzyć jeden alarm obejmujący każdy gatunek. Przydatne z wysokim filtrem IV jak 96-100%, aby złapać każdy wartościowy spawn.

Czytanie kart alarmów

Każda karta alarmu pokazuje kolorowe etykiety podsumowujące twoje filtry:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Strona

Alarmy Rajdów i Jajek

Otrzymuj powiadomienia, gdy pojawi się boss rajdu lub jajko, które cię interesuje.

  • Wg poziomu — Wybierz poziomy rajdów (1-6) lub poziomy jajek, aby śledzić wszystkie rajdy tego poziomu.
  • Wg bossa — Wybierz konkretne Pokemon będące bossami rajdów, na które chcesz polować.
  • Filtr drużyny — Powiadamiaj tylko o rajdach w salach kontrolowanych przez konkretną drużynę (Mystic, Valor, Instinct).
  • Śledzenie sali — Śledź rajdy w konkretnych salach po nazwie, aby dostawać powiadomienia tylko o ulubionych salach.
  • Filtr ruchów — Filtruj bossów rajdów po ich szybkich lub ładowanych atakach.
  • Powiadomienia RSVP — Otrzymuj powiadomienia, gdy inni trenerzy zgłoszą się na rajd lub jajko, które śledzisz.

Alarmy Rajdów i Jajek są zarządzane na osobnych zakładkach na stronie Rajdy. Jajka również obsługują śledzenie konkretnych sal i powiadomienia RSVP.

Alarmy Max Battle (Dynamax)

Otrzymuj powiadomienia o walkach Dynamax i Gigantamax w Power Spots.

  • Wg poziomu — Wybierz poziomy walki, aby śledzić dowolne Pokemon na tych poziomach. Poziomy wahają się od 1 gwiazdki do 5 gwiazdek (Legendary) dla Dynamax, plus Gigantamax i Legendary Gigantamax dla największych walk. Jeden alarm jest tworzony dla każdego wybranego poziomu.
  • Wg Pokemon — Wybierz konkretne Pokemon, z którymi chcesz walczyć na wszystkich poziomach Max Battle. Jeśli baza danych skanera jest skonfigurowana, selektor jest filtrowany, aby pokazywać tylko Pokemon, które pojawiły się w Max Battles.
  • Tylko Gigantamax — Podczas śledzenia wg Pokemon, włącz to, aby otrzymywać powiadomienia tylko gdy Pokemon pojawi się w walkach Gigantamax (walki najwyższego poziomu z unikalnymi ruchami G-Max). Dla śledzenia wg poziomu, Gigantamax obsługuje się przez wybranie poziomu Gigantamax lub Legendary Gigantamax bezpośrednio.
  • Zaznacz wszystko — Szybko zaznacz wszystkie dostępne poziomy naraz (odpowiednik komendy bota !maxbattle everything).

Alarmy zadań

Otrzymuj powiadomienia o zadaniach badawczych z konkretnymi nagrodami.

  • Spotkania z Pokemon — Wybierz Pokemon, których chcesz jako nagrody za zadania.
  • Przedmioty — Śledź zadania nagradzające konkretnymi przedmiotami.
  • Mega Energia — Śledź zadania dające mega energię dla konkretnych Pokemon.
  • Cukierki — Śledź zadania nagradzające cukierkami dla konkretnych Pokemon.

Alarmy inwazji

Otrzymuj powiadomienia o inwazjach Team Rocket.

  • Śledź wszystko — Jeden alarm dla każdego typu grunta i lidera.
  • Wg typu — Wybierz konkretne typy gruntów (Bug, Dragon, Fire itp.), Rocket Leaders lub Giovanni. Nazwy typów gruntów są automatycznie normalizowane (bez rozróżniania wielkości liter), więc nie musisz martwić się o dokładne pisanie.
  • Płeć — Filtruj po płci grunta.

Alarmy przynęt

Otrzymuj powiadomienia, gdy zostanie umieszczona konkretna przynęta. Wybierz spośród Normal, Glacial, Mossy, Magnetic, Rainy i Golden.

Alarmy gniazd

Śledź gniazdujące gatunki Pokemon. Ustaw próg minimalnych spawnów na godzinę, aby dostawać powiadomienia tylko o gniazdach z wystarczającą aktywnością.

Alarmy sal

Śledź zmiany drużyn w salach. Wybierz, które drużyny (Neutral, Mystic, Valor, Instinct) monitorować. Włącz śledzenie Zmian miejsc, aby otrzymywać powiadomienia o wolnych miejscach w sali, lub włącz śledzenie Zmian bitew, aby otrzymywać powiadomienia, gdy sala jest atakowana.

Alarmy zmian fortów

Śledź zmiany w PokéStopach i salach — nie aktywności w nich, ale zmiany w samych punktach zainteresowania.

  • Typ fortu — Wybierz śledzenie PokéStopów, Sal lub Wszystkiego.
  • Typy zmian — Wybierz, które zmiany monitorować: Zmiana nazwy, Zmiana lokalizacji, Zmiana obrazu, Usunięcie lub Dodanie nowego fortu.
  • Uwzględnij puste — Uwzględnij forty bez ustawionej nazwy.
💡
Alarmy zmian fortów są przydatne do śledzenia aktualizacji bazy danych mapy — pojawianie się nowych PokéStopów, przenoszenie sal lub usuwanie POI z gry.

Celowanie w konkretną salę

Podczas tworzenia lub edycji alarmu Rajdu, Jajka lub Sali możesz opcjonalnie wyszukać i wybrać konkretną salę. Jest to przydatne, gdy interesuje cię tylko aktywność w ulubionej sali — np. tej na twojej trasie na lunch lub blisko domu.

  • Jak używać — W oknie dodawania lub edycji wpisz nazwę sali w polu wyszukiwania sal. Wyniki pokazują zdjęcie sali, nazwę i obszar, abyś mógł zidentyfikować właściwą.
  • Gdy sala jest wybrana — Alarm uruchamia się tylko dla wydarzeń w tej konkretnej sali. Nazwa sali pojawia się na karcie alarmu na liście, abyś widział, którą salę śledzi alarm.
  • Gdy nie wybrano sali — To domyślne ustawienie. Alarm działa normalnie dla wszystkich sal w wybranych obszarach lub w promieniu odległości.
💡
Możesz połączyć alarm dla konkretnej sali z szerszym alarmem. Na przykład utwórz jeden alarm rajdowy dla lokalnej sali na wszystkie poziomy i drugi alarm dla rajdów poziomu 5 we wszystkich obszarach.
", - "CONTENT_DELIVERY": "\"Karty

Każdy alarm ma ustawienia dostawy, które kontrolują gdzie dostajesz powiadomienia.

Obszary vs Odległość

Każdy alarm używa jednego z dwóch trybów dostawy:

🗺
Użyj obszarówPowiadamiany, gdy wydarzenia mają miejsce w twoich wybranych obszarach. Dobre do śledzenia konkretnych okolic.
📏
Ustaw odległośćPowiadamiany w promieniu (km) od twojej zapisanej lokalizacji. Dobre do śledzenia wszystkiego w pobliżu.

Możesz używać różnych trybów dla różnych alarmów — na przykład obszary dla Pokemon i odległość dla rajdów.

Szablony powiadomień

Jeśli szablony są włączone, możesz wybrać wygląd swoich powiadomień. Selektor szablonów pokazuje podgląd na żywo tego, jak będzie wyglądać twoja wiadomość DM na Discord, w tym format osadzenia, pola i obrazy.

Tryb czyszczenia

Po włączeniu bot automatycznie usuwa powiadomienie z Discord po wygaśnięciu wydarzenia (np. Pokemon znika lub rajd się kończy). To utrzymuje porządek w twoich DM. Możesz włączyć tryb czyszczenia dla pojedynczego alarmu lub zbiorczo na stronie Czyszczenie.

Ping / Wzmianki ról

Jeśli używasz webhooków, możesz ustawić rolę Discord do wzmiankowania w powiadomieniu (np. @Pokemon). Ma to znaczenie tylko dla konfiguracji z webhookami.

", + "CONTENT_LOCATION": "\"Panel

Twoja lokalizacja to punkt, od którego mierzone są alerty. Alarm, który dociera do ciebie w promieniu, liczy od niej, chyba że skierujesz ten konkretny alarm na zapisane miejsce.

Ustawianie lokalizacji

Otwórz okno lokalizacji z Panelu głównego lub strony Obszary i miejsca. Masz cztery sposoby na jej ustawienie:

  • Szukaj po adresie — Wpisz adres, miasto lub nazwę punktu orientacyjnego. Wybierz z pojawiających się sugestii.
  • Wpisz współrzędne — Wpisz bezpośrednio szerokość i długość geograficzną, jeśli je znasz.
  • Użyj GPS — Kliknij \"Use My Location\", aby użyć aktualnej lokalizacji twojego urządzenia. Przeglądarka poprosi o pozwolenie.
  • Kliknij na mapie — Kliknij dowolne miejsce na mini-mapie, aby ustawić ten punkt jako swoją lokalizację.

Po wybraniu punktu adres wyświetla się automatycznie. Kliknij Zapisz, aby potwierdzić.

To samo okno służy do dodania miejsca lub wybrania punktu dla pojedynczego alarmu. Nosi wtedy tytuł Wybierz punkt i potwierdza się je przyciskiem Użyj tego punktu, a twoja własna lokalizacja pozostaje bez zmian.

💡
Możesz wyczyścić swoją lokalizację na stronie Obszary i miejsca, jeśli chcesz otrzymywać alerty tylko na podstawie obszarów.
", + "CONTENT_AREAS": "\"Strona

Obszary to predefiniowane strefy geograficzne skonfigurowane przez twoją społeczność. Te, które wybierzesz tutaj, są domyślne dla każdego alarmu: alarm ustawiony na Wszędzie w moich obszarach reaguje na wydarzenia w ich wnętrzu.

Wybieranie obszarów

Przejdź do Obszary i miejsca w panelu bocznym. Możesz wybierać obszary na dwa sposoby:

  • Widok mapy — Kliknij kolorowe wielokąty na mapie, aby zaznaczyć lub odznaczyć obszary. Zaznaczone obszary zmieniają kolor na zielony. Najedź na dowolny obszar, aby zobaczyć jego nazwę.
  • Widok listy — Użyj pól wyboru, aby wybrać obszary z przeszukiwalnej listy.

Miejsca

Miejsce to nazwany punkt — praca, siłownia, dom rodziców — od którego alarm może mierzyć swój promień zamiast od twojej lokalizacji. Dodaj je w sekcji Miejsca na tej samej stronie, a potem wybierz je w polu Mierzone od, gdy ustalasz, gdzie ma cię zastać alarm. Miejsca nie da się usunąć, dopóki wskazują na nie alarmy, a komunikat podaje ile.

Filtrowanie regionów

Jeśli twoja społeczność ma wiele obszarów w różnych regionach, użyj rozwijanej listy regionów, aby przybliżyć konkretny region. Ułatwia to znalezienie obszarów w twojej okolicy.

Zagnieżdżone obszary

Niektóre obszary nakładają się — mniejsza strefa wewnątrz większej. Oba są klikalne. Przybliż mapę, aby łatwiej kliknąć mniejszy obszar.

Zapisywanie

Pasek zapisu pojawia się na dole, gdy dokonasz zmian. Kliknij Zapisz, aby potwierdzić wybór, lub Anuluj, aby cofnąć.

ℹ️
Obszary są przypisane do profilu. Każdy profil ma własny zestaw wybranych obszarów. Przełączanie profili pokaże inne zaznaczenia obszarów. Niestandardowe geofence można również włączać lub wyłączać dla każdego profilu na stronie Geofence.
", + "CONTENT_GEOFENCES": "\"Strona

Jeśli predefiniowane obszary nie obejmują miejsca, z którego chcesz alerty, możesz narysować własne niestandardowe granice geofence na mapie.

Rysowanie geofence

  1. Przejdź do Moje Geofence w panelu bocznym.
  2. Kliknij Rysuj Geofence.
  3. Kliknij na mapie, aby umieszczać punkty granicy wielokąta. Kliknij ponownie pierwszy punkt, aby zamknąć kształt (minimum 3 punkty).
  4. Nadaj geofence nazwę i wybierz region, do którego należy. Region jest zwykle wykrywany automatycznie.
  5. Kliknij Zapisz.

Zarządzanie geofence

  • Edytuj — Zmień nazwę geofence lub zmień jego region.
  • Usuń — Usuń geofence, którego już nie potrzebujesz. Geofence jest usuwany ze wszystkich profili automatycznie.

Przełącznik profilu

Każda karta geofence ma suwak do aktywacji lub dezaktywacji dla twojego aktualnego profilu. Gdy tworzysz geofence, jest automatycznie aktywowany w profilu, którego używasz. Przełącz się na inny profil, a suwak pokaże \"Inactive\" — włącz go, aby otrzymywać alerty z tego geofence również w tym profilu. Pozwala to kontrolować, które profile otrzymują powiadomienia z każdego geofence bez jego ponownego tworzenia.

ℹ️
Zatwierdzone geofence (awansowane do publicznych obszarów) nie pokazują suwaka — zarządzaj nimi ze strony Obszary.

Użycie geofence dla jednego alarmu

Narysowany przez ciebie geofence pojawia się też na liście Tylko w wybranych obszarach, gdy ustalasz, gdzie ma cię zastać pojedynczy alarm; jest oznaczony ikoną rysowania. Ogranicza to jeden alarm do niego bez włączania geofence dla całego profilu.

GeoJSON Import & Export

Możesz importować i eksportować geofence w standardowym formacie GeoJSON, co ułatwia udostępnianie granic lub tworzenie ich w zewnętrznych narzędziach, takich jak geojson.io.

  • Import — Kliknij ikonę przesyłania i wklej lub prześlij plik GeoJSON. Każdy wielokąt w pliku staje się nowym geofence. Możesz przejrzeć i zmienić nazwę każdego z nich przed zapisaniem.
  • Eksport — Kliknij ikonę pobierania i wybierz, które geofence chcesz uwzględnić. Wyeksportowany plik GeoJSON zawiera wszystkie wybrane wielokąty i można go otworzyć w dowolnym narzędziu GIS lub edytorze map.
💡
Import GeoJSON jest przydatny do migracji geofence z innych systemów lub rysowania złożonych granic w narzędziu GIS na komputerze, a następnie importowania ich tutaj.

Zgłaszanie do publicznego zatwierdzenia

Jeśli uważasz, że twój geofence byłby przydatny dla całej społeczności, możesz go zgłosić do przeglądu przez administratora. Jeśli zostanie zatwierdzony, stanie się publicznym obszarem, który każdy może wybrać. Twój prywatny geofence nadal działa, dopóki trwa przegląd.

Odznaki statusu

  • Aktywny — Twój prywatny geofence, działający tylko dla ciebie.
  • Oczekuje na przegląd — Zgłoszony i czekający na przegląd administratora.
  • Zatwierdzony — Awansowany do publicznego obszaru.
  • Odrzucony — Nie zatwierdzony. Możesz zobaczyć opinię administratora, a geofence pozostaje aktywny jako prywatna strefa.
ℹ️
Możesz mieć maksymalnie 10 niestandardowych geofence, każdy z maksymalnie 500 punktami granicznymi.
", + "CONTENT_POKEMON": "\"Strona

Alarmy Pokemon powiadamiają cię, gdy dziki Pokemon pojawi się i pasuje do twoich filtrów.

Dodawanie alarmu Pokemon

\"Okno
  1. Przejdź do Pokemon w panelu bocznym i kliknij przycisk +.
  2. Wybierz Pokemon — Szukaj po nazwie lub numerze Pokedex, albo użyj przycisków filtrów generacji i typów do przeglądania. Możesz wybrać wiele Pokemon naraz.
  3. Ustaw filtry — Wybierz, co sprawia, że spawn jest wart powiadomienia:
  • Zakres IV — Minimalny i maksymalny procent IV (0-100%)
  • Zakres CP — Filtruj po sile bojowej
  • Zakres poziomu — Filtruj po poziomie Pokemon (0-55)
  • Indywidualne statystyki — Filtruj po wartościach ATK, DEF i STA (0-15 każda)
  • Forma — Śledź konkretne formy (np. Alolan, Galarian) lub wszystkie formy
  • Płeć — Samiec, samica, bezpłciowy lub wszystkie
  • Waga — Filtruj po zakresie wagi
  • Rozmiar — Filtruj po kategorii rozmiaru: wybierz ALL (brak filtra), aby pasował każdy rozmiar, lub wybierz konkretne rozmiary od XXS do XXL (XXS, XS, Normal, XL, XXL)
  • Minimalny pozostały czas — Pomija spawny, które znikną, zanim dotrzesz. Ustawia się to w sekcji Więcej filtrów; karta pokazuje wtedy plakietkę w stylu "zostało 10 min"
ℹ️
Domyślne wartości filtrów są ustawione tak, aby wszystkie Pokemon pasowały, gdy żadne filtry nie są jawnie skonfigurowane. Na przykład IV domyślnie to 0-100%, poziom to 0-55, a rozmiar to ALL. Musisz dostosować tylko te filtry, które cię interesują.

Filtry PVP

Otrzymuj powiadomienia, gdy Pokemon ma świetne IV do PVP. Wybierz ligę (Great, Ultra lub Little Cup) i ustaw zakres rangi, który cię interesuje (np. ranga 1-50).

Przyciski Limit poziomu wybierają, przy jakim limicie odczytywane są rangi. Zostaw Wszystkie, aby użyć wartości z konfiguracji Poracle twojej społeczności.

Megaewolucja decyduje, czy reguła ocenia formę podstawową, czy megę: Base, Mega, Mega X lub Mega Y. Megi są oceniane osobno, więc reguła dla megi nigdy nie dopasuje spawnu w formie podstawowej.

Alarm \"Wszystkie Pokemon\"

💡
Wybierz \"All Pokemon\" (ID 0), aby utworzyć jeden alarm obejmujący każdy gatunek. Przydatne z wysokim filtrem IV jak 96-100%, aby złapać każdy wartościowy spawn.

Czytanie kart alarmów

Każda karta alarmu pokazuje kolorowe etykiety podsumowujące twoje filtry:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Strona

Alarmy Rajdów i Jajek

Otrzymuj powiadomienia, gdy pojawi się boss rajdu lub jajko, które cię interesuje.

  • Wg poziomu — Wybierz poziomy rajdów (1-6) lub poziomy jajek, aby śledzić wszystkie rajdy tego poziomu.
  • Wg bossa — Wybierz konkretne Pokemon będące bossami rajdów, na które chcesz polować.
  • Filtr drużyny — Powiadamiaj tylko o rajdach w salach kontrolowanych przez konkretną drużynę (Mystic, Valor, Instinct).
  • Śledzenie sali — Śledź rajdy w konkretnych salach po nazwie, aby dostawać powiadomienia tylko o ulubionych salach.
  • Filtr ruchów — Filtruj bossów rajdów po ich szybkich lub ładowanych atakach.
  • Powiadomienia RSVP — Otrzymuj powiadomienia, gdy inni trenerzy zgłoszą się na rajd lub jajko, które śledzisz.

Alarmy Rajdów i Jajek są zarządzane na osobnych zakładkach na stronie Rajdy. Jajka również obsługują śledzenie konkretnych sal i powiadomienia RSVP.

Alarmy Max Battle (Dynamax)

Otrzymuj powiadomienia o walkach Dynamax i Gigantamax w Power Spots.

  • Wg poziomu — Wybierz poziomy walki, aby śledzić dowolne Pokemon na tych poziomach. Poziomy wahają się od 1 gwiazdki do 5 gwiazdek (Legendary) dla Dynamax, plus Gigantamax i Legendary Gigantamax dla największych walk. Jeden alarm jest tworzony dla każdego wybranego poziomu.
  • Wg Pokemon — Wybierz konkretne Pokemon, z którymi chcesz walczyć na wszystkich poziomach Max Battle. Jeśli baza danych skanera jest skonfigurowana, selektor jest filtrowany, aby pokazywać tylko Pokemon, które pojawiły się w Max Battles.
  • Tylko Gigantamax — Podczas śledzenia wg Pokemon, włącz to, aby otrzymywać powiadomienia tylko gdy Pokemon pojawi się w walkach Gigantamax (walki najwyższego poziomu z unikalnymi ruchami G-Max). Dla śledzenia wg poziomu, Gigantamax obsługuje się przez wybranie poziomu Gigantamax lub Legendary Gigantamax bezpośrednio.
  • Zaznacz wszystko — Szybko zaznacz wszystkie dostępne poziomy naraz (odpowiednik komendy bota !maxbattle everything).

Alarmy zadań

Otrzymuj powiadomienia o zadaniach badawczych z konkretnymi nagrodami.

  • Spotkania z Pokemon — Wybierz Pokemon, których chcesz jako nagrody za zadania.
  • Przedmioty — Śledź zadania nagradzające konkretnymi przedmiotami.
  • Mega Energia — Śledź zadania dające mega energię dla konkretnych Pokemon.
  • Cukierki — Śledź zadania nagradzające cukierkami dla konkretnych Pokemon.
  • Gwiezdny pył — Śledź zadania nagradzające gwiezdnym pyłem.

Zakładki przedmiotów, mega energii i cukierków mają pole Minimalna ilość, a zakładka gwiezdnego pyłu Minimalny gwiezdny pył. Zostaw 0, aby pasowała każda ilość. Karty pokazują ilość obok nagrody, na przykład "3× Rare Candy".

Alarmy inwazji

Otrzymuj powiadomienia o inwazjach Team Rocket.

  • Śledź wszystko — Jeden alarm dla każdego typu grunta i lidera.
  • Wg typu — Wybierz konkretne typy gruntów (Bug, Dragon, Fire itp.), Rocket Leaders lub Giovanni. Nazwy typów gruntów są automatycznie normalizowane (bez rozróżniania wielkości liter), więc nie musisz martwić się o dokładne pisanie.
  • Płeć — Filtruj po płci grunta.

Alarmy przynęt

Otrzymuj powiadomienia, gdy zostanie umieszczona konkretna przynęta. Wybierz spośród Normal, Glacial, Mossy, Magnetic, Rainy i Golden.

Alarmy gniazd

Śledź gniazdujące gatunki Pokemon. Ustaw próg minimalnych spawnów na godzinę, aby dostawać powiadomienia tylko o gniazdach z wystarczającą aktywnością.

Alarmy sal

Śledź zmiany drużyn w salach. Wybierz, które drużyny (Neutral, Mystic, Valor, Instinct) monitorować. Włącz śledzenie Zmian miejsc, aby otrzymywać powiadomienia o wolnych miejscach w sali, lub włącz śledzenie Zmian bitew, aby otrzymywać powiadomienia, gdy sala jest atakowana.

Alarmy zmian fortów

Śledź zmiany w PokéStopach i salach — nie aktywności w nich, ale zmiany w samych punktach zainteresowania.

  • Typ fortu — Wybierz śledzenie PokéStopów, Sal lub Wszystkiego.
  • Typy zmian — Wybierz, które zmiany monitorować: Zmiana nazwy, Zmiana opisu, Zmiana lokalizacji, Zmiana obrazu, Usunięcie lub Nowy fort.
  • Uwzględnij puste — Uwzględnij forty bez ustawionej nazwy.
💡
Alarmy zmian fortów są przydatne do śledzenia aktualizacji bazy danych mapy — pojawianie się nowych PokéStopów, przenoszenie sal lub usuwanie POI z gry.

Celowanie w konkretną salę

Podczas tworzenia lub edycji alarmu Rajdu, Jajka lub Sali możesz opcjonalnie wyszukać i wybrać konkretną salę. Jest to przydatne, gdy interesuje cię tylko aktywność w ulubionej sali — np. tej na twojej trasie na lunch lub blisko domu.

  • Jak używać — W oknie dodawania lub edycji wpisz nazwę sali w polu wyszukiwania sal. Wyniki pokazują zdjęcie sali, nazwę i obszar, abyś mógł zidentyfikować właściwą.
  • Gdy sala jest wybrana — Alarm uruchamia się tylko dla wydarzeń w tej konkretnej sali. Nazwa sali pojawia się na karcie alarmu na liście, abyś widział, którą salę śledzi alarm.
  • Gdy nie wybrano sali — To domyślne ustawienie. Alarm działa normalnie dla wszystkich sal w wybranych obszarach lub w promieniu odległości.
💡
Możesz połączyć alarm dla konkretnej sali z szerszym alarmem. Na przykład utwórz jeden alarm rajdowy dla lokalnej sali na wszystkie poziomy i drugi alarm dla rajdów poziomu 5 we wszystkich obszarach.
", + "CONTENT_DELIVERY": "\"Karty

Każdy alarm ma ustawienia dostawy, które kontrolują gdzie dostajesz powiadomienia.

Gdzie zastanie cię alert

Zakładka Dostawa w każdym oknie dodawania i edycji pyta Gdzie ma cię zastać ten alert? i daje trzy odpowiedzi:

  • Wszędzie w moich obszarach — Ustawienie domyślne. Alarm podąża za obszarami wybranymi w profilu, więc zmiana obszarów zmienia też ten alarm.
  • W pobliżu punktu — Promień w kilometrach, mierzony od twojej lokalizacji albo od zapisanego miejsca wybranego w polu Mierzone od. Jeśli nie masz jeszcze lokalizacji, selektor to zgłasza i proponuje ją ustawić.
  • Tylko w wybranych obszarach — Podzbiór obszarów dla tego jednego alarmu, wybrany spośród obszarów publicznych i geofence'ów narysowanych przez ciebie.

Różne alarmy mogą odpowiadać różnie: obszary dla Pokemon, promień od lokalizacji dla rajdów, jedno nazwane miejsce dla zadań.

Plakietka na karcie alarmu

Większość kart alarmów ma plakietkę z odpowiedzią — "Wszędzie w moich obszarach", "Wszędzie, gdzie dostaję alerty", "W promieniu 5 km od mojej lokalizacji", "W promieniu 2 km od Dom", "Tylko w Terrigal, Erina". Kliknij ją, aby zmienić ten jeden alarm bez otwierania pełnego okna edycji.

Domyślne dla nowych alarmów

Nowe alarmy otwierają się domyślnie w trybie Obszary. Aby to zmienić, otwórz menu użytkownika (twój awatar w prawym górnym rogu) i wybierz Domyślne ustawienia alertów — zdecyduj, czy nowe alarmy startują w trybie Obszary czy Odległość, ustaw domyślny promień i wybierz, czy jest on mierzony od twojej lokalizacji, czy od zapisanego miejsca. Preferencja zapisuje się w przeglądarce i wypełnia też okno Szybkiego wyboru. Dotyczy wyłącznie nowo tworzonych alarmów; istniejące pozostają bez zmian, a ty nadal możesz zmieniać, gdzie zastanie cię każdy pojedynczy alarm.

Szablony powiadomień

Jeśli szablony są włączone, możesz wybrać wygląd swoich powiadomień. Selektor szablonów pokazuje podgląd na żywo tego, jak będzie wyglądać twoja wiadomość DM na Discord, w tym format osadzenia, pola i obrazy.

Tryb czyszczenia

Po włączeniu bot automatycznie usuwa powiadomienie z Discord po wygaśnięciu wydarzenia (np. Pokemon znika lub rajd się kończy). To utrzymuje porządek w twoich DM. Możesz włączyć tryb czyszczenia dla pojedynczego alarmu lub zbiorczo na stronie Czyszczenie.

Edycja na miejscu i podsumowania

Niektóre alarmy obsługują dodatkowe tryby dostarczania. Włącz Edytuj wiadomość na miejscu dla wabika, aby aktualizować istniejącą wiadomość na Discordzie po zmianie wabika zamiast wysyłać nową, lub Dzienne podsumowanie dla zadania, aby zebrać pasujące zadania w jednej wiadomości zbiorczej (wymaga skonfigurowanego harmonogramu podsumowań w bocie). Rajdy i jaja są edytowane na miejscu automatycznie po wybraniu trybu RSVP. Te ustawienia są zachowywane, nawet jeśli ustawisz je z bota.

Aktualizacje RSVP (rajdy i jajka)

Alarmy rajdów i jajek dodają ustawienie Powiadomienia RSVP w oknie dodawania/edycji z trzema opcjami: Tylko dopasowania wysyła standardowe alerty rajdów/jajek; Dopasowania + aktualizacje RSVP powiadamia także ponownie, gdy zmienią się liczby RSVP (trenerzy zgłaszający się); a Tylko aktualizacje RSVP pomija początkowe dopasowanie i powiadamia cię tylko o zmianach RSVP. Wybór dowolnego trybu RSVP sprawia, że bot edytuje istniejącą wiadomość na Discordzie na miejscu w miarę zmiany liczb, zamiast wysyłać nowe, a karta pokazuje etykietę "RSVP" lub "Tylko RSVP". Pamiętaj, że Tylko aktualizacje RSVP milczy, chyba że skaner twojej społeczności emituje zdarzenia RSVP — wybierz to tylko, jeśli wiesz, że RSVP są zgłaszane.

", + "CONTENT_QUEST_SUMMARY": "

Zadania Badań Terenowych zmieniają się codziennie i mogą pasować w dużych ilościach, więc ruchliwy filtr zadań może zalać twoje wiadomości prywatne. Dostarczanie podsumowania zadań zbiera pasujące zadania w jedno zaplanowane podsumowanie zamiast wielu osobnych powiadomień.

Dwie współpracujące części

  • Przełącznik dziennego podsumowania — włącz go dla alarmu zadania (w jego oknie dodawania/edycji), aby oznaczyć jego dopasowania do podsumowania zamiast natychmiastowego dostarczenia.
  • Harmonogram dostarczania — wybierz, kiedy zebrane zadania są wysyłane.

Oba są potrzebne: przełącznik określa, które zadania zbierać, a harmonogram określa, kiedy je dostarczyć.

Konfiguracja harmonogramu

Otwórz stronę Zadania, następnie menu na pasku narzędzi i wybierz Dostarczanie podsumowania zadań. Użyj Edytuj harmonogram, aby wybrać dni i godziny — ten sam edytor, którego używa się do aktywnych godzin profili. Zapisane godziny pojawiają się jako bursztynowe plakietki.

Harmonogram jest przypisany do użytkownika i współdzielony przez wszystkie twoje profile — w przeciwieństwie do aktywnych godzin profili, które ustawia się dla każdego profilu osobno.

Wyślij podsumowanie teraz

Wyślij podsumowanie teraz natychmiast dostarcza wszystko, co zebrano od ostatniego podsumowania. Jeśli nic jeszcze nie zebrano, nic nie zostanie wysłane — zadania są buforowane w miarę dopasowywania, więc daj temu czas lub poczekaj na uruchomienie harmonogramu.

Warto wiedzieć

  • Menu pojawia się tylko wtedy, gdy bot twojego serwera ma włączone podsumowania zadań.
  • Czas dostarczenia wykorzystuje zapisaną lokalizację do określenia strefy czasowej — ustaw lokalizację, w przeciwnym razie podsumowania mogą dotrzeć o niewłaściwej godzinie lokalnej (okno ostrzega, gdy nie ustawiono lokalizacji).
  • Usunięcie harmonogramu zachowuje przełącznik dla danego alarmu; zadania są nadal zbierane, ale wracają do domyślnego czasu bota.
", "CONTENT_TEST_ALERTS": "

Każda karta alarmu ma przycisk Test (ikona papierowego samolotu), który wysyła przykładowe powiadomienie na twojego Discord lub Telegram, używając dokładnych filtrów alarmu i twojego aktualnego szablonu dostawy.

Jak to działa

  1. Znajdź dowolną kartę alarmu na liście (Pokemon, Rajd, Zadanie itp.).
  2. Kliknij ikonę wyślij w wierszu akcji karty.
  3. Symulowane wydarzenie pasujące do filtrów twojego alarmu jest generowane i wysyłane przez system powiadomień. Otrzymasz DM tak jak prawdziwy alert.

Co jest testowane

Test używa wartości filtrów twojego alarmu (ID Pokemon, poziom rajdu, nagroda zadania itp.) i twojej zapisanej lokalizacji jako współrzędnych symulowanego wydarzenia. Powiadomienie jest formatowane przy użyciu wybranego szablonu, więc widzisz dokładnie, jak wyglądałby prawdziwy alert.

Czas odnowienia

Aby zapobiec spamowi, każdy alarm ma 15-sekundowy czas odnowienia między testowymi wysyłkami. Przycisk jest wyłączony podczas odnowienia, a pasek informacyjny pokazuje wynik (sukces, błąd lub pozostały czas odnowienia).

💡
Testowe alerty są świetne do sprawdzenia, czy twój szablon wygląda dobrze, lub potwierdzenia, że dostawa przez webhook działa, zanim będziesz czekać na prawdziwe wydarzenie.
", "CONTENT_POKEMON_AVAILABILITY": "

Podczas dodawania lub edycji alarmów Pokemon selektor Pokemon może pokazywać wskaźniki dostępności — małe odznaki informujące, które Pokemon aktualnie spawnują się na dziko.

Jak to działa

Jeśli twoja społeczność ma skonfigurowany skaner Golbat, selektor pokazuje kolorowe kropki obok nazw Pokemon:

  • Zielona kropka — Ten Pokemon był ostatnio widziany jako spawn.
  • Brak kropki — Aktualnie nie zgłoszony w danych skanera.

Pomaga to uniknąć tworzenia alarmów dla Pokemon, które aktualnie nie spawnują się w twoim obszarze (np. sezonowe lub ekskluzywne dla eventów).

Odświeżanie dostępności

Dane odświeżają się automatycznie w tle. Nie musisz nic robić — po prostu szukaj kropek podczas przeglądania selektora Pokemon.

ℹ️
Ta funkcja jest widoczna tylko wtedy, gdy twój administrator skonfigurował integrację skanera Golbat. Jeśli nie widzisz kropek dostępności, funkcja nie jest włączona dla twojej społeczności.
", "CONTENT_BULK": "\"Lista

Wszystkie strony alarmów obsługują operacje zbiorcze, dzięki czemu możesz zarządzać wieloma alarmami naraz.

Tryb zaznaczania

Kliknij ikonę listy kontrolnej na pasku narzędzi, aby wejść w tryb zaznaczania. Następnie kliknij poszczególne karty alarmów, aby je zaznaczyć, lub użyj Zaznacz wszystko, aby wybrać wszystko widoczne.

Akcje zbiorcze

  • Aktualizuj odległość — Zmień tryb dostawy (obszary lub odległość) dla wszystkich zaznaczonych alarmów naraz.
  • Usuń — Usuń wszystkie zaznaczone alarmy jednym potwierdzeniem.
💡
Na dole każdej listy alarmów znajdziesz również przyciski Aktualizuj wszystkie odległości i Usuń wszystko, które dotyczą każdego alarmu danego typu.
", - "CONTENT_QUICK_PICKS": "\"Strona

Szybki wybór to gotowe szablony alarmów stworzone przez administratorów twojej społeczności. Pozwalają skonfigurować typowe alarmy jednym kliknięciem, zamiast tworzyć każdy alarm osobno.

Stosowanie szybkiego wyboru

  1. Przejdź do Szybki wybór w panelu bocznym.
  2. Przeglądaj dostępne opcje, opcjonalnie filtrując według kategorii.
  3. Kliknij Zastosuj przy wybranym szybkim wyborze.
  4. Dostosuj przed zastosowaniem: wybierz tryb dostawy (obszary lub odległość), włącz tryb czyszczenia i opcjonalnie wyklucz konkretne Pokemon.
  5. Potwierdź, aby utworzyć wszystkie alarmy naraz.

Usuwanie alarmów szybkiego wyboru

Jeśli nie chcesz już alarmów z szybkiego wyboru, kliknij Usuń, aby usunąć wszystkie alarmy, które utworzył.

", - "CONTENT_PROFILES": "

Strona Profile to twoje centrum zarządzania profilami i przeglądania wszystkich alarmów ze wszystkich profili w jednym miejscu.

Dlaczego warto używać profili?

Profile pozwalają utrzymywać całkowicie oddzielne konfiguracje alarmów. Każdy profil ma własny zestaw alarmów, wybrane obszary, lokalizację i aktywacje niestandardowych geofence. Przydatne w różnych sytuacjach — na przykład profil \"Dom\" dla twojej okolicy i profil \"Praca\" dla otoczenia biura.

Przegląd

Strona pokazuje pasek statystyk z łącznymi liczbami alarmów według typu, pasek wyszukiwania do filtrowania we wszystkich profilach oraz chipy filtrów typów, aby pokazywać tylko konkretne typy alarmów (Pokemon, Rajdy, Zadania itp.).

Każdy profil pojawia się jako rozwijany panel. Kliknij, aby rozwinąć i zobaczyć wszystkie alarmy pogrupowane według typu, z grafikami z gry (sprite Pokemon, jajka rajdowe, ikony przynęt) i etykietami filtrów pokazującymi IV, CP, Poziom, PVP i inne ustawienia.

Zarządzanie profilami

  • Utwórz — Kliknij przycisk + w prawym górnym rogu. Nazwy profili muszą być unikalne (do 32 znaków).
  • Przełącz — Kliknij Przełącz wewnątrz panelu profilu, aby uczynić go aktywnym profilem. Aktywny profil jest oznaczony zielonym znaczkiem i lewym obramowaniem.
  • Edytuj — Kliknij ikonę ołówka, aby zmienić nazwę profilu.
  • Usuń — Kliknij ikonę kosza, aby usunąć profil i wszystkie jego alarmy. Nie możesz usunąć aktywnego profilu.

Duplikowanie

Kliknij ikonę kopiowania na dowolnym profilu, aby utworzyć dokładną kopię ze wszystkimi alarmami. Zostaniesz poproszony o nazwanie nowego profilu — sugerowana jest domyślna nazwa jak \"Profil (Kopia)\". Duplikat zawiera wszystkie filtry alarmów, ale otrzymuje nowy zestaw wybranych obszarów.

Eksport i import

  • Eksport — Kliknij ikonę pobierania na profilu, aby zapisać plik kopii zapasowej (JSON). Plik zawiera wszystkie filtry alarmów, oczyszczone z wewnętrznych identyfikatorów, więc jest przenośny.
  • Import — Kliknij przycisk Import w prawym górnym rogu, wybierz plik kopii zapasowej i wybierz nazwę dla nowego profilu. Wszystkie alarmy z kopii zapasowej zostaną przywrócone. Jeśli profil o tej samej nazwie istnieje, automatycznie dodawany jest sufiks numeryczny.

Wykrywanie duplikatów

Jeśli ten sam alarm istnieje w wielu profilach (np. śledzenie Pikachu w \"Dom\" i \"Praca\"), te alarmy są podświetlone pomarańczowym obramowaniem i ikoną kopiowania. Gdy istnieją duplikaty, chip filtra Duplikaty pojawia się na pasku filtrów — kliknij go, aby pokazać tylko zduplikowane alarmy między profilami.

⚠️
Uwaga: Usunięcie profilu trwale usuwa wszystkie alarmy w tym profilu. Nie możesz usunąć aktualnie aktywnego profilu. Rozważ wcześniejszy eksport kopii zapasowej.
", - "CONTENT_CLEANING": "\"Strona

Strona Czyszczenie pozwala kontrolować tryb czyszczenia dla wszystkich typów alarmów naraz.

Gdy tryb czyszczenia jest włączony dla typu alarmu, bot automatycznie usuwa powiadomienia z Discord po wygaśnięciu wydarzenia:

  • Pokemon — Usuwane, gdy spawn zniknie
  • Rajdy — Usuwane, gdy rajd się skończy
  • Jajka — Usuwane, gdy jajko się wykluje
  • Zadania — Usuwane, gdy zadania zresetują się o północy
  • Inwazje — Usuwane, gdy grunt odejdzie
  • Przynęty — Usuwane, gdy przynęta wygaśnie
  • Gniazda — Usuwane, gdy gniazda migrują
  • Sale — Usuwane po zmianach w sali
  • Zmiany fortów — Usuwane po wygaśnięciu powiadomienia o zmianie fortu
  • Max Battles — Usuwane, gdy walka się skończy

Użyj Włącz wszystko lub Wyłącz wszystko, aby przełączyć wszystko naraz.

💡
Zalecane: Trzymaj tryb czyszczenia włączony, aby zapobiec gromadzeniu się nieaktualnych alertów w twoich DM.
", - "CONTENT_APPEARANCE": "

Tryb ciemny / jasny

Kliknij ikonę słońca/księżyca na górnym pasku narzędzi, aby przełączać między ciemnym a jasnym motywem. Twój wybór jest zapisywany automatycznie.

\"Pasek

Kolory akcentu

Otwórz menu użytkownika (twój awatar w prawym górnym rogu) i wybierz Motyw akcentu. Wybierz spośród:

  • Domyślny — Niebieski
  • Pokemon — Zielony
  • Rajdy — Czerwony
  • Mystic — Niebieski
  • Valor — Czerwony
  • Instinct — Żółty

Kolor akcentu zmienia gradient paska narzędzi, podświetlenie aktywnej nawigacji i inne akcenty UI w całej stronie.

\"Panel

Język

Jeśli dostępne, użyj selektora języka na pasku narzędzi, aby zmienić język interfejsu. Obsługiwanych jest 18 języków.

Skróty klawiaturowe

?Pokaż skróty klawiaturowe
EscZamknij menu lub okna dialogowe
[Zwiń panel boczny
]Rozwiń panel boczny
", - "CONTENT_ALERTS_LOGOUT": "\"Menu

Wstrzymywanie alertów

Otwórz menu użytkownika (twój awatar) i kliknij Wstrzymaj alerty. Na górze strony pojawi się czerwony baner potwierdzający, że twoje alerty są wstrzymane. Nie będziesz otrzymywać żadnych powiadomień podczas wstrzymania.

Aby wznowić, kliknij Wznów alerty z menu użytkownika lub z banera.

Wylogowanie

Otwórz menu użytkownika i kliknij Wyloguj. Zostaniesz przeniesiony na stronę logowania.

", - "CONTENT_FAQ": "

\"Nie mogę się zalogować\"

Musisz zarejestrować się u bota Poracle na Discord lub Telegram zanim będziesz mógł zalogować się na tej stronie. Jeśli widzisz \"Twoje konto nie jest zarejestrowane\", skontaktuj się z administratorem społeczności w sprawie instrukcji rejestracji.

\"Nie dostaję powiadomień\"

Sprawdź te typowe przyczyny:

  1. Alerty wstrzymane — Szukaj czerwonego banera na górze strony. Wznów alerty z menu użytkownika.
  2. Brak ustawionej lokalizacji — Jeśli twoje alarmy używają trybu odległości, potrzebujesz zapisanej lokalizacji.
  3. Brak wybranych obszarów — Jeśli twoje alarmy używają trybu obszarów, upewnij się, że wybrałeś obszary na stronie Obszary.
  4. Zły profil — Możesz mieć alarmy na innym profilu. Sprawdź, który profil jest aktywny na Panelu głównym.
  5. Zbyt surowe filtry — Spróbuj złagodzić filtry IV, CP lub poziomu, aby zobaczyć, czy powiadomienia zaczną przychodzić.

\"Moje alarmy zniknęły\"

Alarmy są przypisane do profilu. Jeśli przełączyłeś profil, twoje alarmy z drugiego profilu nadal tam są — po prostu przełącz się z powrotem z Panelu głównego lub strony Profile.

\"Nie mogę kliknąć małego obszaru na mapie\"

Gdy obszary nakładają się, przybliż mapę, aby łatwiej kliknąć mniejszy obszar. Mniejsze obszary są zawsze na wierzchu większych.

\"Co robi tryb czyszczenia?\"

Tryb czyszczenia nakazuje botowi automatyczne usuwanie powiadomienia z Discord po wygaśnięciu wydarzenia (np. Pokemon znika). Bez niego stare alerty pozostają w twoich DM na zawsze. Włącz go na stronie Czyszczenie lub dla poszczególnych alarmów w zakładce Dostawa.

\"Jaka jest różnica między Obszarami a Odległością?\"

Każdy alarm używa jednego trybu dostawy. Obszary powiadamiają o wydarzeniach wewnątrz konkretnych stref geograficznych. Odległość powiadamia o wydarzeniach w promieniu od twojej zapisanej lokalizacji. Możesz mieszać oba tryby w różnych alarmach.

" + "CONTENT_QUICK_PICKS": "\"Strona

Szybki wybór to gotowe szablony alarmów stworzone przez administratorów twojej społeczności. Pozwalają skonfigurować typowe alarmy jednym kliknięciem, zamiast tworzyć każdy alarm osobno.

Stosowanie szybkiego wyboru

  1. Przejdź do Szybki wybór w panelu bocznym.
  2. Przeglądaj dostępne opcje, opcjonalnie filtrując według kategorii.
  3. Kliknij Zastosuj przy wybranym szybkim wyborze.
  4. Dostosuj przed zastosowaniem: ustal, gdzie mają cię zastać alerty — zakładka Dostawa to ten sam selektor z trzema opcjami co przy pojedynczym alarmie, więc możesz skierować je na zapisane miejsce albo podzbiór obszarów — włącz tryb czyszczenia i opcjonalnie wyklucz konkretne Pokemon.
  5. Potwierdź, aby utworzyć wszystkie alarmy naraz.

Usuwanie alarmów szybkiego wyboru

Jeśli nie chcesz już alarmów z szybkiego wyboru, kliknij Usuń, aby usunąć wszystkie alarmy, które utworzył.

", + "CONTENT_PROFILES": "

Strona Profile to twoje centrum zarządzania profilami i przeglądania wszystkich alarmów ze wszystkich profili w jednym miejscu.

Dlaczego warto używać profili?

Profile pozwalają utrzymywać całkowicie oddzielne konfiguracje alarmów. Każdy profil ma własny zestaw alarmów, wybrane obszary, lokalizację i aktywacje niestandardowych geofence. Przydatne w różnych sytuacjach — na przykład profil \"Dom\" dla twojej okolicy i profil \"Praca\" dla otoczenia biura.

Przegląd

Strona pokazuje pasek statystyk z łącznymi liczbami alarmów według typu, pasek wyszukiwania do filtrowania we wszystkich profilach oraz chipy filtrów typów, aby pokazywać tylko konkretne typy alarmów (Pokemon, Rajdy, Zadania itp.).

Każdy profil pojawia się jako rozwijany panel. Kliknij, aby rozwinąć i zobaczyć wszystkie alarmy pogrupowane według typu, z grafikami z gry (sprite Pokemon, jajka rajdowe, ikony przynęt) i etykietami filtrów pokazującymi IV, CP, Poziom, PVP i inne ustawienia.

Zarządzanie profilami

  • Utwórz — Kliknij przycisk + w prawym górnym rogu. Nazwy profili muszą być unikalne (do 32 znaków).
  • Przełącz — Kliknij Przełącz wewnątrz panelu profilu, aby uczynić go aktywnym profilem. Aktywny profil jest oznaczony zielonym znaczkiem i lewym obramowaniem.
  • Edytuj — Kliknij ikonę ołówka, aby zmienić nazwę profilu.
  • Usuń — Kliknij ikonę kosza, aby usunąć profil i wszystkie jego alarmy. Nie możesz usunąć aktywnego profilu.

Duplikowanie

Kliknij ikonę kopiowania na dowolnym profilu, aby utworzyć dokładną kopię ze wszystkimi alarmami. Zostaniesz poproszony o nazwanie nowego profilu — sugerowana jest domyślna nazwa jak \"Profil (Kopia)\". Duplikat zawiera wszystkie filtry alarmów, a jego obszary, lokalizacja i godziny aktywności są kopiowane z profilu źródłowego.

Eksport i import

  • Eksport — Kliknij ikonę pobierania na profilu, aby zapisać plik kopii zapasowej (JSON). Plik zawiera wszystkie filtry alarmów, oczyszczone z wewnętrznych identyfikatorów, więc jest przenośny.
  • Import — Kliknij przycisk Import w prawym górnym rogu, wybierz plik kopii zapasowej i wybierz nazwę dla nowego profilu. Wszystkie alarmy z kopii zapasowej zostaną przywrócone. Jeśli profil o tej samej nazwie istnieje, automatycznie dodawany jest sufiks numeryczny.

Wykrywanie duplikatów

Jeśli ten sam alarm istnieje w wielu profilach (np. śledzenie Pikachu w \"Dom\" i \"Praca\"), te alarmy są podświetlone pomarańczowym obramowaniem i ikoną kopiowania. Gdy istnieją duplikaty, chip filtra Duplikaty pojawia się na pasku filtrów — kliknij go, aby pokazać tylko zduplikowane alarmy między profilami.

⚠️
Uwaga: Usunięcie profilu trwale usuwa wszystkie alarmy w tym profilu. Nie możesz usunąć aktualnie aktywnego profilu. Rozważ wcześniejszy eksport kopii zapasowej.
", + "CONTENT_CLEANING": "\"Strona

Strona Czyszczenie pozwala kontrolować tryb czyszczenia dla wszystkich typów alarmów naraz.

Gdy tryb czyszczenia jest włączony dla typu alarmu, bot automatycznie usuwa powiadomienia z Discord po wygaśnięciu wydarzenia:

  • Pokemon — Usuwane, gdy spawn zniknie
  • Rajdy — Usuwane, gdy rajd się skończy
  • Jajka — Usuwane, gdy jajko się wykluje
  • Zadania — Usuwane, gdy zadania zresetują się o północy
  • Inwazje — Usuwane, gdy grunt odejdzie
  • Przynęty — Usuwane, gdy przynęta wygaśnie
  • Gniazda — Usuwane, gdy gniazda migrują
  • Sale — Usuwane po zmianach w sali
  • Max Battles — Usuwane, gdy walka się skończy

Użyj Włącz wszystko lub Wyłącz wszystko, aby przełączyć wszystko naraz.

💡
Zalecane: Trzymaj tryb czyszczenia włączony, aby zapobiec gromadzeniu się nieaktualnych alertów w twoich DM.
", + "CONTENT_APPEARANCE": "

Tryb ciemny / jasny

Kliknij ikonę słońca/księżyca na górnym pasku narzędzi, aby przełączać między ciemnym a jasnym motywem. Twój wybór jest zapisywany automatycznie.

\"Pasek

Kolory akcentu

Otwórz menu użytkownika (twój awatar w prawym górnym rogu) i wybierz Motyw akcentu. Wybierz spośród:

  • Domyślny — Niebieski
  • Pokemon — Zielony
  • Rajdy — Czerwony
  • Mystic — Niebieski
  • Valor — Czerwony
  • Instinct — Żółty

Kolor akcentu zmienia gradient paska narzędzi, podświetlenie aktywnej nawigacji i inne akcenty UI w całej stronie.

\"Panel

Język interfejsu

Otwórz menu użytkownika (twój awatar w prawym górnym rogu) i wybierz Język interfejsu. Dostępnych jest 11 języków. Zmienia tekst tej strony, a także nazwy, typy i formy Pokemon w listach wyboru i na twoich kartach alarmów. Jeśli nigdy nie wybrałeś żadnego, dostajesz język przeglądarki albo ten ustawiony na serwerze Poracle.

Język powiadomień

Zaraz pod nim jest Język powiadomień, osobne ustawienie. Decyduje, w jakim języku Poracle pisze twoje DM-y. Oba są niezależne: polska strona z angielskimi DM-ami, albo odwrotnie, to zupełnie normalna rzecz. Wcześniej było na stronie Obszary.

Skróty klawiaturowe

?Pokaż skróty klawiaturowe
EscZamknij menu lub okna dialogowe
[Zwiń panel boczny
]Rozwiń panel boczny
", + "CONTENT_ALERTS_LOGOUT": "\"Menu

Wstrzymywanie alertów

Otwórz menu użytkownika (twój awatar) i kliknij Wstrzymaj alerty. Na górze strony pojawi się czerwony baner potwierdzający, że twoje alerty są wstrzymane. Nie będziesz otrzymywać żadnych powiadomień podczas wstrzymania.

Aby wznowić, kliknij Wznów alerty z menu użytkownika lub z banera.

Wylogowanie

Otwórz menu użytkownika i kliknij Wyloguj. Zostaniesz przeniesiony na stronę logowania.

Jeśli logujesz się przez dostawcę SSO obsługującego pojedyncze wylogowanie, menu oferuje także Wyloguj się wszędzie — kończy to sesję również u dostawcy, nie tylko tutaj.

", + "CONTENT_FAQ": "

\"Nie mogę się zalogować\"

Musisz zarejestrować się u bota Poracle na Discord lub Telegram zanim będziesz mógł zalogować się na tej stronie. Jeśli widzisz \"Twoje konto nie jest zarejestrowane\", skontaktuj się z administratorem społeczności w sprawie instrukcji rejestracji.

\"Nie dostaję powiadomień\"

Sprawdź te typowe przyczyny:

  1. Alerty wstrzymane — Szukaj czerwonego banera na górze strony. Wznów alerty z menu użytkownika.
  2. Brak ustawionej lokalizacji — Alarm, który dociera do ciebie w promieniu, mierzy od twojej lokalizacji albo od zapisanego miejsca. Ustaw ją na stronie Obszary i miejsca.
  3. Nic w zasięgu — Sprawdź plakietkę na karcie alarmu. Mówi, gdzie alarm cię zastanie, i może wskazywać obszary, których profil już nie obejmuje.
  4. Zły profil — Możesz mieć alarmy na innym profilu. Sprawdź, który profil jest aktywny na Panelu głównym.
  5. Zbyt surowe filtry — Spróbuj złagodzić filtry IV, CP lub poziomu, aby zobaczyć, czy powiadomienia zaczną przychodzić.

\"Moje alarmy zniknęły\"

Alarmy są przypisane do profilu. Jeśli przełączyłeś profil, twoje alarmy z drugiego profilu nadal tam są — po prostu przełącz się z powrotem z Panelu głównego lub strony Profile.

\"Nie mogę kliknąć małego obszaru na mapie\"

Gdy obszary nakładają się, przybliż mapę, aby łatwiej kliknąć mniejszy obszar. Mniejsze obszary są zawsze na wierzchu większych.

\"Co robi tryb czyszczenia?\"

Tryb czyszczenia nakazuje botowi automatyczne usuwanie powiadomienia z Discord po wygaśnięciu wydarzenia (np. Pokemon znika). Bez niego stare alerty pozostają w twoich DM na zawsze. Włącz go na stronie Czyszczenie lub dla poszczególnych alarmów w zakładce Dostawa.

\"Gdzie zastanie mnie alert?\"

Każdy alarm odpowiada na to sam, w swojej zakładce Dostawa. Wszędzie w moich obszarach podąża za obszarami wybranymi w profilu. W pobliżu punktu to promień od twojej lokalizacji albo od zapisanego miejsca. Tylko w wybranych obszarach ogranicza ten jeden alarm do podzbioru obszarów. Plakietka na karcie zawsze pokazuje aktualną odpowiedź, a kliknięcie ją zmienia.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "Alerty DM", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Zaloguj się", "SIGN_IN_DESC": "Zaloguj się, aby zarządzać alarmami powiadomień Pokemon GO.", "SIGN_IN_DISCORD": "Zaloguj się przez Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Zaloguj się przez Telegram", + "SIGN_IN_OIDC": "Zaloguj się przez {{provider}}", + "SIGNED_OUT_TITLE": "Wylogowano", + "SIGNED_OUT_DESC": "Wylogowano Cię z DM Alerts.", + "PROVIDER_DISABLED_BY_ADMIN": "Ta metoda logowania została wyłączona przez administratora.", + "PROVIDER_DISABLED_HINT": "Ta metoda logowania jest wyłączona dla użytkowników bez uprawnień administratora.", + "ERR_TELEGRAM_DISABLED": "Logowanie przez Telegram jest obecnie wyłączone.", "OR": "lub", "NO_METHODS": "Żadne metody logowania nie są aktualnie włączone. Skontaktuj się z administratorem.", "AUTHENTICATING": "Uwierzytelnianie...", "FOOTER": "Zarządzaj alarmami dla Pokemon, rajdów, zadań i więcej", "AUTH_FAILED": "Uwierzytelnianie nieudane", "BACK_TO_LOGIN": "Powrót do logowania", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "Logowanie przez Discord jest obecnie wyłączone.", + "ERR_DISCORD_FETCH": "Nie udało się pobrać twojego profilu Discord. Spróbuj ponownie.", + "ERR_MISSING_CODE": "Logowanie przez Discord zostało anulowane lub się nie powiodło.", + "ERR_MISSING_ROLE": "Nie masz roli na Discordzie wymaganej do wejścia na tę stronę.", + "ERR_NOT_IN_GUILD": "Musisz być członkiem serwera Discord, aby korzystać z tej strony.", + "ERR_NOT_REGISTERED": "Twoje konto nie jest zarejestrowane. Zarejestruj się, aby zacząć.", + "ERR_OIDC_DISABLED": "Logowanie zewnętrzne jest obecnie wyłączone.", + "ERR_OIDC_NO_IDENTITY": "Twój zewnętrzny dostawca logowania nie zwrócił konta, które moglibyśmy dopasować. Upewnij się, że Twoje konto Discord jest połączone.", + "ERR_OIDC_TOKEN_EXCHANGE": "Logowanie zewnętrzne nie powiodło się. Spróbuj ponownie.", + "ERR_OIDC_USERINFO": "Nie udało się pobrać Twojego profilu od zewnętrznego dostawcy logowania. Spróbuj ponownie.", + "ERR_ROLE_CHECK_FAILED": "Nie udało się zweryfikować twoich ról na Discordzie. Spróbuj później.", + "ERR_TELEGRAM_FAILED": "Logowanie przez Telegram się nie powiodło. Spróbuj ponownie.", + "ERR_TOKEN_EXCHANGE": "Logowanie przez Discord się nie powiodło. Spróbuj ponownie.", "ERR_GENERIC": "Błąd uwierzytelniania: {{error}}", "ERR_NO_TOKEN": "Nie otrzymano tokenu uwierzytelniania.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "Zarejestruj się", + "SIGN_UP_DESC": "Nie masz konta? Zarejestruj się, aby zacząć.", + "SIGN_IN_AGAIN": "Zaloguj się ponownie" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Ta funkcja została wyłączona przez administratora." }, "ADMIN": { "USERS_TITLE": "Zarządzanie użytkownikami", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Promowana nazwa", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Nazwa dla promowanego geofence", "APPROVAL_PROMOTED_NAME_HINT": "Opcjonalne. Domyślnie używana jest aktualna nazwa wyświetlana.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Powód odrzucenia", "APPROVAL_REJECT_PLACEHOLDER": "Wyjaśnij, dlaczego ten geofence jest odrzucany...", "USERS_DESC_FULL": "Zarządzaj zarejestrowanymi użytkownikami Discord. Zatrzymany = użytkownik wstrzymał alerty lub przekroczył limity. Zablokowany = zablokowany przez admina.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Nie udało się zatwierdzić zgłoszenia", "SNACK_APPROVED": "\"{{name}}\" zatwierdzony", "SNACK_FAILED_REJECT": "Nie udało się odrzucić zgłoszenia", - "SNACK_REJECTED": "\"{{name}}\" odrzucony" + "SNACK_REJECTED": "\"{{name}}\" odrzucony", + "APPROVAL_REGION_HINT": "Wybierz region, w którym pojawi się ten geofence.", + "SERVER_TITLE": "Serwer Poracle", + "SERVER_REFRESH": "Sprawdź ponownie", + "SERVER_VERSION": "Wersja", + "SERVER_SCHEMA": "Schemat bazy danych", + "SERVER_CHECKED": "Ostatnie sprawdzenie", + "SERVER_CAPABILITIES": "Funkcje", + "SERVER_NO_CAPABILITIES": "Ten serwer nie zgłasza żadnych.", + "SERVER_UNKNOWN": "Nieznana", + "SERVER_UNREACHABLE": "Poracle nie odpowiedział. Alarmy, profile i lokalizacje działają przez niego i będą zawodzić, dopóki nie wróci.", + "SERVER_TOO_OLD": "Poracle {{version}} jest starszy niż {{minimum}}, wymagany przez tę wersję strony. Zasięg pojedynczego alarmu, filtr mega PVP i filtr pozostałego czasu będą wyglądać na zapisane, ale nic nie zmienią.", + "UPDATE_AVAILABLE": "Działa {{name}} {{running}}, a dostępna jest {{latest}}.", + "UPDATE_PRERELEASE": "{{name}} {{running}} jest nowszy niż jakiekolwiek wydanie — to kompilacja rozwojowa.", + "VERSIONS_TITLE": "Wersje", + "VERSIONS_WEB": "Ta strona", + "VERSIONS_BUILD": "Kompilacja", + "UPDATE_CURRENT": "Aktualne.", + "UPDATE_UNCOMPARABLE": "Kanał rozwojowy. Najnowsze wydanie to {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Wybierz punkt", "CANCEL": "Anuluj", "CONFIRM": "Potwierdź", "DONT_ASK_AGAIN": "Nie pytaj ponownie w tej sesji", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Aktualizuj wszystkie odległości", "DISTANCE_DESC": "Ustaw tryb lokalizacji dla wszystkich alarmów tego typu.", "DISTANCE_UPDATE_ALL": "Aktualizuj wszystko", + "DISTANCE_MUST_BE_POSITIVE": "Odległość musi być większa od zera.", "LOCATION_SAVE_ERROR": "Nie udało się zaktualizować lokalizacji", "LOCATION_SAVE_SUCCESS": "Lokalizacja zaktualizowana pomyślnie", "LOCATION_GEO_UNSUPPORTED": "Geolokalizacja nie jest obsługiwana przez twoją przeglądarkę", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Zbyt wiele testowych alertów. Poczekaj chwilę.", "ERROR_NOT_FOUND": "Alarm nie znaleziony — mógł zostać usunięty.", "ERROR_GENERIC": "Nie udało się wysłać testowego alertu. Spróbuj ponownie później.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Zbyt wiele alertów testowych. Poczekaj chwilę.", + "NOT_FOUND": "Nie znaleziono alertu — mógł zostać usunięty.", + "UNSUPPORTED": "Alerty testowe nie są dostępne dla tego typu.", + "FAILED": "Nie udało się wysłać alertu testowego. Spróbuj później." }, "COMMON": { "CANCEL": "Anuluj", @@ -1296,6 +1448,7 @@ "EDIT": "Edytuj", "ADD": "Dodaj", "OK": "OK", + "UNDO": "Cofnij", "CONFIRM": "Potwierdź", "DELETE_ALL": "Usuń wszystko", "CLOSE": "Zamknij", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Szukaj areny (opcjonalnie)", "SEARCH_HINT": "Wpisz nazwę areny...", - "CLEAR_ARIA": "Wyczyść wybór areny" + "CLEAR_ARIA": "Wyczyść wybór areny", + "RATE_LIMITED": "Zbyt wiele zapytań do skanera — zwolnij trochę." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Powiadomienia będą wysyłane dla tych obszarów:", @@ -1392,12 +1546,10 @@ "GROUP_ALARM_TYPES": "Typy alarmów", "GROUP_FEATURES": "Funkcje", "GROUP_ADMINISTRATION": "Administracja", - "GROUP_COMMANDS": "Komendy", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Mapy i zasoby", + "GROUP_OIDC": "Zewnętrzne SSO", "GROUP_ANALYTICS_LINKS": "Analityka i linki", - "GROUP_DEBUG": "Debugowanie", "GROUP_ICON_REPO": "Repozytorium ikon", "GROUP_OTHER": "Inne", "CUSTOM_TITLE_LABEL": "Tytuł witryny", @@ -1411,52 +1563,51 @@ "FAVICON_URL_PREVIEW": "Podgląd favikony (32×32)", "FAVICON_URL_CACHE_WARNING": "Przeglądarki agresywnie buforują favikony. Po zapisaniu użytkownicy muszą wyczyścić pamięć podręczną przeglądarki lub wykonać twarde odświeżenie (Ctrl+F5 / Cmd+Shift+R), aby zobaczyć nową ikonę.", "FAVICON_URL_CSP_NOTE": "Jeśli Twoja witryna używa Content Security Policy, źródło adresu URL favikony musi być dozwolone w dyrektywie img-src; w przeciwnym razie przeglądarka zablokuje pobieranie i wróci do domyślnej ikony.", + "FORCED_BY_PORACLE": "Wyłączone w konfiguracji samego Poracle. Poracle odrzuca te webhooki, a jego bot odmawia wykonania polecenia, więc nie można tego tutaj włączyć.", + "FORCED_BY_PORACLE_TOOLTIP": "Sterowane konfiguracją Poracle, a nie tą stroną.", "CUSTOM_PAGE_NAME_LABEL": "Etykieta linku nawigacyjnego", "CUSTOM_PAGE_NAME_DESC": "Etykieta niestandardowego linku nawigacyjnego (np. „Powrót do mapy”).", "CUSTOM_PAGE_URL_LABEL": "URL linku nawigacyjnego", "CUSTOM_PAGE_URL_DESC": "URL, do którego prowadzi niestandardowy link nawigacyjny.", "CUSTOM_PAGE_ICON_LABEL": "Ikona linku nawigacyjnego", "CUSTOM_PAGE_ICON_DESC": "Klasa FontAwesome dla ikony linku nawigacyjnego (np. „fas fa-map”).", - "DISABLE_MONS_LABEL": "Wyłącz Pokémony", - "DISABLE_MONS_DESC": "Ukryj zarządzanie alarmami Pokémon przed wszystkimi użytkownikami.", - "DISABLE_RAIDS_LABEL": "Wyłącz raidy", - "DISABLE_RAIDS_DESC": "Ukryj zarządzanie alarmami raidów przed wszystkimi użytkownikami.", - "DISABLE_QUESTS_LABEL": "Wyłącz zadania", - "DISABLE_QUESTS_DESC": "Ukryj zarządzanie alarmami zadań przed wszystkimi użytkownikami.", - "DISABLE_INVASIONS_LABEL": "Wyłącz inwazje", - "DISABLE_INVASIONS_DESC": "Ukryj zarządzanie alarmami inwazji przed wszystkimi użytkownikami.", - "DISABLE_LURES_LABEL": "Wyłącz wabiki", - "DISABLE_LURES_DESC": "Ukryj zarządzanie alarmami wabików przed wszystkimi użytkownikami.", - "DISABLE_NESTS_LABEL": "Wyłącz gniazda", - "DISABLE_NESTS_DESC": "Ukryj zarządzanie alarmami gniazd przed wszystkimi użytkownikami.", - "DISABLE_GYMS_LABEL": "Wyłącz gymy", - "DISABLE_GYMS_DESC": "Ukryj zarządzanie alarmami gymów przed wszystkimi użytkownikami.", - "DISABLE_FORT_CHANGES_LABEL": "Wyłącz zmiany fortów", - "DISABLE_FORT_CHANGES_DESC": "Ukryj zarządzanie alarmami zmian fortów przed wszystkimi użytkownikami.", - "DISABLE_MAXBATTLES_LABEL": "Wyłącz Max Battles", - "DISABLE_MAXBATTLES_DESC": "Ukryj zarządzanie alarmami Max Battle przed wszystkimi użytkownikami.", - "DISABLE_AREAS_LABEL": "Wyłącz obszary", - "DISABLE_AREAS_DESC": "Uniemożliw użytkownikom zarządzanie subskrypcjami obszarów.", - "DISABLE_PROFILES_LABEL": "Wyłącz profile", - "DISABLE_PROFILES_DESC": "Uniemożliw użytkownikom tworzenie profili alarmów i ich przełączanie.", - "DISABLE_LOCATION_LABEL": "Wyłącz lokalizację", - "DISABLE_LOCATION_DESC": "Uniemożliw użytkownikom ustawienie domowej lokalizacji.", - "DISABLE_NOMINATIM_LABEL": "Wyłącz geokodowanie", - "DISABLE_NOMINATIM_DESC": "Wyłącz wyszukiwanie adresów Nominatim przy wyborze lokalizacji.", - "DISABLE_GEOMAP_LABEL": "Wyłącz widok mapy", - "DISABLE_GEOMAP_DESC": "Całkowicie ukryj interaktywną mapę geofence.", - "DISABLE_GEOMAP_SELECT_LABEL": "Wyłącz wybór obszarów na mapie", - "DISABLE_GEOMAP_SELECT_DESC": "Uniemożliw użytkownikom wybór obszarów kliknięciem na mapie.", - "ENABLE_TEMPLATES_LABEL": "Włącz szablony", + "DISABLE_MONS_LABEL": "Pokémony", + "DISABLE_MONS_DESC": "Pozwól użytkownikom zarządzać alarmami Pokémon.", + "DISABLE_RAIDS_LABEL": "Raidy", + "DISABLE_RAIDS_DESC": "Pozwól użytkownikom zarządzać alarmami raidów.", + "DISABLE_QUESTS_LABEL": "Zadania", + "DISABLE_QUESTS_DESC": "Pozwól użytkownikom zarządzać alarmami zadań.", + "DISABLE_INVASIONS_LABEL": "Inwazje", + "DISABLE_INVASIONS_DESC": "Pozwól użytkownikom zarządzać alarmami inwazji.", + "DISABLE_LURES_LABEL": "Wabiki", + "DISABLE_LURES_DESC": "Pozwól użytkownikom zarządzać alarmami wabików.", + "DISABLE_NESTS_LABEL": "Gniazda", + "DISABLE_NESTS_DESC": "Pozwól użytkownikom zarządzać alarmami gniazd.", + "DISABLE_GYMS_LABEL": "Gymy", + "DISABLE_GYMS_DESC": "Pozwól użytkownikom zarządzać alarmami gymów.", + "DISABLE_FORT_CHANGES_LABEL": "Zmiany fortów", + "DISABLE_FORT_CHANGES_DESC": "Pozwól użytkownikom zarządzać alarmami zmian fortów.", + "DISABLE_MAXBATTLES_LABEL": "Max Battles", + "DISABLE_MAXBATTLES_DESC": "Pozwól użytkownikom zarządzać alarmami Max Battle.", + "DISABLE_AREAS_LABEL": "Obszary", + "DISABLE_AREAS_DESC": "Pozwól użytkownikom zarządzać subskrypcjami obszarów.", + "DISABLE_PROFILES_LABEL": "Profile", + "DISABLE_PROFILES_DESC": "Pozwól użytkownikom tworzyć i przełączać profile alarmów.", + "DISABLE_LOCATION_LABEL": "Lokalizacja", + "DISABLE_LOCATION_DESC": "Pozwól użytkownikom ustawić domową lokalizację.", + "DISABLE_NOMINATIM_LABEL": "Geokodowanie", + "DISABLE_NOMINATIM_DESC": "Zezwól na wyszukiwanie adresów Nominatim przy wyborze lokalizacji.", + "DISABLE_USER_GEOFENCES_LABEL": "Własne geofence", + "DISABLE_USER_GEOFENCES_DESC": "Pozwól użytkownikom rysować, importować i zgłaszać własne geofence. Istniejące geofence nadal działają.", + "ENABLE_TEMPLATES_LABEL": "Szablony", "ENABLE_TEMPLATES_DESC": "Pozwól użytkownikom wybierać szablony wiadomości powiadomień.", "ALLOWED_LANGUAGES_LABEL": "Dozwolone języki UI", "ALLOWED_LANGUAGES_DESC": "Kody języków oddzielone przecinkami do pokazania w selektorze (np. „en,de,fr,es”). Pozostaw puste, aby pokazać wszystkie 11 języków.", + "PORACLE_LOCALE_HINT": "Domyślny język nowych użytkowników: {{locale}}, pobrany z konfiguracji samego Poracle. Kto wybierze język lub czyja przeglądarka poprosi o dostępny tutaj, dostanie tamten.", "ENABLE_ROLES_LABEL": "Włącz dostęp oparty na rolach", "ENABLE_ROLES_DESC": "Zezwalaj na logowanie tylko użytkownikom z określonymi rolami Discord. Wymaga Bot Token i Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "Dozwolone ID ról", - "ALLOWED_ROLE_IDS_DESC": "ID ról Discord oddzielone przecinkami przyznające dostęp (np. „123456789,987654321”). Pozostaw puste, aby zezwolić wszystkim.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Dozwolone języki", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Lista oddzielonych przecinkami kodów języków, które użytkownicy mogą wybierać (np. „en,de,fr”).", + "ALLOWED_ROLE_IDS_DESC": "ID ról Discord oddzielone przecinkami, np. 123456789,987654321. Użytkownik musi mieć co najmniej jedną z tych ról, aby się zalogować. Pozostaw puste, aby zezwolić wszystkim.", "REGISTER_COMMAND_LABEL": "Komenda rejestracji", "REGISTER_COMMAND_DESC": "Komenda bota Poracle uruchamiana przez użytkowników w celu rejestracji (np. „$!register”).", "LOCATION_COMMAND_LABEL": "Komenda lokalizacji", @@ -1464,9 +1615,30 @@ "ENABLE_TELEGRAM_LABEL": "Włącz logowanie Telegram", "ENABLE_TELEGRAM_DESC": "Zezwól na logowanie Telegram na tej stronie. Wymaga TELEGRAM_ENABLED=true, bot token i bot username w .env (wymagany restart serwera po zmianach w .env).", "TELEGRAM_BOT_LABEL": "Nazwa użytkownika bota", - "TELEGRAM_BOT_DESC": "Nazwa użytkownika bota Telegram (bez @).", + "TELEGRAM_BOT_DESC": "Nazwa użytkownika bota Telegram (bez @). Używane, gdy TELEGRAM_BOT_USERNAME nie jest skonfigurowane.", "ENABLE_DISCORD_LABEL": "Włącz logowanie Discord", "ENABLE_DISCORD_DESC": "Zezwól na logowanie Discord na tej stronie. Wymaga Discord Client ID i Client Secret w .env (wymagany restart serwera po zmianach w .env). Nie wpływa na dostarczanie bota PoracleNG.", + "ENABLE_OIDC_LABEL": "Włącz logowanie przez zewnętrzne SSO", + "ENABLE_OIDC_DESC": "Zezwól na logowanie za pomocą skonfigurowanego zewnętrznego dostawcy OIDC/OAuth2. Wymaga ustawień OIDC_* (adresy URL dostawcy, client ID i secret) w .env (wymagany restart serwera po zmianach w .env).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Wszyscy użytkownicy są przekierowywani do zewnętrznego dostawcy SSO. Logowanie lokalne jest pomijane.", + "AUTH_MODE_SWITCH_CONFIRM": "Przełącz na SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Przełączyć na logowanie SSO?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Po zapisaniu wszyscy użytkownicy (w tym administratorzy) zostaną przekierowani do {{provider}} w celu zalogowania — lokalna strona logowania Discord/Telegram jest pomijana. Jeśli dostawca jest nieosiągalny, możesz zostać zablokowany; odzyskaj dostęp, ustawiając AUTH_FORCE_LOCAL=true w środowisku serwera.", + "AUTH_OIDC_NOT_CONFIGURED": "SSO jest niedostępne, dopóki dostawca OIDC nie zostanie skonfigurowany w środowisku serwera (zmienne środowiskowe OIDC_*).", + "AUTH_OIDC_HIDES_LOCAL": "Discord i Telegram są ukryte, gdy SSO jest aktywnym trybem logowania.", + "AUTH_SLO_LABEL": "Pojedyncze wylogowanie", + "AUTH_SLO_DESC": "Gdy włączone, „Wyloguj się wszędzie” kończy także sesję u dostawcy (nie tylko na tej stronie). Wymaga punktu końcowego zakończenia sesji dostawcy (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Pojedyncze wylogowanie jest niedostępne, dopóki nie zostanie skonfigurowany punkt końcowy zakończenia sesji dostawcy (zmienna środowiskowa OIDC_END_SESSION_URL).", + "OIDC_SERVER_CONFIG": "Konfiguracja dostawcy OIDC", + "OIDC_PROVIDER_LABEL": "Nazwa dostawcy", + "OIDC_AUTHORIZATION_URL_LABEL": "Authorization URL", + "OIDC_TOKEN_URL_LABEL": "Token URL", + "OIDC_USERINFO_URL_LABEL": "UserInfo URL", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Zakresy", + "OIDC_IDENTITY_CLAIM_LABEL": "Oświadczenie tożsamości", + "OIDC_USE_PKCE_LABEL": "Użyj PKCE", "PROVIDER_URL_LABEL": "URL kafelków mapy", "PROVIDER_URL_DESC": "Szablon URL dostawcy kafelków mapy (używany do map statycznych).", "GANALYTICSID_LABEL": "ID Google Analytics", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "ID administratorów", "DISCORD_ADMIN_IDS_DESC": "ID użytkowników Discord z dostępem administratora (zamaskowane).", "DISCORD_GEOFENCE_FORUM_LABEL": "Kanał forum geofence", - "DISCORD_GEOFENCE_FORUM_DESC": "Kanał forum Discord dla wątków zgłoszeń geofence." + "DISCORD_GEOFENCE_FORUM_DESC": "Kanał forum Discord dla wątków zgłoszeń geofence.", + "SEARCH_PLACEHOLDER": "Szukaj ustawień…", + "SEARCH_CLEAR": "Wyczyść wyszukiwanie", + "UNSAVED_CHANGES": "{{count}} niezapisanych", + "SAVE_CHANGES": "Zapisz zmiany", + "DISCARD_CHANGES": "Odrzuć", + "COLLAPSE_SECTION": "Zwiń sekcję", + "EXPAND_SECTION": "Rozwiń sekcję", + "SUMMARY_ENABLED": "Włączono {{count}} z {{total}}", + "GROUP_AUTH": "Uwierzytelnianie", + "AUTH_MODE_LABEL": "Tryb logowania", + "AUTH_MODE_LOCAL": "Lokalne", + "AUTH_MODE_LOCAL_DESC": "Zaloguj się bezpośrednio przez Discord lub Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "Logowanie lokalne jest wymuszone przez konfigurację serwera.", + "DISABLE_UPDATE_CHECK_LABEL": "Nie sprawdzaj aktualizacji", + "DISABLE_UPDATE_CHECK_DESC": "Wyłącza pytanie GitHuba o nowsze wydanie PoracleWeb lub Poracle. To jedyne żądanie wychodzące poza twoją sieć i nie wysyła żadnych danych." }, "GEOFENCE_DETAIL": { "NAME": "Nazwa", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Twoja lokalizacja", "SELECTED_COUNT": "Zaznaczono {{count}}:", "AREAS_SELECTED": "Wybrano {{count}} obszar(ów)" + }, + "ALERT_DEFAULTS": { + "TITLE": "Domyślne ustawienia alertów", + "DESC": "Wybierz, jak domyślnie dostarczane są nowe alerty. Nadal możesz to zmienić dla każdego alertu podczas jego tworzenia.", + "DEFAULT_DISTANCE": "Domyślna odległość", + "DEFAULT_DISTANCE_HINT": "Używana do wstępnego wypełnienia promienia dla nowych alertów opartych na odległości.", + "FOOTNOTE": "Dotyczy tylko nowo utworzonych alertów — istniejące pozostają bez zmian.", + "DISTANCE_TOO_SMALL": "Musi wynosić co najmniej 0,1 km.", + "DISTANCE_TOO_LARGE": "Musi wynosić maksymalnie 100 km." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Elementów na stronę:", + "RANGE": "{{start}} - {{end}} z {{total}}", + "RANGE_EMPTY": "0 z {{total}}", + "NEXT_PAGE": "Następna strona", + "PREVIOUS_PAGE": "Poprzednia strona", + "FIRST_PAGE": "Pierwsza strona", + "LAST_PAGE": "Ostatnia strona" + }, + "WHERE": { + "SET_PIN": "Ustaw lokalizację", + "PIN_MISSING_WARNING": "Nie ustawiono jeszcze lokalizacji, więc ten alert nie miałby punktu odniesienia.", + "PLACES_EMPTY_TITLE": "Brak miejsc", + "PIN_UNSET": "Nie ustawiono", + "PLACES_PAGE_DESC": "Nazwane punkty, na które możesz kierować alerty zamiast na swoją lokalizację.", + "ADD_PLACE": "Dodaj miejsce", + "AREAS_LABEL": "Obszary", + "AREA_LIST_MORE": "{{areas}} i jeszcze {{count}}", + "MEASURED_FROM": "Mierzone od", + "MY_PIN": "Moja lokalizacja", + "NAME_PLACE_MESSAGE": "Jak nazwać to miejsce?", + "NAME_PLACE_TITLE": "Nazwij to miejsce", + "NEAR_PIN": "W promieniu {{distance}} km od mojej lokalizacji", + "NEAR_PLACE": "W promieniu {{distance}} km od {{place}}", + "NO_PLACES": "Brak miejsc. Dodaj jedno poniżej, aby skierować ten alert poza swoją lokalizację.", + "ONLY_IN": "Tylko w {{areas}}", + "OPTION_AREAS": "Tylko w wybranych obszarach", + "OPTION_NEAR": "W pobliżu punktu", + "OPTION_PLACE": "W pobliżu miejsca", + "OPTION_PROFILE": "Wszędzie w moich obszarach", + "PIN_NOTE": "Ustawienie zapasowe dla każdego alertu bez własnego celu.", + "PIN_TITLE": "Moja lokalizacja", + "PLACES_EMPTY": "Dodaj jedno, aby dostawać alerty poza swoją lokalizacją: praca, siłownia, dom rodziców.", + "PLACES_TITLE": "Miejsca", + "PLACE_DELETED": "Usunięto {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerty wskazujące {{place}} wrócą do twojej lokalizacji.", + "PLACE_DELETE_ERROR": "Nie udało się usunąć tego miejsca.", + "PLACE_DELETE_TITLE": "Usunąć to miejsce?", + "PLACE_IN_USE": "{{place}} jest używane przez {{count}} alert(ów). Najpierw je przekieruj.", + "PLACE_LABEL": "Miejsce", + "PLACE_NAME": "Nazwa", + "PLACE_SAVED": "Zapisano {{place}}.", + "PLACE_SAVE_ERROR": "Nie udało się zapisać tego miejsca.", + "PROFILE_ANYWHERE": "Wszędzie, gdzie dostaję alerty", + "PROFILE_AREAS": "Wszędzie w moich obszarach", + "RADIUS_KM": "Promień (km)", + "SAVE": "Ustaw zasięg", + "SCOPE_SAVED": "Zasięg zaktualizowany.", + "SCOPE_SAVE_ERROR": "Nie udało się zmienić zasięgu tego alertu.", + "SHEET_TITLE": "Gdzie ma cię zastać ten alert?", + "USE_THIS_POINT": "Użyj tego punktu" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json index fe575a29..d3e3b45e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json @@ -16,7 +16,7 @@ "GYMS": "Ginásios", "FORT_CHANGES": "Mudanças de Forte", "PROFILES": "Perfis", - "AREAS": "Áreas", + "AREAS": "Áreas e locais", "MY_GEOFENCES": "Minhas Geofences", "CLEANING": "Limpeza", "HELP": "Ajuda", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Visualizando como", - "BACK_TO_ADMIN": "Voltar ao Admin", + "EXIT_IMPERSONATION": "Voltar para sua conta", "DISABLED_ACCOUNT": "Sua conta foi desativada. Isso pode ser devido a limite de requisições ou uma ação administrativa.", + "DISABLED_ACCOUNT_INSPECTED": "Esta conta foi desativada por um administrador e não recebe notificações.", "DISABLED_SUPPORT": "Para obter ajuda, pergunte em", "PAUSED_ALERTS": "Seus alertas estão pausados. Você não receberá notificações.", "RESUME": "Retomar" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Altera apenas o texto deste site.", "PROFILE_PREFIX": "Perfil #", "PAUSE_ALERTS": "Pausar Alertas", "RESUME_ALERTS": "Retomar Alertas", "SWITCH_PROFILE": "Trocar Perfil", - "AREAS_LOCATION": "Áreas e Localização", "CLEANING": "Limpeza", "ACCENT_THEME": "Tema de Destaque", - "LANGUAGE": "Idioma", + "DISPLAY_LANGUAGE": "Idioma da interface", + "ALERT_LANGUAGE": "Idioma dos alertas", + "ALERT_LANGUAGE_HINT": "Usado no texto dos alertas e nos nomes dos Pokemon.", "LOGOUT": "Sair", + "LOGOUT_EVERYWHERE": "Sair de todos os lugares", "ACCENT_DEFAULT": "Padrão", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Padrões de alertas" }, "SHORTCUTS": { "TITLE": "Atalhos do Teclado", @@ -77,6 +82,7 @@ "NETWORK": "Não foi possível conectar ao servidor. Verifique sua conexão.", "BAD_REQUEST": "Requisição inválida. Verifique seus dados.", "UNAUTHORIZED": "Sua sessão expirou. Faça login novamente.", + "INSPECTION_ENDED": "Inspeção encerrada — você voltou à sua própria sessão.", "FORBIDDEN": "Você não tem permissão para realizar esta ação.", "NOT_FOUND": "O recurso solicitado não foi encontrado.", "CONFLICT": "Ocorreu um conflito. O item pode ter sido modificado.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Onboarding de boas-vindas" }, "POKEMON": { + "PVP_EVOLUTION": "Megaevolução", + "PVP_EVOLUTION_HINT": "Classifique as formas base ou uma mega. As megas são classificadas à parte, então uma regra mega não corresponde a uma forma base.", + "PVP_EVO_BASE": "Base", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Alarmes de Pokemon", "PAGE_DESC": "Rastreie spawns selvagens de Pokemon com filtros personalizados de IV, CP, nível e PVP.", "SEARCH_PLACEHOLDER": "Buscar por nome ou #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Forma e Gênero", "LABEL_FORM": "Forma", "ALL_FORMS": "Todas as Formas", + "FORM_MULTI_HINT": "Deixe vazio para incluir todas as formas", "LABEL_GENDER": "Gênero", "GENDER_ALL": "Todos", "GENDER_MALE": "Macho", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Só alertar se o CP evoluído atingir este mínimo", "PVP_DISABLED_HINT": "Selecione uma liga para filtrar por rank PVP.", "SNACK_CREATED": "{{count}} alarme(s) de Pokemon criado(s)", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} alarme(s) de Pokemon criado(s), {{duplicates}} ja monitorado(s)", "SNACK_UPDATED": "Alarme de Pokemon atualizado", "SNACK_DELETED": "Alarme de Pokemon excluído", "SNACK_DELETED_ALL": "Todos os alarmes de Pokemon excluídos", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Limite de nível", + "PVP_CAP_ALL": "Todos", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Padrão — da configuração do Poracle", + "FILTER_TIME_LEFT": "Tempo Restante", + "LABEL_MIN_TIME": "Tempo restante mínimo", + "MIN_TIME_HINT": "Ignora aparições que somem antes de você chegar.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "restam {{count}} min", + "PILL_TIME_LEFT_SECONDS": "restam {{count}} s", + "MIN_TIME_ANY": "Qualquer" }, "ALARM": { "LOCATION_MODE": "Modo de Localização", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Exclui automaticamente a notificação do Discord após a isca expirar", "CLEAN_HINT_NEST": "Exclui automaticamente a notificação do Discord quando os ninhos migram", "CLEAN_HINT_GYM": "Exclui automaticamente a notificação do Discord após mudança de atividade do ginásio", - "CLEAN_HINT_FORT": "Exclui automaticamente a notificação do Discord após expirar", "CLEAN_HINT_MAX_BATTLE": "Exclui automaticamente a notificação do Discord após a batalha max acabar", "SAVING": "Salvando...", "SAVE": "Salvar", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Cooldown ativo", "TEST_SEND": "Enviar notificação de teste", "TAB_DELIVERY": "Entrega", - "COMMON_SETTINGS": "Configurações comuns" + "COMMON_SETTINGS": "Configurações comuns", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} criados, {{duplicates}} ja monitorados" }, "RAIDS": { + "RSVP_LABEL": "Notificações RSVP", + "RSVP_OFF": "Apenas correspondências", + "RSVP_INCLUDE": "Correspondências + atualizações RSVP", + "RSVP_ONLY": "Apenas atualizações RSVP", + "RSVP_OFF_DESC": "Apenas alertas padrão de raid/ovo.", + "RSVP_INCLUDE_DESC": "Também notificar novamente quando as contagens de RSVP mudarem.", + "RSVP_ONLY_DESC": "Pular correspondências iniciais; notificar apenas alterações de RSVP. Sem um scanner que emita RSVP, esse alarme fica em silêncio.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Apenas RSVP", "PAGE_TITLE": "Alarmes de Raid e Ovo", "PAGE_DESC": "Receba notificações sobre chefes de raid e eclosões de ovos em ginásios próximos.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Tem certeza que deseja excluir TODOS os alarmes de raid e ovo? Esta ação não pode ser desfeita.", "CONFIRM_BULK_DELETE_TITLE": "Excluir Alarmes Selecionados", "CONFIRM_BULK_DELETE_MSG": "Tem certeza que deseja excluir {{count}} alarmes?", - "CONFIRM_DELETE_SELECTED": "Excluir Selecionados" + "CONFIRM_DELETE_SELECTED": "Excluir Selecionados", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Nível", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Padrão", + "SECTION_SPECIAL": "Especiais", + "SECTION_CUSTOM": "Personalizados", + "ADD": "Adicionar nível", + "ADD_PLACEHOLDER": "ex. 42", + "ADD_HELP": "Qualquer inteiro positivo que seu servidor use. 9000 significa \"qualquer nível\".", + "INVALID": "O nível precisa ser 1 ou maior.", + "DUPLICATE": "O nível {{value}} já está na lista.", + "SR_REMOVE": "Remover o nível personalizado {{value}}", + "REMOVED": "Nível {{value}} removido", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes de Quest", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Mega Energia", "TAB_CANDY": "Doce", "ITEM_REWARD": "Recompensa de Item", - "ANY_ITEM": "Qualquer Item", + "ANY_ITEM": "Qualquer item", "QUEST_TYPE_LABEL": "Tipo de Quest:", "SNACK_CREATED": "Alarme de quest criado", "SNACK_UPDATED": "Alarme de quest atualizado", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Todos os alarmes de quest excluídos", "SNACK_FAILED_DELETE_ALL": "Falha ao excluir alarmes", "SNACK_FAILED_DISTANCE": "Falha ao atualizar distâncias", - "CONFIRM_DELETE_SELECTED": "Excluir Selecionados" + "CONFIRM_DELETE_SELECTED": "Excluir Selecionados", + "SUMMARY_MODE": "Resumo diário", + "SUMMARY_HINT": "Reúne as missões correspondentes em uma única mensagem de resumo em vez de uma notificação para cada uma. Requer um agendamento de resumo configurado no bot.", + "SUMMARY_BADGE": "Resumo", + "SUMMARY_SCHEDULE": "Entrega do resumo de missões", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Resumo de missões", + "SUMMARY_SCHEDULE_EMPTY": "Nenhum agendamento de resumo definido. As missões são entregues individualmente.", + "SUMMARY_SCHEDULE_EDIT": "Editar agendamento", + "SUMMARY_SCHEDULE_CLEAR": "Remover agendamento", + "SUMMARY_SCHEDULE_SEND_NOW": "Enviar resumo agora", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Envia as correspondências de missões coletadas desde o seu último resumo. Se ainda não houver nada em buffer, nada é enviado.", + "SUMMARY_SCHEDULE_SAVED": "Agendamento de resumo salvo", + "SUMMARY_SCHEDULE_CLEARED": "Agendamento de resumo removido", + "SUMMARY_SCHEDULE_SENT": "Resumo enviado", + "SUMMARY_SCHEDULE_FAILED": "Não foi possível atualizar o agendamento do resumo", + "SUMMARY_SCHEDULE_UNAVAILABLE": "A entrega de resumos está temporariamente indisponível. Tente novamente mais tarde.", + "SUMMARY_DISABLED_HINT": "O agendamento de resumos não está disponível neste servidor.", + "TAB_STARDUST": "Poeira Estelar", + "MIN_AMOUNT": "Quantidade mínima", + "MIN_AMOUNT_HINT": "0 = qualquer quantidade", + "MIN_STARDUST": "Poeira estelar mínima", + "MIN_STARDUST_HINT": "0 = qualquer tarefa de poeira estelar", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Alarmes de Invasão", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnético", "TYPE_RAINY": "Chuvoso", "TYPE_GOLDEN": "Dourado", - "TYPE_UNKNOWN": "Módulo #{{id}}" + "TYPE_UNKNOWN": "Módulo #{{id}}", + "EDIT_MODE": "Editar a mensagem no local", + "EDIT_HINT": "Atualiza a mensagem existente do Discord quando a isca muda em vez de enviar uma nova.", + "EDIT_BADGE": "Editar", + "CONFIRM_DELETE_TITLE": "Excluir alarme de isca?", + "SNACK_FAILED_DISTANCE": "Não foi possível atualizar a distância." }, "NESTS": { "PAGE_TITLE": "Alarmes de Ninho", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Alarme de ninho excluído", "SNACK_FAILED_CREATE": "Falha ao criar alarme", "SNACK_FAILED_UPDATE": "Falha ao atualizar alarme", - "SNACK_FAILED_DELETE": "Falha ao excluir alarme" + "SNACK_FAILED_DELETE": "Falha ao excluir alarme", + "CONFIRM_DELETE_TITLE": "Excluir alarme de ninho?", + "SNACK_FAILED_DISTANCE": "Não foi possível atualizar a distância." }, "GYMS": { "PAGE_TITLE": "Alarmes de Ginásio", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Sabedoria", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinto", - "TEAM_UNKNOWN": "Time {{id}}" + "TEAM_UNKNOWN": "Time {{id}}", + "CONFIRM_DELETE_TITLE": "Excluir alarme de ginásio?", + "SNACK_FAILED_DISTANCE": "Não foi possível atualizar a distância." }, "FORT_CHANGES": { "PAGE_TITLE": "Alarmes de Mudança de Forte", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Removido", "CHANGE_NEW": "Novo forte", "INCLUDE_EMPTY": "Incluir fortes sem nome", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Não foi possível criar o alerta", + "CREATE_SUCCESS": "Alerta de mudanças de ginásio criado", + "UPDATE_FAILED": "Não foi possível atualizar o alerta", + "UPDATE_SUCCESS": "Alerta de mudanças de ginásio atualizado", "ALL_CHANGES": "Todas as alterações", "LABEL_NAME": "Nome", "LABEL_LOCATION": "Localização", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Excluir o alarme de alteração {{type}}?", "SNACK_DELETED": "Alarme de alteração excluído", "SNACK_FAILED_DISTANCE": "Falha ao atualizar as distâncias", - "SNACK_ALL_DISTANCE": "Todas as distâncias atualizadas" + "SNACK_ALL_DISTANCE": "Todas as distâncias atualizadas", + "FORT_TYPE_LABEL": "Tipo de forte", + "CHANGE_TYPES_LABEL": "Tipos de mudança", + "TRACKING_SUBTITLE": "Monitoramento de mudanças de forte", + "CHANGE_DESCRIPTION": "Descrição alterada" }, "MAX_BATTLES": { "PAGE_TITLE": "Alarmes de Batalha Max", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Não foi possível criar os alertas", + "CREATE_SUCCESS": "{{count}} alerta(s) criado(s)", "ANY_POKEMON": "Qualquer Pokémon", "ANY_LEVEL": "Qualquer nível", "STAR_LABEL": "{{stars}} estrelas", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Falha ao atualizar as distâncias", "SNACK_ALL_DISTANCE": "Todas as distâncias atualizadas", "SNACK_FAILED_UPDATE": "Falha ao atualizar o alarme", - "SNACK_UPDATED": "Alarme de Batalha Max atualizado" + "SNACK_UPDATED": "Alarme de Batalha Max atualizado", + "HINT_BY_LEVEL": "Acompanha qualquer Pokemon nesses níveis de batalha. Cada nível escolhido vira um alarme próprio.", + "HINT_BY_POKEMON": "Acompanha Pokemon específicos em Batalhas Max, em qualquer nível.", + "HINT_GMAX_ONLY_ADD": "Avisa só sobre batalhas Gigantamax dos Pokemon escolhidos.", + "HINT_GMAX_ONLY_EDIT": "Avisa só sobre batalhas Gigantamax deste Pokemon.", + "HINT_ALL_LEVELS": "Este alarme acompanha um Pokemon em todos os níveis de Batalha Max.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Áreas e Localização", + "MANAGE_PLACES": "Gerenciar locais", + "PAGE_TITLE": "Áreas e locais", "PAGE_DESC": "Controle onde você recebe notificações.", "METHOD_AREAS": "Áreas", "METHOD_AREAS_ACTIVE": "{{count}} área(s) ativa(s)", "METHOD_NOT_CONFIGURED": "Não configurado", "METHOD_AREAS_DESC": "Receba notificações sobre tudo que acontece dentro das suas zonas de geofence selecionadas.", "METHOD_AREAS_TIP": "Melhor para: cobrir cidades inteiras, bairros ou parques", - "METHOD_LOCATION": "Localização", - "METHOD_LOCATION_NOT_SET": "Não definida", - "METHOD_LOCATION_DESC": "Receba notificações sobre tudo dentro de uma distância definida da sua localização fixada.", + "METHOD_LOCATION": "Minha localização", + "METHOD_LOCATION_NOT_SET": "Sem localização definida", + "METHOD_LOCATION_DESC": "Receba alertas sobre tudo dentro de uma distância definida da sua localização.", "METHOD_LOCATION_TIP": "Melhor para: alertas perto de casa, trabalho ou um local específico", "CLEAR_LOCATION": "Limpar", "CHANGE_LOCATION": "Alterar", "SET_LOCATION": "Definir", "METHOD_NOTE": "Cada alarme escolhe um método na aba Entrega.", + "NOTIFICATION_LANGUAGE": "Idioma das notificações", + "NOTIFICATION_LANGUAGE_DESC": "O idioma que o Poracle usa para suas mensagens de alerta e nomes de Pokémon. É diferente do idioma de exibição no menu superior.", + "SNACK_LANGUAGE_UPDATED": "Idioma das notificações atualizado", + "SNACK_LANGUAGE_FAILED": "Falha ao atualizar o idioma das notificações", "SELECT_AREAS": "Selecionar Áreas", "MAP_VIEW": "Mapa", "LIST_VIEW": "Lista", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Falha ao atualizar localização", "SEARCH_AREAS": "Buscar áreas", "MANUAL_ADD_PLACEHOLDER": "Digite um nome de área e pressione Enter", - "FILTER_PLACEHOLDER": "Filtrar por nome..." + "FILTER_PLACEHOLDER": "Filtrar por nome...", + "SNACK_LOAD_SELECTED_FAILED": "Não foi possível carregar suas áreas atuais. Recarregue antes de alterá-las.", + "SELECTION_UNKNOWN": "Suas áreas atuais não foram carregadas — recarregue a página antes de salvar." }, "PROFILES": { "PAGE_TITLE": "Perfis", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Selecionar Região", "SEARCH_REGIONS": "Buscar regiões...", "TOGGLE_TOOLTIP": "Ativar/desativar notificações para este geofence no perfil atual", - "CREATED_PREFIX": "Criado" + "CREATED_PREFIX": "Criado", + "REGION_OPTIONAL_HINT": "Opcional. Escolha uma região se sua geocerca pertencer a uma." }, "CLEANING": { "PAGE_TITLE": "Modo de Limpeza", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Parte do conteúdo de ajuda pode ainda não estar disponível no seu idioma.", "TRANSLATION_CTA_LINK": "Ajude a traduzir", "FALLBACK_CHIP": "Inglês", + "IMAGE_ENLARGE": "Clique para ampliar", "SECTION_GETTING_STARTED": "Primeiros Passos", "SECTION_GETTING_STARTED_SUB": "Login, assistente de configuração e setup inicial", "SECTION_DASHBOARD": "Painel", "SECTION_DASHBOARD_SUB": "Sua visão geral de alarmes, áreas e status", "SECTION_LOCATION": "Definindo Sua Localização", "SECTION_LOCATION_SUB": "GPS, busca de endereço e coordenadas", - "SECTION_AREAS": "Escolhendo Suas Áreas", - "SECTION_AREAS_SUB": "Visão em mapa, visão em lista e filtragem por região", + "SECTION_AREAS": "Áreas e locais", + "SECTION_AREAS_SUB": "Visão em mapa, visão em lista, filtragem por região e locais", "SECTION_GEOFENCES": "Geofences Personalizadas", "SECTION_GEOFENCES_SUB": "Desenhe limites, envie para aprovação pública", "SECTION_POKEMON": "Alarmes de Pokemon", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Outros Tipos de Alarme", "SECTION_OTHER_ALARMS_SUB": "Raids, ovos, quests, rockets, iscas, ninhos, ginásios, mudanças de forte", "SECTION_DELIVERY": "Configurações de Entrega", - "SECTION_DELIVERY_SUB": "Áreas vs distância, templates e modo de limpeza", + "SECTION_DELIVERY_SUB": "Alcance de entrega, templates e modo de limpeza", + "SECTION_QUEST_SUMMARY": "Entrega do resumo de missões", + "SECTION_QUEST_SUMMARY_SUB": "Agrupe missões barulhentas em um único resumo agendado", "SECTION_TEST_ALERTS": "Alertas de Teste", "SECTION_TEST_ALERTS_SUB": "Envie notificações de amostra para visualizar seus alarmes", "SECTION_POKEMON_AVAILABILITY": "Disponibilidade de Pokemon", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Problemas comuns e como resolvê-los", "CONTENT_GETTING_STARTED": "

O site DM Alerts permite que você personalize exatamente quais notificações do Pokemon GO você recebe como mensagens diretas. Em vez de receber todos os alertas, você escolhe o que importa para você — Pokemon específicos, raids, quests e mais — e só recebe notificações sobre eles.

ℹ️
Antes de usar o site, você precisa se registrar com o bot Poracle no Discord ou Telegram primeiro. Depois de registrado, volte aqui e faça login.

Fazendo Login

  • Discord — Clique em \"Sign in with Discord\" na página de login. Você será levado ao Discord para autorizar o app, e depois redirecionado de volta automaticamente.
  • Telegram — Se ativado, use o widget de login do Telegram na página de login. Confirme o login no seu app do Telegram.
\"Página

Configuração Inicial

Quando você faz login pela primeira vez, um assistente de boas-vindas te guia por três passos:

  1. Defina sua localização — Usada para calcular distâncias para notificações próximas.
  2. Escolha suas áreas — Selecione as zonas geográficas das quais você quer receber alertas.
  3. Adicione seu primeiro alarme — Crie um alarme de Pokemon, Raid ou Quest para começar a receber notificações.
\"Assistente

Você pode pular qualquer passo e voltar depois. O assistente não aparecerá novamente depois que você fechá-lo ou completar todos os passos.

", "CONTENT_DASHBOARD": "\"Painel

O Painel é sua base. Ele mostra uma visão geral da sua configuração atual.

Cartões de Status

  • Localização — Mostra suas coordenadas salvas ou endereço. Clique para definir ou atualizar sua localização.
  • Áreas Ativas — Mostra quantas áreas você está monitorando. Clique para gerenciar suas áreas.
  • Perfil — Mostra seu perfil ativo. Se você tem vários perfis, clique para alternar entre eles.

Filtros Ativos

Uma grade de cartões mostra quantos alarmes você tem para cada tipo (Pokemon, Raids, Quests, etc.). Clique em qualquer cartão para ir à lista de alarmes.

Clima

Se você tem uma localização definida, o painel mostra o clima atual do jogo nas suas coordenadas junto com o horário da última atualização. O clima das áreas também é exibido para cada uma das suas áreas selecionadas, para que você possa ver as condições climáticas em todas as zonas que monitora.

Ações Rápidas

Botões de atalho para adicionar alarmes de Pokemon, Raid ou Quest, gerenciar áreas ou configurar limpeza — tudo sem navegar pelo painel lateral.

Dicas

Lembretes úteis aparecem quando sua configuração está incompleta — como localização faltando, nenhuma área selecionada ou nenhum alarme configurado. Cada dica tem um botão de ação para corrigir. Você pode dispensar dicas que não precisa.

Navegação

Use o painel lateral para navegar entre seções. Tipos de alarme estão listados no topo, seguidos por configurações como Áreas, Geofences, Perfis e Limpeza. Ajuda está sempre no final.

\"Painel", - "CONTENT_LOCATION": "\"Painel

Sua localização é usada para notificações baseadas em distância. Quando um alarme usa o modo \"Definir Distância\", você recebe notificações sobre eventos dentro de um raio desta localização.

Definindo Sua Localização

Abra a caixa de diálogo de localização a partir do Painel ou da página de Áreas. Você tem quatro formas de defini-la:

  • Buscar por endereço — Digite um endereço, cidade ou nome de ponto de referência. Selecione das sugestões que aparecem.
  • Inserir coordenadas — Digite latitude e longitude diretamente se você souber.
  • Usar seu GPS — Clique em \"Use My Location\" para usar a localização atual do seu dispositivo. Seu navegador pedirá permissão.
  • Clicar no mapa — Clique em qualquer lugar no mini-mapa para definir esse ponto como sua localização.

Depois de selecionar uma localização, o endereço é mostrado automaticamente. Clique em Salvar para confirmar.

💡
Você pode limpar sua localização na página de Áreas se quiser apenas alertas baseados em áreas.
", - "CONTENT_AREAS": "\"Página

Áreas são zonas geográficas predefinidas configuradas pela sua comunidade. Quando um alarme usa o modo \"Usar Áreas\", você recebe notificações sobre eventos que acontecem dentro das suas áreas selecionadas.

Selecionando Áreas

Vá para Áreas e Localização no painel lateral. Você pode selecionar áreas de duas formas:

  • Vista do mapa — Clique nos polígonos coloridos no mapa para selecionar ou desmarcar áreas. Áreas selecionadas ficam verdes. Passe o mouse sobre qualquer área para ver seu nome.
  • Vista de lista — Use caixas de seleção para escolher áreas de uma lista pesquisável.

Filtragem por Região

Se sua comunidade tem muitas áreas em diferentes regiões, use o menu suspenso de região para dar zoom em uma região específica. Isso facilita encontrar áreas perto de você.

Áreas Aninhadas

Algumas áreas se sobrepõem — uma zona menor dentro de uma maior. Ambas são clicáveis. Dê zoom para facilitar o clique na área menor.

Salvando

Uma barra de salvamento aparece na parte inferior quando você faz alterações. Clique em Salvar para confirmar suas seleções, ou Cancelar para reverter.

ℹ️
Áreas são por perfil. Cada perfil tem seu próprio conjunto de áreas selecionadas. Trocar de perfil mostrará seleções de áreas diferentes. Geofences personalizados também podem ser ativados ou desativados por perfil na página de Geofences.
", - "CONTENT_GEOFENCES": "\"Página

Se as áreas predefinidas não cobrem onde você quer alertas, você pode desenhar seus próprios limites de geofence personalizados no mapa.

Desenhando um Geofence

  1. Vá para Meus Geofences no painel lateral.
  2. Clique em Desenhar Geofence.
  3. Clique no mapa para colocar pontos do limite do polígono. Clique no primeiro ponto novamente para fechar a forma (mínimo 3 pontos).
  4. Dê um nome ao seu geofence e selecione a qual região ele pertence. A região geralmente é detectada automaticamente.
  5. Clique em Salvar.

Gerenciando Geofences

  • Editar — Renomeie seu geofence ou mude sua região.
  • Excluir — Remova um geofence que você não precisa mais. O geofence é removido de todos os perfis automaticamente.

Alternância de Perfil

Cada cartão de geofence tem um interruptor deslizante para ativar ou desativar para o seu perfil atual. Quando você cria um geofence, ele é automaticamente ativado no perfil que você está usando. Mude para outro perfil e o interruptor mostrará \"Inativo\" — ative-o para receber alertas desse geofence nesse perfil também. Isso permite controlar quais perfis recebem notificações de cada geofence sem recriá-lo.

ℹ️
Geofences aprovados (promovidos a áreas públicas) não mostram o interruptor — gerencie-os na página de Áreas.

GeoJSON Import & Export

Você pode importar e exportar geofences no formato padrão GeoJSON, facilitando o compartilhamento de limites ou a criação deles em ferramentas externas como geojson.io.

  • Importar — Clique no ícone de upload e cole ou envie um arquivo GeoJSON. Cada polígono no arquivo se torna um novo geofence. Você pode revisar e renomear cada um antes de salvar.
  • Exportar — Clique no ícone de download e selecione quais geofences incluir. O arquivo GeoJSON exportado contém todos os polígonos selecionados e pode ser aberto em qualquer ferramenta GIS ou editor de mapas.
💡
A importação GeoJSON é útil para migrar geofences de outros sistemas ou desenhar limites complexos em uma ferramenta GIS no computador e depois importá-los aqui.

Enviando para Aprovação Pública

Se você acha que seu geofence seria útil para toda a comunidade, pode enviá-lo para revisão do administrador. Se aprovado, ele se torna uma área pública que todos podem selecionar. Seu geofence privado continua funcionando enquanto a revisão está pendente.

Selos de Status

  • Ativo — Seu geofence privado, funcionando apenas para você.
  • Em Revisão — Enviado e aguardando revisão do administrador.
  • Aprovado — Promovido a uma área pública.
  • Rejeitado — Não aprovado. Você pode ver o feedback do administrador e o geofence permanece ativo como uma zona privada.
ℹ️
Você pode ter até 10 geofences personalizados, cada um com até 500 pontos de limite.
", - "CONTENT_POKEMON": "\"Página

Alarmes de Pokemon te notificam quando um Pokemon selvagem aparece e corresponde aos seus filtros.

Adicionando um Alarme de Pokemon

\"Diálogo
  1. Vá para Pokemon no painel lateral e clique no botão +.
  2. Selecionar Pokemon — Busque por nome ou número da Pokedex, ou use os botões de filtro de geração e tipo para navegar. Você pode selecionar vários Pokemon de uma vez.
  3. Definir Filtros — Escolha o que faz um spawn valer a notificação:
  • Faixa de IV — Porcentagem mínima e máxima de IV (0-100%)
  • Faixa de CP — Filtrar por poder de combate
  • Faixa de nível — Filtrar por nível do Pokemon (0-55)
  • Stats individuais — Filtrar por valores de ATK, DEF e STA (0-15 cada)
  • Forma — Acompanhar formas específicas (ex. Alolan, Galarian) ou todas as formas
  • Gênero — Macho, fêmea, sem gênero ou todos
  • Peso — Filtrar por faixa de peso
  • Tamanho — Filtrar por categoria de tamanho: selecione ALL (sem filtro) para corresponder a qualquer tamanho, ou escolha tamanhos específicos de XXS até XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Valores padrão dos filtros são definidos para que todos os Pokemon correspondam quando nenhum filtro é explicitamente configurado. Por exemplo, IV padrão é 0-100%, nível é 0-55 e tamanho é ALL. Você só precisa ajustar os filtros que te interessam.

Filtros PVP

Receba notificações quando um Pokemon tem ótimos IVs para PVP. Selecione uma liga (Great, Ultra ou Little Cup) e defina a faixa de ranking que te interessa (ex. ranking 1-50).

Alarme \"Todos os Pokemon\"

💡
Selecione \"All Pokemon\" (ID 0) para criar um alarme que cobre todas as espécies. Útil com um filtro de IV alto como 96-100% para pegar qualquer spawn valioso.

Lendo Cartões de Alarme

Cada cartão de alarme mostra pílulas coloridas resumindo seus filtros:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Página

Alarmes de Raid e Ovo

Receba notificações quando um boss de raid ou ovo aparece que te interessa.

  • Por Nível — Selecione níveis de raid (1-6) ou níveis de ovo para acompanhar todas as raids daquele nível.
  • Por Boss — Selecione Pokemon específicos como bosses de raid que você quer caçar.
  • Filtro de time — Notificar apenas raids em gyms controlados por um time específico (Mystic, Valor, Instinct).
  • Rastreamento de gym — Acompanhe raids em gyms específicos por nome para só receber notificações dos seus gyms favoritos.
  • Filtro de golpe — Filtrar bosses de raid pelos seus golpes rápidos ou carregados.
  • Notificações de RSVP — Receba notificações quando outros treinadores confirmam presença em um raid ou ovo que você acompanha.

Alarmes de Raid e Ovo são gerenciados em abas separadas na página de Raids. Ovos também suportam rastreamento de gym específico e notificações de RSVP.

Alarmes de Max Battle (Dynamax)

Receba notificações sobre batalhas Dynamax e Gigantamax em Power Spots.

  • Por Nível — Selecione níveis de batalha para acompanhar qualquer Pokemon nesses níveis. Níveis vão de 1 Estrela até 5 Estrelas (Legendary) para Dynamax, mais Gigantamax e Legendary Gigantamax para as maiores batalhas. Um alarme é criado por nível selecionado.
  • Por Pokemon — Selecione Pokemon específicos contra os quais você quer lutar em todos os níveis de Max Battle. Se o banco de dados do scanner estiver configurado, o seletor é filtrado para mostrar apenas Pokemon que apareceram em Max Battles.
  • Apenas Gigantamax — Ao acompanhar por Pokemon, ative isso para receber notificações apenas quando aquele Pokemon aparece em batalhas Gigantamax (as batalhas de nível mais alto com golpes G-Max exclusivos). Para rastreamento por nível, Gigantamax é controlado selecionando os níveis Gigantamax ou Legendary Gigantamax diretamente.
  • Selecionar Tudo — Selecione rapidamente todos os níveis disponíveis de uma vez (equivalente ao comando !maxbattle everything do bot).

Alarmes de Quest

Receba notificações sobre tarefas de pesquisa de campo com recompensas específicas.

  • Encontros com Pokemon — Selecione Pokemon que você quer como recompensas de quest.
  • Itens — Acompanhe quests que recompensam itens específicos.
  • Mega Energia — Acompanhe quests que dão mega energia para Pokemon específicos.
  • Doces — Acompanhe quests que recompensam doces para Pokemon específicos.

Alarmes de Invasão

Receba notificações sobre invasões do Team Rocket.

  • Acompanhar Tudo — Um alarme para cada tipo de recruta e líder.
  • Por Tipo — Selecione tipos específicos de recrutas (Bug, Dragon, Fire etc.), Rocket Leaders ou Giovanni. Nomes de tipos de recrutas são normalizados automaticamente (sem distinção de maiúsculas), então você não precisa se preocupar com a grafia exata.
  • Gênero — Filtrar por gênero do recruta.

Alarmes de Isca

Receba notificações quando um tipo específico de isca é colocado. Escolha entre Normal, Glacial, Mossy, Magnetic, Rainy e Golden.

Alarmes de Ninho

Acompanhe espécies de Pokemon que fazem ninho. Defina um limite de spawns mínimos por hora para só receber notificações de ninhos com atividade suficiente.

Alarmes de Gym

Acompanhe mudanças de time em gyms. Selecione quais times (Neutral, Mystic, Valor, Instinct) monitorar. Ative o rastreamento de Mudanças de Vaga para receber notificações quando vagas abrem no gym, ou ative o rastreamento de Mudanças de Batalha para receber notificações quando um gym está sob ataque.

Alarmes de Mudança de Fort

Acompanhe mudanças em PokéStops e gyms em si — não as atividades neles, mas mudanças nos próprios pontos de interesse.

  • Tipo de Fort — Escolha acompanhar PokéStops, Gyms ou Tudo.
  • Tipos de Mudança — Selecione quais mudanças monitorar: Nome alterado, Localização alterada, Imagem alterada, Remoção ou Novo fort adicionado.
  • Incluir Vazios — Incluir forts sem nome definido.
💡
Alarmes de mudança de fort são úteis para acompanhar atualizações do banco de dados do mapa — novos PokéStops aparecendo, gyms sendo realocados ou POIs sendo removidos do jogo.

Mirando um Gym Específico

Ao criar ou editar um alarme de Raid, Ovo ou Gym, você pode opcionalmente buscar e selecionar um gym específico. Isso é útil quando você só se importa com atividade no seu gym favorito — como aquele no caminho do almoço ou perto da sua casa.

  • Como usar — No diálogo de adição ou edição, digite um nome de gym no campo de busca de gym. Os resultados mostram a foto do gym, nome e área para você identificar o correto.
  • Quando um gym é selecionado — O alarme só dispara para eventos naquele gym específico. O nome do gym aparece no cartão de alarme na sua lista para você ver qual gym ele visa.
  • Quando nenhum gym é selecionado — Esse é o padrão. O alarme funciona normalmente para todos os gyms nas suas áreas selecionadas ou dentro do seu raio de distância.
💡
Você pode combinar um alarme específico de gym com um alarme mais amplo. Por exemplo, crie um alarme de raid mirando seu gym local para todos os níveis, e um segundo alarme para raids nível 5 em todas as suas áreas.
", - "CONTENT_DELIVERY": "\"Cartões

Todo alarme tem configurações de entrega que controlam onde você recebe notificações.

Áreas vs Distância

Cada alarme usa um de dois modos de entrega:

🗺
Usar ÁreasNotificado quando eventos acontecem dentro das suas áreas selecionadas. Bom para acompanhar bairros específicos.
📏
Definir DistânciaNotificado dentro de um raio (km) da sua localização salva. Bom para acompanhar tudo perto de você.

Você pode usar modos diferentes para alarmes diferentes — por exemplo, áreas para Pokemon e distância para raids.

Modelos de Notificação

Se modelos estão ativados, você pode escolher como suas mensagens de notificação aparecem. O seletor de modelo mostra uma prévia ao vivo de como seu DM do Discord vai parecer, incluindo o formato de embed, campos e imagens.

Modo Limpeza

Quando ativado, o bot automaticamente deleta a notificação do Discord depois que o evento expira (ex. um Pokemon desaparece ou um raid termina). Isso mantém seus DMs organizados. Você pode ativar o modo limpeza por alarme ou em massa na página de Limpeza.

Ping / Menções de Cargo

Se você usa webhooks, pode definir um cargo do Discord para mencionar na notificação (ex. @Pokemon). Isso só é relevante para configurações com webhooks.

", + "CONTENT_LOCATION": "\"Painel

Sua localização é o ponto a partir do qual seus alertas são medidos. Um alarme que chega até você dentro de um raio parte dela, a menos que você aponte esse alarme para um local salvo.

Definindo Sua Localização

Abra a caixa de diálogo de localização a partir do Painel ou da página Áreas e locais. Você tem quatro formas de defini-la:

  • Buscar por endereço — Digite um endereço, cidade ou nome de ponto de referência. Selecione das sugestões que aparecem.
  • Inserir coordenadas — Digite latitude e longitude diretamente se você souber.
  • Usar seu GPS — Clique em \"Use My Location\" para usar a localização atual do seu dispositivo. Seu navegador pedirá permissão.
  • Clicar no mapa — Clique em qualquer lugar no mini-mapa para definir esse ponto como sua localização.

Depois de escolher um ponto, o endereço é mostrado automaticamente. Clique em Salvar para confirmar.

A mesma caixa de diálogo é reutilizada quando você adiciona um local ou escolhe um ponto para um único alarme. Ela se chama então Escolha um ponto e é confirmada com Usar este ponto, sem mexer na sua própria localização.

💡
Você pode limpar sua localização na página Áreas e locais se quiser apenas alertas baseados em áreas.
", + "CONTENT_AREAS": "\"Página

Áreas são zonas geográficas predefinidas configuradas pela sua comunidade. As que você escolher aqui são o que todo alarme segue por padrão: um alarme definido como Em qualquer parte das minhas áreas dispara com os eventos que acontecem dentro delas.

Selecionando Áreas

Vá para Áreas e locais no painel lateral. Você pode selecionar áreas de duas formas:

  • Vista do mapa — Clique nos polígonos coloridos no mapa para selecionar ou desmarcar áreas. Áreas selecionadas ficam verdes. Passe o mouse sobre qualquer área para ver seu nome.
  • Vista de lista — Use caixas de seleção para escolher áreas de uma lista pesquisável.

Locais

Um local é um ponto nomeado — o trabalho, a academia, a casa dos seus pais — a partir do qual um alarme pode medir seu raio, em vez da sua localização. Adicione um na seção Locais da mesma página e depois escolha-o em Medido a partir de ao decidir onde um alarme deve chegar até você. Um local não pode ser excluído enquanto houver alarmes apontando para ele, e a mensagem diz quantos.

Filtragem por Região

Se sua comunidade tem muitas áreas em diferentes regiões, use o menu suspenso de região para dar zoom em uma região específica. Isso facilita encontrar áreas perto de você.

Áreas Aninhadas

Algumas áreas se sobrepõem — uma zona menor dentro de uma maior. Ambas são clicáveis. Dê zoom para facilitar o clique na área menor.

Salvando

Uma barra de salvamento aparece na parte inferior quando você faz alterações. Clique em Salvar para confirmar suas seleções, ou Cancelar para reverter.

ℹ️
Áreas são por perfil. Cada perfil tem seu próprio conjunto de áreas selecionadas. Trocar de perfil mostrará seleções de áreas diferentes. Geofences personalizados também podem ser ativados ou desativados por perfil na página de Geofences.
", + "CONTENT_GEOFENCES": "\"Página

Se as áreas predefinidas não cobrem onde você quer alertas, você pode desenhar seus próprios limites de geofence personalizados no mapa.

Desenhando um Geofence

  1. Vá para Meus Geofences no painel lateral.
  2. Clique em Desenhar Geofence.
  3. Clique no mapa para colocar pontos do limite do polígono. Clique no primeiro ponto novamente para fechar a forma (mínimo 3 pontos).
  4. Dê um nome ao seu geofence e selecione a qual região ele pertence. A região geralmente é detectada automaticamente.
  5. Clique em Salvar.

Gerenciando Geofences

  • Editar — Renomeie seu geofence ou mude sua região.
  • Excluir — Remova um geofence que você não precisa mais. O geofence é removido de todos os perfis automaticamente.

Alternância de Perfil

Cada cartão de geofence tem um interruptor deslizante para ativar ou desativar para o seu perfil atual. Quando você cria um geofence, ele é automaticamente ativado no perfil que você está usando. Mude para outro perfil e o interruptor mostrará \"Inativo\" — ative-o para receber alertas desse geofence nesse perfil também. Isso permite controlar quais perfis recebem notificações de cada geofence sem recriá-lo.

ℹ️
Geofences aprovados (promovidos a áreas públicas) não mostram o interruptor — gerencie-os na página de Áreas.

Usar uma geofence em um único alarme

Uma geofence que você desenhou também aparece na lista Apenas em áreas específicas quando você decide onde um alarme específico deve chegar até você, marcada com um ícone de desenho. Isso limita um alarme a ela sem ativar a geofence para o perfil inteiro.

GeoJSON Import & Export

Você pode importar e exportar geofences no formato padrão GeoJSON, facilitando o compartilhamento de limites ou a criação deles em ferramentas externas como geojson.io.

  • Importar — Clique no ícone de upload e cole ou envie um arquivo GeoJSON. Cada polígono no arquivo se torna um novo geofence. Você pode revisar e renomear cada um antes de salvar.
  • Exportar — Clique no ícone de download e selecione quais geofences incluir. O arquivo GeoJSON exportado contém todos os polígonos selecionados e pode ser aberto em qualquer ferramenta GIS ou editor de mapas.
💡
A importação GeoJSON é útil para migrar geofences de outros sistemas ou desenhar limites complexos em uma ferramenta GIS no computador e depois importá-los aqui.

Enviando para Aprovação Pública

Se você acha que seu geofence seria útil para toda a comunidade, pode enviá-lo para revisão do administrador. Se aprovado, ele se torna uma área pública que todos podem selecionar. Seu geofence privado continua funcionando enquanto a revisão está pendente.

Selos de Status

  • Ativo — Seu geofence privado, funcionando apenas para você.
  • Em Revisão — Enviado e aguardando revisão do administrador.
  • Aprovado — Promovido a uma área pública.
  • Rejeitado — Não aprovado. Você pode ver o feedback do administrador e o geofence permanece ativo como uma zona privada.
ℹ️
Você pode ter até 10 geofences personalizados, cada um com até 500 pontos de limite.
", + "CONTENT_POKEMON": "\"Página

Alarmes de Pokemon te notificam quando um Pokemon selvagem aparece e corresponde aos seus filtros.

Adicionando um Alarme de Pokemon

\"Diálogo
  1. Vá para Pokemon no painel lateral e clique no botão +.
  2. Selecionar Pokemon — Busque por nome ou número da Pokedex, ou use os botões de filtro de geração e tipo para navegar. Você pode selecionar vários Pokemon de uma vez.
  3. Definir Filtros — Escolha o que faz um spawn valer a notificação:
  • Faixa de IV — Porcentagem mínima e máxima de IV (0-100%)
  • Faixa de CP — Filtrar por poder de combate
  • Faixa de nível — Filtrar por nível do Pokemon (0-55)
  • Stats individuais — Filtrar por valores de ATK, DEF e STA (0-15 cada)
  • Forma — Acompanhar formas específicas (ex. Alolan, Galarian) ou todas as formas
  • Gênero — Macho, fêmea, sem gênero ou todos
  • Peso — Filtrar por faixa de peso
  • Tamanho — Filtrar por categoria de tamanho: selecione ALL (sem filtro) para corresponder a qualquer tamanho, ou escolha tamanhos específicos de XXS até XXL (XXS, XS, Normal, XL, XXL)
  • Tempo mínimo restante — Ignora spawns que vão sumir antes de você chegar. Define-se em Mais filtros; o cartão passa a mostrar uma etiqueta como "faltam 10 min"
ℹ️
Valores padrão dos filtros são definidos para que todos os Pokemon correspondam quando nenhum filtro é explicitamente configurado. Por exemplo, IV padrão é 0-100%, nível é 0-55 e tamanho é ALL. Você só precisa ajustar os filtros que te interessam.

Filtros PVP

Receba notificações quando um Pokemon tem ótimos IVs para PVP. Selecione uma liga (Great, Ultra ou Little Cup) e defina a faixa de ranking que te interessa (ex. ranking 1-50).

Os botões Limite de nível escolhem com qual limite os rankings são lidos. Deixe em Todos para usar o valor definido na configuração do Poracle da sua comunidade.

Megaevolução decide se a regra classifica a forma base ou uma mega: Base, Mega, Mega X ou Mega Y. As megas são classificadas à parte, então uma regra de mega nunca corresponde a um spawn em forma base.

Alarme \"Todos os Pokemon\"

💡
Selecione \"All Pokemon\" (ID 0) para criar um alarme que cobre todas as espécies. Útil com um filtro de IV alto como 96-100% para pegar qualquer spawn valioso.

Lendo Cartões de Alarme

Cada cartão de alarme mostra pílulas coloridas resumindo seus filtros:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Página

Alarmes de Raid e Ovo

Receba notificações quando um boss de raid ou ovo aparece que te interessa.

  • Por Nível — Selecione níveis de raid (1-6) ou níveis de ovo para acompanhar todas as raids daquele nível.
  • Por Boss — Selecione Pokemon específicos como bosses de raid que você quer caçar.
  • Filtro de time — Notificar apenas raids em gyms controlados por um time específico (Mystic, Valor, Instinct).
  • Rastreamento de gym — Acompanhe raids em gyms específicos por nome para só receber notificações dos seus gyms favoritos.
  • Filtro de golpe — Filtrar bosses de raid pelos seus golpes rápidos ou carregados.
  • Notificações de RSVP — Receba notificações quando outros treinadores confirmam presença em um raid ou ovo que você acompanha.

Alarmes de Raid e Ovo são gerenciados em abas separadas na página de Raids. Ovos também suportam rastreamento de gym específico e notificações de RSVP.

Alarmes de Max Battle (Dynamax)

Receba notificações sobre batalhas Dynamax e Gigantamax em Power Spots.

  • Por Nível — Selecione níveis de batalha para acompanhar qualquer Pokemon nesses níveis. Níveis vão de 1 Estrela até 5 Estrelas (Legendary) para Dynamax, mais Gigantamax e Legendary Gigantamax para as maiores batalhas. Um alarme é criado por nível selecionado.
  • Por Pokemon — Selecione Pokemon específicos contra os quais você quer lutar em todos os níveis de Max Battle. Se o banco de dados do scanner estiver configurado, o seletor é filtrado para mostrar apenas Pokemon que apareceram em Max Battles.
  • Apenas Gigantamax — Ao acompanhar por Pokemon, ative isso para receber notificações apenas quando aquele Pokemon aparece em batalhas Gigantamax (as batalhas de nível mais alto com golpes G-Max exclusivos). Para rastreamento por nível, Gigantamax é controlado selecionando os níveis Gigantamax ou Legendary Gigantamax diretamente.
  • Selecionar Tudo — Selecione rapidamente todos os níveis disponíveis de uma vez (equivalente ao comando !maxbattle everything do bot).

Alarmes de Quest

Receba notificações sobre tarefas de pesquisa de campo com recompensas específicas.

  • Encontros com Pokemon — Selecione Pokemon que você quer como recompensas de quest.
  • Itens — Acompanhe quests que recompensam itens específicos.
  • Mega Energia — Acompanhe quests que dão mega energia para Pokemon específicos.
  • Doces — Acompanhe quests que recompensam doces para Pokemon específicos.
  • Poeira estelar — Acompanhe quests que recompensam poeira estelar.

As abas de itens, mega energia e doces têm cada uma um campo Quantidade mínima, e a de poeira estelar uma Poeira estelar mínima. Deixe em 0 para aceitar qualquer quantidade. Os cartões mostram a quantidade ao lado da recompensa, por exemplo "3× Rare Candy".

Alarmes de Invasão

Receba notificações sobre invasões do Team Rocket.

  • Acompanhar Tudo — Um alarme para cada tipo de recruta e líder.
  • Por Tipo — Selecione tipos específicos de recrutas (Bug, Dragon, Fire etc.), Rocket Leaders ou Giovanni. Nomes de tipos de recrutas são normalizados automaticamente (sem distinção de maiúsculas), então você não precisa se preocupar com a grafia exata.
  • Gênero — Filtrar por gênero do recruta.

Alarmes de Isca

Receba notificações quando um tipo específico de isca é colocado. Escolha entre Normal, Glacial, Mossy, Magnetic, Rainy e Golden.

Alarmes de Ninho

Acompanhe espécies de Pokemon que fazem ninho. Defina um limite de spawns mínimos por hora para só receber notificações de ninhos com atividade suficiente.

Alarmes de Gym

Acompanhe mudanças de time em gyms. Selecione quais times (Neutral, Mystic, Valor, Instinct) monitorar. Ative o rastreamento de Mudanças de Vaga para receber notificações quando vagas abrem no gym, ou ative o rastreamento de Mudanças de Batalha para receber notificações quando um gym está sob ataque.

Alarmes de Mudança de Fort

Acompanhe mudanças em PokéStops e gyms em si — não as atividades neles, mas mudanças nos próprios pontos de interesse.

  • Tipo de Fort — Escolha acompanhar PokéStops, Gyms ou Tudo.
  • Tipos de Mudança — Selecione quais mudanças monitorar: Nome alterado, Descrição alterada, Localização alterada, Imagem alterada, Removido ou Novo fort.
  • Incluir Vazios — Incluir forts sem nome definido.
💡
Alarmes de mudança de fort são úteis para acompanhar atualizações do banco de dados do mapa — novos PokéStops aparecendo, gyms sendo realocados ou POIs sendo removidos do jogo.

Mirando um Gym Específico

Ao criar ou editar um alarme de Raid, Ovo ou Gym, você pode opcionalmente buscar e selecionar um gym específico. Isso é útil quando você só se importa com atividade no seu gym favorito — como aquele no caminho do almoço ou perto da sua casa.

  • Como usar — No diálogo de adição ou edição, digite um nome de gym no campo de busca de gym. Os resultados mostram a foto do gym, nome e área para você identificar o correto.
  • Quando um gym é selecionado — O alarme só dispara para eventos naquele gym específico. O nome do gym aparece no cartão de alarme na sua lista para você ver qual gym ele visa.
  • Quando nenhum gym é selecionado — Esse é o padrão. O alarme funciona normalmente para todos os gyms nas suas áreas selecionadas ou dentro do seu raio de distância.
💡
Você pode combinar um alarme específico de gym com um alarme mais amplo. Por exemplo, crie um alarme de raid mirando seu gym local para todos os níveis, e um segundo alarme para raids nível 5 em todas as suas áreas.
", + "CONTENT_DELIVERY": "\"Cartões

Todo alarme tem configurações de entrega que controlam onde você recebe notificações.

Onde um alerta chega até você

A aba Entrega de cada caixa de diálogo de criação e edição pergunta Onde este alerta deve chegar até você? e oferece três respostas:

  • Em qualquer parte das minhas áreas — O padrão. O alarme segue as áreas selecionadas no seu perfil, então mudar suas áreas muda também este alarme.
  • Perto de um ponto — Um raio em quilômetros, medido a partir da sua localização ou de um local salvo que você escolha em Medido a partir de. Se ainda não houver localização, o seletor avisa e oferece defini-la.
  • Apenas em áreas específicas — Um subconjunto de áreas para este alarme em particular, escolhido entre as áreas públicas e as geofences que você mesmo desenhou.

Alarmes diferentes podem responder de formas diferentes: áreas para Pokemon, um raio a partir da sua localização para raids, um local nomeado para quests.

A etiqueta no cartão

A maioria dos cartões de alarme traz uma etiqueta com a sua resposta — "Em qualquer parte das minhas áreas", "Onde quer que eu receba alertas", "A menos de 5 km da minha localização", "A menos de 2 km de Casa", "Apenas em Terrigal, Erina". Clique nela para mudar esse alarme sem abrir a caixa de diálogo de edição completa.

Padrão para alarmes novos

Alarmes novos abrem no modo Áreas. Para mudar isso, abra o menu do usuário (seu avatar, no canto superior direito) e escolha Padrões de alertas — decida se os alarmes novos começam em Áreas ou em Distância, defina um raio padrão e escolha se esse raio é medido a partir da sua localização ou de um local salvo. A preferência fica salva no seu navegador e também preenche a caixa de diálogo dos Quick Picks. Ela vale só para alarmes criados a partir daí; os existentes não mudam, e você ainda pode alterar onde cada alarme chega até você.

Modelos de Notificação

Se modelos estão ativados, você pode escolher como suas mensagens de notificação aparecem. O seletor de modelo mostra uma prévia ao vivo de como seu DM do Discord vai parecer, incluindo o formato de embed, campos e imagens.

Modo Limpeza

Quando ativado, o bot automaticamente deleta a notificação do Discord depois que o evento expira (ex. um Pokemon desaparece ou um raid termina). Isso mantém seus DMs organizados. Você pode ativar o modo limpeza por alarme ou em massa na página de Limpeza.

Editar no local e resumos

Alguns alarmes oferecem modos de entrega adicionais. Ative Editar mensagem no local em uma isca para atualizar a mensagem existente do Discord quando a isca mudar, em vez de enviar uma nova, ou Resumo diário em uma missão para agrupar as missões correspondentes em uma única mensagem de resumo (requer um agendamento de resumo configurado no bot). Raids e ovos são editados no local automaticamente quando você escolhe um modo RSVP. Essas configurações são mantidas mesmo que você as defina pelo bot.

Atualizações de RSVP (raids & ovos)

Os alarmes de raid e ovo adicionam uma configuração de Notificações RSVP no diálogo de adição/edição com três opções: Apenas correspondências envia os alertas padrão de raid/ovo; Correspondências + atualizações RSVP também notifica novamente quando as contagens de RSVP mudam (treinadores confirmando presença); e Apenas atualizações RSVP pula a correspondência inicial e notifica você apenas sobre alterações de RSVP. Escolher qualquer um dos modos RSVP faz o bot editar a mensagem existente do Discord no local conforme as contagens mudam, em vez de enviar novas, e o cartão mostra uma pílula "RSVP" ou "Apenas RSVP". Observe que Apenas atualizações RSVP fica em silêncio a menos que o scanner da sua comunidade emita eventos RSVP — escolha-o apenas se souber que os RSVP são reportados.

", + "CONTENT_QUEST_SUMMARY": "

As missões de Pesquisa de campo mudam diariamente e podem corresponder em grande quantidade, então um filtro de missões movimentado pode inundar suas DMs. Entrega do resumo de missões reúne as missões correspondentes em um único resumo agendado em vez de muitos alertas separados.

Duas partes que funcionam juntas

  • Botão de resumo diário — ative-o em um alarme de missão (na janela de adicionar/editar) para marcar suas correspondências para o resumo em vez da entrega imediata.
  • Agendamento de entrega — escolha quando as missões reunidas são enviadas.

Ambos são necessários: o botão indica quais missões reunir, e o agendamento indica quando entregá-las.

Configurando seu agendamento

Abra a página Missões, depois o menu na barra de ferramentas e escolha Entrega do resumo de missões. Use Editar agendamento para escolher dias e horários — o mesmo editor usado para os horários ativos dos perfis. Os horários salvos aparecem como etiquetas âmbar.

O agendamento é por usuário e compartilhado entre todos os seus perfis — diferentemente dos horários ativos dos perfis, que são configurados por perfil.

Enviar resumo agora

Enviar resumo agora entrega imediatamente tudo o que foi reunido desde o seu último resumo. Se nada foi reunido ainda, nada é enviado — as missões são armazenadas em buffer conforme correspondem, então dê um tempo ou aguarde o agendamento ser acionado.

Bom saber

  • O menu só aparece quando o bot do seu servidor tem os resumos de missões habilitados.
  • O horário de entrega usa sua localização salva para o fuso horário — defina uma localização, ou os resumos podem chegar no horário local errado (a janela avisa quando nenhuma localização está definida).
  • Remover o agendamento mantém o botão por alarme; as missões continuam sendo reunidas, mas voltam ao horário padrão do bot.
", "CONTENT_TEST_ALERTS": "

Todo cartão de alarme tem um botão Teste (ícone de avião de papel) que envia uma notificação de amostra para seu Discord ou Telegram, usando os filtros exatos do alarme e seu modelo de entrega atual.

Como Funciona

  1. Encontre qualquer cartão de alarme na sua lista (Pokemon, Raid, Quest etc.).
  2. Clique no ícone de enviar na linha de ações do cartão.
  3. Um evento simulado que corresponde aos filtros do seu alarme é gerado e enviado através do pipeline de notificações. Você receberá um DM igual a um alerta real.

O Que é Testado

O teste usa os valores de filtro do seu alarme (ID do Pokemon, nível do raid, recompensa da quest etc.) e sua localização salva como coordenadas do evento simulado. A notificação é formatada usando seu modelo selecionado, então você vê exatamente como um alerta real ficaria.

Tempo de Espera

Para evitar spam, cada alarme tem um tempo de espera de 15 segundos entre envios de teste. O botão fica desativado durante o tempo de espera e uma barra de informação mostra feedback (sucesso, erro ou tempo de espera restante).

💡
Alertas de teste são ótimos para verificar se seu modelo está correto ou confirmar que sua entrega por webhook está funcionando antes de esperar por um evento real.
", "CONTENT_POKEMON_AVAILABILITY": "

Ao adicionar ou editar alarmes de Pokemon, o seletor de Pokemon pode mostrar indicadores de disponibilidade — pequenos selos que dizem quais Pokemon estão aparecendo na natureza atualmente.

Como Funciona

Se sua comunidade tem um scanner Golbat configurado, o seletor mostra pontos coloridos ao lado dos nomes dos Pokemon:

  • Ponto verde — Este Pokemon foi visto aparecendo recentemente.
  • Sem ponto — Não reportado atualmente nos dados do scanner.

Isso ajuda você a evitar criar alarmes para Pokemon que não estão aparecendo na sua área agora (ex. espécies sazonais ou exclusivas de eventos).

Atualização de Disponibilidade

Os dados são atualizados automaticamente em segundo plano. Você não precisa fazer nada — apenas procure os pontos ao navegar pelo seletor de Pokemon.

ℹ️
Esta funcionalidade só é visível se seu administrador configurou a integração do scanner Golbat. Se você não vê pontos de disponibilidade, a funcionalidade não está ativada para sua comunidade.
", "CONTENT_BULK": "\"Lista

Todas as páginas de alarme suportam operações em massa para que você possa gerenciar muitos alarmes de uma vez.

Modo de Seleção

Clique no ícone de checklist na barra de ferramentas para entrar no modo de seleção. Depois clique em cartões de alarme individuais para selecioná-los, ou use Selecionar Tudo para pegar tudo visível.

Ações em Massa

  • Atualizar Distância — Mudar o modo de entrega (áreas ou distância) para todos os alarmes selecionados de uma vez.
  • Excluir — Remover todos os alarmes selecionados com uma confirmação.
💡
Na parte inferior de cada lista de alarmes, você também encontrará os botões Atualizar Todas as Distâncias e Excluir Tudo que se aplicam a cada alarme daquele tipo.
", - "CONTENT_QUICK_PICKS": "\"Página

Quick Picks são modelos de alarme pré-construídos criados pelos administradores da sua comunidade. Eles permitem configurar configurações de alarme comuns com um clique em vez de criar cada alarme individualmente.

Aplicando um Quick Pick

  1. Vá para Quick Picks no painel lateral.
  2. Navegue pelas opções disponíveis, opcionalmente filtrando por categoria.
  3. Clique em Aplicar no Quick Pick que você quer.
  4. Personalize antes de aplicar: escolha seu modo de entrega (áreas ou distância), ative o modo limpeza e opcionalmente exclua Pokemon específicos.
  5. Confirme para criar todos os alarmes de uma vez.

Removendo Alarmes de Quick Pick

Se você não quer mais alarmes de um Quick Pick, clique em Remover para excluir todos os alarmes que ele criou.

", - "CONTENT_PROFILES": "

A página de Perfis é seu centro unificado para gerenciar perfis e visualizar todos os alarmes de todos os perfis em um só lugar.

Por Que Usar Perfis?

Perfis permitem manter configurações de alarme completamente separadas. Cada perfil tem seu próprio conjunto de alarmes, áreas selecionadas, localização e ativações de geofence personalizadas. Útil para situações diferentes — por exemplo, um perfil \"Casa\" para seu bairro e um perfil \"Trabalho\" para perto do seu escritório.

Visão Geral

A página mostra uma barra de estatísticas com totais de alarmes por tipo, uma barra de busca para filtrar em todos os perfis e chips de filtro de tipo para mostrar apenas tipos específicos de alarme (Pokemon, Raids, Quests etc.).

Cada perfil aparece como um painel expansível. Clique para expandir e ver todos os alarmes agrupados por tipo, com imagens do jogo (sprites de Pokemon, ovos de raid, ícones de isca) e pílulas de filtro mostrando IV, CP, Nível, PVP e outras configurações.

Gerenciando Perfis

  • Criar — Clique no botão + no canto superior direito. Nomes de perfil devem ser únicos (até 32 caracteres).
  • Trocar — Clique em Trocar dentro de um painel de perfil para torná-lo seu perfil ativo. Seu perfil ativo é marcado com um selo verde e borda esquerda.
  • Editar — Clique no ícone de lápis para renomear um perfil.
  • Excluir — Clique no ícone de lixeira para remover um perfil e todos os seus alarmes. Você não pode excluir seu perfil ativo.

Duplicar

Clique no ícone de cópia em qualquer perfil para criar uma cópia exata com todos os seus alarmes. Você será solicitado a nomear o novo perfil — um nome padrão como \"Perfil (Cópia)\" é sugerido. O duplicado inclui todos os filtros de alarme mas recebe um novo conjunto de seleções de área.

Exportar & Importar

  • Exportar — Clique no ícone de download em um perfil para salvar um arquivo de backup (JSON). O arquivo contém todos os filtros de alarme, sem IDs internos para ser portável.
  • Importar — Clique no botão Importar no canto superior direito, selecione um arquivo de backup e escolha um nome para o novo perfil. Todos os alarmes do backup são restaurados. Se um perfil com o mesmo nome existir, um sufixo numérico é adicionado automaticamente.

Detecção de Duplicatas

Se o mesmo alarme existe em múltiplos perfis (ex. acompanhando Pikachu em \"Casa\" e \"Trabalho\"), esses alarmes são destacados com uma borda laranja e um ícone de cópia. Quando duplicatas existem, um chip de filtro Duplicatas aparece na barra de filtros — clique nele para mostrar apenas alarmes duplicados entre perfis.

⚠️
Aviso: Excluir um perfil remove permanentemente todos os alarmes naquele perfil. Você não pode excluir seu perfil ativo atualmente. Considere exportar um backup primeiro.
", - "CONTENT_CLEANING": "\"Página

A página de Limpeza permite controlar o modo limpeza em todos os seus tipos de alarme de uma vez.

Quando o modo limpeza está ativado para um tipo de alarme, o bot automaticamente deleta notificações do Discord depois que o evento expira:

  • Pokemon — Deletado quando o spawn desaparece
  • Raids — Deletado quando o raid termina
  • Eggs — Deletado quando o ovo choca
  • Quests — Deletado quando as quests resetam à meia-noite
  • Invasions — Deletado quando o recruta vai embora
  • Lures — Deletado quando a isca expira
  • Nests — Deletado quando os ninhos migram
  • Gyms — Deletado após mudanças de gym
  • Fort Changes — Deletado após a notificação de mudança de fort expirar
  • Max Battles — Deletado quando a batalha termina

Use Ativar Tudo ou Desativar Tudo para alternar tudo de uma vez.

💡
Recomendado: Mantenha o modo limpeza ativado para evitar que alertas desatualizados se acumulem nos seus DMs.
", - "CONTENT_APPEARANCE": "

Modo Escuro / Claro

Clique no ícone de sol/lua na barra de ferramentas superior para alternar entre temas escuro e claro. Sua escolha é salva automaticamente.

\"Barra

Cores de Destaque

Abra o menu do usuário (seu avatar no canto superior direito) e selecione Tema de Destaque. Escolha entre:

  • Padrão — Azul
  • Pokemon — Verde
  • Raids — Vermelho
  • Mystic — Azul
  • Valor — Vermelho
  • Instinct — Amarelo

A cor de destaque muda o gradiente da barra de ferramentas, destaque de navegação ativa e outros destaques de UI em todo o site.

\"Painel

Idioma

Se disponível, use o seletor de idioma na barra de ferramentas para mudar o idioma da interface. 18 idiomas são suportados.

Atalhos de Teclado

?Mostrar atalhos de teclado
EscFechar menus ou diálogos
[Recolher painel lateral
]Expandir painel lateral
", - "CONTENT_ALERTS_LOGOUT": "\"Menu

Pausando Alertas

Abra o menu do usuário (seu avatar) e clique em Pausar Alertas. Um banner vermelho aparecerá no topo do site confirmando que seus alertas estão pausados. Você não receberá nenhuma notificação enquanto estiver pausado.

Para retomar, clique em Retomar Alertas no menu do usuário ou no banner.

Fazendo Logout

Abra o menu do usuário e clique em Sair. Você será redirecionado para a página de login.

", - "CONTENT_FAQ": "

\"Não consigo fazer login\"

Você precisa se registrar com o bot Poracle no Discord ou Telegram antes de poder fazer login neste site. Se você vê \"Sua conta não está registrada\", entre em contato com o administrador da sua comunidade para instruções de registro.

\"Não estou recebendo notificações\"

Verifique essas causas comuns:

  1. Alertas pausados — Procure um banner vermelho no topo do site. Retome os alertas pelo menu do usuário.
  2. Nenhuma localização definida — Se seus alarmes usam modo de distância, você precisa de uma localização salva.
  3. Nenhuma área selecionada — Se seus alarmes usam modo de áreas, certifique-se de ter selecionado áreas na página de Áreas.
  4. Perfil errado — Você pode ter alarmes em outro perfil. Verifique qual perfil está ativo no Painel.
  5. Filtros muito restritos — Tente relaxar seus filtros de IV, CP ou nível para ver se as notificações começam a chegar.

\"Meus alarmes sumiram\"

Alarmes são específicos por perfil. Se você trocou de perfil, seus alarmes do outro perfil ainda estão lá — basta voltar pelo Painel ou pela página de Perfis.

\"Não consigo clicar em uma área pequena no mapa\"

Quando áreas se sobrepõem, dê zoom para facilitar o clique na área menor. Áreas menores estão sempre acima das maiores.

\"O que o modo limpeza faz?\"

O modo limpeza diz ao bot para automaticamente deletar uma notificação do Discord depois que o evento expira (ex. um Pokemon desaparece). Sem ele, alertas antigos ficam nos seus DMs para sempre. Ative na página de Limpeza ou por alarme na aba de Entrega.

\"Qual a diferença entre Áreas e Distância?\"

Cada alarme usa um modo de entrega. Áreas notifica sobre eventos dentro de zonas geográficas específicas. Distância notifica sobre eventos dentro de um raio da sua localização salva. Você pode misturar ambos em alarmes diferentes.

" + "CONTENT_QUICK_PICKS": "\"Página

Quick Picks são modelos de alarme pré-construídos criados pelos administradores da sua comunidade. Eles permitem configurar configurações de alarme comuns com um clique em vez de criar cada alarme individualmente.

Aplicando um Quick Pick

  1. Vá para Quick Picks no painel lateral.
  2. Navegue pelas opções disponíveis, opcionalmente filtrando por categoria.
  3. Clique em Aplicar no Quick Pick que você quer.
  4. Personalize antes de aplicar: decida onde os alertas devem chegar até você — a aba Entrega é o mesmo seletor de três opções que um alarme individual usa, então você pode apontá-los para um local salvo ou um subconjunto de áreas —, ative o modo limpeza e opcionalmente exclua Pokemon específicos.
  5. Confirme para criar todos os alarmes de uma vez.

Removendo Alarmes de Quick Pick

Se você não quer mais alarmes de um Quick Pick, clique em Remover para excluir todos os alarmes que ele criou.

", + "CONTENT_PROFILES": "

A página de Perfis é seu centro unificado para gerenciar perfis e visualizar todos os alarmes de todos os perfis em um só lugar.

Por Que Usar Perfis?

Perfis permitem manter configurações de alarme completamente separadas. Cada perfil tem seu próprio conjunto de alarmes, áreas selecionadas, localização e ativações de geofence personalizadas. Útil para situações diferentes — por exemplo, um perfil \"Casa\" para seu bairro e um perfil \"Trabalho\" para perto do seu escritório.

Visão Geral

A página mostra uma barra de estatísticas com totais de alarmes por tipo, uma barra de busca para filtrar em todos os perfis e chips de filtro de tipo para mostrar apenas tipos específicos de alarme (Pokemon, Raids, Quests etc.).

Cada perfil aparece como um painel expansível. Clique para expandir e ver todos os alarmes agrupados por tipo, com imagens do jogo (sprites de Pokemon, ovos de raid, ícones de isca) e pílulas de filtro mostrando IV, CP, Nível, PVP e outras configurações.

Gerenciando Perfis

  • Criar — Clique no botão + no canto superior direito. Nomes de perfil devem ser únicos (até 32 caracteres).
  • Trocar — Clique em Trocar dentro de um painel de perfil para torná-lo seu perfil ativo. Seu perfil ativo é marcado com um selo verde e borda esquerda.
  • Editar — Clique no ícone de lápis para renomear um perfil.
  • Excluir — Clique no ícone de lixeira para remover um perfil e todos os seus alarmes. Você não pode excluir seu perfil ativo.

Duplicar

Clique no ícone de cópia em qualquer perfil para criar uma cópia exata com todos os seus alarmes. Você será solicitado a nomear o novo perfil — um nome padrão como \"Perfil (Cópia)\" é sugerido. A cópia inclui todos os filtros de alarme, e suas áreas, localização e horários ativos também são copiados do perfil de origem.

Exportar & Importar

  • Exportar — Clique no ícone de download em um perfil para salvar um arquivo de backup (JSON). O arquivo contém todos os filtros de alarme, sem IDs internos para ser portável.
  • Importar — Clique no botão Importar no canto superior direito, selecione um arquivo de backup e escolha um nome para o novo perfil. Todos os alarmes do backup são restaurados. Se um perfil com o mesmo nome existir, um sufixo numérico é adicionado automaticamente.

Detecção de Duplicatas

Se o mesmo alarme existe em múltiplos perfis (ex. acompanhando Pikachu em \"Casa\" e \"Trabalho\"), esses alarmes são destacados com uma borda laranja e um ícone de cópia. Quando duplicatas existem, um chip de filtro Duplicatas aparece na barra de filtros — clique nele para mostrar apenas alarmes duplicados entre perfis.

⚠️
Aviso: Excluir um perfil remove permanentemente todos os alarmes naquele perfil. Você não pode excluir seu perfil ativo atualmente. Considere exportar um backup primeiro.
", + "CONTENT_CLEANING": "\"Página

A página de Limpeza permite controlar o modo limpeza em todos os seus tipos de alarme de uma vez.

Quando o modo limpeza está ativado para um tipo de alarme, o bot automaticamente deleta notificações do Discord depois que o evento expira:

  • Pokemon — Deletado quando o spawn desaparece
  • Raids — Deletado quando o raid termina
  • Eggs — Deletado quando o ovo choca
  • Quests — Deletado quando as quests resetam à meia-noite
  • Invasions — Deletado quando o recruta vai embora
  • Lures — Deletado quando a isca expira
  • Nests — Deletado quando os ninhos migram
  • Gyms — Deletado após mudanças de gym
  • Max Battles — Deletado quando a batalha termina

Use Ativar Tudo ou Desativar Tudo para alternar tudo de uma vez.

💡
Recomendado: Mantenha o modo limpeza ativado para evitar que alertas desatualizados se acumulem nos seus DMs.
", + "CONTENT_APPEARANCE": "

Modo Escuro / Claro

Clique no ícone de sol/lua na barra de ferramentas superior para alternar entre temas escuro e claro. Sua escolha é salva automaticamente.

\"Barra

Cores de Destaque

Abra o menu do usuário (seu avatar no canto superior direito) e selecione Tema de Destaque. Escolha entre:

  • Padrão — Azul
  • Pokemon — Verde
  • Raids — Vermelho
  • Mystic — Azul
  • Valor — Vermelho
  • Instinct — Amarelo

A cor de destaque muda o gradiente da barra de ferramentas, destaque de navegação ativa e outros destaques de UI em todo o site.

\"Painel

Idioma da interface

Abra o menu do usuário (seu avatar, no canto superior direito) e escolha Idioma da interface. São 11 idiomas. Muda o texto do site e também os nomes, tipos e formas de Pokemon mostrados nos seletores e nos seus cards de alarme. Se você nunca escolheu um, recebe o do seu navegador ou o do seu servidor Poracle.

Idioma dos alertas

Logo abaixo está Idioma dos alertas, uma configuração separada. Controla o idioma em que o Poracle escreve suas DMs. São independentes: um site em português com DMs em inglês, ou o contrário, é perfeitamente normal. Antes ficava na página de Áreas.

Atalhos de Teclado

?Mostrar atalhos de teclado
EscFechar menus ou diálogos
[Recolher painel lateral
]Expandir painel lateral
", + "CONTENT_ALERTS_LOGOUT": "\"Menu

Pausando Alertas

Abra o menu do usuário (seu avatar) e clique em Pausar Alertas. Um banner vermelho aparecerá no topo do site confirmando que seus alertas estão pausados. Você não receberá nenhuma notificação enquanto estiver pausado.

Para retomar, clique em Retomar Alertas no menu do usuário ou no banner.

Fazendo Logout

Abra o menu do usuário e clique em Sair. Você será redirecionado para a página de login.

Se você entrou por um provedor SSO com logout único, o menu também oferece Sair de todos os lugares — isso encerra sua sessão também no provedor, não apenas aqui.

", + "CONTENT_FAQ": "

\"Não consigo fazer login\"

Você precisa se registrar com o bot Poracle no Discord ou Telegram antes de poder fazer login neste site. Se você vê \"Sua conta não está registrada\", entre em contato com o administrador da sua comunidade para instruções de registro.

\"Não estou recebendo notificações\"

Verifique essas causas comuns:

  1. Alertas pausados — Procure um banner vermelho no topo do site. Retome os alertas pelo menu do usuário.
  2. Nenhuma localização definida — Um alarme que chega até você dentro de um raio mede a partir da sua localização ou de um local salvo. Defina uma na página Áreas e locais.
  3. Nada ao alcance — Veja a etiqueta no cartão do alarme. Ela diz onde o alarme chega até você, e pode estar apontada para áreas que seu perfil não cobre mais.
  4. Perfil errado — Você pode ter alarmes em outro perfil. Verifique qual perfil está ativo no Painel.
  5. Filtros muito restritos — Tente relaxar seus filtros de IV, CP ou nível para ver se as notificações começam a chegar.

\"Meus alarmes sumiram\"

Alarmes são específicos por perfil. Se você trocou de perfil, seus alarmes do outro perfil ainda estão lá — basta voltar pelo Painel ou pela página de Perfis.

\"Não consigo clicar em uma área pequena no mapa\"

Quando áreas se sobrepõem, dê zoom para facilitar o clique na área menor. Áreas menores estão sempre acima das maiores.

\"O que o modo limpeza faz?\"

O modo limpeza diz ao bot para automaticamente deletar uma notificação do Discord depois que o evento expira (ex. um Pokemon desaparece). Sem ele, alertas antigos ficam nos seus DMs para sempre. Ative na página de Limpeza ou por alarme na aba de Entrega.

\"Onde um alerta chega até mim?\"

Cada alarme responde por si, na sua aba Entrega. Em qualquer parte das minhas áreas segue as áreas selecionadas no seu perfil. Perto de um ponto é um raio a partir da sua localização ou de um local salvo. Apenas em áreas específicas limita esse alarme a um subconjunto de áreas. A etiqueta no cartão sempre mostra a resposta atual, e um clique a muda.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "Alertas DM", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Entrar", "SIGN_IN_DESC": "Entre para gerenciar seus alarmes de notificação do Pokemon GO.", "SIGN_IN_DISCORD": "Entrar com Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Entrar com Telegram", + "SIGN_IN_OIDC": "Entrar com {{provider}}", + "SIGNED_OUT_TITLE": "Sessão encerrada", + "SIGNED_OUT_DESC": "Você saiu do Alertas DM.", + "PROVIDER_DISABLED_BY_ADMIN": "Este método de login foi desativado por um administrador.", + "PROVIDER_DISABLED_HINT": "Este método de login está desativado para usuários não administradores.", + "ERR_TELEGRAM_DISABLED": "O login com Telegram está desativado no momento.", "OR": "ou", "NO_METHODS": "Nenhum método de login está ativo no momento. Entre em contato com um administrador.", "AUTHENTICATING": "Autenticando...", "FOOTER": "Gerencie alarmes para Pokemon, Raids, Quests e mais", "AUTH_FAILED": "Autenticação Falhou", "BACK_TO_LOGIN": "Voltar ao Login", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "O login com Discord está desativado no momento.", + "ERR_DISCORD_FETCH": "Não foi possível obter seu perfil do Discord. Tente novamente.", + "ERR_MISSING_CODE": "O login com Discord foi cancelado ou falhou.", + "ERR_MISSING_ROLE": "Você não tem o cargo do Discord necessário para acessar este site.", + "ERR_NOT_IN_GUILD": "Você precisa ser membro do servidor do Discord para acessar este site.", + "ERR_NOT_REGISTERED": "Sua conta não está registrada. Cadastre-se para começar.", + "ERR_OIDC_DISABLED": "O login externo está desativado no momento.", + "ERR_OIDC_NO_IDENTITY": "Seu provedor de login externo não retornou uma conta que possamos associar. Verifique se sua conta do Discord está vinculada.", + "ERR_OIDC_TOKEN_EXCHANGE": "Falha no login externo. Tente novamente.", + "ERR_OIDC_USERINFO": "Não foi possível obter seu perfil do provedor de login externo. Tente novamente.", + "ERR_ROLE_CHECK_FAILED": "Não foi possível verificar seus cargos do Discord. Tente mais tarde.", + "ERR_TELEGRAM_FAILED": "O login com Telegram falhou. Tente novamente.", + "ERR_TOKEN_EXCHANGE": "O login com Discord falhou. Tente novamente.", "ERR_GENERIC": "Erro de autenticação: {{error}}", "ERR_NO_TOKEN": "Nenhum token de autenticação recebido.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_UP": "Cadastrar", + "SIGN_UP_DESC": "Ainda não tem conta? Cadastre-se para começar.", + "SIGN_IN_AGAIN": "Entrar novamente" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Este recurso foi desativado pelo administrador." }, "ADMIN": { "USERS_TITLE": "Gerenciamento de Usuários", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Nome promovido", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Nome para a geofence promovida", "APPROVAL_PROMOTED_NAME_HINT": "Opcional. Usa o nome de exibição atual por padrão.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Motivo da rejeição", "APPROVAL_REJECT_PLACEHOLDER": "Explique por que esta geofence está sendo rejeitada...", "USERS_DESC_FULL": "Gerencie usuários Discord registrados. Parado = usuário pausou alertas ou atingiu limites de requisição. Bloqueado = bloqueado pelo admin.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Falha ao aprovar envio", "SNACK_APPROVED": "\"{{name}}\" aprovada", "SNACK_FAILED_REJECT": "Falha ao rejeitar envio", - "SNACK_REJECTED": "\"{{name}}\" rejeitada" + "SNACK_REJECTED": "\"{{name}}\" rejeitada", + "APPROVAL_REGION_HINT": "Escolha a região sob a qual esta geocerca aparecerá.", + "SERVER_TITLE": "Servidor Poracle", + "SERVER_REFRESH": "Verificar de novo", + "SERVER_VERSION": "Versão", + "SERVER_SCHEMA": "Esquema do banco de dados", + "SERVER_CHECKED": "Última verificação", + "SERVER_CAPABILITIES": "Recursos", + "SERVER_NO_CAPABILITIES": "Este servidor não informa nenhum.", + "SERVER_UNKNOWN": "Desconhecida", + "SERVER_UNREACHABLE": "O Poracle não respondeu. Alarmes, perfis e locais passam por ele e vão falhar até ele voltar.", + "SERVER_TOO_OLD": "O Poracle {{version}} é mais antigo que {{minimum}}, exigido por esta versão do site. O alcance por alarme, o filtro mega de PVP e o de tempo restante vão parecer salvos sem mudar nada.", + "UPDATE_AVAILABLE": "Está rodando o {{name}} {{running}} e a {{latest}} já saiu.", + "UPDATE_PRERELEASE": "O {{name}} {{running}} é mais novo que qualquer versão publicada — é uma compilação de desenvolvimento.", + "VERSIONS_TITLE": "Versões", + "VERSIONS_WEB": "Este site", + "VERSIONS_BUILD": "Compilação", + "UPDATE_CURRENT": "Atualizado.", + "UPDATE_UNCOMPARABLE": "Canal de desenvolvimento. A versão mais recente é {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Escolha um ponto", "CANCEL": "Cancelar", "CONFIRM": "Confirmar", "DONT_ASK_AGAIN": "Não perguntar novamente nesta sessão", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Atualizar Todas as Distâncias", "DISTANCE_DESC": "Defina o modo de localização para todos os alarmes deste tipo.", "DISTANCE_UPDATE_ALL": "Atualizar Tudo", + "DISTANCE_MUST_BE_POSITIVE": "A distância precisa ser maior que zero.", "LOCATION_SAVE_ERROR": "Falha ao atualizar localização", "LOCATION_SAVE_SUCCESS": "Localização atualizada com sucesso", "LOCATION_GEO_UNSUPPORTED": "Geolocalização não é suportada pelo seu navegador", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Muitos alertas de teste. Aguarde um momento.", "ERROR_NOT_FOUND": "Alarme não encontrado — pode ter sido excluído.", "ERROR_GENERIC": "Falha ao enviar alerta de teste. Tente novamente mais tarde.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Muitos alertas de teste. Aguarde um momento.", + "NOT_FOUND": "Alerta não encontrado — pode ter sido excluído.", + "UNSUPPORTED": "Alertas de teste não estão disponíveis para este tipo.", + "FAILED": "Não foi possível enviar o alerta de teste. Tente mais tarde." }, "COMMON": { "CANCEL": "Cancelar", @@ -1296,6 +1448,7 @@ "EDIT": "Editar", "ADD": "Adicionar", "OK": "OK", + "UNDO": "Desfazer", "CONFIRM": "Confirmar", "DELETE_ALL": "Excluir Tudo", "CLOSE": "Fechar", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Buscar um ginásio (opcional)", "SEARCH_HINT": "Digite o nome do ginásio...", - "CLEAR_ARIA": "Limpar seleção de ginásio" + "CLEAR_ARIA": "Limpar seleção de ginásio", + "RATE_LIMITED": "Muitas solicitações ao scanner — vá com mais calma." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Notificações serão enviadas para estas áreas:", @@ -1392,12 +1546,9 @@ "GROUP_ALARM_TYPES": "Tipos de alarme", "GROUP_FEATURES": "Recursos", "GROUP_ADMINISTRATION": "Administração", - "GROUP_COMMANDS": "Comandos", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Mapas e recursos", "GROUP_ANALYTICS_LINKS": "Análise e links", - "GROUP_DEBUG": "Depuração", "GROUP_ICON_REPO": "Repositório de ícones", "GROUP_OTHER": "Outro", "CUSTOM_TITLE_LABEL": "Título do site", @@ -1411,52 +1562,51 @@ "FAVICON_URL_PREVIEW": "Prévia do favicon (32×32)", "FAVICON_URL_CACHE_WARNING": "Os navegadores armazenam favicons em cache de forma agressiva. Após salvar, os usuários precisam limpar o cache do navegador ou fazer uma atualização forçada (Ctrl+F5 / Cmd+Shift+R) para ver o novo ícone.", "FAVICON_URL_CSP_NOTE": "Se o seu site usa uma Content Security Policy, a origem da URL do favicon precisa estar permitida pela diretiva img-src; caso contrário, o navegador bloqueia a busca e volta ao ícone padrão.", + "FORCED_BY_PORACLE": "Desativado na própria configuração do Poracle. O Poracle descarta esses webhooks e seu bot recusa o comando, então isso não pode ser ativado aqui.", + "FORCED_BY_PORACLE_TOOLTIP": "Controlado pela configuração do Poracle, não por esta página.", "CUSTOM_PAGE_NAME_LABEL": "Rótulo do link de navegação", "CUSTOM_PAGE_NAME_DESC": "Rótulo para o link de navegação personalizado (ex.: \"Voltar ao mapa\").", "CUSTOM_PAGE_URL_LABEL": "URL do link de navegação", "CUSTOM_PAGE_URL_DESC": "URL para onde o link de navegação personalizado aponta.", "CUSTOM_PAGE_ICON_LABEL": "Ícone do link de navegação", "CUSTOM_PAGE_ICON_DESC": "Classe FontAwesome para o ícone do link de navegação (ex.: \"fas fa-map\").", - "DISABLE_MONS_LABEL": "Desativar Pokémon", - "DISABLE_MONS_DESC": "Ocultar o gerenciamento de alarmes de Pokémon para todos os usuários.", - "DISABLE_RAIDS_LABEL": "Desativar Raides", - "DISABLE_RAIDS_DESC": "Ocultar o gerenciamento de alarmes de Raides para todos os usuários.", - "DISABLE_QUESTS_LABEL": "Desativar Missões", - "DISABLE_QUESTS_DESC": "Ocultar o gerenciamento de alarmes de missões para todos os usuários.", - "DISABLE_INVASIONS_LABEL": "Desativar Invasões", - "DISABLE_INVASIONS_DESC": "Ocultar o gerenciamento de alarmes de invasão para todos os usuários.", - "DISABLE_LURES_LABEL": "Desativar Módulos Isca", - "DISABLE_LURES_DESC": "Ocultar o gerenciamento de alarmes de isca para todos os usuários.", - "DISABLE_NESTS_LABEL": "Desativar Ninhos", - "DISABLE_NESTS_DESC": "Ocultar o gerenciamento de alarmes de ninho para todos os usuários.", - "DISABLE_GYMS_LABEL": "Desativar Ginásios", - "DISABLE_GYMS_DESC": "Ocultar o gerenciamento de alarmes de ginásio para todos os usuários.", - "DISABLE_FORT_CHANGES_LABEL": "Desativar alterações de fortes", - "DISABLE_FORT_CHANGES_DESC": "Ocultar o gerenciamento de alarmes de alterações de fortes para todos os usuários.", - "DISABLE_MAXBATTLES_LABEL": "Desativar Batalhas Max", - "DISABLE_MAXBATTLES_DESC": "Ocultar o gerenciamento de alarmes de Batalha Max para todos os usuários.", - "DISABLE_AREAS_LABEL": "Desativar áreas", - "DISABLE_AREAS_DESC": "Impedir que os usuários gerenciem suas inscrições em áreas.", - "DISABLE_PROFILES_LABEL": "Desativar perfis", - "DISABLE_PROFILES_DESC": "Impedir que os usuários criem e alternem perfis de alarme.", - "DISABLE_LOCATION_LABEL": "Desativar localização", - "DISABLE_LOCATION_DESC": "Impedir que os usuários definam um local de casa.", - "DISABLE_NOMINATIM_LABEL": "Desativar geocodificação", - "DISABLE_NOMINATIM_DESC": "Desativar a pesquisa de endereços Nominatim para escolha de localização.", - "DISABLE_GEOMAP_LABEL": "Desativar visualização de mapa", - "DISABLE_GEOMAP_DESC": "Ocultar completamente o mapa interativo de geofences.", - "DISABLE_GEOMAP_SELECT_LABEL": "Desativar seleção de áreas no mapa", - "DISABLE_GEOMAP_SELECT_DESC": "Impedir que os usuários selecionem áreas clicando no mapa.", - "ENABLE_TEMPLATES_LABEL": "Ativar modelos", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Permite que os usuários gerenciem alarmes de Pokémon.", + "DISABLE_RAIDS_LABEL": "Raides", + "DISABLE_RAIDS_DESC": "Permite que os usuários gerenciem alarmes de raide.", + "DISABLE_QUESTS_LABEL": "Missões", + "DISABLE_QUESTS_DESC": "Permite que os usuários gerenciem alarmes de missão.", + "DISABLE_INVASIONS_LABEL": "Invasões", + "DISABLE_INVASIONS_DESC": "Permite que os usuários gerenciem alarmes de invasão.", + "DISABLE_LURES_LABEL": "Módulos Isca", + "DISABLE_LURES_DESC": "Permite que os usuários gerenciem alarmes de isca.", + "DISABLE_NESTS_LABEL": "Ninhos", + "DISABLE_NESTS_DESC": "Permite que os usuários gerenciem alarmes de ninho.", + "DISABLE_GYMS_LABEL": "Ginásios", + "DISABLE_GYMS_DESC": "Permite que os usuários gerenciem alarmes de ginásio.", + "DISABLE_FORT_CHANGES_LABEL": "Alterações de fortes", + "DISABLE_FORT_CHANGES_DESC": "Permite que os usuários gerenciem alarmes de alterações de fortes.", + "DISABLE_MAXBATTLES_LABEL": "Batalhas Max", + "DISABLE_MAXBATTLES_DESC": "Permite que os usuários gerenciem alarmes de Batalha Max.", + "DISABLE_AREAS_LABEL": "Áreas", + "DISABLE_AREAS_DESC": "Permite que os usuários gerenciem suas inscrições em áreas.", + "DISABLE_PROFILES_LABEL": "Perfis", + "DISABLE_PROFILES_DESC": "Permite que os usuários criem e alternem perfis de alarme.", + "DISABLE_LOCATION_LABEL": "Localização", + "DISABLE_LOCATION_DESC": "Permite que os usuários definam um local de casa.", + "DISABLE_NOMINATIM_LABEL": "Geocodificação", + "DISABLE_NOMINATIM_DESC": "Permite a pesquisa de endereços Nominatim para escolha de localização.", + "DISABLE_USER_GEOFENCES_LABEL": "Geofences personalizadas", + "DISABLE_USER_GEOFENCES_DESC": "Permite que os usuários desenhem, importem e enviem suas próprias geofences. As geofences existentes continuam funcionando.", + "ENABLE_TEMPLATES_LABEL": "Modelos", "ENABLE_TEMPLATES_DESC": "Permitir que os usuários escolham modelos de mensagens de notificação.", "ALLOWED_LANGUAGES_LABEL": "Idiomas da UI permitidos", "ALLOWED_LANGUAGES_DESC": "Códigos de idioma separados por vírgulas a mostrar no seletor (ex.: \"en,de,fr,es\"). Deixe em branco para mostrar os 11 idiomas.", + "PORACLE_LOCALE_HINT": "Idioma padrão para novos usuários: {{locale}}, obtido da configuração do próprio Poracle. Quem escolher um idioma, ou cujo navegador peça um que este site tenha, recebe esse.", "ENABLE_ROLES_LABEL": "Ativar acesso baseado em funções", "ENABLE_ROLES_DESC": "Permitir apenas o login de usuários com funções Discord específicas. Requer Bot Token e Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "IDs de funções permitidas", - "ALLOWED_ROLE_IDS_DESC": "IDs de funções Discord separados por vírgulas que concedem acesso (ex.: \"123456789,987654321\"). Deixe em branco para permitir todos.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Idiomas permitidos", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Lista separada por vírgulas de códigos de idioma que os usuários podem selecionar (ex.: \"en,de,fr\").", + "ALLOWED_ROLE_IDS_DESC": "IDs de funções Discord separados por vírgulas, ex.: 123456789,987654321. Um usuário precisa de pelo menos uma dessas funções para fazer login. Deixe em branco para permitir todos.", "REGISTER_COMMAND_LABEL": "Comando de registro", "REGISTER_COMMAND_DESC": "Comando do bot Poracle que os usuários executam para se registrarem (ex.: \"$!register\").", "LOCATION_COMMAND_LABEL": "Comando de localização", @@ -1464,9 +1614,31 @@ "ENABLE_TELEGRAM_LABEL": "Ativar login do Telegram", "ENABLE_TELEGRAM_DESC": "Permitir login do Telegram neste site. Requer TELEGRAM_ENABLED=true, bot token e bot username em .env (reinicialização do servidor necessária após alterações em .env).", "TELEGRAM_BOT_LABEL": "Nome de usuário do bot", - "TELEGRAM_BOT_DESC": "Nome de usuário do bot do Telegram (sem @).", + "TELEGRAM_BOT_DESC": "Nome de usuário do bot do Telegram (sem @). Usado quando TELEGRAM_BOT_USERNAME não está configurado.", "ENABLE_DISCORD_LABEL": "Ativar login do Discord", "ENABLE_DISCORD_DESC": "Permitir login do Discord neste site. Requer Discord Client ID e Client Secret em .env (reinicialização do servidor necessária após alterações em .env). Não afeta a entrega do bot PoracleNG.", + "ENABLE_OIDC_LABEL": "Ativar login SSO externo", + "ENABLE_OIDC_DESC": "Permitir login pelo provedor OIDC/OAuth2 externo configurado. Requer as configurações OIDC_* (URLs do provedor, client ID e secret) em .env (reinicialização do servidor necessária após alterações em .env).", + "GROUP_OIDC": "SSO externo", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Todos os usuários são redirecionados para o provedor SSO externo. O login local é ignorado.", + "AUTH_MODE_SWITCH_CONFIRM": "Mudar para SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Mudar para login via SSO?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Após salvar, todos os usuários (incluindo administradores) serão redirecionados para {{provider}} para entrar — a página de login local do Discord/Telegram é ignorada. Se o provedor estiver inacessível, você pode ficar bloqueado; recupere o acesso definindo AUTH_FORCE_LOCAL=true no ambiente do servidor.", + "AUTH_OIDC_NOT_CONFIGURED": "O SSO fica indisponível até que o provedor OIDC seja configurado no ambiente do servidor (variáveis OIDC_*).", + "AUTH_OIDC_HIDES_LOCAL": "O Discord e o Telegram ficam ocultos enquanto o SSO é o modo de login ativo.", + "AUTH_SLO_LABEL": "Logout único", + "AUTH_SLO_DESC": "Quando ativado, \"Sair de todos os lugares\" também encerra a sessão do provedor (não apenas deste site). Requer o endpoint de fim de sessão do provedor (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "O logout único fica indisponível até que o endpoint de fim de sessão do provedor seja configurado (variável OIDC_END_SESSION_URL).", + "OIDC_SERVER_CONFIG": "Configuração do provedor OIDC", + "OIDC_PROVIDER_LABEL": "Nome do provedor", + "OIDC_AUTHORIZATION_URL_LABEL": "URL de autorização", + "OIDC_TOKEN_URL_LABEL": "URL de token", + "OIDC_USERINFO_URL_LABEL": "URL de UserInfo", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Escopos", + "OIDC_IDENTITY_CLAIM_LABEL": "Claim de identidade", + "OIDC_USE_PKCE_LABEL": "Usar PKCE", "PROVIDER_URL_LABEL": "URL dos blocos do mapa", "PROVIDER_URL_DESC": "Modelo de URL do provedor de blocos de mapa (usado para mapas estáticos).", "GANALYTICSID_LABEL": "ID do Google Analytics", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "IDs de admin", "DISCORD_ADMIN_IDS_DESC": "IDs de usuários Discord com acesso de admin (mascarado).", "DISCORD_GEOFENCE_FORUM_LABEL": "Canal de fórum de geofences", - "DISCORD_GEOFENCE_FORUM_DESC": "Canal de fórum Discord para threads de envio de geofences." + "DISCORD_GEOFENCE_FORUM_DESC": "Canal de fórum Discord para threads de envio de geofences.", + "SEARCH_PLACEHOLDER": "Pesquisar configurações…", + "SEARCH_CLEAR": "Limpar pesquisa", + "UNSAVED_CHANGES": "{{count}} não salva(s)", + "SAVE_CHANGES": "Salvar alterações", + "DISCARD_CHANGES": "Descartar", + "COLLAPSE_SECTION": "Recolher seção", + "EXPAND_SECTION": "Expandir seção", + "SUMMARY_ENABLED": "{{count}} de {{total}} ativadas", + "GROUP_AUTH": "Autenticação", + "AUTH_MODE_LABEL": "Modo de login", + "AUTH_MODE_LOCAL": "Local", + "AUTH_MODE_LOCAL_DESC": "Faça login diretamente com Discord ou Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "O login local é forçado pela configuração do servidor.", + "DISABLE_UPDATE_CHECK_LABEL": "Não procurar atualizações", + "DISABLE_UPDATE_CHECK_DESC": "Impede o site de perguntar ao GitHub se saiu uma versão mais recente do PoracleWeb ou do Poracle. É a única requisição que sai da sua rede e não envia nada." }, "GEOFENCE_DETAIL": { "NAME": "Nome", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Sua localização", "SELECTED_COUNT": "{{count}} selecionados:", "AREAS_SELECTED": "{{count}} área(s) selecionada(s)" + }, + "ALERT_DEFAULTS": { + "TITLE": "Padrões de alertas", + "DESC": "Escolha como os novos alertas são entregues por padrão. Você ainda pode alterar isso para cada alerta ao criá-lo.", + "DEFAULT_DISTANCE": "Distância padrão", + "DEFAULT_DISTANCE_HINT": "Usada para preencher previamente o raio de novos alertas baseados em distância.", + "FOOTNOTE": "Aplica-se apenas a alertas recém-criados — os existentes não são alterados.", + "DISTANCE_TOO_SMALL": "Deve ser pelo menos 0,1 km.", + "DISTANCE_TOO_LARGE": "Deve ser 100 km ou menos." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Itens por página:", + "RANGE": "{{start}} - {{end}} de {{total}}", + "RANGE_EMPTY": "0 de {{total}}", + "NEXT_PAGE": "Próxima página", + "PREVIOUS_PAGE": "Página anterior", + "FIRST_PAGE": "Primeira página", + "LAST_PAGE": "Última página" + }, + "WHERE": { + "SET_PIN": "Definir sua localização", + "PIN_MISSING_WARNING": "Você ainda não definiu sua localização, então este alerta não teria de onde medir.", + "PLACES_EMPTY_TITLE": "Nenhum local ainda", + "PIN_UNSET": "Não definida", + "PLACES_PAGE_DESC": "Pontos nomeados para onde direcionar seus alertas, em vez da sua localização.", + "ADD_PLACE": "Adicionar um local", + "AREAS_LABEL": "Áreas", + "AREA_LIST_MORE": "{{areas}} e mais {{count}}", + "MEASURED_FROM": "Medido a partir de", + "MY_PIN": "Minha localização", + "NAME_PLACE_MESSAGE": "Que nome dar a este local?", + "NAME_PLACE_TITLE": "Dar nome a este local", + "NEAR_PIN": "A menos de {{distance}} km da minha localização", + "NEAR_PLACE": "A menos de {{distance}} km de {{place}}", + "NO_PLACES": "Nenhum local ainda. Adicione um abaixo para direcionar este alerta para fora da sua localização.", + "ONLY_IN": "Apenas em {{areas}}", + "OPTION_AREAS": "Apenas em áreas específicas", + "OPTION_NEAR": "Perto de um ponto", + "OPTION_PLACE": "Perto de um local", + "OPTION_PROFILE": "Em qualquer parte das minhas áreas", + "PIN_NOTE": "O padrão para todo alerta sem destino próprio.", + "PIN_TITLE": "Minha localização", + "PLACES_EMPTY": "Adicione um para receber alertas fora da sua localização: o trabalho, a academia, a casa dos seus pais.", + "PLACES_TITLE": "Locais", + "PLACE_DELETED": "{{place}} excluído.", + "PLACE_DELETE_CONFIRM": "Os alertas voltados para {{place}} voltarão à sua localização.", + "PLACE_DELETE_ERROR": "Não foi possível excluir esse local.", + "PLACE_DELETE_TITLE": "Excluir este local?", + "PLACE_IN_USE": "{{place}} é usado por {{count}} alerta(s). Redirecione-os primeiro.", + "PLACE_LABEL": "Local", + "PLACE_NAME": "Nome", + "PLACE_SAVED": "{{place}} salvo.", + "PLACE_SAVE_ERROR": "Não foi possível salvar esse local.", + "PROFILE_ANYWHERE": "Onde quer que eu receba alertas", + "PROFILE_AREAS": "Em qualquer parte das minhas áreas", + "RADIUS_KM": "Raio (km)", + "SAVE": "Definir destino", + "SCOPE_SAVED": "Destino atualizado.", + "SCOPE_SAVE_ERROR": "Não foi possível atualizar onde esse alerta chega até você.", + "SHEET_TITLE": "Onde este alerta deve chegar até você?", + "USE_THIS_POINT": "Usar este ponto" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json index 47316704..9d902cab 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -16,7 +16,7 @@ "GYMS": "Ginásios", "FORT_CHANGES": "Alterações de Forte", "PROFILES": "Perfis", - "AREAS": "Áreas", + "AREAS": "Áreas e locais", "MY_GEOFENCES": "As Minhas Geofences", "CLEANING": "Limpeza", "HELP": "Ajuda", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "A ver como", - "BACK_TO_ADMIN": "Voltar à Administração", + "EXIT_IMPERSONATION": "Voltar à tua conta", "DISABLED_ACCOUNT": "A tua conta foi desativada. Isto pode ser devido a limitação de pedidos ou a uma ação administrativa.", + "DISABLED_ACCOUNT_INSPECTED": "Esta conta foi desativada por um administrador e não recebe notificações.", "DISABLED_SUPPORT": "Para obter ajuda, pergunta em", "PAUSED_ALERTS": "Os teus alertas estão em pausa. Não vais receber notificações.", "RESUME": "Retomar" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Altera apenas o texto deste site.", "PROFILE_PREFIX": "Perfil #", "PAUSE_ALERTS": "Pausar Alertas", "RESUME_ALERTS": "Retomar Alertas", "SWITCH_PROFILE": "Trocar Perfil", - "AREAS_LOCATION": "Áreas e Localização", "CLEANING": "Limpeza", "ACCENT_THEME": "Tema de Destaque", - "LANGUAGE": "Idioma", + "DISPLAY_LANGUAGE": "Idioma da interface", + "ALERT_LANGUAGE": "Idioma dos alertas", + "ALERT_LANGUAGE_HINT": "Usado no texto dos alertas e nos nomes dos Pokemon.", "LOGOUT": "Sair", + "LOGOUT_EVERYWHERE": "Terminar sessão em todo o lado", "ACCENT_DEFAULT": "Predefinição", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Padrões de alertas" }, "SHORTCUTS": { "TITLE": "Atalhos de Teclado", @@ -77,6 +82,7 @@ "NETWORK": "Não foi possível contactar o servidor. Verifica a tua ligação.", "BAD_REQUEST": "Pedido inválido. Verifica os dados introduzidos.", "UNAUTHORIZED": "A tua sessão expirou. Inicia sessão novamente.", + "INSPECTION_ENDED": "Inspeção terminada — voltou à sua própria sessão.", "FORBIDDEN": "Não tens permissão para realizar esta ação.", "NOT_FOUND": "O recurso solicitado não foi encontrado.", "CONFLICT": "Ocorreu um conflito. O item pode ter sido modificado.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Boas-vindas de integração" }, "POKEMON": { + "PVP_EVOLUTION": "Megaevolução", + "PVP_EVOLUTION_HINT": "Classifica as formas base ou uma mega. As megas são classificadas à parte, por isso uma regra mega não corresponde a uma forma base.", + "PVP_EVO_BASE": "Base", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Alarmes de Pokemon", "PAGE_DESC": "Monitoriza spawns de Pokemon selvagens com filtros personalizados de IV, CP, nível e PVP.", "SEARCH_PLACEHOLDER": "Pesquisar por nome ou #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Forma e Género", "LABEL_FORM": "Forma", "ALL_FORMS": "Todas as Formas", + "FORM_MULTI_HINT": "Deixe vazio para incluir todas as formas", "LABEL_GENDER": "Género", "GENDER_ALL": "Todos", "GENDER_MALE": "Masculino", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Só alerta se o CP evoluído atingir este mínimo", "PVP_DISABLED_HINT": "Seleciona uma liga para filtrar por ranking PVP.", "SNACK_CREATED": "{{count}} alarme(s) de Pokemon criado(s)", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} alarme(s) de Pokemon criado(s), {{duplicates}} ja monitorizado(s)", "SNACK_UPDATED": "Alarme de Pokemon atualizado", "SNACK_DELETED": "Alarme de Pokemon eliminado", "SNACK_DELETED_ALL": "Todos os alarmes de Pokemon eliminados", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Limite de nível", + "PVP_CAP_ALL": "Todos", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Predefinido — da configuração do Poracle", + "FILTER_TIME_LEFT": "Tempo Restante", + "LABEL_MIN_TIME": "Tempo restante mínimo", + "MIN_TIME_HINT": "Ignora aparições que desaparecem antes de chegares.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "restam {{count}} min", + "PILL_TIME_LEFT_SECONDS": "restam {{count}} s", + "MIN_TIME_ANY": "Qualquer" }, "ALARM": { "LOCATION_MODE": "Modo de Localização", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Elimina automaticamente a notificação do Discord após o isco expirar", "CLEAN_HINT_NEST": "Elimina automaticamente a notificação do Discord quando os ninhos migram", "CLEAN_HINT_GYM": "Elimina automaticamente a notificação do Discord quando a atividade do ginásio muda", - "CLEAN_HINT_FORT": "Elimina automaticamente a notificação do Discord após expirar", "CLEAN_HINT_MAX_BATTLE": "Elimina automaticamente a notificação do Discord após a batalha max terminar", "SAVING": "A guardar...", "SAVE": "Guardar", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Tempo de espera ativo", "TEST_SEND": "Enviar notificação de teste", "TAB_DELIVERY": "Entrega", - "COMMON_SETTINGS": "Definições Comuns" + "COMMON_SETTINGS": "Definições Comuns", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} criados, {{duplicates}} ja monitorizados" }, "RAIDS": { + "RSVP_LABEL": "Notificações RSVP", + "RSVP_OFF": "Apenas correspondências", + "RSVP_INCLUDE": "Correspondências + atualizações RSVP", + "RSVP_ONLY": "Apenas atualizações RSVP", + "RSVP_OFF_DESC": "Apenas alertas padrão de raid/ovo.", + "RSVP_INCLUDE_DESC": "Também notificar novamente quando as contagens de RSVP mudarem.", + "RSVP_ONLY_DESC": "Ignorar correspondências iniciais; notificar apenas alterações de RSVP. Sem um scanner que emita RSVP este alarme fica silenciado.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Apenas RSVP", "PAGE_TITLE": "Alarmes de Raid e Ovos", "PAGE_DESC": "Recebe notificações sobre chefes de raid e eclosão de ovos em ginásios próximos.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Tens a certeza de que queres eliminar TODOS os alarmes de raid e ovos? Esta ação não pode ser revertida.", "CONFIRM_BULK_DELETE_TITLE": "Eliminar Alarmes Selecionados", "CONFIRM_BULK_DELETE_MSG": "Tens a certeza de que queres eliminar {{count}} alarmes?", - "CONFIRM_DELETE_SELECTED": "Eliminar Selecionados" + "CONFIRM_DELETE_SELECTED": "Eliminar Selecionados", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Nível", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Padrão", + "SECTION_SPECIAL": "Especiais", + "SECTION_CUSTOM": "Personalizados", + "ADD": "Adicionar nível", + "ADD_PLACEHOLDER": "ex. 42", + "ADD_HELP": "Qualquer inteiro positivo que o teu servidor use. 9000 significa «qualquer nível».", + "INVALID": "O nível tem de ser 1 ou superior.", + "DUPLICATE": "O nível {{value}} já está na lista.", + "SR_REMOVE": "Remover o nível personalizado {{value}}", + "REMOVED": "Nível {{value}} removido", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes de Missões", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Mega Energia", "TAB_CANDY": "Doces", "ITEM_REWARD": "Recompensa de Item", - "ANY_ITEM": "Qualquer Item", + "ANY_ITEM": "Qualquer item", "QUEST_TYPE_LABEL": "Tipo de Missão:", "SNACK_CREATED": "Alarme de missão criado", "SNACK_UPDATED": "Alarme de missão atualizado", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Todos os alarmes de missões eliminados", "SNACK_FAILED_DELETE_ALL": "Falha ao eliminar alarmes", "SNACK_FAILED_DISTANCE": "Falha ao atualizar distâncias", - "CONFIRM_DELETE_SELECTED": "Eliminar Selecionados" + "CONFIRM_DELETE_SELECTED": "Eliminar Selecionados", + "SUMMARY_MODE": "Resumo diário", + "SUMMARY_HINT": "Reúne as missões correspondentes numa única mensagem de resumo em vez de uma notificação por cada. Requer um agendamento de resumo configurado no bot.", + "SUMMARY_BADGE": "Resumo", + "SUMMARY_SCHEDULE": "Entrega do resumo de missões", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Resumo de missões", + "SUMMARY_SCHEDULE_EMPTY": "Nenhum agendamento de resumo definido. As missões são entregues individualmente.", + "SUMMARY_SCHEDULE_EDIT": "Editar agendamento", + "SUMMARY_SCHEDULE_CLEAR": "Remover agendamento", + "SUMMARY_SCHEDULE_SEND_NOW": "Enviar resumo agora", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Envia as correspondências de missões recolhidas desde o teu último resumo. Se ainda não houver nada em buffer, nada é enviado.", + "SUMMARY_SCHEDULE_SAVED": "Agendamento de resumo guardado", + "SUMMARY_SCHEDULE_CLEARED": "Agendamento de resumo removido", + "SUMMARY_SCHEDULE_SENT": "Resumo enviado", + "SUMMARY_SCHEDULE_FAILED": "Não foi possível atualizar o agendamento do resumo", + "SUMMARY_SCHEDULE_UNAVAILABLE": "A entrega de resumos está temporariamente indisponível. Tente novamente mais tarde.", + "SUMMARY_DISABLED_HINT": "O agendamento de resumos não está disponível neste servidor.", + "TAB_STARDUST": "Pó Estelar", + "MIN_AMOUNT": "Quantidade mínima", + "MIN_AMOUNT_HINT": "0 = qualquer quantidade", + "MIN_STARDUST": "Pó estelar mínimo", + "MIN_STARDUST_HINT": "0 = qualquer tarefa de pó estelar", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Alarmes de Invasões", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnético", "TYPE_RAINY": "Chuvoso", "TYPE_GOLDEN": "Dourado", - "TYPE_UNKNOWN": "Módulo #{{id}}" + "TYPE_UNKNOWN": "Módulo #{{id}}", + "EDIT_MODE": "Editar a mensagem no local", + "EDIT_HINT": "Atualiza a mensagem existente do Discord quando o engodo muda em vez de enviar uma nova.", + "EDIT_BADGE": "Editar", + "CONFIRM_DELETE_TITLE": "Eliminar alerta de isco?", + "SNACK_FAILED_DISTANCE": "Não foi possível atualizar a distância." }, "NESTS": { "PAGE_TITLE": "Alarmes de Ninhos", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Alarme de ninho eliminado", "SNACK_FAILED_CREATE": "Falha ao criar alarme", "SNACK_FAILED_UPDATE": "Falha ao atualizar alarme", - "SNACK_FAILED_DELETE": "Falha ao eliminar alarme" + "SNACK_FAILED_DELETE": "Falha ao eliminar alarme", + "CONFIRM_DELETE_TITLE": "Eliminar alerta de ninho?", + "SNACK_FAILED_DISTANCE": "Não foi possível atualizar a distância." }, "GYMS": { "PAGE_TITLE": "Alarmes de Ginásios", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Sabedoria", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinto", - "TEAM_UNKNOWN": "Equipa {{id}}" + "TEAM_UNKNOWN": "Equipa {{id}}", + "CONFIRM_DELETE_TITLE": "Eliminar alerta de ginásio?", + "SNACK_FAILED_DISTANCE": "Não foi possível atualizar a distância." }, "FORT_CHANGES": { "PAGE_TITLE": "Alarmes de Alterações de Forte", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Removido", "CHANGE_NEW": "Novo forte", "INCLUDE_EMPTY": "Incluir fortes sem nome", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Não foi possível criar o alerta", + "CREATE_SUCCESS": "Alerta de alterações de ginásio criado", + "UPDATE_FAILED": "Não foi possível atualizar o alerta", + "UPDATE_SUCCESS": "Alerta de alterações de ginásio atualizado", "ALL_CHANGES": "Todas as alterações", "LABEL_NAME": "Nome", "LABEL_LOCATION": "Localização", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Eliminar o alarme de alteração {{type}}?", "SNACK_DELETED": "Alarme de alteração eliminado", "SNACK_FAILED_DISTANCE": "Falha ao atualizar as distâncias", - "SNACK_ALL_DISTANCE": "Todas as distâncias atualizadas" + "SNACK_ALL_DISTANCE": "Todas as distâncias atualizadas", + "FORT_TYPE_LABEL": "Tipo de forte", + "CHANGE_TYPES_LABEL": "Tipos de alteração", + "TRACKING_SUBTITLE": "Monitorização de alterações de forte", + "CHANGE_DESCRIPTION": "Descrição alterada" }, "MAX_BATTLES": { "PAGE_TITLE": "Alarmes de Batalhas Max", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Não foi possível criar os alertas", + "CREATE_SUCCESS": "{{count}} alerta(s) criado(s)", "ANY_POKEMON": "Qualquer Pokémon", "ANY_LEVEL": "Qualquer nível", "STAR_LABEL": "{{stars}} estrelas", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Falha ao atualizar as distâncias", "SNACK_ALL_DISTANCE": "Todas as distâncias atualizadas", "SNACK_FAILED_UPDATE": "Falha ao atualizar o alarme", - "SNACK_UPDATED": "Alarme de Combate Max atualizado" + "SNACK_UPDATED": "Alarme de Combate Max atualizado", + "HINT_BY_LEVEL": "Segue qualquer Pokemon nestes níveis de batalha. Cada nível escolhido cria o seu próprio alarme.", + "HINT_BY_POKEMON": "Segue Pokemon específicos em Batalhas Max, seja qual for o nível.", + "HINT_GMAX_ONLY_ADD": "Avisa apenas sobre batalhas Gigantamax dos Pokemon escolhidos.", + "HINT_GMAX_ONLY_EDIT": "Avisa apenas sobre batalhas Gigantamax deste Pokemon.", + "HINT_ALL_LEVELS": "Este alarme segue um Pokemon em todos os níveis de Batalha Max.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Áreas e Localização", + "MANAGE_PLACES": "Gerir locais", + "PAGE_TITLE": "Áreas e locais", "PAGE_DESC": "Controla onde recebes notificações.", "METHOD_AREAS": "Áreas", "METHOD_AREAS_ACTIVE": "{{count}} área(s) ativa(s)", "METHOD_NOT_CONFIGURED": "Não configurado", "METHOD_AREAS_DESC": "Recebe notificações sobre tudo o que acontece dentro das tuas zonas geofence selecionadas.", "METHOD_AREAS_TIP": "Ideal para: cobrir cidades inteiras, bairros ou parques", - "METHOD_LOCATION": "Localização", - "METHOD_LOCATION_NOT_SET": "Não definida", - "METHOD_LOCATION_DESC": "Recebe notificações sobre tudo num raio definido a partir da tua localização fixa.", + "METHOD_LOCATION": "A minha localização", + "METHOD_LOCATION_NOT_SET": "Sem localização definida", + "METHOD_LOCATION_DESC": "Recebe alertas sobre tudo dentro de uma distância definida da tua localização.", "METHOD_LOCATION_TIP": "Ideal para: alertas perto de casa, trabalho ou um local específico", "CLEAR_LOCATION": "Limpar", "CHANGE_LOCATION": "Alterar", "SET_LOCATION": "Definir", "METHOD_NOTE": "Cada alarme escolhe um método no separador Entrega.", + "NOTIFICATION_LANGUAGE": "Idioma das notificações", + "NOTIFICATION_LANGUAGE_DESC": "O idioma que o Poracle usa para as suas mensagens de alerta e nomes de Pokémon. É distinto do idioma de exibição no menu superior.", + "SNACK_LANGUAGE_UPDATED": "Idioma das notificações atualizado", + "SNACK_LANGUAGE_FAILED": "Falha ao atualizar o idioma das notificações", "SELECT_AREAS": "Selecionar Áreas", "MAP_VIEW": "Mapa", "LIST_VIEW": "Lista", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Falha ao atualizar localização", "SEARCH_AREAS": "Pesquisar áreas", "MANUAL_ADD_PLACEHOLDER": "Escreve um nome de área e prime Enter", - "FILTER_PLACEHOLDER": "Filtrar por nome..." + "FILTER_PLACEHOLDER": "Filtrar por nome...", + "SNACK_LOAD_SELECTED_FAILED": "Não foi possível carregar as tuas áreas atuais. Recarrega antes de as alterares.", + "SELECTION_UNKNOWN": "As tuas áreas atuais não foram carregadas — recarrega a página antes de guardar." }, "PROFILES": { "PAGE_TITLE": "Perfis", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Selecionar Região", "SEARCH_REGIONS": "Pesquisar regiões...", "TOGGLE_TOOLTIP": "Ativar/desativar notificações para este geofence no perfil atual", - "CREATED_PREFIX": "Criado" + "CREATED_PREFIX": "Criado", + "REGION_OPTIONAL_HINT": "Opcional. Escolhe uma região se a tua geocerca pertencer a uma." }, "CLEANING": { "PAGE_TITLE": "Modo de Limpeza", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Some help content may not be available in your language yet.", "TRANSLATION_CTA_LINK": "Help translate", "FALLBACK_CHIP": "English", + "IMAGE_ENLARGE": "Clique para ampliar", "SECTION_GETTING_STARTED": "Getting Started", "SECTION_GETTING_STARTED_SUB": "Login, onboarding wizard, and initial setup", "SECTION_DASHBOARD": "Dashboard", "SECTION_DASHBOARD_SUB": "Your overview of alarms, areas, and status", - "SECTION_LOCATION": "Setting Your Location", + "SECTION_LOCATION": "Definir a Tua Localização", "SECTION_LOCATION_SUB": "GPS, address search, and coordinates", - "SECTION_AREAS": "Choosing Your Areas", - "SECTION_AREAS_SUB": "Map view, list view, and region filtering", + "SECTION_AREAS": "Áreas e locais", + "SECTION_AREAS_SUB": "Vista de mapa, vista de lista, filtro por região e locais", "SECTION_GEOFENCES": "Custom Geofences", "SECTION_GEOFENCES_SUB": "Draw boundaries, submit for public approval", "SECTION_POKEMON": "Pokemon Alarms", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Other Alarm Types", "SECTION_OTHER_ALARMS_SUB": "Raids, eggs, quests, rockets, lures, nests, gyms, fort changes", "SECTION_DELIVERY": "Delivery Settings", - "SECTION_DELIVERY_SUB": "Areas vs distance, templates, and clean mode", + "SECTION_DELIVERY_SUB": "Alcance de entrega, templates e modo de limpeza", + "SECTION_QUEST_SUMMARY": "Entrega do resumo de missões", + "SECTION_QUEST_SUMMARY_SUB": "Agrupa missões barulhentas num único resumo agendado", "SECTION_TEST_ALERTS": "Test Alerts", "SECTION_TEST_ALERTS_SUB": "Send sample notifications to preview your alarms", "SECTION_POKEMON_AVAILABILITY": "Pokemon Availability", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Common issues and how to fix them", "CONTENT_GETTING_STARTED": "

O site de Alertas DM permite-te personalizar exatamente quais notificações de Pokemon GO recebes como mensagens diretas. Em vez de receberes todos os alertas, escolhes o que te interessa — Pokemon específicos, raids, quests e mais — e só és notificado sobre esses.

ℹ️
Antes de poderes usar o site, precisas de te registar com o bot Poracle no Discord ou Telegram. Depois de registado, volta aqui e inicia sessão.

Iniciar Sessão

  • Discord — Clica em \"Iniciar sessão com Discord\" na página de login. Serás redirecionado para o Discord para autorizar a app e depois voltas automaticamente.
  • Telegram — Se ativado, usa o widget de login do Telegram na página de login. Confirma o login na tua app Telegram.
\"Página

Configuração Inicial

Quando inicias sessão pela primeira vez, um assistente de boas-vindas guia-te por três passos:

  1. Define a tua localização — Usada para calcular distâncias para notificações nas proximidades.
  2. Escolhe as tuas áreas — Seleciona as zonas geográficas das quais queres receber alertas.
  3. Adiciona o teu primeiro alarme — Cria um alarme de Pokemon, Raid ou Quest para começares a receber notificações.
\"Assistente

Podes saltar qualquer passo e voltar mais tarde. O assistente não aparece novamente depois de o fechares ou completares todos os passos.

", "CONTENT_DASHBOARD": "\"Dashboard

O Dashboard é a tua base. Mostra uma visão geral da tua configuração atual num relance.

Cartões de Estado

  • Localização — Mostra as tuas coordenadas ou endereço guardados. Clica para definir ou atualizar a tua localização.
  • Áreas Ativas — Mostra quantas áreas estás a seguir. Clica para gerir as tuas áreas.
  • Perfil — Mostra o teu perfil ativo. Se tens vários perfis, clica para alternar entre eles.

Filtros Ativos

Uma grelha de cartões mostra quantos alarmes tens para cada tipo (Pokemon, Raids, Quests, etc.). Clica em qualquer cartão para saltar para essa lista de alarmes.

Meteorologia

Se tens uma localização definida, o dashboard mostra a meteorologia atual no jogo nas tuas coordenadas juntamente com a hora da última atualização. A meteorologia da área também é apresentada para cada uma das tuas áreas selecionadas, para que possas ver as condições meteorológicas em todas as zonas que segues.

Ações Rápidas

Botões de atalho para adicionar alarmes de Pokemon, Raid ou Quest, gerir áreas ou configurar a limpeza — tudo sem navegar pela barra lateral.

Dicas

Lembretes úteis aparecem quando a tua configuração está incompleta — como localização em falta, nenhuma área selecionada ou nenhum alarme configurado. Cada dica tem um botão de ação para resolver. Podes fechar dicas que não precisas.

Navegação

Usa a barra lateral para navegar entre secções. Os tipos de alarme estão listados no topo, seguidos de definições como Áreas, Geofences, Perfis e Limpeza. A Ajuda está sempre no fundo.

\"Barra", - "CONTENT_LOCATION": "\"Dashboard

A tua localização é usada para notificações baseadas em distância. Quando um alarme usa o modo \"Definir Distância\", serás notificado sobre eventos dentro de um raio desta localização.

Definir a Tua Localização

Abre a janela de localização a partir do Dashboard ou da página Áreas. Tens quatro formas de a definir:

  • Pesquisar por endereço — Escreve um endereço, cidade ou ponto de referência. Seleciona das sugestões que aparecem.
  • Inserir coordenadas — Escreve latitude e longitude diretamente se as souberes.
  • Usar o teu GPS — Clica em \"Usar a minha localização\" para usar a localização atual do teu dispositivo. O browser pedirá permissão.
  • Clicar no mapa — Clica em qualquer ponto do mini-mapa para definir esse ponto como a tua localização.

Depois de selecionares uma localização, o endereço é mostrado automaticamente. Clica em Guardar para confirmar.

💡
Podes limpar a tua localização na página Áreas se quiseres apenas alertas baseados em áreas.
", - "CONTENT_AREAS": "\"Página

As áreas são zonas geográficas predefinidas configuradas pela tua comunidade. Quando um alarme usa o modo \"Usar Áreas\", és notificado sobre eventos que acontecem nas tuas áreas selecionadas.

Selecionar Áreas

Vai a Áreas e Localização na barra lateral. Podes selecionar áreas de duas formas:

  • Vista de mapa — Clica nos polígonos coloridos no mapa para selecionar ou desselecionar áreas. As áreas selecionadas ficam verdes. Passa o rato sobre uma área para ver o nome.
  • Vista de lista — Usa caixas de seleção para escolher áreas de uma lista pesquisável.

Filtro por Região

Se a tua comunidade tem muitas áreas em diferentes regiões, usa o menu de regiões para focar numa região específica. Isto torna mais fácil encontrar áreas perto de ti.

Áreas Sobrepostas

Algumas áreas sobrepem-se — uma zona mais pequena dentro de uma maior. Ambas são clicáveis. Faz zoom para tornar mais fácil clicar na área mais pequena.

Guardar

Uma barra de guardar aparece no fundo quando fizeste alterações. Clica em Guardar para confirmar as tuas seleções, ou Cancelar para reverter.

ℹ️
As áreas são por perfil. Cada perfil tem o seu próprio conjunto de áreas selecionadas. Ao mudar de perfil verás seleções de áreas diferentes. As geofences personalizadas também podem ser ativadas ou desativadas por perfil na página Geofences.
", - "CONTENT_GEOFENCES": "\"Página

Se as áreas predefinidas não cobrem onde queres alertas, podes desenhar os teus próprios limites de geofence personalizados no mapa.

Desenhar uma Geofence

  1. Vai a As Minhas Geofences na barra lateral.
  2. Clica em Desenhar Geofence.
  3. Clica no mapa para colocar pontos do limite do teu polígono. Clica novamente no primeiro ponto para fechar a forma (mínimo 3 pontos).
  4. Dá um nome à tua geofence e seleciona a que região pertence. A região é normalmente detetada automaticamente.
  5. Clica em Guardar.

Gerir Geofences

  • Editar — Renomeia a tua geofence ou altera a sua região.
  • Eliminar — Remove uma geofence que já não precisas. A geofence é removida de todos os perfis automaticamente.

Interruptor de Perfil

Cada cartão de geofence tem um interruptor deslizante para a ativar ou desativar para o teu perfil atual. Quando crias uma geofence, é automaticamente ativada no perfil que estás a usar. Muda para outro perfil e o interruptor mostrará \"Inativa\" — liga-o para receber alertas para essa geofence nesse perfil também. Isto permite-te controlar quais perfis recebem notificações para cada geofence sem a recriar.

ℹ️
Geofences aprovadas (promovidas a áreas públicas) não mostram o interruptor — gere-as na página Áreas.

Importação & Exportação GeoJSON

Podes importar e exportar geofences usando o formato padrão GeoJSON, tornando fácil partilhar limites ou criá-los em ferramentas externas como geojson.io.

  • Importar — Clica no ícone de upload e cola ou carrega um ficheiro GeoJSON. Cada polígono no ficheiro torna-se uma nova geofence. Podes rever e renomear cada uma antes de guardar.
  • Exportar — Clica no ícone de download e seleciona quais geofences incluir. O ficheiro GeoJSON exportado contém todos os polígonos selecionados e pode ser aberto em qualquer ferramenta GIS ou editor de mapas.
💡
A importação GeoJSON é útil para migrar geofences de outros sistemas ou desenhar limites complexos numa ferramenta GIS desktop e depois importá-los aqui.

Submeter para Aprovação Pública

Se achas que a tua geofence seria útil para toda a comunidade, podes submetê-la para revisão dos administradores. Se aprovada, torna-se uma área pública que todos podem selecionar. A tua geofence privada continua a funcionar enquanto a revisão está pendente.

Badges de Estado

  • Ativa — A tua geofence privada, a funcionar apenas para ti.
  • Em Revisão — Submetida e a aguardar revisão dos administradores.
  • Aprovada — Promovida a área pública.
  • Rejeitada — Não aprovada. Podes ver o feedback do administrador e a geofence permanece ativa como zona privada.
ℹ️
Podes ter até 10 geofences personalizadas, cada uma com um máximo de 500 pontos de limite.
", - "CONTENT_POKEMON": "\"Página

Os alarmes Pokemon notificam-te quando um Pokemon selvagem aparece e corresponde aos teus filtros.

Adicionar um Alarme Pokemon

\"Janela
  1. Vai a Pokemon na barra lateral e clica no botão +.
  2. Seleciona Pokemon — Pesquisa por nome ou número Pokedex, ou usa os botões de filtro por geração e tipo para navegar. Podes selecionar vários Pokemon de uma vez.
  3. Define os filtros — Escolhe o que torna um spawn digno de notificação:
  • Intervalo IV — Percentagem IV mínima e máxima (0-100%)
  • Intervalo CP — Filtra por poder de combate
  • Intervalo de nível — Filtra por nível Pokemon (0-55)
  • Estatísticas individuais — Filtra por valores de ATK, DEF e STA (0-15 cada)
  • Forma — Segue formas específicas (ex. Alolan, Galarian) ou todas as formas
  • Género — Masculino, feminino, sem género, ou todos
  • Peso — Filtra por intervalo de peso
  • Tamanho — Filtra por categoria de tamanho: seleciona ALL (sem filtro) para qualquer tamanho, ou escolhe tamanhos específicos de XXS a XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Os valores predefinidos dos filtros estão configurados para que todos os Pokemon correspondam quando nenhum filtro é explicitamente configurado. Por exemplo, IV predefinido 0-100%, nível 0-55 e tamanho ALL. Só precisas de ajustar os filtros que te interessam.

Filtros PVP

Recebe uma notificação quando um Pokemon tem ótimos IV para PVP. Seleciona uma liga (Great, Ultra ou Little Cup) e define o intervalo de ranking que te interessa (ex. rank 1-50).

Alarme \"Todos os Pokemon\"

💡
Seleciona \"Todos os Pokemon\" (ID 0) para criar um único alarme que cobre todas as espécies. Útil com um filtro IV alto como 96-100% para apanhar qualquer spawn valioso.

Ler os Cartões de Alarme

Cada cartão de alarme mostra pílulas coloridas que resumem os teus filtros num relance:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Página

Alarmes de Raid e Ovo

Recebe uma notificação quando aparece um boss de raid ou ovo que te interessa.

  • Por nível — Seleciona níveis de raid (1-6) ou níveis de ovo para seguir todos os raids desse nível.
  • Por boss — Seleciona bosses de raid Pokemon específicos que queres enfrentar.
  • Filtro de equipa — Notifica apenas para raids em gyms controlados por uma equipa específica (Mystic, Valor, Instinct).
  • Seguimento de gym — Segue raids em gyms específicos por nome para seres notificado apenas sobre os teus gyms favoritos.
  • Filtro de movimentos — Filtra bosses de raid pelos seus movimentos rápidos ou carregados.
  • Notificações RSVP — Recebe uma notificação quando outros treinadores confirmam presença num raid ou ovo que estás a seguir.

Os alarmes de Raid e Ovo são geridos em separadores distintos na página Raids. Os Ovos também suportam seguimento de gym específico e notificações RSVP.

Alarmes Max Battle (Dynamax)

Recebe notificações sobre batalhas Dynamax e Gigantamax nos Power Spots.

  • Por nível — Seleciona níveis de batalha para seguir qualquer Pokemon nesses níveis. Os níveis vão de 1 Estrela a 5 Estrelas (Lendário) para Dynamax, mais Gigantamax e Gigantamax Lendário para as maiores batalhas. É criado um alarme por cada nível selecionado.
  • Por Pokemon — Seleciona Pokemon específicos que queres enfrentar em todos os níveis Max Battle. Se a base de dados do scanner estiver configurada, o seletor mostra apenas Pokemon que apareceram em Max Battles.
  • Apenas Gigantamax — Ao seguir por Pokemon, ativa isto para receber notificações apenas quando esse Pokemon aparece em batalhas Gigantamax (as batalhas de nível mais alto com movimentos G-Max únicos). Para seguimento por nível, o Gigantamax é gerido selecionando diretamente os níveis Gigantamax ou Gigantamax Lendário.
  • Selecionar tudo — Seleciona rapidamente todos os níveis disponíveis de uma vez (equivalente ao comando !maxbattle everything do bot).

Alarmes de Quest

Recebe notificações sobre tarefas de investigação de campo com recompensas específicas.

  • Encontros Pokemon — Seleciona Pokemon que queres como recompensa de quests.
  • Itens — Segue quests que recompensam com itens específicos.
  • Mega Energia — Segue quests que dão mega energia para Pokemon específicos.
  • Doces — Segue quests que recompensam com doces para Pokemon específicos.

Alarmes de Invasão

Recebe notificações sobre invasões do Team Rocket.

  • Seguir tudo — Um alarme para cada tipo de recruta e líder.
  • Por tipo — Seleciona tipos de recrutas específicos (Bug, Dragon, Fire, etc.), Líderes Rocket ou Giovanni. Os nomes dos tipos de recruta são normalizados automaticamente (sem distinção de maiúsculas), por isso não precisas de te preocupar com a capitalização exata.
  • Género — Filtra por género do recruta.

Alarmes de Isco

Recebe uma notificação quando um tipo específico de isco é colocado. Escolhe entre iscos Normal, Glacial, Mossy, Magnetic, Rainy e Golden.

Alarmes de Ninho

Segue espécies Pokemon em ninhos. Define um limite de spawns mínimos por hora para seres notificado apenas sobre ninhos com atividade suficiente.

Alarmes de Gym

Segue mudanças de equipa em gyms. Seleciona quais equipas (Neutro, Mystic, Valor, Instinct) monitorar. Ativa o seguimento de Mudanças de Lugar para seres notificado quando lugares ficam livres no gym, ou ativa o seguimento de Mudanças de Batalha para seres notificado quando um gym está a ser atacado.

Alarmes de Alteração de Forte

Segue alterações a pokestops e gyms em si — não as atividades neles, mas alterações aos pontos de interesse reais.

  • Tipo de forte — Escolhe seguir Pokestops, Gyms ou Tudo.
  • Tipos de alteração — Seleciona quais alterações monitorar: Nome alterado, Localização alterada, Imagem alterada, Remoção ou Novo forte adicionado.
  • Incluir vazios — Inclui fortes que não têm nome definido.
💡
Os alarmes de alteração de forte são úteis para seguir atualizações da base de dados do mapa — novos pokestops a aparecer, gyms a serem realocados ou POIs removidos do jogo.

Apontar a um Gym Específico

Ao criar ou editar um alarme de Raid, Ovo ou Gym, podes opcionalmente pesquisar e selecionar um gym específico. Isto é útil quando só te interessa a atividade no teu gym favorito — como o do teu percurso de almoço ou perto da tua casa.

  • Como usar — Na janela de adição ou edição, escreve o nome de um gym no campo de pesquisa de gym. Os resultados mostram a foto, nome e área do gym para poderes identificar o correto.
  • Quando um gym está selecionado — O alarme dispara apenas para eventos nesse gym específico. O nome do gym aparece no cartão de alarme na tua lista para veres num relance qual gym é o alvo.
  • Quando nenhum gym está selecionado — Este é o comportamento predefinido. O alarme funciona normalmente para todos os gyms nas tuas áreas selecionadas ou dentro do teu raio de distância.
💡
Podes combinar um alarme para gym específico com um alarme mais amplo. Por exemplo, cria um alarme de raid para o teu gym local para todos os níveis e um segundo alarme para raids de nível 5 em todas as tuas áreas.
", - "CONTENT_DELIVERY": "\"Cartões

Cada alarme tem definições de entrega que controlam onde recebes notificações.

Áreas vs Distância

Cada alarme usa um de dois modos de entrega:

🗺
Usar ÁreasRecebes notificações quando eventos acontecem nas tuas áreas selecionadas. Bom para seguir bairros específicos.
📏
Definir DistânciaRecebes notificações dentro de um raio (km) da tua localização guardada. Bom para seguir tudo perto de ti.

Podes usar modos diferentes para alarmes diferentes — por exemplo, usar áreas para Pokemon e distância para raids.

Templates de Notificação

Se os templates estiverem ativados, podes escolher o aspeto das tuas mensagens de notificação. O seletor de templates mostra uma pré-visualização ao vivo de como o teu DM do Discord ficará, incluindo o formato embed, campos e imagens.

Modo de Limpeza

Quando ativado, o bot elimina automaticamente a notificação do Discord após o evento expirar (ex. um Pokemon desaparece ou um raid termina). Isto mantém os teus DMs arrumados. Podes ativar o modo de limpeza por alarme ou em massa na página Limpeza.

Ping / Menções de Cargo

Se usas webhooks, podes definir um cargo Discord para mencionar na notificação (ex. @Pokemon). Isto é relevante apenas para configurações de webhook.

", + "CONTENT_LOCATION": "\"Dashboard

A tua localização é o ponto a partir do qual os teus alertas são medidos. Um alarme que te chega dentro de um raio parte dela, a não ser que apontes esse alarme para um local guardado.

Definir a Tua Localização

Abre a janela de localização a partir do Dashboard ou da página Áreas e locais. Tens quatro formas de a definir:

  • Pesquisar por endereço — Escreve um endereço, cidade ou ponto de referência. Seleciona das sugestões que aparecem.
  • Inserir coordenadas — Escreve latitude e longitude diretamente se as souberes.
  • Usar o teu GPS — Clica em \"Usar a minha localização\" para usar a localização atual do teu dispositivo. O browser pedirá permissão.
  • Clicar no mapa — Clica em qualquer ponto do mini-mapa para definir esse ponto como a tua localização.

Depois de escolheres um ponto, o endereço é mostrado automaticamente. Clica em Guardar para confirmar.

A mesma janela é reutilizada quando adicionas um local ou escolhes um ponto para um único alarme. Chama-se então Escolhe um ponto e confirma-se com Usar este ponto, sem mexer na tua própria localização.

💡
Podes limpar a tua localização na página Áreas e locais se quiseres apenas alertas baseados em áreas.
", + "CONTENT_AREAS": "\"Página

As áreas são zonas geográficas predefinidas configuradas pela tua comunidade. As que escolheres aqui são o que cada alarme segue por omissão: um alarme definido como Em qualquer parte das minhas áreas dispara com os eventos que acontecem dentro delas.

Selecionar Áreas

Vai a Áreas e locais na barra lateral. Podes selecionar áreas de duas formas:

  • Vista de mapa — Clica nos polígonos coloridos no mapa para selecionar ou desselecionar áreas. As áreas selecionadas ficam verdes. Passa o rato sobre uma área para ver o nome.
  • Vista de lista — Usa caixas de seleção para escolher áreas de uma lista pesquisável.

Locais

Um local é um ponto com nome — o trabalho, o ginásio, a casa dos teus pais — a partir do qual um alarme pode medir o seu raio, em vez da tua localização. Adiciona-o na secção Locais da mesma página e depois escolhe-o em Medido a partir de quando decidires onde um alarme te deve chegar. Um local não pode ser apagado enquanto houver alarmes a apontar-lhe, e a mensagem diz quantos.

Filtro por Região

Se a tua comunidade tem muitas áreas em diferentes regiões, usa o menu de regiões para focar numa região específica. Isto torna mais fácil encontrar áreas perto de ti.

Áreas Sobrepostas

Algumas áreas sobrepem-se — uma zona mais pequena dentro de uma maior. Ambas são clicáveis. Faz zoom para tornar mais fácil clicar na área mais pequena.

Guardar

Uma barra de guardar aparece no fundo quando fizeste alterações. Clica em Guardar para confirmar as tuas seleções, ou Cancelar para reverter.

ℹ️
As áreas são por perfil. Cada perfil tem o seu próprio conjunto de áreas selecionadas. Ao mudar de perfil verás seleções de áreas diferentes. As geofences personalizadas também podem ser ativadas ou desativadas por perfil na página Geofences.
", + "CONTENT_GEOFENCES": "\"Página

Se as áreas predefinidas não cobrem onde queres alertas, podes desenhar os teus próprios limites de geofence personalizados no mapa.

Desenhar uma Geofence

  1. Vai a As Minhas Geofences na barra lateral.
  2. Clica em Desenhar Geofence.
  3. Clica no mapa para colocar pontos do limite do teu polígono. Clica novamente no primeiro ponto para fechar a forma (mínimo 3 pontos).
  4. Dá um nome à tua geofence e seleciona a que região pertence. A região é normalmente detetada automaticamente.
  5. Clica em Guardar.

Gerir Geofences

  • Editar — Renomeia a tua geofence ou altera a sua região.
  • Eliminar — Remove uma geofence que já não precisas. A geofence é removida de todos os perfis automaticamente.

Interruptor de Perfil

Cada cartão de geofence tem um interruptor deslizante para a ativar ou desativar para o teu perfil atual. Quando crias uma geofence, é automaticamente ativada no perfil que estás a usar. Muda para outro perfil e o interruptor mostrará \"Inativa\" — liga-o para receber alertas para essa geofence nesse perfil também. Isto permite-te controlar quais perfis recebem notificações para cada geofence sem a recriar.

ℹ️
Geofences aprovadas (promovidas a áreas públicas) não mostram o interruptor — gere-as na página Áreas.

Usar uma geofence num só alarme

Uma geofence que desenhaste aparece também na lista Apenas em áreas específicas quando decides onde um alarme concreto te deve chegar, marcada com um ícone de desenho. Isso limita um alarme a essa geofence sem a ativar para todo o perfil.

Importação & Exportação GeoJSON

Podes importar e exportar geofences usando o formato padrão GeoJSON, tornando fácil partilhar limites ou criá-los em ferramentas externas como geojson.io.

  • Importar — Clica no ícone de upload e cola ou carrega um ficheiro GeoJSON. Cada polígono no ficheiro torna-se uma nova geofence. Podes rever e renomear cada uma antes de guardar.
  • Exportar — Clica no ícone de download e seleciona quais geofences incluir. O ficheiro GeoJSON exportado contém todos os polígonos selecionados e pode ser aberto em qualquer ferramenta GIS ou editor de mapas.
💡
A importação GeoJSON é útil para migrar geofences de outros sistemas ou desenhar limites complexos numa ferramenta GIS desktop e depois importá-los aqui.

Submeter para Aprovação Pública

Se achas que a tua geofence seria útil para toda a comunidade, podes submetê-la para revisão dos administradores. Se aprovada, torna-se uma área pública que todos podem selecionar. A tua geofence privada continua a funcionar enquanto a revisão está pendente.

Badges de Estado

  • Ativa — A tua geofence privada, a funcionar apenas para ti.
  • Em Revisão — Submetida e a aguardar revisão dos administradores.
  • Aprovada — Promovida a área pública.
  • Rejeitada — Não aprovada. Podes ver o feedback do administrador e a geofence permanece ativa como zona privada.
ℹ️
Podes ter até 10 geofences personalizadas, cada uma com um máximo de 500 pontos de limite.
", + "CONTENT_POKEMON": "\"Página

Os alarmes Pokemon notificam-te quando um Pokemon selvagem aparece e corresponde aos teus filtros.

Adicionar um Alarme Pokemon

\"Janela
  1. Vai a Pokemon na barra lateral e clica no botão +.
  2. Seleciona Pokemon — Pesquisa por nome ou número Pokedex, ou usa os botões de filtro por geração e tipo para navegar. Podes selecionar vários Pokemon de uma vez.
  3. Define os filtros — Escolhe o que torna um spawn digno de notificação:
  • Intervalo IV — Percentagem IV mínima e máxima (0-100%)
  • Intervalo CP — Filtra por poder de combate
  • Intervalo de nível — Filtra por nível Pokemon (0-55)
  • Estatísticas individuais — Filtra por valores de ATK, DEF e STA (0-15 cada)
  • Forma — Segue formas específicas (ex. Alolan, Galarian) ou todas as formas
  • Género — Masculino, feminino, sem género, ou todos
  • Peso — Filtra por intervalo de peso
  • Tamanho — Filtra por categoria de tamanho: seleciona ALL (sem filtro) para qualquer tamanho, ou escolhe tamanhos específicos de XXS a XXL (XXS, XS, Normal, XL, XXL)
  • Tempo mínimo restante — Ignora spawns que vão desaparecer antes de lá chegares. Define-se em Mais filtros; o cartão mostra depois uma etiqueta como "faltam 10 min"
ℹ️
Os valores predefinidos dos filtros estão configurados para que todos os Pokemon correspondam quando nenhum filtro é explicitamente configurado. Por exemplo, IV predefinido 0-100%, nível 0-55 e tamanho ALL. Só precisas de ajustar os filtros que te interessam.

Filtros PVP

Recebe uma notificação quando um Pokemon tem ótimos IV para PVP. Seleciona uma liga (Great, Ultra ou Little Cup) e define o intervalo de ranking que te interessa (ex. rank 1-50).

Os botões Limite de nível escolhem com que limite os rankings são lidos. Deixa em Todos para usar o valor definido na configuração do Poracle da tua comunidade.

Megaevolução decide se a regra classifica a forma base ou uma mega: Base, Mega, Mega X ou Mega Y. As megas são classificadas à parte, por isso uma regra de mega nunca corresponde a um spawn em forma base.

Alarme \"Todos os Pokemon\"

💡
Seleciona \"Todos os Pokemon\" (ID 0) para criar um único alarme que cobre todas as espécies. Útil com um filtro IV alto como 96-100% para apanhar qualquer spawn valioso.

Ler os Cartões de Alarme

Cada cartão de alarme mostra pílulas coloridas que resumem os teus filtros num relance:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Página

Alarmes de Raid e Ovo

Recebe uma notificação quando aparece um boss de raid ou ovo que te interessa.

  • Por nível — Seleciona níveis de raid (1-6) ou níveis de ovo para seguir todos os raids desse nível.
  • Por boss — Seleciona bosses de raid Pokemon específicos que queres enfrentar.
  • Filtro de equipa — Notifica apenas para raids em gyms controlados por uma equipa específica (Mystic, Valor, Instinct).
  • Seguimento de gym — Segue raids em gyms específicos por nome para seres notificado apenas sobre os teus gyms favoritos.
  • Filtro de movimentos — Filtra bosses de raid pelos seus movimentos rápidos ou carregados.
  • Notificações RSVP — Recebe uma notificação quando outros treinadores confirmam presença num raid ou ovo que estás a seguir.

Os alarmes de Raid e Ovo são geridos em separadores distintos na página Raids. Os Ovos também suportam seguimento de gym específico e notificações RSVP.

Alarmes Max Battle (Dynamax)

Recebe notificações sobre batalhas Dynamax e Gigantamax nos Power Spots.

  • Por nível — Seleciona níveis de batalha para seguir qualquer Pokemon nesses níveis. Os níveis vão de 1 Estrela a 5 Estrelas (Lendário) para Dynamax, mais Gigantamax e Gigantamax Lendário para as maiores batalhas. É criado um alarme por cada nível selecionado.
  • Por Pokemon — Seleciona Pokemon específicos que queres enfrentar em todos os níveis Max Battle. Se a base de dados do scanner estiver configurada, o seletor mostra apenas Pokemon que apareceram em Max Battles.
  • Apenas Gigantamax — Ao seguir por Pokemon, ativa isto para receber notificações apenas quando esse Pokemon aparece em batalhas Gigantamax (as batalhas de nível mais alto com movimentos G-Max únicos). Para seguimento por nível, o Gigantamax é gerido selecionando diretamente os níveis Gigantamax ou Gigantamax Lendário.
  • Selecionar tudo — Seleciona rapidamente todos os níveis disponíveis de uma vez (equivalente ao comando !maxbattle everything do bot).

Alarmes de Quest

Recebe notificações sobre tarefas de investigação de campo com recompensas específicas.

  • Encontros Pokemon — Seleciona Pokemon que queres como recompensa de quests.
  • Itens — Segue quests que recompensam com itens específicos.
  • Mega Energia — Segue quests que dão mega energia para Pokemon específicos.
  • Doces — Segue quests que recompensam com doces para Pokemon específicos.
  • Pó de estrelas — Segue quests que recompensam com pó de estrelas.

Os separadores de itens, mega energia e doces têm cada um um campo Quantidade mínima, e o do pó de estrelas um Pó de estrelas mínimo. Deixa a 0 para aceitar qualquer quantidade. Os cartões mostram a quantidade ao lado da recompensa, por exemplo "3× Rare Candy".

Alarmes de Invasão

Recebe notificações sobre invasões do Team Rocket.

  • Seguir tudo — Um alarme para cada tipo de recruta e líder.
  • Por tipo — Seleciona tipos de recrutas específicos (Bug, Dragon, Fire, etc.), Líderes Rocket ou Giovanni. Os nomes dos tipos de recruta são normalizados automaticamente (sem distinção de maiúsculas), por isso não precisas de te preocupar com a capitalização exata.
  • Género — Filtra por género do recruta.

Alarmes de Isco

Recebe uma notificação quando um tipo específico de isco é colocado. Escolhe entre iscos Normal, Glacial, Mossy, Magnetic, Rainy e Golden.

Alarmes de Ninho

Segue espécies Pokemon em ninhos. Define um limite de spawns mínimos por hora para seres notificado apenas sobre ninhos com atividade suficiente.

Alarmes de Gym

Segue mudanças de equipa em gyms. Seleciona quais equipas (Neutro, Mystic, Valor, Instinct) monitorar. Ativa o seguimento de Mudanças de Lugar para seres notificado quando lugares ficam livres no gym, ou ativa o seguimento de Mudanças de Batalha para seres notificado quando um gym está a ser atacado.

Alarmes de Alteração de Forte

Segue alterações a pokestops e gyms em si — não as atividades neles, mas alterações aos pontos de interesse reais.

  • Tipo de forte — Escolhe seguir Pokestops, Gyms ou Tudo.
  • Tipos de alteração — Seleciona quais alterações monitorar: Nome alterado, Descrição alterada, Localização alterada, Imagem alterada, Removido ou Novo forte.
  • Incluir vazios — Inclui fortes que não têm nome definido.
💡
Os alarmes de alteração de forte são úteis para seguir atualizações da base de dados do mapa — novos pokestops a aparecer, gyms a serem realocados ou POIs removidos do jogo.

Apontar a um Gym Específico

Ao criar ou editar um alarme de Raid, Ovo ou Gym, podes opcionalmente pesquisar e selecionar um gym específico. Isto é útil quando só te interessa a atividade no teu gym favorito — como o do teu percurso de almoço ou perto da tua casa.

  • Como usar — Na janela de adição ou edição, escreve o nome de um gym no campo de pesquisa de gym. Os resultados mostram a foto, nome e área do gym para poderes identificar o correto.
  • Quando um gym está selecionado — O alarme dispara apenas para eventos nesse gym específico. O nome do gym aparece no cartão de alarme na tua lista para veres num relance qual gym é o alvo.
  • Quando nenhum gym está selecionado — Este é o comportamento predefinido. O alarme funciona normalmente para todos os gyms nas tuas áreas selecionadas ou dentro do teu raio de distância.
💡
Podes combinar um alarme para gym específico com um alarme mais amplo. Por exemplo, cria um alarme de raid para o teu gym local para todos os níveis e um segundo alarme para raids de nível 5 em todas as tuas áreas.
", + "CONTENT_DELIVERY": "\"Cartões

Cada alarme tem definições de entrega que controlam onde recebes notificações.

Onde um alerta te chega

O separador Entrega de cada janela de criação e edição pergunta Onde deve este alerta chegar-te? e oferece três respostas:

  • Em qualquer parte das minhas áreas — A opção por omissão. O alarme segue as áreas selecionadas no teu perfil, por isso mudar as áreas muda também este alarme.
  • Perto de um ponto — Um raio em quilómetros, medido a partir da tua localização ou de um local guardado que escolhas em Medido a partir de. Se ainda não tiveres localização, o seletor avisa e propõe defini-la.
  • Apenas em áreas específicas — Um subconjunto de áreas para este alarme em particular, escolhido entre as áreas públicas e as geofences que desenhaste.

Alarmes diferentes podem responder de forma diferente: áreas para Pokemon, um raio a partir da tua localização para raids, um local com nome para quests.

A etiqueta no cartão

A maioria dos cartões de alarme tem uma etiqueta com a sua resposta — "Em qualquer parte das minhas áreas", "Onde quer que receba alertas", "A menos de 5 km da minha localização", "A menos de 2 km de Casa", "Apenas em Terrigal, Erina". Clica nela para mudar esse alarme sem abrir a janela de edição completa.

Predefinição para alarmes novos

Os alarmes novos abrem em modo Áreas. Para mudar isso, abre o menu de utilizador (o teu avatar, no canto superior direito) e escolhe Padrões de alertas — decide se os alarmes novos começam em Áreas ou em Distância, define um raio predefinido e escolhe se esse raio é medido a partir da tua localização ou de um local guardado. A preferência fica guardada no teu navegador e também preenche a janela dos Quick Picks. Só afeta os alarmes criados a partir daí; os existentes ficam iguais, e podes na mesma mudar onde cada alarme te chega.

Templates de Notificação

Se os templates estiverem ativados, podes escolher o aspeto das tuas mensagens de notificação. O seletor de templates mostra uma pré-visualização ao vivo de como o teu DM do Discord ficará, incluindo o formato embed, campos e imagens.

Modo de Limpeza

Quando ativado, o bot elimina automaticamente a notificação do Discord após o evento expirar (ex. um Pokemon desaparece ou um raid termina). Isto mantém os teus DMs arrumados. Podes ativar o modo de limpeza por alarme ou em massa na página Limpeza.

Editar no local e resumos

Alguns alarmes suportam modos de entrega adicionais. Ative Editar mensagem no local num engodo para atualizar a mensagem existente do Discord quando o engodo muda, em vez de enviar uma nova, ou Resumo diário numa missão para agrupar as missões correspondentes numa única mensagem de resumo (requer um agendamento de resumo configurado no bot). Raids e ovos são editados no local automaticamente quando escolhe um modo RSVP. Estas definições são mantidas mesmo que as defina a partir do bot.

Atualizações RSVP (raids & ovos)

Os alarmes de raid e ovo acrescentam uma definição de Notificações RSVP na janela de adição/edição com três opções: Apenas correspondências envia os alertas padrão de raid/ovo; Correspondências + atualizações RSVP também notifica novamente quando as contagens de RSVP mudam (treinadores a confirmar presença); e Apenas atualizações RSVP ignora a correspondência inicial e notifica-te apenas sobre alterações de RSVP. Escolher qualquer um dos modos RSVP faz o bot editar a mensagem existente do Discord no local à medida que as contagens mudam, em vez de enviar novas, e o cartão mostra uma pílula "RSVP" ou "Apenas RSVP". Repara que Apenas atualizações RSVP fica silenciado a menos que o scanner da tua comunidade emita eventos RSVP — escolhe-o apenas se souberes que os RSVP são reportados.

", + "CONTENT_QUEST_SUMMARY": "

As missões de Pesquisa de campo mudam diariamente e podem corresponder em grande quantidade, por isso um filtro de missões movimentado pode inundar as tuas MD. Entrega do resumo de missões reúne as missões correspondentes num único resumo agendado em vez de muitos alertas separados.

Duas partes que funcionam em conjunto

  • Botão de resumo diário — ativa-o num alarme de missão (na sua janela de adicionar/editar) para marcar as suas correspondências para o resumo em vez de entrega imediata.
  • Agendamento de entrega — escolhe quando as missões reunidas são enviadas.

Ambos são necessários: o botão indica quais missões reunir, e o agendamento indica quando entregá-las.

Configurar o teu agendamento

Abre a página Missões, depois o menu na barra de ferramentas e escolhe Entrega do resumo de missões. Usa Editar agendamento para escolher dias e horas — o mesmo editor usado para as horas ativas dos perfis. As horas guardadas aparecem como etiquetas âmbar.

O agendamento é por utilizador e partilhado por todos os teus perfis — ao contrário das horas ativas dos perfis, que se configuram por perfil.

Enviar resumo agora

Enviar resumo agora entrega imediatamente tudo o que foi reunido desde o teu último resumo. Se ainda nada foi reunido, nada é enviado — as missões são colocadas em buffer à medida que correspondem, por isso dá-lhe tempo ou espera que o agendamento seja acionado.

Bom saber

  • O menu só aparece quando o bot do teu servidor tem os resumos de missões ativados.
  • A hora de entrega usa a tua localização guardada para o fuso horário — define uma localização, ou os resumos podem chegar à hora local errada (a janela avisa-te quando não há localização definida).
  • Remover o agendamento mantém o botão por alarme; as missões continuam a ser reunidas, mas voltam ao horário predefinido do bot.
", "CONTENT_TEST_ALERTS": "

Cada cartão de alarme tem um botão Teste (ícone de avião de papel) que envia uma notificação de exemplo para o teu Discord ou Telegram, usando os filtros exatos do alarme e o teu template de entrega atual.

Como Funciona

  1. Encontra qualquer cartão de alarme na tua lista (Pokemon, Raid, Quest, etc.).
  2. Clica no ícone enviar na linha de ações do cartão.
  3. É gerado um evento fictício que corresponde aos filtros do teu alarme e enviado através do pipeline de notificação. Recebes um DM tal como um alerta real.

O Que é Testado

O teste usa os valores dos filtros do teu alarme (ID Pokemon, nível de raid, recompensa de quest, etc.) e a tua localização guardada como coordenadas do evento fictício. A notificação é formatada usando o template selecionado, para que vejas exatamente como um alerta real ficaria.

Tempo de Espera

Para prevenir spam, cada alarme tem um tempo de espera de 15 segundos entre envios de teste. O botão fica desativado durante a espera e uma notificação mostra o feedback (sucesso, erro ou tempo restante).

💡
Os alertas de teste são ótimos para verificar que o teu template está correto ou confirmar que a entrega via webhook está a funcionar antes de esperares por um evento real.
", "CONTENT_POKEMON_AVAILABILITY": "

Ao adicionar ou editar alarmes Pokemon, o seletor Pokemon pode mostrar indicadores de disponibilidade — pequenos badges que te dizem quais Pokemon estão atualmente a spawnar na natureza.

Como Funciona

Se a tua comunidade tem um scanner Golbat configurado, o seletor mostra pontos coloridos junto aos nomes dos Pokemon:

  • Ponto verde — Este Pokemon foi visto a spawnar recentemente.
  • Sem ponto — Não reportado atualmente nos dados do scanner.

Isto ajuda-te a evitar criar alarmes para Pokemon que não estão a spawnar na tua zona neste momento (ex. espécies sazonais ou exclusivas de eventos).

Atualização de Disponibilidade

Os dados atualizam-se automaticamente em segundo plano. Não precisas de fazer nada — procura simplesmente os pontos ao navegar no seletor Pokemon.

ℹ️
Esta funcionalidade só é visível se o teu administrador configurou a integração do scanner Golbat. Se não vires pontos de disponibilidade, a funcionalidade não está ativada para a tua comunidade.
", "CONTENT_BULK": "\"Lista

Todas as páginas de alarmes suportam operações em massa para poderes gerir muitos alarmes de uma vez.

Modo de Seleção

Clica no ícone de checklist na barra de ferramentas para entrar no modo de seleção. Depois clica em cartões de alarme individuais para os selecionar, ou usa Selecionar Tudo para apanhar tudo o que está visível.

Ações em Massa

  • Atualizar Distância — Altera o modo de entrega (áreas ou distância) para todos os alarmes selecionados de uma vez.
  • Eliminar — Remove todos os alarmes selecionados com uma única confirmação.
💡
No fundo de cada lista de alarmes encontrarás também os botões Atualizar Toda a Distância e Eliminar Tudo que se aplicam a todos os alarmes desse tipo.
", - "CONTENT_QUICK_PICKS": "\"Página

Os Quick Picks são templates de alarme pré-construídos criados pelos administradores da tua comunidade. Permitem-te configurar alarmes comuns com um clique em vez de criar cada alarme individualmente.

Aplicar um Quick Pick

  1. Vai a Quick Picks na barra lateral.
  2. Navega pelas opções disponíveis, filtrando opcionalmente por categoria.
  3. Clica em Aplicar no Quick Pick que queres.
  4. Personaliza antes de aplicar: escolhe o teu modo de entrega (áreas ou distância), ativa o modo de limpeza e opcionalmente exclui Pokemon específicos.
  5. Confirma para criar todos os alarmes de uma vez.

Remover Alarmes de Quick Pick

Se já não quiseres os alarmes de um Quick Pick, clica em Remover para eliminar todos os alarmes que criou.

", - "CONTENT_PROFILES": "

A página Perfis é o teu centro unificado para gerir perfis e ver todos os alarmes de cada perfil num só lugar.

Porquê Usar Perfis?

Os perfis permitem-te manter configurações de alarme completamente separadas. Cada perfil tem o seu próprio conjunto de alarmes, áreas selecionadas, localização e ativações de geofence personalizadas. Útil para situações diferentes — por exemplo, um perfil \"Casa\" para o teu bairro e um perfil \"Trabalho\" para à volta do teu escritório.

Visão Geral

A página mostra uma barra de estatísticas com contagens totais de alarmes por tipo, uma barra de pesquisa para filtrar em todos os perfis e chips de filtro por tipo para mostrar apenas tipos de alarme específicos (Pokemon, Raids, Quests, etc.).

Cada perfil aparece como um painel expansível. Clica para expandir e ver todos os alarmes agrupados por tipo, com imagens de assets do jogo (sprites Pokemon, ovos de raid, ícones de isco) e pílulas de filtro a mostrar IV, CP, Nível, PVP e outras definições num relance.

Gerir Perfis

  • Criar — Clica no botão + no canto superior direito. Os nomes dos perfis devem ser únicos (máximo 32 caracteres).
  • Mudar — Clica em Mudar dentro de um painel de perfil para o tornar o teu perfil ativo. O perfil ativo é marcado com um badge verde e bordo esquerdo.
  • Editar — Clica no ícone de lápis para renomear um perfil.
  • Eliminar — Clica no ícone de lixo para remover um perfil e todos os seus alarmes. Não podes eliminar o teu perfil ativo.

Duplicar

Clica no ícone de cópia em qualquer perfil para criar uma cópia exata com todos os seus alarmes. Serás convidado a dar um nome ao novo perfil — um nome predefinido como \"Perfil (Cópia)\" é sugerido. O duplicado inclui todos os filtros de alarme mas recebe um novo conjunto de seleções de áreas.

Exportar e Importar

  • Exportar — Clica no ícone de download num perfil para guardar um ficheiro de backup (JSON). O ficheiro contém todos os filtros de alarme, sem IDs internos para ser portável.
  • Importar — Clica no botão Importar no canto superior direito, seleciona um ficheiro de backup e escolhe um nome para o novo perfil. Todos os alarmes do backup são restaurados. Se já existir um perfil com o mesmo nome, é adicionado automaticamente um sufixo numérico.

Deteção de Duplicados

Se o mesmo alarme existe em vários perfis (ex. seguir Pikachu tanto em \"Casa\" como em \"Trabalho\"), esses alarmes são destacados com um bordo laranja e um ícone de cópia. Quando existem duplicados, aparece um chip de filtro Duplicados na barra de filtros — clica nele para mostrar apenas alarmes duplicados entre perfis.

⚠️
Atenção: Eliminar um perfil remove permanentemente todos os alarmes nesse perfil. Não podes eliminar o teu perfil atualmente ativo. Considera exportar um backup primeiro.
", - "CONTENT_CLEANING": "\"Página

A página de Limpeza permite-te controlar o modo de limpeza para todos os teus tipos de alarme de uma vez.

Quando o modo de limpeza está ativo para um tipo de alarme, o bot elimina automaticamente as notificações do Discord após o evento expirar:

  • Pokemon — Eliminada quando o spawn desaparece
  • Raids — Eliminada quando o raid termina
  • Ovos — Eliminada quando o ovo eclodir
  • Quests — Eliminada quando as quests reiniciam à meia-noite
  • Invasões — Eliminada quando o recruta sai
  • Iscos — Eliminada quando o isco expira
  • Ninhos — Eliminada quando os ninhos migram
  • Gyms — Eliminada após mudanças de gym
  • Alterações de Forte — Eliminada após a notificação de alteração de forte expirar
  • Max Battles — Eliminada quando a batalha termina

Usa Ativar Tudo ou Desativar Tudo para alternar tudo de uma vez.

💡
Recomendado: Mantém o modo de limpeza ativado para evitar que alertas expirados se acumulem nos teus DMs.
", - "CONTENT_APPEARANCE": "

Modo Escuro / Claro

Clica no ícone sol/lua na barra de ferramentas superior para alternar entre temas escuro e claro. A tua escolha é guardada automaticamente.

\"Barra

Cores de Destaque

Abre o menu de utilizador (o teu avatar no canto superior direito) e seleciona Tema de Destaque. Escolhe entre:

  • Predefinido — Azul
  • Pokemon — Verde
  • Raids — Vermelho
  • Mystic — Azul
  • Valor — Vermelho
  • Instinct — Amarelo

A cor de destaque altera o gradiente da barra de ferramentas, o realce da navegação ativa e outros destaques da interface em todo o site.

\"Dashboard

Idioma

Se disponível, usa o seletor de idioma na barra de ferramentas para mudar o idioma da interface. 18 idiomas são suportados.

Atalhos de Teclado

?Mostrar atalhos de teclado
EscFechar menus ou janelas
[Recolher barra lateral
]Expandir barra lateral
", - "CONTENT_ALERTS_LOGOUT": "\"Menu

Pausar Alertas

Abre o menu de utilizador (o teu avatar) e clica em Pausar Alertas. Aparece um banner vermelho no topo do site a confirmar que os teus alertas estão pausados. Não recebes nenhuma notificação enquanto estiverem pausados.

Para retomar, clica em Retomar Alertas no menu de utilizador ou no banner.

Terminar Sessão

Abre o menu de utilizador e clica em Terminar Sessão. Serás redirecionado para a página de login.

", - "CONTENT_FAQ": "

\"Não consigo iniciar sessão\"

Tens de te registar com o bot Poracle no Discord ou Telegram antes de poderes iniciar sessão neste site. Se vires \"A tua conta não está registada\", contacta o administrador da tua comunidade para instruções de registo.

\"Não estou a receber notificações\"

Verifica estas causas comuns:

  1. Alertas pausados — Procura um banner vermelho no topo do site. Retoma os alertas no menu de utilizador.
  2. Sem localização definida — Se os teus alarmes usam o modo distância, precisas de uma localização guardada.
  3. Sem áreas selecionadas — Se os teus alarmes usam o modo áreas, certifica-te de que selecionaste áreas na página Áreas.
  4. Perfil errado — Podes ter alarmes noutro perfil. Verifica qual perfil está ativo no Dashboard.
  5. Filtros demasiado restritos — Tenta relaxar os filtros de IV, CP ou nível para ver se as notificações começam a chegar.

\"Os meus alarmes desapareceram\"

Os alarmes são específicos de cada perfil. Se mudaste de perfil, os teus alarmes do outro perfil ainda lá estão — basta voltar a mudar a partir do Dashboard ou da página Perfis.

\"Não consigo clicar numa área pequena no mapa\"

Quando áreas se sobrepõem, faz zoom para tornar a área mais pequena mais fácil de clicar. As áreas mais pequenas estão sempre por cima das maiores.

\"O que faz o modo Limpeza?\"

O modo de limpeza diz ao bot para eliminar automaticamente uma notificação do Discord após o evento expirar (ex. um Pokemon desaparece). Sem ele, os alertas antigos ficam nos teus DMs para sempre. Ativa-o na página Limpeza ou por alarme no separador Entrega.

\"Qual é a diferença entre Áreas e Distância?\"

Cada alarme usa um modo de entrega. Áreas notifica-te sobre eventos dentro de zonas geográficas específicas. Distância notifica-te sobre eventos dentro de um raio da tua localização guardada. Podes combinar ambos os modos em alarmes diferentes.

" + "CONTENT_QUICK_PICKS": "\"Página

Os Quick Picks são templates de alarme pré-construídos criados pelos administradores da tua comunidade. Permitem-te configurar alarmes comuns com um clique em vez de criar cada alarme individualmente.

Aplicar um Quick Pick

  1. Vai a Quick Picks na barra lateral.
  2. Navega pelas opções disponíveis, filtrando opcionalmente por categoria.
  3. Clica em Aplicar no Quick Pick que queres.
  4. Personaliza antes de aplicar: decide onde os alertas te devem chegar — o separador Entrega é o mesmo seletor de três opções que um alarme individual usa, por isso podes apontá-los a um local guardado ou a um subconjunto de áreas —, ativa o modo de limpeza e opcionalmente exclui Pokemon específicos.
  5. Confirma para criar todos os alarmes de uma vez.

Remover Alarmes de Quick Pick

Se já não quiseres os alarmes de um Quick Pick, clica em Remover para eliminar todos os alarmes que criou.

", + "CONTENT_PROFILES": "

A página Perfis é o teu centro unificado para gerir perfis e ver todos os alarmes de cada perfil num só lugar.

Porquê Usar Perfis?

Os perfis permitem-te manter configurações de alarme completamente separadas. Cada perfil tem o seu próprio conjunto de alarmes, áreas selecionadas, localização e ativações de geofence personalizadas. Útil para situações diferentes — por exemplo, um perfil \"Casa\" para o teu bairro e um perfil \"Trabalho\" para à volta do teu escritório.

Visão Geral

A página mostra uma barra de estatísticas com contagens totais de alarmes por tipo, uma barra de pesquisa para filtrar em todos os perfis e chips de filtro por tipo para mostrar apenas tipos de alarme específicos (Pokemon, Raids, Quests, etc.).

Cada perfil aparece como um painel expansível. Clica para expandir e ver todos os alarmes agrupados por tipo, com imagens de assets do jogo (sprites Pokemon, ovos de raid, ícones de isco) e pílulas de filtro a mostrar IV, CP, Nível, PVP e outras definições num relance.

Gerir Perfis

  • Criar — Clica no botão + no canto superior direito. Os nomes dos perfis devem ser únicos (máximo 32 caracteres).
  • Mudar — Clica em Mudar dentro de um painel de perfil para o tornar o teu perfil ativo. O perfil ativo é marcado com um badge verde e bordo esquerdo.
  • Editar — Clica no ícone de lápis para renomear um perfil.
  • Eliminar — Clica no ícone de lixo para remover um perfil e todos os seus alarmes. Não podes eliminar o teu perfil ativo.

Duplicar

Clica no ícone de cópia em qualquer perfil para criar uma cópia exata com todos os seus alarmes. Serás convidado a dar um nome ao novo perfil — um nome predefinido como \"Perfil (Cópia)\" é sugerido. O duplicado inclui todos os filtros de alarme e as suas áreas, localização e horas ativas são igualmente copiadas do perfil de origem.

Exportar e Importar

  • Exportar — Clica no ícone de download num perfil para guardar um ficheiro de backup (JSON). O ficheiro contém todos os filtros de alarme, sem IDs internos para ser portável.
  • Importar — Clica no botão Importar no canto superior direito, seleciona um ficheiro de backup e escolhe um nome para o novo perfil. Todos os alarmes do backup são restaurados. Se já existir um perfil com o mesmo nome, é adicionado automaticamente um sufixo numérico.

Deteção de Duplicados

Se o mesmo alarme existe em vários perfis (ex. seguir Pikachu tanto em \"Casa\" como em \"Trabalho\"), esses alarmes são destacados com um bordo laranja e um ícone de cópia. Quando existem duplicados, aparece um chip de filtro Duplicados na barra de filtros — clica nele para mostrar apenas alarmes duplicados entre perfis.

⚠️
Atenção: Eliminar um perfil remove permanentemente todos os alarmes nesse perfil. Não podes eliminar o teu perfil atualmente ativo. Considera exportar um backup primeiro.
", + "CONTENT_CLEANING": "\"Página

A página de Limpeza permite-te controlar o modo de limpeza para todos os teus tipos de alarme de uma vez.

Quando o modo de limpeza está ativo para um tipo de alarme, o bot elimina automaticamente as notificações do Discord após o evento expirar:

  • Pokemon — Eliminada quando o spawn desaparece
  • Raids — Eliminada quando o raid termina
  • Ovos — Eliminada quando o ovo eclodir
  • Quests — Eliminada quando as quests reiniciam à meia-noite
  • Invasões — Eliminada quando o recruta sai
  • Iscos — Eliminada quando o isco expira
  • Ninhos — Eliminada quando os ninhos migram
  • Gyms — Eliminada após mudanças de gym
  • Max Battles — Eliminada quando a batalha termina

Usa Ativar Tudo ou Desativar Tudo para alternar tudo de uma vez.

💡
Recomendado: Mantém o modo de limpeza ativado para evitar que alertas expirados se acumulem nos teus DMs.
", + "CONTENT_APPEARANCE": "

Modo Escuro / Claro

Clica no ícone sol/lua na barra de ferramentas superior para alternar entre temas escuro e claro. A tua escolha é guardada automaticamente.

\"Barra

Cores de Destaque

Abre o menu de utilizador (o teu avatar no canto superior direito) e seleciona Tema de Destaque. Escolhe entre:

  • Predefinido — Azul
  • Pokemon — Verde
  • Raids — Vermelho
  • Mystic — Azul
  • Valor — Vermelho
  • Instinct — Amarelo

A cor de destaque altera o gradiente da barra de ferramentas, o realce da navegação ativa e outros destaques da interface em todo o site.

\"Dashboard

Idioma da interface

Abre o menu de utilizador (o teu avatar, no canto superior direito) e escolhe Idioma da interface. Há 11 idiomas. Muda o texto do site e também os nomes, tipos e formas de Pokemon mostrados nos seletores e nos teus cartões de alarme. Se nunca escolheste um, recebes o do teu navegador ou o do teu servidor Poracle.

Idioma dos alertas

Mesmo abaixo está Idioma dos alertas, uma definição separada. Controla o idioma em que o Poracle escreve as tuas DM. São independentes: um site em português com DM em inglês, ou o contrário, é perfeitamente normal. Antes ficava na página de Áreas.

Atalhos de Teclado

?Mostrar atalhos de teclado
EscFechar menus ou janelas
[Recolher barra lateral
]Expandir barra lateral
", + "CONTENT_ALERTS_LOGOUT": "\"Menu

Pausar Alertas

Abre o menu de utilizador (o teu avatar) e clica em Pausar Alertas. Aparece um banner vermelho no topo do site a confirmar que os teus alertas estão pausados. Não recebes nenhuma notificação enquanto estiverem pausados.

Para retomar, clica em Retomar Alertas no menu de utilizador ou no banner.

Terminar Sessão

Abre o menu de utilizador e clica em Terminar Sessão. Serás redirecionado para a página de login.

Se iniciaste sessão através de um fornecedor SSO com terminar sessão único, o menu oferece também Terminar sessão em todo o lado — isso termina igualmente a tua sessão no fornecedor, não apenas aqui.

", + "CONTENT_FAQ": "

\"Não consigo iniciar sessão\"

Tens de te registar com o bot Poracle no Discord ou Telegram antes de poderes iniciar sessão neste site. Se vires \"A tua conta não está registada\", contacta o administrador da tua comunidade para instruções de registo.

\"Não estou a receber notificações\"

Verifica estas causas comuns:

  1. Alertas pausados — Procura um banner vermelho no topo do site. Retoma os alertas no menu de utilizador.
  2. Sem localização definida — Um alarme que te chega dentro de um raio mede a partir da tua localização ou de um local guardado. Define uma na página Áreas e locais.
  3. Nada ao alcance — Vê a etiqueta no cartão do alarme. Diz onde o alarme te chega, e pode estar apontada a áreas que o teu perfil já não cobre.
  4. Perfil errado — Podes ter alarmes noutro perfil. Verifica qual perfil está ativo no Dashboard.
  5. Filtros demasiado restritos — Tenta relaxar os filtros de IV, CP ou nível para ver se as notificações começam a chegar.

\"Os meus alarmes desapareceram\"

Os alarmes são específicos de cada perfil. Se mudaste de perfil, os teus alarmes do outro perfil ainda lá estão — basta voltar a mudar a partir do Dashboard ou da página Perfis.

\"Não consigo clicar numa área pequena no mapa\"

Quando áreas se sobrepõem, faz zoom para tornar a área mais pequena mais fácil de clicar. As áreas mais pequenas estão sempre por cima das maiores.

\"O que faz o modo Limpeza?\"

O modo de limpeza diz ao bot para eliminar automaticamente uma notificação do Discord após o evento expirar (ex. um Pokemon desaparece). Sem ele, os alertas antigos ficam nos teus DMs para sempre. Ativa-o na página Limpeza ou por alarme no separador Entrega.

\"Onde é que um alerta me chega?\"

Cada alarme responde por si, no seu separador Entrega. Em qualquer parte das minhas áreas segue as áreas selecionadas no teu perfil. Perto de um ponto é um raio a partir da tua localização ou de um local guardado. Apenas em áreas específicas limita esse alarme a um subconjunto de áreas. A etiqueta no cartão mostra sempre a resposta atual, e um clique muda-a.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "Alertas DM", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Iniciar Sessão", "SIGN_IN_DESC": "Inicia sessão para gerir os teus alarmes de notificação Pokemon GO.", "SIGN_IN_DISCORD": "Iniciar sessão com Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Entrar com Telegram", + "PROVIDER_DISABLED_BY_ADMIN": "Este método de acesso foi desativado por um administrador.", + "PROVIDER_DISABLED_HINT": "Este método de acesso está desativado para utilizadores não administradores.", + "ERR_TELEGRAM_DISABLED": "O início de sessão com Telegram está desativado.", "OR": "ou", "NO_METHODS": "Nenhum método de login está atualmente ativado. Contacta um administrador.", "AUTHENTICATING": "A autenticar...", "FOOTER": "Gere alarmes para Pokemon, Raids, Missões e mais", "AUTH_FAILED": "Autenticação Falhou", "BACK_TO_LOGIN": "Voltar ao Login", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "O início de sessão com Discord está desativado.", + "ERR_DISCORD_FETCH": "Não foi possível obter o teu perfil do Discord. Tenta novamente.", + "ERR_MISSING_CODE": "O início de sessão com Discord foi cancelado ou falhou.", + "ERR_MISSING_ROLE": "Não tens o cargo do Discord necessário para aceder a este site.", + "ERR_NOT_IN_GUILD": "Tens de ser membro do servidor de Discord para aceder a este site.", + "ERR_NOT_REGISTERED": "A tua conta não está registada. Regista-te para começar.", + "ERR_ROLE_CHECK_FAILED": "Não foi possível verificar os teus cargos do Discord. Tenta mais tarde.", + "ERR_TELEGRAM_FAILED": "O início de sessão com Telegram falhou. Tenta novamente.", + "ERR_TOKEN_EXCHANGE": "O início de sessão com Discord falhou. Tenta novamente.", "ERR_GENERIC": "Erro de autenticação: {{error}}", "ERR_NO_TOKEN": "Nenhum token de autenticação recebido.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_IN_OIDC": "Iniciar sessão com {{provider}}", + "SIGNED_OUT_TITLE": "Sessão terminada", + "SIGNED_OUT_DESC": "A tua sessão nos Alertas DM foi terminada.", + "ERR_OIDC_DISABLED": "O início de sessão externo está atualmente desativado.", + "ERR_OIDC_NO_IDENTITY": "O teu fornecedor de início de sessão externo não devolveu uma conta que possamos associar. Certifica-te de que a tua conta do Discord está associada.", + "ERR_OIDC_TOKEN_EXCHANGE": "O início de sessão externo falhou. Tenta novamente.", + "ERR_OIDC_USERINFO": "Não foi possível obter o teu perfil do fornecedor de início de sessão externo. Tenta novamente.", + "SIGN_UP": "Registar", + "SIGN_UP_DESC": "Ainda não tens conta? Regista-te para começar.", + "SIGN_IN_AGAIN": "Iniciar sessão novamente" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Esta funcionalidade foi desativada pelo administrador." }, "ADMIN": { "USERS_TITLE": "Gestão de Utilizadores", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Nome promovido", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Nome para a geofence promovida", "APPROVAL_PROMOTED_NAME_HINT": "Opcional. Utiliza o nome de exibição atual por predefinição.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Motivo da rejeição", "APPROVAL_REJECT_PLACEHOLDER": "Explica porque esta geofence está a ser rejeitada...", "USERS_DESC_FULL": "Gere utilizadores Discord registados. Parado = utilizador pausou alertas ou atingiu limites. Bloqueado = bloqueado pelo administrador.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Falha ao aprovar submissão", "SNACK_APPROVED": "\"{{name}}\" aprovada", "SNACK_FAILED_REJECT": "Falha ao rejeitar submissão", - "SNACK_REJECTED": "\"{{name}}\" rejeitada" + "SNACK_REJECTED": "\"{{name}}\" rejeitada", + "APPROVAL_REGION_HINT": "Escolhe a região sob a qual esta geocerca aparecerá.", + "SERVER_TITLE": "Servidor Poracle", + "SERVER_REFRESH": "Verificar de novo", + "SERVER_VERSION": "Versão", + "SERVER_SCHEMA": "Esquema da base de dados", + "SERVER_CHECKED": "Última verificação", + "SERVER_CAPABILITIES": "Funcionalidades", + "SERVER_NO_CAPABILITIES": "Este servidor não indica nenhuma.", + "SERVER_UNKNOWN": "Desconhecida", + "SERVER_UNREACHABLE": "O Poracle não respondeu. Alarmes, perfis e locais passam por ele e vão falhar até responder.", + "SERVER_TOO_OLD": "O Poracle {{version}} é anterior a {{minimum}}, exigido por esta versão do site. O alcance por alarme, o filtro mega de PVP e o de tempo restante parecerão guardados sem alterar nada.", + "UPDATE_AVAILABLE": "Está a correr o {{name}} {{running}} e já saiu a {{latest}}.", + "UPDATE_PRERELEASE": "O {{name}} {{running}} é mais recente do que qualquer versão publicada — é uma compilação de desenvolvimento.", + "VERSIONS_TITLE": "Versões", + "VERSIONS_WEB": "Este site", + "VERSIONS_BUILD": "Compilação", + "UPDATE_CURRENT": "Atualizado.", + "UPDATE_UNCOMPARABLE": "Canal de desenvolvimento. A versão mais recente é {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Escolhe um ponto", "CANCEL": "Cancelar", "CONFIRM": "Confirmar", "DONT_ASK_AGAIN": "Não perguntar novamente nesta sessão", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Atualizar Todas as Distâncias", "DISTANCE_DESC": "Define o modo de localização para todos os alarmes deste tipo.", "DISTANCE_UPDATE_ALL": "Atualizar Tudo", + "DISTANCE_MUST_BE_POSITIVE": "A distância tem de ser maior do que zero.", "LOCATION_SAVE_ERROR": "Falha ao atualizar localização", "LOCATION_SAVE_SUCCESS": "Localização atualizada com sucesso", "LOCATION_GEO_UNSUPPORTED": "A geolocalização não é suportada pelo teu navegador", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "Demasiados alertas de teste. Aguarda um momento.", "ERROR_NOT_FOUND": "Alarme não encontrado — pode ter sido eliminado.", "ERROR_GENERIC": "Falha ao enviar alerta de teste. Tenta novamente mais tarde.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "Demasiados alertas de teste. Aguarda um momento.", + "NOT_FOUND": "Alerta não encontrado — pode ter sido eliminado.", + "UNSUPPORTED": "Os alertas de teste não estão disponíveis para este tipo.", + "FAILED": "Não foi possível enviar o alerta de teste. Tenta mais tarde." }, "COMMON": { "CANCEL": "Cancelar", @@ -1296,6 +1448,7 @@ "EDIT": "Editar", "ADD": "Adicionar", "OK": "OK", + "UNDO": "Anular", "CONFIRM": "Confirmar", "DELETE_ALL": "Eliminar Tudo", "CLOSE": "Fechar", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Pesquisar ginásio (opcional)", "SEARCH_HINT": "Escreve o nome do ginásio...", - "CLEAR_ARIA": "Limpar seleção de ginásio" + "CLEAR_ARIA": "Limpar seleção de ginásio", + "RATE_LIMITED": "Demasiados pedidos ao scanner — abrande um pouco." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "As notificações serão enviadas para estas áreas:", @@ -1382,6 +1536,28 @@ "PING_TOOLTIP": "Ping: {{ping}}" }, "ADMIN_SETTINGS": { + "GROUP_OIDC": "SSO Externo", + "ENABLE_OIDC_LABEL": "Ativar início de sessão por SSO Externo", + "ENABLE_OIDC_DESC": "Permite o início de sessão através do fornecedor OIDC/OAuth2 externo configurado. Requer as definições OIDC_* (URLs do fornecedor, client ID e secret) no .env (é necessário reiniciar o servidor para aplicar alterações ao .env).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Todos os utilizadores são redirecionados para o fornecedor SSO externo. O início de sessão local é ignorado.", + "AUTH_MODE_SWITCH_CONFIRM": "Mudar para SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Mudar para início de sessão por SSO?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Após guardar, todos os utilizadores (incluindo administradores) serão redirecionados para {{provider}} para iniciar sessão — a página de início de sessão local do Discord/Telegram é ignorada. Se o fornecedor estiver inacessível, podes ficar bloqueado; recupera definindo AUTH_FORCE_LOCAL=true no ambiente do servidor.", + "AUTH_OIDC_NOT_CONFIGURED": "O SSO está indisponível até o fornecedor OIDC ser configurado no ambiente do servidor (variáveis de ambiente OIDC_*).", + "AUTH_OIDC_HIDES_LOCAL": "O Discord e o Telegram ficam ocultos enquanto o SSO for o modo de início de sessão ativo.", + "AUTH_SLO_LABEL": "Terminar sessão única", + "AUTH_SLO_DESC": "Quando ativado, \"Terminar sessão em todo o lado\" também termina a sessão do fornecedor (não apenas neste site). Requer o endpoint de fim de sessão do fornecedor (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Terminar sessão única está indisponível até o endpoint de fim de sessão do fornecedor ser configurado (variável de ambiente OIDC_END_SESSION_URL).", + "OIDC_SERVER_CONFIG": "Configuração do Fornecedor OIDC", + "OIDC_PROVIDER_LABEL": "Nome do fornecedor", + "OIDC_AUTHORIZATION_URL_LABEL": "URL de autorização", + "OIDC_TOKEN_URL_LABEL": "URL de token", + "OIDC_USERINFO_URL_LABEL": "URL de UserInfo", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Identity claim", + "OIDC_USE_PKCE_LABEL": "Usar PKCE", "SIGNUP_URL_LABEL": "Signup URL", "SIGNUP_URL_DESC": "External signup/registration page URL. When set, non-registered users will see a sign-up button on the login page.", "LOAD_FAILED": "Failed to load settings", @@ -1392,12 +1568,9 @@ "GROUP_ALARM_TYPES": "Tipos de alarme", "GROUP_FEATURES": "Funcionalidades", "GROUP_ADMINISTRATION": "Administração", - "GROUP_COMMANDS": "Comandos", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Mapas e recursos", "GROUP_ANALYTICS_LINKS": "Análise e ligações", - "GROUP_DEBUG": "Depuração", "GROUP_ICON_REPO": "Repositório de ícones", "GROUP_OTHER": "Outro", "CUSTOM_TITLE_LABEL": "Título do site", @@ -1411,52 +1584,51 @@ "FAVICON_URL_PREVIEW": "Pré-visualização do favicon (32×32)", "FAVICON_URL_CACHE_WARNING": "Os navegadores armazenam os favicons em cache de forma agressiva. Após guardar, os utilizadores têm de limpar a cache do navegador ou fazer uma atualização forçada (Ctrl+F5 / Cmd+Shift+R) para verem o novo ícone.", "FAVICON_URL_CSP_NOTE": "Se o seu site utilizar uma Content Security Policy, a origem do URL do favicon tem de ser permitida pela diretiva img-src; caso contrário, o navegador bloqueia a obtenção e recorre ao ícone predefinido.", + "FORCED_BY_PORACLE": "Desativado na própria configuração do Poracle. O Poracle descarta estes webhooks e o seu bot recusa o comando, pelo que não é possível ativar isto aqui.", + "FORCED_BY_PORACLE_TOOLTIP": "Controlado pela configuração do Poracle, não por esta página.", "CUSTOM_PAGE_NAME_LABEL": "Etiqueta do link de navegação", "CUSTOM_PAGE_NAME_DESC": "Etiqueta para o link de navegação personalizado (ex.: \"Voltar ao mapa\").", "CUSTOM_PAGE_URL_LABEL": "URL do link de navegação", "CUSTOM_PAGE_URL_DESC": "URL para onde o link de navegação personalizado aponta.", "CUSTOM_PAGE_ICON_LABEL": "Ícone do link de navegação", "CUSTOM_PAGE_ICON_DESC": "Classe FontAwesome para o ícone do link de navegação (ex.: \"fas fa-map\").", - "DISABLE_MONS_LABEL": "Desativar Pokémon", - "DISABLE_MONS_DESC": "Ocultar a gestão de alarmes de Pokémon a todos os utilizadores.", - "DISABLE_RAIDS_LABEL": "Desativar Raides", - "DISABLE_RAIDS_DESC": "Ocultar a gestão de alarmes de Raides a todos os utilizadores.", - "DISABLE_QUESTS_LABEL": "Desativar Missões", - "DISABLE_QUESTS_DESC": "Ocultar a gestão de alarmes de missões a todos os utilizadores.", - "DISABLE_INVASIONS_LABEL": "Desativar Invasões", - "DISABLE_INVASIONS_DESC": "Ocultar a gestão de alarmes de invasão a todos os utilizadores.", - "DISABLE_LURES_LABEL": "Desativar Módulos Engodo", - "DISABLE_LURES_DESC": "Ocultar a gestão de alarmes de engodo a todos os utilizadores.", - "DISABLE_NESTS_LABEL": "Desativar Ninhos", - "DISABLE_NESTS_DESC": "Ocultar a gestão de alarmes de ninho a todos os utilizadores.", - "DISABLE_GYMS_LABEL": "Desativar Ginásios", - "DISABLE_GYMS_DESC": "Ocultar a gestão de alarmes de ginásio a todos os utilizadores.", - "DISABLE_FORT_CHANGES_LABEL": "Desativar alterações de fortes", - "DISABLE_FORT_CHANGES_DESC": "Ocultar a gestão de alarmes de alterações a fortes a todos os utilizadores.", - "DISABLE_MAXBATTLES_LABEL": "Desativar Combates Max", - "DISABLE_MAXBATTLES_DESC": "Ocultar a gestão de alarmes de Combate Max a todos os utilizadores.", - "DISABLE_AREAS_LABEL": "Desativar áreas", - "DISABLE_AREAS_DESC": "Impedir que os utilizadores giram as suas subscrições de áreas.", - "DISABLE_PROFILES_LABEL": "Desativar perfis", - "DISABLE_PROFILES_DESC": "Impedir que os utilizadores criem e mudem de perfis de alarme.", - "DISABLE_LOCATION_LABEL": "Desativar localização", - "DISABLE_LOCATION_DESC": "Impedir que os utilizadores definam uma localização de casa.", - "DISABLE_NOMINATIM_LABEL": "Desativar geocodificação", - "DISABLE_NOMINATIM_DESC": "Desativar a pesquisa de endereços Nominatim para escolha de localização.", - "DISABLE_GEOMAP_LABEL": "Desativar vista de mapa", - "DISABLE_GEOMAP_DESC": "Ocultar completamente o mapa interativo de geofences.", - "DISABLE_GEOMAP_SELECT_LABEL": "Desativar seleção de áreas no mapa", - "DISABLE_GEOMAP_SELECT_DESC": "Impedir que os utilizadores selecionem áreas ao clicar no mapa.", - "ENABLE_TEMPLATES_LABEL": "Ativar modelos", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Permitir que os utilizadores giram alarmes de Pokémon.", + "DISABLE_RAIDS_LABEL": "Raides", + "DISABLE_RAIDS_DESC": "Permitir que os utilizadores giram alarmes de raides.", + "DISABLE_QUESTS_LABEL": "Missões", + "DISABLE_QUESTS_DESC": "Permitir que os utilizadores giram alarmes de missões.", + "DISABLE_INVASIONS_LABEL": "Invasões", + "DISABLE_INVASIONS_DESC": "Permitir que os utilizadores giram alarmes de invasão.", + "DISABLE_LURES_LABEL": "Módulos Engodo", + "DISABLE_LURES_DESC": "Permitir que os utilizadores giram alarmes de engodo.", + "DISABLE_NESTS_LABEL": "Ninhos", + "DISABLE_NESTS_DESC": "Permitir que os utilizadores giram alarmes de ninho.", + "DISABLE_GYMS_LABEL": "Ginásios", + "DISABLE_GYMS_DESC": "Permitir que os utilizadores giram alarmes de ginásio.", + "DISABLE_FORT_CHANGES_LABEL": "Alterações de fortes", + "DISABLE_FORT_CHANGES_DESC": "Permitir que os utilizadores giram alarmes de alterações a fortes.", + "DISABLE_MAXBATTLES_LABEL": "Combates Max", + "DISABLE_MAXBATTLES_DESC": "Permitir que os utilizadores giram alarmes de Combate Max.", + "DISABLE_AREAS_LABEL": "Áreas", + "DISABLE_AREAS_DESC": "Permitir que os utilizadores giram as suas subscrições de áreas.", + "DISABLE_PROFILES_LABEL": "Perfis", + "DISABLE_PROFILES_DESC": "Permitir que os utilizadores criem e mudem de perfis de alarme.", + "DISABLE_LOCATION_LABEL": "Localização", + "DISABLE_LOCATION_DESC": "Permitir que os utilizadores definam uma localização de casa.", + "DISABLE_NOMINATIM_LABEL": "Geocodificação", + "DISABLE_NOMINATIM_DESC": "Permitir a pesquisa de endereços Nominatim para escolha de localização.", + "DISABLE_USER_GEOFENCES_LABEL": "Geofences personalizadas", + "DISABLE_USER_GEOFENCES_DESC": "Permitir que os utilizadores desenhem, importem e submetam as suas próprias geofences. As geofences existentes continuam a funcionar.", + "ENABLE_TEMPLATES_LABEL": "Modelos", "ENABLE_TEMPLATES_DESC": "Permitir que os utilizadores escolham modelos de mensagens de notificação.", "ALLOWED_LANGUAGES_LABEL": "Idiomas de UI permitidos", "ALLOWED_LANGUAGES_DESC": "Códigos de idioma separados por vírgulas a mostrar no seletor (ex.: \"en,de,fr,es\"). Deixe em branco para mostrar os 11 idiomas.", + "PORACLE_LOCALE_HINT": "Idioma predefinido para novos utilizadores: {{locale}}, retirado da configuração do próprio Poracle. Quem escolher um idioma, ou cujo navegador peça um que este site tenha, recebe esse.", "ENABLE_ROLES_LABEL": "Ativar acesso baseado em funções", "ENABLE_ROLES_DESC": "Permitir apenas o login de utilizadores com funções Discord específicas. Requer Bot Token e Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "IDs de funções permitidas", - "ALLOWED_ROLE_IDS_DESC": "IDs de funções Discord separados por vírgulas que concedem acesso (ex.: \"123456789,987654321\"). Deixe em branco para permitir todos.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Idiomas permitidos", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Lista separada por vírgulas de códigos de idioma que os utilizadores podem selecionar (ex.: \"en,de,fr\").", + "ALLOWED_ROLE_IDS_DESC": "IDs de funções Discord separados por vírgulas, ex.: 123456789,987654321. Um utilizador precisa de pelo menos uma destas funções para iniciar sessão. Deixe em branco para permitir todos.", "REGISTER_COMMAND_LABEL": "Comando de registo", "REGISTER_COMMAND_DESC": "Comando do bot Poracle que os utilizadores executam para se registarem (ex.: \"$!register\").", "LOCATION_COMMAND_LABEL": "Comando de localização", @@ -1464,7 +1636,7 @@ "ENABLE_TELEGRAM_LABEL": "Ativar login do Telegram", "ENABLE_TELEGRAM_DESC": "Permitir login Telegram neste site. Requer TELEGRAM_ENABLED=true, bot token e bot username em .env (reinício do servidor necessário após alterações em .env).", "TELEGRAM_BOT_LABEL": "Nome de utilizador do bot", - "TELEGRAM_BOT_DESC": "Nome de utilizador do bot Telegram (sem @).", + "TELEGRAM_BOT_DESC": "Nome de utilizador do bot Telegram (sem @). Usado quando TELEGRAM_BOT_USERNAME não está configurado.", "ENABLE_DISCORD_LABEL": "Ativar login do Discord", "ENABLE_DISCORD_DESC": "Permitir login Discord neste site. Requer Discord Client ID e Client Secret em .env (reinício do servidor necessário após alterações em .env). Não afeta a entrega do bot PoracleNG.", "PROVIDER_URL_LABEL": "URL dos mosaicos do mapa", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "IDs de admin", "DISCORD_ADMIN_IDS_DESC": "IDs de utilizadores Discord com acesso de admin (mascarado).", "DISCORD_GEOFENCE_FORUM_LABEL": "Canal de fórum de geofences", - "DISCORD_GEOFENCE_FORUM_DESC": "Canal de fórum Discord para threads de submissão de geofences." + "DISCORD_GEOFENCE_FORUM_DESC": "Canal de fórum Discord para threads de submissão de geofences.", + "SEARCH_PLACEHOLDER": "Pesquisar definições…", + "SEARCH_CLEAR": "Limpar pesquisa", + "UNSAVED_CHANGES": "{{count}} por guardar", + "SAVE_CHANGES": "Guardar alterações", + "DISCARD_CHANGES": "Descartar", + "COLLAPSE_SECTION": "Recolher secção", + "EXPAND_SECTION": "Expandir secção", + "SUMMARY_ENABLED": "{{count}} de {{total}} ativos", + "GROUP_AUTH": "Autenticação", + "AUTH_MODE_LABEL": "Modo de início de sessão", + "AUTH_MODE_LOCAL": "Local", + "AUTH_MODE_LOCAL_DESC": "Inicia sessão diretamente com o Discord ou o Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "O início de sessão local é imposto pela configuração do servidor.", + "DISABLE_UPDATE_CHECK_LABEL": "Não procurar atualizações", + "DISABLE_UPDATE_CHECK_DESC": "Impede o site de perguntar ao GitHub se saiu uma versão mais recente do PoracleWeb ou do Poracle. É o único pedido que sai da tua rede e não envia nada." }, "GEOFENCE_DETAIL": { "NAME": "Nome", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "A tua localização", "SELECTED_COUNT": "{{count}} selecionados:", "AREAS_SELECTED": "{{count}} área(s) selecionada(s)" + }, + "ALERT_DEFAULTS": { + "TITLE": "Padrões de alertas", + "DESC": "Escolha como os novos alertas são entregues por padrão. Ainda poderá alterar isto para cada alerta ao criá-lo.", + "DEFAULT_DISTANCE": "Distância padrão", + "DEFAULT_DISTANCE_HINT": "Usada para preencher previamente o raio de novos alertas baseados em distância.", + "FOOTNOTE": "Aplica-se apenas a alertas recém-criados — os existentes não são alterados.", + "DISTANCE_TOO_SMALL": "Tem de ser pelo menos 0,1 km.", + "DISTANCE_TOO_LARGE": "Tem de ser 100 km ou menos." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Itens por página:", + "RANGE": "{{start}} - {{end}} de {{total}}", + "RANGE_EMPTY": "0 de {{total}}", + "NEXT_PAGE": "Página seguinte", + "PREVIOUS_PAGE": "Página anterior", + "FIRST_PAGE": "Primeira página", + "LAST_PAGE": "Última página" + }, + "WHERE": { + "SET_PIN": "Definir a localização", + "PIN_MISSING_WARNING": "Ainda não definiste a tua localização, por isso este alerta não teria de onde medir.", + "PLACES_EMPTY_TITLE": "Ainda sem locais", + "PIN_UNSET": "Por definir", + "PLACES_PAGE_DESC": "Pontos com nome para onde podes dirigir os teus alertas, em vez da tua localização.", + "ADD_PLACE": "Adicionar um local", + "AREAS_LABEL": "Áreas", + "AREA_LIST_MORE": "{{areas}} e mais {{count}}", + "MEASURED_FROM": "Medido a partir de", + "MY_PIN": "A minha localização", + "NAME_PLACE_MESSAGE": "Que nome dar a este local?", + "NAME_PLACE_TITLE": "Dar nome a este local", + "NEAR_PIN": "A menos de {{distance}} km da minha localização", + "NEAR_PLACE": "A menos de {{distance}} km de {{place}}", + "NO_PLACES": "Ainda sem locais. Adiciona um abaixo para dirigir este alerta para fora da tua localização.", + "ONLY_IN": "Apenas em {{areas}}", + "OPTION_AREAS": "Apenas em áreas específicas", + "OPTION_NEAR": "Perto de um ponto", + "OPTION_PLACE": "Perto de um local", + "OPTION_PROFILE": "Em qualquer parte das minhas áreas", + "PIN_NOTE": "O recurso para qualquer alerta sem destino próprio.", + "PIN_TITLE": "A minha localização", + "PLACES_EMPTY": "Adiciona um para receberes alertas fora da tua localização: o trabalho, o ginásio, a casa dos teus pais.", + "PLACES_TITLE": "Locais", + "PLACE_DELETED": "{{place}} eliminado.", + "PLACE_DELETE_CONFIRM": "Os alertas dirigidos a {{place}} voltam à tua localização.", + "PLACE_DELETE_ERROR": "Não foi possível eliminar esse local.", + "PLACE_DELETE_TITLE": "Eliminar este local?", + "PLACE_IN_USE": "{{place}} é usado por {{count}} alerta(s). Redireciona-os primeiro.", + "PLACE_LABEL": "Local", + "PLACE_NAME": "Nome", + "PLACE_SAVED": "{{place}} guardado.", + "PLACE_SAVE_ERROR": "Não foi possível guardar esse local.", + "PROFILE_ANYWHERE": "Onde quer que receba alertas", + "PROFILE_AREAS": "Em qualquer parte das minhas áreas", + "RADIUS_KM": "Raio (km)", + "SAVE": "Definir destino", + "SCOPE_SAVED": "Destino atualizado.", + "SCOPE_SAVE_ERROR": "Não foi possível atualizar onde esse alerta te chega.", + "SHEET_TITLE": "Onde deve este alerta chegar-te?", + "USE_THIS_POINT": "Usar este ponto" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json index 33defcee..09ec98e0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -16,7 +16,7 @@ "GYMS": "Gym", "FORT_CHANGES": "Fort-ändringar", "PROFILES": "Profiler", - "AREAS": "Områden", + "AREAS": "Områden och platser", "MY_GEOFENCES": "Mina geofences", "CLEANING": "Städning", "HELP": "Hjälp", @@ -39,28 +39,33 @@ }, "BANNER": { "VIEWING_AS": "Visar som", - "BACK_TO_ADMIN": "Tillbaka till Admin", + "EXIT_IMPERSONATION": "Tillbaka till ditt konto", "DISABLED_ACCOUNT": "Ditt konto har inaktiverats. Det kan bero på hastighetsbegränsning eller en administratörsåtgärd.", + "DISABLED_ACCOUNT_INSPECTED": "Det här kontot har inaktiverats av en administratör och tar inte emot aviseringar.", "DISABLED_SUPPORT": "För att få hjälp, fråga i", "PAUSED_ALERTS": "Dina larm är pausade. Du kommer inte att få notiser.", "RESUME": "Återuppta" }, "MENU": { + "DISPLAY_LANGUAGE_HINT": "Ändrar bara texten på den här sidan.", "PROFILE_PREFIX": "Profil #", "PAUSE_ALERTS": "Pausa larm", "RESUME_ALERTS": "Återuppta larm", "SWITCH_PROFILE": "Byt profil", - "AREAS_LOCATION": "Områden och plats", "CLEANING": "Städning", "ACCENT_THEME": "Accenttema", - "LANGUAGE": "Språk", + "DISPLAY_LANGUAGE": "Visningsspråk", + "ALERT_LANGUAGE": "Aviseringsspråk", + "ALERT_LANGUAGE_HINT": "Används för aviseringstext och Pokemon-namn.", "LOGOUT": "Logga ut", + "LOGOUT_EVERYWHERE": "Logga ut överallt", "ACCENT_DEFAULT": "Standard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Standardinställningar för aviseringar" }, "SHORTCUTS": { "TITLE": "Kortkommandon", @@ -77,6 +82,7 @@ "NETWORK": "Kan inte nå servern. Kontrollera din anslutning.", "BAD_REQUEST": "Ogiltig begäran. Kontrollera dina uppgifter.", "UNAUTHORIZED": "Din session har gått ut. Logga in igen.", + "INSPECTION_ENDED": "Inspektionen avslutades – du är tillbaka i din egen session.", "FORBIDDEN": "Du har inte behörighet att utföra denna åtgärd.", "NOT_FOUND": "Den begärda resursen hittades inte.", "CONFLICT": "En konflikt uppstod. Objektet kan ha ändrats.", @@ -177,6 +183,12 @@ "ARIA_LABEL": "Välkomst-onboarding" }, "POKEMON": { + "PVP_EVOLUTION": "Megautveckling", + "PVP_EVOLUTION_HINT": "Ranka grundformerna eller en mega. Megor rankas separat, så en megaregel matchar inte en grundform.", + "PVP_EVO_BASE": "Grund", + "PVP_EVO_MEGA": "Mega", + "PVP_EVO_MEGA_X": "Mega X", + "PVP_EVO_MEGA_Y": "Mega Y", "PAGE_TITLE": "Pokemon-larm", "PAGE_DESC": "Spåra vilda Pokemon-spawns med anpassade IV-, CP-, nivå- och PVP-filter.", "SEARCH_PLACEHOLDER": "Sök efter namn eller #...", @@ -227,6 +239,7 @@ "FILTER_FORM_GENDER": "Form och kön", "LABEL_FORM": "Form", "ALL_FORMS": "Alla former", + "FORM_MULTI_HINT": "Lämna tomt för att matcha alla former", "LABEL_GENDER": "Kön", "GENDER_ALL": "Alla", "GENDER_MALE": "Hane", @@ -256,6 +269,7 @@ "PVP_MIN_CP_HINT": "Notifiera bara om utvecklad CP uppfyller detta minimum", "PVP_DISABLED_HINT": "Välj en liga för att filtrera efter PVP-rang.", "SNACK_CREATED": "{{count}} Pokemon-larm skapade", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} Pokemon-larm skapade, {{duplicates}} bevakas redan", "SNACK_UPDATED": "Pokemon-larm uppdaterat", "SNACK_DELETED": "Pokemon-larm raderat", "SNACK_DELETED_ALL": "Alla Pokemon-larm raderade", @@ -294,7 +308,19 @@ "SIZE_LABEL_XS": "XS", "SIZE_LABEL_NORMAL": "Normal", "SIZE_LABEL_XL": "XL", - "SIZE_LABEL_XXL": "XXL" + "SIZE_LABEL_XXL": "XXL", + "PVP_CAP": "Nivågräns", + "PVP_CAP_ALL": "Alla", + "PVP_CAP_LEVEL": "L{{level}}", + "PVP_CAP_HINT_DEFAULT": "Standard — från Poracle-konfigurationen", + "FILTER_TIME_LEFT": "Återstående Tid", + "LABEL_MIN_TIME": "Minsta återstående tid", + "MIN_TIME_HINT": "Hoppar över spawns som är borta innan du hinner fram.", + "MIN_TIME_MINUTES": "{{count}} min", + "MIN_TIME_SECONDS": "{{count}} s", + "PILL_TIME_LEFT_MINUTES": "{{count}} min kvar", + "PILL_TIME_LEFT_SECONDS": "{{count}} s kvar", + "MIN_TIME_ANY": "Alla" }, "ALARM": { "LOCATION_MODE": "Platsläge", @@ -317,7 +343,6 @@ "CLEAN_HINT_LURE": "Raderar automatiskt notisen från Discord när lockmodulen går ut", "CLEAN_HINT_NEST": "Raderar automatiskt notisen från Discord när nästen migrerar", "CLEAN_HINT_GYM": "Raderar automatiskt notisen från Discord när gym-aktiviteten ändras", - "CLEAN_HINT_FORT": "Raderar automatiskt notisen från Discord när den går ut", "CLEAN_HINT_MAX_BATTLE": "Raderar automatiskt notisen från Discord när max-striden är slut", "SAVING": "Sparar...", "SAVE": "Spara", @@ -336,9 +361,19 @@ "TEST_COOLDOWN": "Nedkylning aktiv", "TEST_SEND": "Skicka testnotis", "TAB_DELIVERY": "Leverans", - "COMMON_SETTINGS": "Gemensamma inställningar" + "COMMON_SETTINGS": "Gemensamma inställningar", + "SNACK_CREATED_WITH_DUPLICATES": "{{count}} skapade, {{duplicates}} bevakas redan" }, "RAIDS": { + "RSVP_LABEL": "RSVP-aviseringar", + "RSVP_OFF": "Endast träffar", + "RSVP_INCLUDE": "Träffar + RSVP-uppdateringar", + "RSVP_ONLY": "Endast RSVP-uppdateringar", + "RSVP_OFF_DESC": "Endast vanliga raid-/äggaviseringar.", + "RSVP_INCLUDE_DESC": "Meddela även när RSVP-antalet ändras.", + "RSVP_ONLY_DESC": "Hoppa över inledande träffar; meddela endast vid RSVP-ändringar. Utan en skanner som skickar RSVP blir larmet tyst.", + "RSVP_PILL_INCLUDE": "RSVP", + "RSVP_PILL_ONLY": "Endast RSVP", "PAGE_TITLE": "Raid- och ägglarm", "PAGE_DESC": "Få notiser om raid-bossar och ägg-kläckningar på närliggande gym.", "TAB_RAIDS": "Raids ({{count}})", @@ -401,7 +436,47 @@ "CONFIRM_DELETE_ALL_MSG": "Är du säker på att du vill radera ALLA raid- och ägglarm? Denna åtgärd kan inte ångras.", "CONFIRM_BULK_DELETE_TITLE": "Radera valda larm", "CONFIRM_BULK_DELETE_MSG": "Är du säker på att du vill radera {{count}} larm?", - "CONFIRM_DELETE_SELECTED": "Radera valda" + "CONFIRM_DELETE_SELECTED": "Radera valda", + "LEVEL": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Nivå", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Särskilda", + "SECTION_CUSTOM": "Egna", + "ADD": "Lägg till nivå", + "ADD_PLACEHOLDER": "t.ex. 42", + "ADD_HELP": "Vilket positivt heltal som helst som din server använder. 9000 betyder \"alla nivåer\".", + "INVALID": "Nivån måste vara minst 1.", + "DUPLICATE": "Nivå {{value}} finns redan i listan.", + "SR_REMOVE": "Ta bort egen nivå {{value}}", + "REMOVED": "Nivå {{value}} borttagen", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest-larm", @@ -417,7 +492,7 @@ "TAB_MEGA_ENERGY": "Mega-energi", "TAB_CANDY": "Godis", "ITEM_REWARD": "Föremålsbelöning", - "ANY_ITEM": "Alla föremål", + "ANY_ITEM": "Valfritt föremål", "QUEST_TYPE_LABEL": "Quest-typ:", "SNACK_CREATED": "Quest-larm skapat", "SNACK_UPDATED": "Quest-larm uppdaterat", @@ -453,7 +528,29 @@ "SNACK_DELETED_ALL": "Alla quest-larm raderade", "SNACK_FAILED_DELETE_ALL": "Kunde inte radera larm", "SNACK_FAILED_DISTANCE": "Kunde inte uppdatera avstånd", - "CONFIRM_DELETE_SELECTED": "Radera valda" + "CONFIRM_DELETE_SELECTED": "Radera valda", + "SUMMARY_MODE": "Daglig sammanfattning", + "SUMMARY_HINT": "Samlar matchande uppdrag i ett enda sammanfattningsmeddelande i stället för en avisering per uppdrag. Kräver ett konfigurerat sammanfattningsschema i boten.", + "SUMMARY_BADGE": "Sammanfattning", + "SUMMARY_SCHEDULE": "Leverans av uppdragssammanfattning", + "SUMMARY_SCHEDULE_ALERT_LABEL": "Uppdragssammanfattning", + "SUMMARY_SCHEDULE_EMPTY": "Inget sammanfattningsschema angivet. Uppdrag levereras individuellt.", + "SUMMARY_SCHEDULE_EDIT": "Redigera schema", + "SUMMARY_SCHEDULE_CLEAR": "Ta bort schema", + "SUMMARY_SCHEDULE_SEND_NOW": "Skicka sammanfattning nu", + "SUMMARY_SCHEDULE_SEND_NOW_HINT": "Levererar questträffar som samlats sedan din senaste sammanfattning. Om inget har buffrats än skickas ingenting.", + "SUMMARY_SCHEDULE_SAVED": "Sammanfattningsschema sparat", + "SUMMARY_SCHEDULE_CLEARED": "Sammanfattningsschema borttaget", + "SUMMARY_SCHEDULE_SENT": "Sammanfattning skickad", + "SUMMARY_SCHEDULE_FAILED": "Det gick inte att uppdatera sammanfattningsschemat", + "SUMMARY_SCHEDULE_UNAVAILABLE": "Leverans av sammanfattningar är tillfälligt otillgänglig. Försök igen senare.", + "SUMMARY_DISABLED_HINT": "Schemaläggning av sammanfattningar är inte tillgänglig på den här servern.", + "TAB_STARDUST": "Stjärnstoft", + "MIN_AMOUNT": "Minsta antal", + "MIN_AMOUNT_HINT": "0 = valfritt antal", + "MIN_STARDUST": "Minsta stjärnstoft", + "MIN_STARDUST_HINT": "0 = alla stjärnstoftsuppdrag", + "AMOUNT_PREFIX": "{{count}}× {{reward}}" }, "INVASIONS": { "PAGE_TITLE": "Invasionslarm", @@ -561,7 +658,12 @@ "TYPE_MAGNETIC": "Magnetisk", "TYPE_RAINY": "Regnig", "TYPE_GOLDEN": "Gyllene", - "TYPE_UNKNOWN": "Lockbete #{{id}}" + "TYPE_UNKNOWN": "Lockbete #{{id}}", + "EDIT_MODE": "Redigera meddelandet på plats", + "EDIT_HINT": "Uppdaterar det befintliga Discord-meddelandet när betet ändras i stället för att skicka ett nytt.", + "EDIT_BADGE": "Redigera", + "CONFIRM_DELETE_TITLE": "Ta bort lockbete-larm?", + "SNACK_FAILED_DISTANCE": "Avståndet kunde inte uppdateras." }, "NESTS": { "PAGE_TITLE": "Nästlarm", @@ -578,7 +680,9 @@ "SNACK_DELETED": "Nästlarm raderat", "SNACK_FAILED_CREATE": "Kunde inte skapa larm", "SNACK_FAILED_UPDATE": "Kunde inte uppdatera larm", - "SNACK_FAILED_DELETE": "Kunde inte radera larm" + "SNACK_FAILED_DELETE": "Kunde inte radera larm", + "CONFIRM_DELETE_TITLE": "Ta bort bo-larm?", + "SNACK_FAILED_DISTANCE": "Avståndet kunde inte uppdateras." }, "GYMS": { "PAGE_TITLE": "Gymlarm", @@ -603,7 +707,9 @@ "TEAM_MYSTIC": "Mystic", "TEAM_VALOR": "Valor", "TEAM_INSTINCT": "Instinct", - "TEAM_UNKNOWN": "Lag {{id}}" + "TEAM_UNKNOWN": "Lag {{id}}", + "CONFIRM_DELETE_TITLE": "Ta bort gym-larm?", + "SNACK_FAILED_DISTANCE": "Avståndet kunde inte uppdateras." }, "FORT_CHANGES": { "PAGE_TITLE": "Fort-ändringslarm", @@ -622,10 +728,10 @@ "CHANGE_REMOVAL": "Borttagen", "CHANGE_NEW": "Nytt fort", "INCLUDE_EMPTY": "Inkludera fort utan namn", - "CREATE_FAILED": "Failed to create alarm", - "CREATE_SUCCESS": "Fort change alarm created", - "UPDATE_FAILED": "Failed to update alarm", - "UPDATE_SUCCESS": "Fort change alarm updated", + "CREATE_FAILED": "Aviseringen kunde inte skapas", + "CREATE_SUCCESS": "Avisering för gymförändringar skapad", + "UPDATE_FAILED": "Aviseringen kunde inte uppdateras", + "UPDATE_SUCCESS": "Avisering för gymförändringar uppdaterad", "ALL_CHANGES": "Alla ändringar", "LABEL_NAME": "Namn", "LABEL_LOCATION": "Plats", @@ -640,7 +746,11 @@ "CONFIRM_DELETE_MSG": "Ta bort {{type}}-ändringslarmet?", "SNACK_DELETED": "Fort-ändringslarm raderat", "SNACK_FAILED_DISTANCE": "Kunde inte uppdatera avstånd", - "SNACK_ALL_DISTANCE": "Alla avstånd uppdaterade" + "SNACK_ALL_DISTANCE": "Alla avstånd uppdaterade", + "FORT_TYPE_LABEL": "Fort-typ", + "CHANGE_TYPES_LABEL": "Ändringstyper", + "TRACKING_SUBTITLE": "Bevakning av fort-ändringar", + "CHANGE_DESCRIPTION": "Beskrivning ändrad" }, "MAX_BATTLES": { "PAGE_TITLE": "Max-stridslarm", @@ -662,8 +772,8 @@ "LEVEL_5": "5 Star (Legendary)", "LEVEL_GMAX": "Gigantamax", "LEVEL_GMAX_LEGENDARY": "Legendary Gigantamax", - "CREATE_FAILED": "Failed to create alarm(s)", - "CREATE_SUCCESS": "{{count}} alarm(s) created", + "CREATE_FAILED": "Aviseringarna kunde inte skapas", + "CREATE_SUCCESS": "{{count}} avisering(ar) skapade", "ANY_POKEMON": "Valfri Pokémon", "ANY_LEVEL": "Valfri nivå", "STAR_LABEL": "{{stars}} stjärnor", @@ -681,24 +791,35 @@ "SNACK_FAILED_DISTANCE": "Kunde inte uppdatera avstånd", "SNACK_ALL_DISTANCE": "Alla avstånd uppdaterade", "SNACK_FAILED_UPDATE": "Kunde inte uppdatera larm", - "SNACK_UPDATED": "Max Battle-larm uppdaterat" + "SNACK_UPDATED": "Max Battle-larm uppdaterat", + "HINT_BY_LEVEL": "Bevakar valfri Pokemon på dessa stridsnivåer. Varje vald nivå blir ett eget larm.", + "HINT_BY_POKEMON": "Bevakar utvalda Pokemon i Max-strider, oavsett nivå.", + "HINT_GMAX_ONLY_ADD": "Larmar bara om Gigantamax-strider för de valda Pokemon.", + "HINT_GMAX_ONLY_EDIT": "Larmar bara om Gigantamax-strider för denna Pokemon.", + "HINT_ALL_LEVELS": "Det här larmet följer en Pokemon på alla Max-stridsnivåer.", + "GMAX_OPTION_SUFFIX": "(Gigantamax)" }, "AREAS": { - "PAGE_TITLE": "Områden och plats", + "MANAGE_PLACES": "Hantera platser", + "PAGE_TITLE": "Områden och platser", "PAGE_DESC": "Kontrollera var du tar emot notiser.", "METHOD_AREAS": "Områden", "METHOD_AREAS_ACTIVE": "{{count}} område(n) aktiva", "METHOD_NOT_CONFIGURED": "Ej konfigurerad", "METHOD_AREAS_DESC": "Få notiser om allt som händer inom dina valda geofence-zoner.", "METHOD_AREAS_TIP": "Bäst för: täckning av hela städer, stadsdelar eller parker", - "METHOD_LOCATION": "Plats", - "METHOD_LOCATION_NOT_SET": "Ej angiven", - "METHOD_LOCATION_DESC": "Få notiser om allt inom ett visst avstånd från din fastgjorda plats.", + "METHOD_LOCATION": "Min position", + "METHOD_LOCATION_NOT_SET": "Ingen position angiven", + "METHOD_LOCATION_DESC": "Få aviseringar om allt inom ett angivet avstånd från din position.", "METHOD_LOCATION_TIP": "Bäst för: larm nära hemmet, jobbet eller en specifik plats", "CLEAR_LOCATION": "Rensa", "CHANGE_LOCATION": "Ändra", "SET_LOCATION": "Ange", "METHOD_NOTE": "Varje larm väljer en metod i sin Leverans-flik.", + "NOTIFICATION_LANGUAGE": "Aviseringsspråk", + "NOTIFICATION_LANGUAGE_DESC": "Språket som Poracle använder för dina aviseringar och Pokémon-namn. Det är skilt från visningsspråket i toppmenyn.", + "SNACK_LANGUAGE_UPDATED": "Aviseringsspråk uppdaterat", + "SNACK_LANGUAGE_FAILED": "Det gick inte att uppdatera aviseringsspråket", "SELECT_AREAS": "Välj områden", "MAP_VIEW": "Karta", "LIST_VIEW": "Lista", @@ -721,7 +842,9 @@ "SNACK_LOCATION_FAILED": "Kunde inte uppdatera plats", "SEARCH_AREAS": "Sök områden", "MANUAL_ADD_PLACEHOLDER": "Skriv ett områdesnamn och tryck Enter", - "FILTER_PLACEHOLDER": "Filtrera efter namn..." + "FILTER_PLACEHOLDER": "Filtrera efter namn...", + "SNACK_LOAD_SELECTED_FAILED": "Dina nuvarande områden kunde inte laddas. Ladda om innan du ändrar dem.", + "SELECTION_UNKNOWN": "Dina nuvarande områden kunde inte laddas — ladda om sidan innan du sparar." }, "PROFILES": { "PAGE_TITLE": "Profiler", @@ -901,7 +1024,8 @@ "SELECT_REGION": "Välj region", "SEARCH_REGIONS": "Sök regioner...", "TOGGLE_TOOLTIP": "Aktivera/inaktivera aviseringar för denna geofence i aktuell profil", - "CREATED_PREFIX": "Skapad" + "CREATED_PREFIX": "Skapad", + "REGION_OPTIONAL_HINT": "Valfritt. Välj en region om ditt geostängsel hör till en." }, "CLEANING": { "PAGE_TITLE": "Städningsläge", @@ -1016,14 +1140,15 @@ "TRANSLATION_CTA": "Visst hjälpinnehåll kanske ännu inte är tillgängligt på ditt språk.", "TRANSLATION_CTA_LINK": "Hjälp till att översätta", "FALLBACK_CHIP": "Engelska", + "IMAGE_ENLARGE": "Klicka för att förstora", "SECTION_GETTING_STARTED": "Kom igång", "SECTION_GETTING_STARTED_SUB": "Inloggning, onboarding-guide och första inställningen", "SECTION_DASHBOARD": "Översikt", "SECTION_DASHBOARD_SUB": "Din översikt över larm, områden och status", - "SECTION_LOCATION": "Ange din plats", + "SECTION_LOCATION": "Ange din position", "SECTION_LOCATION_SUB": "GPS, adresssökning och koordinater", - "SECTION_AREAS": "Välj dina områden", - "SECTION_AREAS_SUB": "Kartvy, listvy och regionfiltrering", + "SECTION_AREAS": "Områden och platser", + "SECTION_AREAS_SUB": "Kartvy, listvy, regionfiltrering och platser", "SECTION_GEOFENCES": "Anpassade geofences", "SECTION_GEOFENCES_SUB": "Rita gränser, skicka in för offentligt godkännande", "SECTION_POKEMON": "Pokemon-larm", @@ -1031,7 +1156,9 @@ "SECTION_OTHER_ALARMS": "Andra larmtyper", "SECTION_OTHER_ALARMS_SUB": "Raids, ägg, quests, rockets, lockmoduler, nästen, gym, fort-ändringar", "SECTION_DELIVERY": "Leveransinställningar", - "SECTION_DELIVERY_SUB": "Områden vs avstånd, mallar och städningsläge", + "SECTION_DELIVERY_SUB": "Leveransomfång, mallar och städningsläge", + "SECTION_QUEST_SUMMARY": "Leverans av uppdragssammanfattning", + "SECTION_QUEST_SUMMARY_SUB": "Samla bullriga uppdrag i en schemalagd sammanfattning", "SECTION_TEST_ALERTS": "Testlarm", "SECTION_TEST_ALERTS_SUB": "Skicka provnotiser för att förhandsgranska dina larm", "SECTION_POKEMON_AVAILABILITY": "Pokemon-tillgänglighet", @@ -1052,21 +1179,22 @@ "SECTION_FAQ_SUB": "Vanliga problem och hur du löser dem", "CONTENT_GETTING_STARTED": "

DM Alerts-sidan låter dig anpassa exakt vilka Pokemon GO-notifikationer du får som direktmeddelanden. Istället för att få varje alert väljer du vad som är viktigt för dig — specifika Pokemon, raids, quests och mer — och får bara notifikationer om dem.

ℹ️
Innan du kan använda sidan måste du först registrera dig hos Poracle-botten på Discord eller Telegram. När du är registrerad, kom tillbaka hit och logga in.

Logga in

  • Discord — Klicka på \"Sign in with Discord\" på inloggningssidan. Du skickas till Discord för att auktorisera appen och omdirigeras sedan automatiskt tillbaka.
  • Telegram — Om det är aktiverat, använd Telegram-inloggningswidgeten på inloggningssidan. Bekräfta inloggningen i din Telegram-app.
\"Inloggningssida

Första konfigurationen

När du loggar in för första gången guidar en välkomstguide dig genom tre steg:

  1. Ange din plats — Används för att beräkna avstånd för närliggande notifikationer.
  2. Välj dina områden — Välj de geografiska zoner du vill ha alarmer från.
  3. Lägg till ditt första alarm — Skapa ett Pokemon-, Raid- eller Quest-alarm för att börja få notifikationer.
\"Välkomstguide

Du kan hoppa över vilket steg som helst och komma tillbaka senare. Guiden visas inte igen när du stänger den eller slutför alla steg.

", "CONTENT_DASHBOARD": "\"Dashboard

Dashboarden är din hembas. Den visar en översikt över din nuvarande konfiguration.

Statuskort

  • Plats — Visar dina sparade koordinater eller adress. Klicka för att ställa in eller uppdatera din plats.
  • Aktiva områden — Visar hur många områden du följer. Klicka för att hantera dina områden.
  • Profil — Visar din aktiva profil. Om du har flera profiler, klicka för att växla mellan dem.

Aktiva filter

Ett rutnät av kort visar hur många alarm du har för varje typ (Pokemon, Raids, Quests etc.). Klicka på något kort för att gå till den alarmlistan.

Väder

Om du har en plats inställd visar dashboarden det aktuella vädret i spelet vid dina koordinater tillsammans med senaste uppdateringstid. Områdesväder visas också för vart och ett av dina valda områden, så du kan se väderförhållandena i alla zoner du följer.

Snabbkommandon

Genvägsknappar för att lägga till Pokemon-, Raid- eller Quest-alarm, hantera områden eller konfigurera städning — allt utan att navigera genom sidopanelen.

Tips

Hjälpsamma påminnelser visas när din konfiguration är ofullständig — som saknad plats, inga valda områden eller inga konfigurerade alarm. Varje tips har en åtgärdsknapp för att fixa det. Du kan avfärda tips du inte behöver.

Navigation

Använd sidopanelen för att navigera mellan sektioner. Alarmtyper listas överst, följt av inställningar som Områden, Geofences, Profiler och Städning. Hjälp finns alltid längst ner.

\"Sidopanel", - "CONTENT_LOCATION": "\"Dashboard

Din plats används för avståndsbaserade notifikationer. När ett alarm använder läget \"Ange avstånd\" får du notifikationer om händelser inom en radie från den här platsen.

Ställa in din plats

Öppna platsdialogen från Dashboarden eller Områdessidan. Du har fyra sätt att ställa in den:

  • Sök efter adress — Skriv en adress, stad eller namn på en plats. Välj från förslagen som visas.
  • Ange koordinater — Skriv latitud och longitud direkt om du känner till dem.
  • Använd din GPS — Klicka på \"Use My Location\" för att använda din enhets aktuella plats. Din webbläsare frågar om tillstånd.
  • Klicka på kartan — Klicka var som helst på minikartan för att sätta den punkten som din plats.

När du valt en plats visas adressen automatiskt. Klicka på Spara för att bekräfta.

💡
Du kan rensa din plats från Områdessidan om du bara vill ha områdesbaserade alarmer.
", - "CONTENT_AREAS": "\"Områden

Områden är fördefinierade geografiska zoner konfigurerade av din community. När ett alarm använder läget \"Använd områden\" får du notifikationer om händelser som sker i dina valda områden.

Välja områden

Gå till Områden och Plats i sidopanelen. Du kan välja områden på två sätt:

  • Kartvy — Klicka på färgade polygoner på kartan för att välja eller avmarkera områden. Valda områden blir gröna. Håll musen över ett område för att se dess namn.
  • Listvy — Använd kryssrutor för att välja områden från en sökbar lista.

Regionfiltrering

Om din community har många områden i olika regioner, använd regionens rullgardinsmeny för att zooma in på en specifik region. Det gör det lättare att hitta områden nära dig.

Nästlade områden

Vissa områden överlappar — en mindre zon inuti en större. Båda är klickbara. Zooma in för att göra det lättare att klicka på det mindre området.

Spara

En sparrad visas längst ner när du har gjort ändringar. Klicka på Spara för att bekräfta dina val, eller Avbryt för att ångra.

ℹ️
Områden är per profil. Varje profil har sin egen uppsättning valda områden. Att byta profil visar andra områdesval. Anpassade geofences kan också slås på eller av per profil från Geofence-sidan.
", - "CONTENT_GEOFENCES": "\"Mina

Om de fördefinierade områdena inte täcker platsen du vill ha alarmer från kan du rita dina egna anpassade geofence-gränser på kartan.

Rita en geofence

  1. Gå till Mina Geofences i sidopanelen.
  2. Klicka på Rita Geofence.
  3. Klicka på kartan för att placera punkter för din polygongräns. Klicka på första punkten igen för att stänga formen (minst 3 punkter).
  4. Ge din geofence ett namn och välj vilken region den tillhör. Regionen detekteras vanligtvis automatiskt.
  5. Klicka på Spara.

Hantera geofences

  • Redigera — Byt namn på din geofence eller ändra dess region.
  • Ta bort — Ta bort en geofence du inte längre behöver. Geofencen tas bort från alla profiler automatiskt.

Profilväxling

Varje geofence-kort har en skjutkontroll för att aktivera eller avaktivera den för din aktiva profil. När du skapar en geofence aktiveras den automatiskt på profilen du använder. Växla till en annan profil och kontrollen visar \"Inaktiv\" — slå på den för att även få alarmer för den geofencen på den profilen. Det låter dig styra vilka profiler som får notifikationer för varje geofence utan att återskapa den.

ℹ️
Godkända geofences (befordrade till offentliga områden) visar inte kontrollen — hantera dem från Områden-sidan istället.

GeoJSON Import & Export

Du kan importera och exportera geofences i standard GeoJSON-format, vilket gör det enkelt att dela gränser eller skapa dem i externa verktyg som geojson.io.

  • Import — Klicka på uppladdningsikonen och klistra in eller ladda upp en GeoJSON-fil. Varje polygon i filen blir en ny geofence. Du kan granska och byta namn på var och en innan du sparar.
  • Export — Klicka på nedladdningsikonen och välj vilka geofences som ska inkluderas. Den exporterade GeoJSON-filen innehåller alla valda polygoner och kan öppnas i valfritt GIS-verktyg eller kartredigerare.
💡
GeoJSON-import är användbar för att migrera geofences från andra system eller rita komplexa gränser i ett GIS-verktyg på datorn och sedan importera dem här.

Skicka in för offentligt godkännande

Om du tycker att din geofence skulle vara användbar för hela communityn kan du skicka in den för admin-granskning. Om den godkänns blir den ett offentligt område som alla kan välja. Din privata geofence fortsätter fungera medan granskningen pågår.

Statusmärken

  • Aktiv — Din privata geofence, fungerar bara för dig.
  • Väntar på granskning — Inskickad och väntar på admin-granskning.
  • Godkänd — Befordrad till ett offentligt område.
  • Avvisad — Inte godkänd. Du kan se adminens feedback och geofencen förblir aktiv som en privat zon.
ℹ️
Du kan ha upp till 10 anpassade geofences, var och en med upp till 500 gränspunkter.
", - "CONTENT_POKEMON": "\"Pokemon-alarmsida

Pokemon-alarm meddelar dig när en vild Pokemon spawnar som matchar dina filter.

Lägga till ett Pokemon-alarm

\"Lägg
  1. Gå till Pokemon i sidopanelen och klicka på +-knappen.
  2. Välj Pokemon — Sök efter namn eller Pokedex-nummer, eller använd generations- och typfilterknappar för att bläddra. Du kan välja flera Pokemon på en gång.
  3. Ställ in filter — Välj vad som gör en spawn värd att meddela om:
  • IV-intervall — Minimum och maximum IV-procent (0-100%)
  • CP-intervall — Filtrera efter stridsstyrka
  • Nivåintervall — Filtrera efter Pokemon-nivå (0-55)
  • Individuella stats — Filtrera efter ATK, DEF och STA värden (0-15 vardera)
  • Form — Följ specifika former (t.ex. Alolan, Galarian) eller alla former
  • Kön — Hane, hona, könslös eller alla
  • Vikt — Filtrera efter viktintervall
  • Storlek — Filtrera efter storlekskategori: välj ALL (inget filter) för att matcha alla storlekar, eller välj specifika storlekar från XXS till XXL (XXS, XS, Normal, XL, XXL)
ℹ️
Standardfiltervärden är inställda så att alla Pokemon matchar när inga filter är explicit konfigurerade. Till exempel är IV standard 0-100%, nivå 0-55 och storlek ALL. Du behöver bara justera de filter du bryr dig om.

PVP-filter

Få notifikationer när en Pokemon har bra PVP IV. Välj en liga (Great, Ultra eller Little Cup) och ställ in det rangintervall du bryr dig om (t.ex. rang 1-50).

\"Alla Pokemon\"-alarm

💡
Välj \"All Pokemon\" (ID 0) för att skapa ett alarm som täcker alla arter. Användbart med ett högt IV-filter som 96-100% för att fånga varje värdefull spawn.

Läsa alarmkort

Varje alarmkort visar färgade etiketter som sammanfattar dina filter:

IV 90-100%CP 2000+L30-35PVP GLXXL
", - "CONTENT_OTHER_ALARMS": "\"Raids-sida

Raid- och Ägg-alarm

Få notifikationer när en raidboss eller ett ägg dyker upp som du är intresserad av.

  • Efter nivå — Välj raidnivåer (1-6) eller äggnivåer för att följa alla raids på den nivån.
  • Efter boss — Välj specifika Pokemon raidbossar du vill jaga.
  • Lagfilter — Få bara notifikationer om raids vid gym kontrollerade av ett specifikt lag (Mystic, Valor, Instinct).
  • Gymföljning — Följ raids vid specifika gym efter namn så du bara får notifikationer om dina favoritgym.
  • Attackfilter — Filtrera raidbossar efter deras snabba eller laddade attacker.
  • RSVP-notifikationer — Få notifikationer när andra tränare anmäler sig till en raid eller ett ägg du följer.

Raid- och Ägg-alarm hanteras på separata flikar på Raids-sidan. Ägg stöder också gymspecifik följning och RSVP-notifikationer.

Max Battle (Dynamax)-alarm

Få notifikationer om Dynamax- och Gigantamax-strider vid Power Spots.

  • Efter nivå — Välj stridsnivåer för att följa alla Pokemon på de nivåerna. Nivåer går från 1 stjärna till 5 stjärnor (Legendary) för Dynamax, plus Gigantamax och Legendary Gigantamax för de största striderna. Ett alarm skapas per vald nivå.
  • Efter Pokemon — Välj specifika Pokemon du vill strida mot på alla Max Battle-nivåer. Om scannerdatabasen är konfigurerad filtreras väljaren till att bara visa Pokemon som har dykt upp i Max Battles.
  • Bara Gigantamax — När du följer efter Pokemon, slå på detta för att bara få notifikationer när den Pokemon dyker upp i Gigantamax-strider (de högsta striderna med unika G-Max-attacker). För nivåbaserad följning hanteras Gigantamax genom att välja Gigantamax- eller Legendary Gigantamax-nivåerna direkt.
  • Välj alla — Välj snabbt alla tillgängliga nivåer på en gång (motsvarar bottens !maxbattle everything kommando).

Quest-alarm

Få notifikationer om fältforskningsuppgifter med specifika belöningar.

  • Pokemon-möten — Välj Pokemon du vill ha som questbelöningar.
  • Föremål — Följ quests som ger specifika föremål.
  • Mega Energi — Följ quests som ger mega-energi för specifika Pokemon.
  • Godis — Följ quests som ger godis för specifika Pokemon.

Invasionsalarm

Få notifikationer om Team Rocket-invasioner.

  • Följ alla — Ett alarm för varje grunttyp och ledare.
  • Efter typ — Välj specifika grunttyper (Bug, Dragon, Fire etc.), Rocket Leaders eller Giovanni. Grunttypnamn normaliseras automatiskt (skiftlägesokkänsligt), så du behöver inte oroa dig för exakt stavning.
  • Kön — Filtrera efter gruntens kön.

Lure-alarm

Få notifikationer när en specifik lure-typ placeras. Välj mellan Normal, Glacial, Mossy, Magnetic, Rainy och Golden.

Bo-alarm

Följ Pokemon-arter som har bon. Ställ in en minsta spawns per timme-tröskel så du bara får notifikationer om bon med tillräcklig aktivitet.

Gym-alarm

Följ gymlagbyten. Välj vilka lag (Neutral, Mystic, Valor, Instinct) som ska övervakas. Aktivera Platsändringar för att få notifikationer när gymplatser öppnas, eller aktivera Stridsändringar för att få notifikationer när ett gym är under attack.

Fortändringsalarm

Följ ändringar i PokéStops och gym själva — inte aktiviteterna vid dem, utan ändringar i själva intressepunkterna.

  • Forttyp — Välj att följa PokéStops, Gym eller Allt.
  • Ändringstyper — Välj vilka ändringar som ska övervakas: Namn ändrat, Plats ändrad, Bild ändrad, Borttagning eller Nytt fort tillagt.
  • Inkludera tomma — Inkludera fort utan namn.
💡
Fortändringsalarm är användbara för att följa kartdatabasuppdateringar — nya PokéStops som dyker upp, gym som flyttas eller POI:er som tas bort från spelet.

Rikta in sig på ett specifikt gym

När du skapar eller redigerar ett Raid-, Ägg- eller Gym-alarm kan du valfritt söka efter och välja ett specifikt gym. Det är användbart när du bara bryr dig om aktivitet vid ditt favoritgym — som det på din lunchrutt eller nära ditt hem.

  • Så här använder du det — I lägg till- eller redigeringsdialogen, skriv ett gymnamn i gymsökfältet. Resultaten visar gymmets foto, namn och område så du kan identifiera rätt gym.
  • När ett gym är valt — Alarmet utlöses bara för händelser vid det specifika gymmet. Gymnamnet visas på alarmkortet i din lista så du kan se vilket gym det riktar sig mot.
  • När inget gym är valt — Det är standard. Alarmet fungerar normalt för alla gym i dina valda områden eller inom din avståndsradie.
💡
Du kan kombinera ett gymspecifikt alarm med ett bredare alarm. Skapa till exempel ett raidalarm riktat mot ditt lokala gym för alla nivåer, och ett andra alarm för nivå 5-raids över alla dina områden.
", - "CONTENT_DELIVERY": "\"Pokemon-alarmkort

Varje alarm har leveransinställningar som styr var du får notifikationer.

Områden vs Avstånd

Varje alarm använder ett av två leveranslägen:

🗺
Använd områdenFå notifikationer när händelser sker i dina valda områden. Bra för att följa specifika kvarter.
📏
Ange avståndFå notifikationer inom en radie (km) från din sparade plats. Bra för att följa allt i närheten.

Du kan använda olika lägen för olika alarm — till exempel områden för Pokemon och avstånd för raids.

Notifikationsmallar

Om mallar är aktiverade kan du välja hur dina notifikationsmeddelanden ser ut. Mallväljaren visar en live-förhandsgranskning av hur ditt Discord DM kommer att se ut, inklusive embed-format, fält och bilder.

Städningsläge

När det är aktiverat tar botten automatiskt bort notifikationen från Discord efter att händelsen löper ut (t.ex. en Pokemon despawnar eller en raid slutar). Det håller dina DM snygga. Du kan aktivera städningsläge per alarm eller i bulk från Städning-sidan.

Ping / Rollomnämnanden

Om du använder webhooks kan du ställa in en Discord-roll att nämna i notifikationen (t.ex. @Pokemon). Det är bara relevant för webhook-konfigurationer.

", + "CONTENT_LOCATION": "\"Dashboard

Din plats är punkten dina aviseringar mäts från. Ett alarm som når dig inom en radie utgår från den, om du inte riktar just det alarmet mot en sparad plats i stället.

Ställa in din plats

Öppna platsdialogen från Dashboarden eller sidan Områden och platser. Du har fyra sätt att ställa in den:

  • Sök efter adress — Skriv en adress, stad eller namn på en plats. Välj från förslagen som visas.
  • Ange koordinater — Skriv latitud och longitud direkt om du känner till dem.
  • Använd din GPS — Klicka på \"Use My Location\" för att använda din enhets aktuella plats. Din webbläsare frågar om tillstånd.
  • Klicka på kartan — Klicka var som helst på minikartan för att sätta den punkten som din plats.

När du valt en punkt visas adressen automatiskt. Klicka på Spara för att bekräfta.

Samma dialog återanvänds när du lägger till en plats eller väljer en punkt för ett enskilt alarm. Den heter då Välj en punkt och bekräftas med Använd den här punkten, och din egen plats lämnas orörd.

💡
Du kan rensa din plats från sidan Områden och platser om du bara vill ha områdesbaserade alarmer.
", + "CONTENT_AREAS": "\"Sidan

Områden är fördefinierade geografiska zoner konfigurerade av din community. De du väljer här är vad varje alarm följer som standard: ett alarm satt till Var som helst i mina områden utlöses av händelser inuti dem.

Välja områden

Gå till Områden och platser i sidopanelen. Du kan välja områden på två sätt:

  • Kartvy — Klicka på färgade polygoner på kartan för att välja eller avmarkera områden. Valda områden blir gröna. Håll musen över ett område för att se dess namn.
  • Listvy — Använd kryssrutor för att välja områden från en sökbar lista.

Platser

En plats är en namngiven punkt — jobbet, gymmet, dina föräldrars hus — som ett alarm kan mäta sin radie från i stället för din plats. Lägg till en i avsnittet Platser på samma sida och välj den sedan under Mätt från när du bestämmer var ett alarm ska nå dig. En plats går inte att ta bort så länge alarm pekar på den, och meddelandet säger hur många.

Regionfiltrering

Om din community har många områden i olika regioner, använd regionens rullgardinsmeny för att zooma in på en specifik region. Det gör det lättare att hitta områden nära dig.

Nästlade områden

Vissa områden överlappar — en mindre zon inuti en större. Båda är klickbara. Zooma in för att göra det lättare att klicka på det mindre området.

Spara

En sparrad visas längst ner när du har gjort ändringar. Klicka på Spara för att bekräfta dina val, eller Avbryt för att ångra.

ℹ️
Områden är per profil. Varje profil har sin egen uppsättning valda områden. Att byta profil visar andra områdesval. Anpassade geofences kan också slås på eller av per profil från Geofence-sidan.
", + "CONTENT_GEOFENCES": "\"Mina

Om de fördefinierade områdena inte täcker platsen du vill ha alarmer från kan du rita dina egna anpassade geofence-gränser på kartan.

Rita en geofence

  1. Gå till Mina Geofences i sidopanelen.
  2. Klicka på Rita Geofence.
  3. Klicka på kartan för att placera punkter för din polygongräns. Klicka på första punkten igen för att stänga formen (minst 3 punkter).
  4. Ge din geofence ett namn och välj vilken region den tillhör. Regionen detekteras vanligtvis automatiskt.
  5. Klicka på Spara.

Hantera geofences

  • Redigera — Byt namn på din geofence eller ändra dess region.
  • Ta bort — Ta bort en geofence du inte längre behöver. Geofencen tas bort från alla profiler automatiskt.

Profilväxling

Varje geofence-kort har en skjutkontroll för att aktivera eller avaktivera den för din aktiva profil. När du skapar en geofence aktiveras den automatiskt på profilen du använder. Växla till en annan profil och kontrollen visar \"Inaktiv\" — slå på den för att även få alarmer för den geofencen på den profilen. Det låter dig styra vilka profiler som får notifikationer för varje geofence utan att återskapa den.

ℹ️
Godkända geofences (befordrade till offentliga områden) visar inte kontrollen — hantera dem från Områden-sidan istället.

Använda en geofence för ett enda alarm

En geofence du ritat själv dyker också upp i listan Endast i valda områden när du bestämmer var ett enskilt alarm ska nå dig; den är märkt med en rit-ikon. Det begränsar ett alarm till den utan att aktivera geofencen för hela profilen.

GeoJSON Import & Export

Du kan importera och exportera geofences i standard GeoJSON-format, vilket gör det enkelt att dela gränser eller skapa dem i externa verktyg som geojson.io.

  • Import — Klicka på uppladdningsikonen och klistra in eller ladda upp en GeoJSON-fil. Varje polygon i filen blir en ny geofence. Du kan granska och byta namn på var och en innan du sparar.
  • Export — Klicka på nedladdningsikonen och välj vilka geofences som ska inkluderas. Den exporterade GeoJSON-filen innehåller alla valda polygoner och kan öppnas i valfritt GIS-verktyg eller kartredigerare.
💡
GeoJSON-import är användbar för att migrera geofences från andra system eller rita komplexa gränser i ett GIS-verktyg på datorn och sedan importera dem här.

Skicka in för offentligt godkännande

Om du tycker att din geofence skulle vara användbar för hela communityn kan du skicka in den för admin-granskning. Om den godkänns blir den ett offentligt område som alla kan välja. Din privata geofence fortsätter fungera medan granskningen pågår.

Statusmärken

  • Aktiv — Din privata geofence, fungerar bara för dig.
  • Väntar på granskning — Inskickad och väntar på admin-granskning.
  • Godkänd — Befordrad till ett offentligt område.
  • Avvisad — Inte godkänd. Du kan se adminens feedback och geofencen förblir aktiv som en privat zon.
ℹ️
Du kan ha upp till 10 anpassade geofences, var och en med upp till 500 gränspunkter.
", + "CONTENT_POKEMON": "\"Pokemon-alarmsida

Pokemon-alarm meddelar dig när en vild Pokemon spawnar som matchar dina filter.

Lägga till ett Pokemon-alarm

\"Lägg
  1. Gå till Pokemon i sidopanelen och klicka på +-knappen.
  2. Välj Pokemon — Sök efter namn eller Pokedex-nummer, eller använd generations- och typfilterknappar för att bläddra. Du kan välja flera Pokemon på en gång.
  3. Ställ in filter — Välj vad som gör en spawn värd att meddela om:
  • IV-intervall — Minimum och maximum IV-procent (0-100%)
  • CP-intervall — Filtrera efter stridsstyrka
  • Nivåintervall — Filtrera efter Pokemon-nivå (0-55)
  • Individuella stats — Filtrera efter ATK, DEF och STA värden (0-15 vardera)
  • Form — Följ specifika former (t.ex. Alolan, Galarian) eller alla former
  • Kön — Hane, hona, könslös eller alla
  • Vikt — Filtrera efter viktintervall
  • Storlek — Filtrera efter storlekskategori: välj ALL (inget filter) för att matcha alla storlekar, eller välj specifika storlekar från XXS till XXL (XXS, XS, Normal, XL, XXL)
  • Minsta återstående tid — Hoppa över spawns som hinner försvinna innan du är framme. Ställs in under Fler filter; kortet visar sedan en pill som "10 min kvar"
ℹ️
Standardfiltervärden är inställda så att alla Pokemon matchar när inga filter är explicit konfigurerade. Till exempel är IV standard 0-100%, nivå 0-55 och storlek ALL. Du behöver bara justera de filter du bryr dig om.

PVP-filter

Få notifikationer när en Pokemon har bra PVP IV. Välj en liga (Great, Ultra eller Little Cup) och ställ in det rangintervall du bryr dig om (t.ex. rang 1-50).

Knapparna Nivåtak väljer vilket tak rangerna läses vid. Låt Alla stå kvar för att använda värdet från din communitys Poracle-konfiguration.

Megautveckling avgör om regeln rankar grundformen eller en mega: Base, Mega, Mega X eller Mega Y. Megor rankas separat, så en megaregel matchar aldrig en spawn i grundform.

\"Alla Pokemon\"-alarm

💡
Välj \"All Pokemon\" (ID 0) för att skapa ett alarm som täcker alla arter. Användbart med ett högt IV-filter som 96-100% för att fånga varje värdefull spawn.

Läsa alarmkort

Varje alarmkort visar färgade etiketter som sammanfattar dina filter:

IV 90-100%CP 2000+L30-35PVP GLXXL
", + "CONTENT_OTHER_ALARMS": "\"Raids-sida

Raid- och Ägg-alarm

Få notifikationer när en raidboss eller ett ägg dyker upp som du är intresserad av.

  • Efter nivå — Välj raidnivåer (1-6) eller äggnivåer för att följa alla raids på den nivån.
  • Efter boss — Välj specifika Pokemon raidbossar du vill jaga.
  • Lagfilter — Få bara notifikationer om raids vid gym kontrollerade av ett specifikt lag (Mystic, Valor, Instinct).
  • Gymföljning — Följ raids vid specifika gym efter namn så du bara får notifikationer om dina favoritgym.
  • Attackfilter — Filtrera raidbossar efter deras snabba eller laddade attacker.
  • RSVP-notifikationer — Få notifikationer när andra tränare anmäler sig till en raid eller ett ägg du följer.

Raid- och Ägg-alarm hanteras på separata flikar på Raids-sidan. Ägg stöder också gymspecifik följning och RSVP-notifikationer.

Max Battle (Dynamax)-alarm

Få notifikationer om Dynamax- och Gigantamax-strider vid Power Spots.

  • Efter nivå — Välj stridsnivåer för att följa alla Pokemon på de nivåerna. Nivåer går från 1 stjärna till 5 stjärnor (Legendary) för Dynamax, plus Gigantamax och Legendary Gigantamax för de största striderna. Ett alarm skapas per vald nivå.
  • Efter Pokemon — Välj specifika Pokemon du vill strida mot på alla Max Battle-nivåer. Om scannerdatabasen är konfigurerad filtreras väljaren till att bara visa Pokemon som har dykt upp i Max Battles.
  • Bara Gigantamax — När du följer efter Pokemon, slå på detta för att bara få notifikationer när den Pokemon dyker upp i Gigantamax-strider (de högsta striderna med unika G-Max-attacker). För nivåbaserad följning hanteras Gigantamax genom att välja Gigantamax- eller Legendary Gigantamax-nivåerna direkt.
  • Välj alla — Välj snabbt alla tillgängliga nivåer på en gång (motsvarar bottens !maxbattle everything kommando).

Quest-alarm

Få notifikationer om fältforskningsuppgifter med specifika belöningar.

  • Pokemon-möten — Välj Pokemon du vill ha som questbelöningar.
  • Föremål — Följ quests som ger specifika föremål.
  • Mega Energi — Följ quests som ger mega-energi för specifika Pokemon.
  • Godis — Följ quests som ger godis för specifika Pokemon.
  • Stardust — Följ quests som ger stardust.

Flikarna för föremål, megaenergi och godis har var sitt fält Minsta antal, och stardust-fliken ett Minsta stardust. Lämna 0 för att godta vilket antal som helst. Korten visar antalet bredvid belöningen, till exempel "3× Rare Candy".

Invasionsalarm

Få notifikationer om Team Rocket-invasioner.

  • Följ alla — Ett alarm för varje grunttyp och ledare.
  • Efter typ — Välj specifika grunttyper (Bug, Dragon, Fire etc.), Rocket Leaders eller Giovanni. Grunttypnamn normaliseras automatiskt (skiftlägesokkänsligt), så du behöver inte oroa dig för exakt stavning.
  • Kön — Filtrera efter gruntens kön.

Lure-alarm

Få notifikationer när en specifik lure-typ placeras. Välj mellan Normal, Glacial, Mossy, Magnetic, Rainy och Golden.

Bo-alarm

Följ Pokemon-arter som har bon. Ställ in en minsta spawns per timme-tröskel så du bara får notifikationer om bon med tillräcklig aktivitet.

Gym-alarm

Följ gymlagbyten. Välj vilka lag (Neutral, Mystic, Valor, Instinct) som ska övervakas. Aktivera Platsändringar för att få notifikationer när gymplatser öppnas, eller aktivera Stridsändringar för att få notifikationer när ett gym är under attack.

Fortändringsalarm

Följ ändringar i PokéStops och gym själva — inte aktiviteterna vid dem, utan ändringar i själva intressepunkterna.

  • Forttyp — Välj att följa PokéStops, Gym eller Allt.
  • Ändringstyper — Välj vilka ändringar som ska övervakas: Namn ändrat, Beskrivning ändrad, Plats ändrad, Bild ändrad, Borttaget eller Nytt fort.
  • Inkludera tomma — Inkludera fort utan namn.
💡
Fortändringsalarm är användbara för att följa kartdatabasuppdateringar — nya PokéStops som dyker upp, gym som flyttas eller POI:er som tas bort från spelet.

Rikta in sig på ett specifikt gym

När du skapar eller redigerar ett Raid-, Ägg- eller Gym-alarm kan du valfritt söka efter och välja ett specifikt gym. Det är användbart när du bara bryr dig om aktivitet vid ditt favoritgym — som det på din lunchrutt eller nära ditt hem.

  • Så här använder du det — I lägg till- eller redigeringsdialogen, skriv ett gymnamn i gymsökfältet. Resultaten visar gymmets foto, namn och område så du kan identifiera rätt gym.
  • När ett gym är valt — Alarmet utlöses bara för händelser vid det specifika gymmet. Gymnamnet visas på alarmkortet i din lista så du kan se vilket gym det riktar sig mot.
  • När inget gym är valt — Det är standard. Alarmet fungerar normalt för alla gym i dina valda områden eller inom din avståndsradie.
💡
Du kan kombinera ett gymspecifikt alarm med ett bredare alarm. Skapa till exempel ett raidalarm riktat mot ditt lokala gym för alla nivåer, och ett andra alarm för nivå 5-raids över alla dina områden.
", + "CONTENT_DELIVERY": "\"Pokemon-alarmkort,

Varje alarm har leveransinställningar som styr var du får notifikationer.

Var en avisering når dig

Leverans-fliken i varje lägg till- och redigera-dialog frågar Var ska aviseringen nå dig? och ger tre svar:

  • Var som helst i mina områden — Standardvalet. Alarmet följer områdena som din profil har valt, så när du ändrar dina områden ändras även det här alarmet.
  • Nära en punkt — En radie i kilometer, mätt från din plats eller från en sparad plats som du väljer under Mätt från. Har du ingen plats än säger väljaren det och erbjuder att ange en.
  • Endast i valda områden — En delmängd områden för just det här alarmet, vald bland de publika områdena och de geofences du själv ritat.

Olika alarm får svara olika: områden för Pokemon, en radie från din plats för raids, en namngiven plats för quests.

Chippen på alarmkortet

De flesta alarmkort bär en chip med sitt svar — "Var som helst i mina områden", "Överallt där jag får aviseringar", "Inom 5 km från min position", "Inom 2 km från Hemma", "Endast i Terrigal, Erina". Klicka på den för att ändra just det alarmet utan att öppna hela redigera-dialogen.

Standard för nya alarm

Nya alarm öppnas i läget Områden som standard. Vill du ändra det, öppna användarmenyn (din avatar uppe till höger) och välj Standardinställningar för aviseringar — bestäm om nya alarm startar i Områden eller Avstånd, ange en standardradie och välj om radien mäts från din plats eller från en sparad plats. Valet sparas i din webbläsare och förifyller även Quick Pick-dialogen. Det gäller bara alarm du skapar därefter; befintliga ändras inte, och du kan fortfarande ändra var varje enskilt alarm når dig.

Notifikationsmallar

Om mallar är aktiverade kan du välja hur dina notifikationsmeddelanden ser ut. Mallväljaren visar en live-förhandsgranskning av hur ditt Discord DM kommer att se ut, inklusive embed-format, fält och bilder.

Städningsläge

När det är aktiverat tar botten automatiskt bort notifikationen från Discord efter att händelsen löper ut (t.ex. en Pokemon despawnar eller en raid slutar). Det håller dina DM snygga. Du kan aktivera städningsläge per alarm eller i bulk från Städning-sidan.

Redigera på plats & sammanfattningar

Vissa larm stöder extra leveranslägen. Aktivera Redigera meddelandet på plats för ett lockbete så att det befintliga Discord-meddelandet uppdateras när lockbetet ändras i stället för att ett nytt skickas, eller Daglig sammanfattning för ett uppdrag för att samla matchande uppdrag i ett enda sammanfattningsmeddelande (kräver ett konfigurerat sammanfattningsschema på boten). Raider och ägg redigeras på plats automatiskt när du väljer ett RSVP-läge. Dessa inställningar behålls även om du anger dem från boten.

RSVP-uppdateringar (raider & ägg)

Raid- och äggalarm lägger till en inställning för RSVP-aviseringar i lägg till-/redigeringsdialogen med tre alternativ: Endast träffar skickar vanliga raid-/äggaviseringar; Träffar + RSVP-uppdateringar meddelar dig även när RSVP-antalet ändras (tränare som anmäler sig); och Endast RSVP-uppdateringar hoppar över den inledande träffen och meddelar dig endast vid RSVP-ändringar. Att välja något av RSVP-lägena gör att botten redigerar det befintliga Discord-meddelandet på plats när antalet ändras i stället för att skicka nya, och kortet visar en "RSVP"- eller "Endast RSVP"-etikett. Observera att Endast RSVP-uppdateringar blir tyst om inte din gemenskaps skanner skickar RSVP-händelser — välj det bara om du vet att RSVP rapporteras.

", + "CONTENT_QUEST_SUMMARY": "

Fältforskningsuppdrag roterar dagligen och kan matcha i stora mängder, så ett fullt uppdragsfilter kan översvämma dina DM. Leverans av uppdragssammanfattning samlar matchande uppdrag i en schemalagd sammanfattning i stället för många separata aviseringar.

Två delar som samverkar

  • Reglaget Daglig sammanfattning — slå på det för ett uppdragslarm (i dess lägg till-/redigeringsdialog) för att markera dess matchningar för sammanfattningen i stället för omedelbar leverans.
  • Leveransschema — välj när de insamlade uppdragen skickas.

Båda behövs: reglaget anger vilka uppdrag som ska samlas in, schemat anger när de ska levereras.

Ställ in ditt schema

Öppna sidan Uppdrag, sedan menyn i verktygsfältet och välj Leverans av uppdragssammanfattning. Använd Redigera schema för att välja dagar och tider — samma redigerare som används för profilers aktiva timmar. Sparade tider visas som bärnstensfärgade chips.

Schemat är per användare och delas mellan alla dina profiler — till skillnad från profilers aktiva timmar, som ställs in per profil.

Skicka sammanfattning nu

Skicka sammanfattning nu levererar omedelbart allt som samlats in sedan din senaste sammanfattning. Om inget har samlats in ännu skickas ingenting — uppdrag buffras allteftersom de matchar, så ge det tid eller vänta tills schemat utlöses.

Bra att veta

  • Menyn visas bara när din servers bot har uppdragssammanfattningar aktiverade.
  • Leveranstiden använder din sparade plats för tidszonen — ange en plats, annars kan sammanfattningar komma vid fel lokal tid (dialogen varnar dig när ingen plats är angiven).
  • Att ta bort schemat behåller reglaget per larm; uppdrag samlas fortfarande in men återgår till botens standardtid.
", "CONTENT_TEST_ALERTS": "

Varje alarmkort har en Test-knapp (pappersflygplansikon) som skickar en provnotifikation till din Discord eller Telegram, med alarmets exakta filter och din nuvarande leveransmall.

Så här fungerar det

  1. Hitta ett alarmkort på din lista (Pokemon, Raid, Quest etc.).
  2. Klicka på skicka-ikonen i kortets åtgärdsrad.
  3. En simulerad händelse som matchar ditt alarms filter genereras och skickas genom notifikationspipelinen. Du får ett DM precis som en riktig alert.

Vad som testas

Testet använder ditt alarms filtervärden (Pokemon ID, raidnivå, questbelöning etc.) och din sparade plats som de simulerade händelsekoordinaterna. Notifikationen formateras med din valda mall, så du ser exakt hur en riktig alert skulle se ut.

Nedkylning

För att förhindra spam har varje alarm en 15-sekunders nedkylningsperiod mellan testutskick. Knappen är avaktiverad under nedkylningen och en infobar visar feedback (lyckad, fel eller återstående nedkylning).

💡
Testalarm är bra för att verifiera att din mall ser rätt ut eller bekräfta att din webhook-leverans fungerar innan du väntar på en riktig händelse.
", "CONTENT_POKEMON_AVAILABILITY": "

När du lägger till eller redigerar Pokemon-alarm kan Pokemon-väljaren visa tillgänglighetsindikatorer — små märken som berättar vilka Pokemon som för närvarande spawnar i det vilda.

Så här fungerar det

Om din community har en Golbat-scanner konfigurerad visar väljaren färgade prickar bredvid Pokemon-namn:

  • Grön prick — Denna Pokemon har setts spawna nyligen.
  • Ingen prick — Inte rapporterad i scannerdatan just nu.

Det hjälper dig undvika att skapa alarm för Pokemon som inte spawnar i ditt område just nu (t.ex. säsongsbundna eller eventexklusiva arter).

Uppdatering av tillgänglighet

Datan uppdateras automatiskt i bakgrunden. Du behöver inte göra något — titta bara efter prickarna när du bläddrar i Pokemon-väljaren.

ℹ️
Den här funktionen är bara synlig om din admin har konfigurerat Golbat-scannerintegrationen. Om du inte ser tillgänglighetsprickar är funktionen inte aktiverad för din community.
", "CONTENT_BULK": "\"Pokemon-alarmlista

Alla alarmsidor stöder massoperationer så du kan hantera många alarm på en gång.

Väljläge

Klicka på checklisteikonen i verktygsfältet för att gå in i väljläge. Klicka sedan på individuella alarmkort för att välja dem, eller använd Välj alla för att ta allt synligt.

Massåtgärder

  • Uppdatera avstånd — Ändra leveransläge (områden eller avstånd) för alla valda alarm på en gång.
  • Ta bort — Ta bort alla valda alarm med en bekräftelse.
💡
Längst ner i varje alarmlista hittar du också knapparna Uppdatera alla avstånd och Ta bort alla som gäller för varje alarm av den typen.
", - "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks är färdiga alarmmallar skapade av din communitys administratörer. De låter dig konfigurera vanliga alarmuppsättningar med ett klick istället för att skapa varje alarm individuellt.

Tillämpa ett Quick Pick

  1. Gå till Quick Picks i sidopanelen.
  2. Bläddra bland de tillgängliga valen, eventuellt filtrerat efter kategori.
  3. Klicka på Tillämpa på det Quick Pick du vill ha.
  4. Anpassa innan du tillämpar: välj leveransläge (områden eller avstånd), aktivera städningsläge och uteslut eventuellt specifika Pokemon.
  5. Bekräfta för att skapa alla alarm på en gång.

Ta bort Quick Pick-alarm

Om du inte längre vill ha alarm från ett Quick Pick, klicka på Ta bort för att radera alla alarm det skapade.

", - "CONTENT_PROFILES": "

Profilsidan är ditt samlade nav för att hantera profiler och se alla alarm över alla profiler på ett ställe.

Varför använda profiler?

Profiler låter dig underhålla helt separata alarmkonfigurationer. Varje profil har sin egen uppsättning alarm, valda områden, plats och anpassade geofence-aktiveringar. Användbart för olika situationer — till exempel en \"Hem\"-profil för ditt kvarter och en \"Jobb\"-profil för runt ditt kontor.

Översikt

Sidan visar en statistikrad med totala alarmantal per typ, ett sökfält för att filtrera över alla profiler, och typfilterchips för att bara visa specifika alarmtyper (Pokemon, Raids, Quests etc.).

Varje profil visas som en expanderbar panel. Klicka för att expandera och se alla alarm grupperade efter typ, med spelgrafik (Pokemon-sprites, raid-ägg, lure-ikoner) och filteretiketter som visar IV, CP, Nivå, PVP och andra inställningar.

Hantera profiler

  • Skapa — Klicka på +-knappen uppe till höger. Profilnamn måste vara unika (upp till 32 tecken).
  • Växla — Klicka på Växla inuti en profilpanel för att göra den till din aktiva profil. Din aktiva profil är markerad med ett grönt märke och vänster kant.
  • Redigera — Klicka på pennikonen för att byta namn på en profil.
  • Ta bort — Klicka på papperskorgsikonen för att ta bort en profil och alla dess alarm. Du kan inte ta bort din aktiva profil.

Duplicera

Klicka på kopieringsikonen på en profil för att skapa en exakt kopia med alla dess alarm. Du ombeds namnge den nya profilen — ett standardnamn som \"Profil (Kopia)\" föreslås. Duplikatet inkluderar alla alarmfilter men får en ny uppsättning områdesval.

Export & Import

  • Export — Klicka på nedladdningsikonen på en profil för att spara en säkerhetskopia (JSON). Filen innehåller alla alarmfilter, rensade från interna ID:n så den är portabel.
  • Import — Klicka på Import-knappen uppe till höger, välj en säkerhetskopia och välj ett namn för den nya profilen. Alla alarm från säkerhetskopian återställs. Om en profil med samma namn finns läggs ett nummersuffix till automatiskt.

Dubblettdetektering

Om samma alarm finns på flera profiler (t.ex. följer Pikachu på både \"Hem\" och \"Jobb\"), markeras dessa alarm med en orange kant och en kopieringsikon. När dubbletter finns visas ett Dubbletter-filterchip i filterraden — klicka på det för att bara visa duplicerade alarm över profiler.

⚠️
Varning: Att ta bort en profil tar permanent bort alla alarm i den profilen. Du kan inte ta bort din aktuellt aktiva profil. Överväg att exportera en säkerhetskopia först.
", - "CONTENT_CLEANING": "\"Städningssida

Städningssidan låter dig styra städningsläge över alla dina alarmtyper på en gång.

När städningsläge är påslaget för en alarmtyp tar botten automatiskt bort notifikationer från Discord efter att händelsen löper ut:

  • Pokemon — Tas bort när spawnet despawnar
  • Raids — Tas bort när raiden slutar
  • Eggs — Tas bort när ägget kläcks
  • Quests — Tas bort när quests återställs vid midnatt
  • Invasions — Tas bort när grunten lämnar
  • Lures — Tas bort när luren löper ut
  • Nests — Tas bort när bon migrerar
  • Gyms — Tas bort efter gymändringar
  • Fort Changes — Tas bort efter att fortändringsnotifikation löper ut
  • Max Battles — Tas bort när striden slutar

Använd Aktivera alla eller Avaktivera alla för att växla allt på en gång.

💡
Rekommenderat: Håll städningsläge aktiverat för att förhindra att föråldrade alarmer hopar sig i dina DM.
", - "CONTENT_APPEARANCE": "

Mörkt / Ljust läge

Klicka på sol/måne-ikonen i översta verktygsfältet för att växla mellan mörkt och ljust tema. Ditt val sparas automatiskt.

\"Verktygsfält

Accentfärger

Öppna användarmenyn (din avatar uppe till höger) och välj Accenttema. Välj bland:

  • Standard — Blå
  • Pokemon — Grön
  • Raids — Röd
  • Mystic — Blå
  • Valor — Röd
  • Instinct — Gul

Accentfärgen ändrar verktygsfältets gradient, aktiv navigationsmarkering och andra UI-accenter över hela sidan.

\"Dashboard

Språk

Om tillgängligt, använd språkväljaren i verktygsfältet för att byta gränssnittspråk. 18 språk stöds.

Tangentbordsgenvägar

?Visa tangentbordsgenvägar
EscStäng menyer eller dialoger
[Fäll ihop sidopanelen
]Expandera sidopanelen
", - "CONTENT_ALERTS_LOGOUT": "\"Användarmeny

Pausa alarm

Öppna användarmenyn (din avatar) och klicka på Pausa alarm. En röd banner visas överst på sidan som bekräftar att dina alarm är pausade. Du får inga notifikationer medan de är pausade.

För att återuppta, klicka på Återuppta alarm från användarmenyn eller bannern.

Logga ut

Öppna användarmenyn och klicka på Logga ut. Du skickas tillbaka till inloggningssidan.

", - "CONTENT_FAQ": "

\"Jag kan inte logga in\"

Du måste registrera dig hos Poracle-botten på Discord eller Telegram innan du kan logga in på den här sidan. Om du ser \"Ditt konto är inte registrerat\", kontakta din community-admin för registreringsinstruktioner.

\"Jag får inga notifikationer\"

Kontrollera dessa vanliga orsaker:

  1. Alarm pausade — Leta efter en röd banner överst på sidan. Återuppta alarm från användarmenyn.
  2. Ingen plats inställd — Om dina alarm använder avståndsläge behöver du en sparad plats.
  3. Inga områden valda — Om dina alarm använder områdesläge, se till att du har valt områden på Områdessidan.
  4. Fel profil — Du kanske har alarm på en annan profil. Kontrollera vilken profil som är aktiv på Dashboarden.
  5. För strikta filter — Försök lätta på dina IV-, CP- eller nivåfilter för att se om notifikationer börjar komma.

\"Mina alarm har försvunnit\"

Alarm är profilspecifika. Om du bytte profil finns dina alarm från den andra profilen fortfarande kvar — växla bara tillbaka från Dashboarden eller Profilsidan.

\"Jag kan inte klicka på ett litet område på kartan\"

När områden överlappar, zooma in för att göra det mindre området lättare att klicka på. Mindre områden är alltid ovanpå större.

\"Vad gör städningsläge?\"

Städningsläge säger åt botten att automatiskt ta bort en notifikation från Discord efter att händelsen löper ut (t.ex. en Pokemon despawnar). Utan det stannar gamla alarmer i dina DM för evigt. Aktivera det på Städningssidan eller per alarm i Leveransfliken.

\"Vad är skillnaden mellan Områden och Avstånd?\"

Varje alarm använder ett leveransläge. Områden meddelar dig om händelser i specifika geografiska zoner. Avstånd meddelar dig om händelser inom en radie från din sparade plats. Du kan blanda båda över olika alarm.

" + "CONTENT_QUICK_PICKS": "\"Quick

Quick Picks är färdiga alarmmallar skapade av din communitys administratörer. De låter dig konfigurera vanliga alarmuppsättningar med ett klick istället för att skapa varje alarm individuellt.

Tillämpa ett Quick Pick

  1. Gå till Quick Picks i sidopanelen.
  2. Bläddra bland de tillgängliga valen, eventuellt filtrerat efter kategori.
  3. Klicka på Tillämpa på det Quick Pick du vill ha.
  4. Anpassa innan du tillämpar: bestäm var aviseringarna ska nå dig — Leverans-fliken är samma väljare med tre alternativ som ett enskilt alarm, så du kan rikta dem mot en sparad plats eller en delmängd områden — aktivera städningsläge och uteslut eventuellt specifika Pokemon.
  5. Bekräfta för att skapa alla alarm på en gång.

Ta bort Quick Pick-alarm

Om du inte längre vill ha alarm från ett Quick Pick, klicka på Ta bort för att radera alla alarm det skapade.

", + "CONTENT_PROFILES": "

Profilsidan är ditt samlade nav för att hantera profiler och se alla alarm över alla profiler på ett ställe.

Varför använda profiler?

Profiler låter dig underhålla helt separata alarmkonfigurationer. Varje profil har sin egen uppsättning alarm, valda områden, plats och anpassade geofence-aktiveringar. Användbart för olika situationer — till exempel en \"Hem\"-profil för ditt kvarter och en \"Jobb\"-profil för runt ditt kontor.

Översikt

Sidan visar en statistikrad med totala alarmantal per typ, ett sökfält för att filtrera över alla profiler, och typfilterchips för att bara visa specifika alarmtyper (Pokemon, Raids, Quests etc.).

Varje profil visas som en expanderbar panel. Klicka för att expandera och se alla alarm grupperade efter typ, med spelgrafik (Pokemon-sprites, raid-ägg, lure-ikoner) och filteretiketter som visar IV, CP, Nivå, PVP och andra inställningar.

Hantera profiler

  • Skapa — Klicka på +-knappen uppe till höger. Profilnamn måste vara unika (upp till 32 tecken).
  • Växla — Klicka på Växla inuti en profilpanel för att göra den till din aktiva profil. Din aktiva profil är markerad med ett grönt märke och vänster kant.
  • Redigera — Klicka på pennikonen för att byta namn på en profil.
  • Ta bort — Klicka på papperskorgsikonen för att ta bort en profil och alla dess alarm. Du kan inte ta bort din aktiva profil.

Duplicera

Klicka på kopieringsikonen på en profil för att skapa en exakt kopia med alla dess alarm. Du ombeds namnge den nya profilen — ett standardnamn som \"Profil (Kopia)\" föreslås. Kopian innehåller alla larmfilter, och dess områden, plats och aktiva timmar kopieras också från källprofilen.

Export & Import

  • Export — Klicka på nedladdningsikonen på en profil för att spara en säkerhetskopia (JSON). Filen innehåller alla alarmfilter, rensade från interna ID:n så den är portabel.
  • Import — Klicka på Import-knappen uppe till höger, välj en säkerhetskopia och välj ett namn för den nya profilen. Alla alarm från säkerhetskopian återställs. Om en profil med samma namn finns läggs ett nummersuffix till automatiskt.

Dubblettdetektering

Om samma alarm finns på flera profiler (t.ex. följer Pikachu på både \"Hem\" och \"Jobb\"), markeras dessa alarm med en orange kant och en kopieringsikon. När dubbletter finns visas ett Dubbletter-filterchip i filterraden — klicka på det för att bara visa duplicerade alarm över profiler.

⚠️
Varning: Att ta bort en profil tar permanent bort alla alarm i den profilen. Du kan inte ta bort din aktuellt aktiva profil. Överväg att exportera en säkerhetskopia först.
", + "CONTENT_CLEANING": "\"Städningssida

Städningssidan låter dig styra städningsläge över alla dina alarmtyper på en gång.

När städningsläge är påslaget för en alarmtyp tar botten automatiskt bort notifikationer från Discord efter att händelsen löper ut:

  • Pokemon — Tas bort när spawnet despawnar
  • Raids — Tas bort när raiden slutar
  • Eggs — Tas bort när ägget kläcks
  • Quests — Tas bort när quests återställs vid midnatt
  • Invasions — Tas bort när grunten lämnar
  • Lures — Tas bort när luren löper ut
  • Nests — Tas bort när bon migrerar
  • Gyms — Tas bort efter gymändringar
  • Max Battles — Tas bort när striden slutar

Använd Aktivera alla eller Avaktivera alla för att växla allt på en gång.

💡
Rekommenderat: Håll städningsläge aktiverat för att förhindra att föråldrade alarmer hopar sig i dina DM.
", + "CONTENT_APPEARANCE": "

Mörkt / Ljust läge

Klicka på sol/måne-ikonen i översta verktygsfältet för att växla mellan mörkt och ljust tema. Ditt val sparas automatiskt.

\"Verktygsfält

Accentfärger

Öppna användarmenyn (din avatar uppe till höger) och välj Accenttema. Välj bland:

  • Standard — Blå
  • Pokemon — Grön
  • Raids — Röd
  • Mystic — Blå
  • Valor — Röd
  • Instinct — Gul

Accentfärgen ändrar verktygsfältets gradient, aktiv navigationsmarkering och andra UI-accenter över hela sidan.

\"Dashboard

Visningsspråk

Öppna användarmenyn (din avatar uppe till höger) och välj Visningsspråk. Det finns 11 språk. Det ändrar texten på den här webbplatsen och även Pokemon-namn, -typer och -former i väljarna och på dina alarmkort. Har du aldrig valt något får du webbläsarens språk, eller det som din Poracle-server är inställd på.

Aviseringsspråk

Direkt under ligger Aviseringsspråk, en separat inställning. Den styr vilket språk Poracle skriver dina DM på. De två är oberoende: en svensk webbplats med engelska DM, eller tvärtom, är helt normalt. Den låg tidigare på Områden-sidan.

Tangentbordsgenvägar

?Visa tangentbordsgenvägar
EscStäng menyer eller dialoger
[Fäll ihop sidopanelen
]Expandera sidopanelen
", + "CONTENT_ALERTS_LOGOUT": "\"Användarmeny

Pausa alarm

Öppna användarmenyn (din avatar) och klicka på Pausa alarm. En röd banner visas överst på sidan som bekräftar att dina alarm är pausade. Du får inga notifikationer medan de är pausade.

För att återuppta, klicka på Återuppta alarm från användarmenyn eller bannern.

Logga ut

Öppna användarmenyn och klicka på Logga ut. Du skickas tillbaka till inloggningssidan.

Om du loggade in via en SSO-leverantör som stöder single logout erbjuder menyn även Logga ut överallt — det avslutar din session hos leverantören också, inte bara här.

", + "CONTENT_FAQ": "

\"Jag kan inte logga in\"

Du måste registrera dig hos Poracle-botten på Discord eller Telegram innan du kan logga in på den här sidan. Om du ser \"Ditt konto är inte registrerat\", kontakta din community-admin för registreringsinstruktioner.

\"Jag får inga notifikationer\"

Kontrollera dessa vanliga orsaker:

  1. Alarm pausade — Leta efter en röd banner överst på sidan. Återuppta alarm från användarmenyn.
  2. Ingen plats inställd — Ett alarm som når dig inom en radie mäter från din plats eller från en sparad plats. Ange en på sidan Områden och platser.
  3. Inget inom räckhåll — Titta på chippen på alarmkortet. Den säger var alarmet når dig, och den kan peka på områden som din profil inte längre täcker.
  4. Fel profil — Du kanske har alarm på en annan profil. Kontrollera vilken profil som är aktiv på Dashboarden.
  5. För strikta filter — Försök lätta på dina IV-, CP- eller nivåfilter för att se om notifikationer börjar komma.

\"Mina alarm har försvunnit\"

Alarm är profilspecifika. Om du bytte profil finns dina alarm från den andra profilen fortfarande kvar — växla bara tillbaka från Dashboarden eller Profilsidan.

\"Jag kan inte klicka på ett litet område på kartan\"

När områden överlappar, zooma in för att göra det mindre området lättare att klicka på. Mindre områden är alltid ovanpå större.

\"Vad gör städningsläge?\"

Städningsläge säger åt botten att automatiskt ta bort en notifikation från Discord efter att händelsen löper ut (t.ex. en Pokemon despawnar). Utan det stannar gamla alarmer i dina DM för evigt. Aktivera det på Städningssidan eller per alarm i Leveransfliken.

\"Var når en avisering mig?\"

Varje alarm svarar på det själv, i sin Leverans-flik. Var som helst i mina områden följer områdena som din profil har valt. Nära en punkt är en radie från din plats eller från en sparad plats. Endast i valda områden begränsar just det alarmet till en delmängd områden. Chippen på kortet visar alltid det aktuella svaret, och ett klick ändrar det.

" }, "AUTH": { "SITE_TITLE_DEFAULT": "DM-larm", @@ -1074,38 +1202,40 @@ "SIGN_IN": "Logga in", "SIGN_IN_DESC": "Logga in för att hantera dina Pokemon GO-notifikationslarm.", "SIGN_IN_DISCORD": "Logga in med Discord", - "SIGN_IN_TELEGRAM": "Sign in with Telegram", - "PROVIDER_DISABLED_BY_ADMIN": "This login method has been disabled by an administrator.", - "PROVIDER_DISABLED_HINT": "This login method is currently disabled for non-admin users.", - "ERR_TELEGRAM_DISABLED": "Telegram login is currently disabled.", + "SIGN_IN_TELEGRAM": "Logga in med Telegram", + "PROVIDER_DISABLED_BY_ADMIN": "Den här inloggningsmetoden har stängts av av en administratör.", + "PROVIDER_DISABLED_HINT": "Den här inloggningsmetoden är avstängd för icke-administratörer.", + "ERR_TELEGRAM_DISABLED": "Inloggning med Telegram är för närvarande avstängd.", "OR": "eller", "NO_METHODS": "Inga inloggningsmetoder är aktiverade just nu. Kontakta en administratör.", "AUTHENTICATING": "Autentiserar...", "FOOTER": "Hantera larm för Pokemon, Raids, Quests och mer", "AUTH_FAILED": "Autentisering misslyckades", "BACK_TO_LOGIN": "Tillbaka till inloggning", - "ERR_DISCORD_DISABLED": "Discord login is currently disabled.", - "ERR_DISCORD_FETCH": "Could not retrieve your Discord profile. Please try again.", - "ERR_MISSING_CODE": "Discord authentication was cancelled or failed.", - "ERR_MISSING_ROLE": "You do not have the required Discord role to access this site.", - "ERR_NOT_IN_GUILD": "You must be a member of the Discord server to access this site.", - "ERR_NOT_REGISTERED": "Your account is not registered. Please sign up to get started.", - "ERR_ROLE_CHECK_FAILED": "Unable to verify your Discord roles. Please try again later.", - "ERR_TELEGRAM_FAILED": "Telegram authentication failed. Please try again.", - "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", + "ERR_DISCORD_DISABLED": "Inloggning med Discord är för närvarande avstängd.", + "ERR_DISCORD_FETCH": "Din Discord-profil kunde inte hämtas. Försök igen.", + "ERR_MISSING_CODE": "Inloggningen med Discord avbröts eller misslyckades.", + "ERR_MISSING_ROLE": "Du har inte den Discord-roll som krävs för den här sidan.", + "ERR_NOT_IN_GUILD": "Du måste vara medlem i Discord-servern för att använda sidan.", + "ERR_NOT_REGISTERED": "Ditt konto är inte registrerat. Registrera dig för att komma igång.", + "ERR_ROLE_CHECK_FAILED": "Dina Discord-roller kunde inte verifieras. Försök igen senare.", + "ERR_TELEGRAM_FAILED": "Inloggningen med Telegram misslyckades. Försök igen.", + "ERR_TOKEN_EXCHANGE": "Inloggningen med Discord misslyckades. Försök igen.", "ERR_GENERIC": "Autentiseringsfel: {{error}}", "ERR_NO_TOKEN": "Ingen autentiseringstoken mottagen.", - "SIGN_UP": "Sign Up", - "SIGN_UP_DESC": "Don't have an account? Sign up to get started." + "SIGN_IN_OIDC": "Logga in med {{provider}}", + "SIGNED_OUT_TITLE": "Utloggad", + "SIGNED_OUT_DESC": "Du har loggats ut från DM Alerts.", + "ERR_OIDC_DISABLED": "Extern inloggning är för närvarande inaktiverad.", + "ERR_OIDC_NO_IDENTITY": "Din externa inloggningsleverantör returnerade inget konto som vi kan matcha. Kontrollera att ditt Discord-konto är länkat.", + "ERR_OIDC_TOKEN_EXCHANGE": "Extern inloggning misslyckades. Försök igen.", + "ERR_OIDC_USERINFO": "Kunde inte hämta din profil från den externa inloggningsleverantören. Försök igen.", + "SIGN_UP": "Registrera dig", + "SIGN_UP_DESC": "Har du inget konto? Registrera dig för att komma igång.", + "SIGN_IN_AGAIN": "Logga in igen" }, "ERROR": { - "SESSION_EXPIRED": "Session expired. Please log in again.", - "PERMISSION_DENIED": "You don't have permission for this action.", - "FEATURE_DISABLED": "This feature has been disabled by the administrator.", - "NOT_FOUND": "The requested resource was not found.", - "NETWORK": "Network error. Check your connection.", - "GENERIC": "Something went wrong. Please try again.", - "SERVER_UNAVAILABLE": "Server is temporarily unavailable." + "FEATURE_DISABLED": "Den här funktionen har inaktiverats av administratören." }, "ADMIN": { "USERS_TITLE": "Användarhantering", @@ -1160,6 +1290,8 @@ "APPROVAL_PROMOTED_NAME": "Befordrat namn", "APPROVAL_PROMOTED_NAME_PLACEHOLDER": "Namn för den befordrade geofencen", "APPROVAL_PROMOTED_NAME_HINT": "Valfritt. Använder det nuvarande visningsnamnet som standard.", + "APPROVAL_PROMOTED_NAME_TOO_LONG": "Must be 50 characters or fewer.", + "APPROVAL_PROMOTED_NAME_INVALID": "Only letters, numbers, spaces and - ' . ( ) & are allowed.", "APPROVAL_REJECT_REASON": "Anledning till avvisning", "APPROVAL_REJECT_PLACEHOLDER": "Förklara varför denna geofence avvisas...", "USERS_DESC_FULL": "Hantera registrerade Discord-användare. Stoppad = användaren pausade larm eller nådde hastighetsgränser. Blockerad = hårdblockerad av admin.", @@ -1255,9 +1387,28 @@ "SNACK_FAILED_APPROVE": "Kunde inte godkänna inskickning", "SNACK_APPROVED": "\"{{name}}\" godkänd", "SNACK_FAILED_REJECT": "Kunde inte avvisa inskickning", - "SNACK_REJECTED": "\"{{name}}\" avvisad" + "SNACK_REJECTED": "\"{{name}}\" avvisad", + "APPROVAL_REGION_HINT": "Välj den region som detta geostängsel ska visas under.", + "SERVER_TITLE": "Poracle-server", + "SERVER_REFRESH": "Kontrollera igen", + "SERVER_VERSION": "Version", + "SERVER_SCHEMA": "Databasschema", + "SERVER_CHECKED": "Senast kontrollerad", + "SERVER_CAPABILITIES": "Funktioner", + "SERVER_NO_CAPABILITIES": "Den här servern rapporterar inga.", + "SERVER_UNKNOWN": "Okänd", + "SERVER_UNREACHABLE": "Poracle svarade inte. Larm, profiler och platser går via den och kommer att misslyckas tills den svarar.", + "SERVER_TOO_OLD": "Poracle {{version}} är äldre än {{minimum}}, som den här versionen av webbplatsen kräver. Räckvidd per larm, PVP-megafiltret och filtret för återstående tid kommer att se ut att sparas utan att ändra något.", + "UPDATE_AVAILABLE": "{{name}} {{running}} körs, och {{latest}} har släppts.", + "UPDATE_PRERELEASE": "{{name}} {{running}} är nyare än någon release — det här är ett utvecklingsbygge.", + "VERSIONS_TITLE": "Versioner", + "VERSIONS_WEB": "Den här webbplatsen", + "VERSIONS_BUILD": "Bygge", + "UPDATE_CURRENT": "Uppdaterad.", + "UPDATE_UNCOMPARABLE": "Utvecklingskanal. Senaste släppet är {{latest}}." }, "DIALOG": { + "LOCATION_PICK_TITLE": "Välj en punkt", "CANCEL": "Avbryt", "CONFIRM": "Bekräfta", "DONT_ASK_AGAIN": "Fråga inte igen under denna session", @@ -1273,6 +1424,7 @@ "DISTANCE_TITLE": "Uppdatera alla avstånd", "DISTANCE_DESC": "Ange platsläge för alla larm av denna typ.", "DISTANCE_UPDATE_ALL": "Uppdatera alla", + "DISTANCE_MUST_BE_POSITIVE": "Avståndet måste vara större än noll.", "LOCATION_SAVE_ERROR": "Kunde inte uppdatera plats", "LOCATION_SAVE_SUCCESS": "Plats uppdaterad", "LOCATION_GEO_UNSUPPORTED": "Geolokalisering stöds inte av din webbläsare", @@ -1284,10 +1436,10 @@ "ERROR_RATE_LIMIT": "För många testlarm. Vänta en stund.", "ERROR_NOT_FOUND": "Larm hittades inte — det kan ha raderats.", "ERROR_GENERIC": "Kunde inte skicka testlarm. Försök igen senare.", - "RATE_LIMITED": "Too many test alerts. Please wait a moment.", - "NOT_FOUND": "Alarm not found — it may have been deleted.", - "UNSUPPORTED": "Test alerts are not supported for this alarm type.", - "FAILED": "Failed to send test alert. Try again later." + "RATE_LIMITED": "För många testaviseringar. Vänta ett ögonblick.", + "NOT_FOUND": "Aviseringen hittades inte — den kan ha tagits bort.", + "UNSUPPORTED": "Testaviseringar stöds inte för den här typen.", + "FAILED": "Testaviseringen kunde inte skickas. Försök igen senare." }, "COMMON": { "CANCEL": "Avbryt", @@ -1296,6 +1448,7 @@ "EDIT": "Redigera", "ADD": "Lägg till", "OK": "OK", + "UNDO": "Ångra", "CONFIRM": "Bekräfta", "DELETE_ALL": "Radera alla", "CLOSE": "Stäng", @@ -1360,7 +1513,8 @@ "GYM_PICKER": { "SEARCH_LABEL": "Sök efter ett gym (valfritt)", "SEARCH_HINT": "Skriv gym-namn...", - "CLEAR_ARIA": "Rensa gym-val" + "CLEAR_ARIA": "Rensa gym-val", + "RATE_LIMITED": "För många skannerförfrågningar — sakta ner lite." }, "DELIVERY_PREVIEW": { "AREAS_LABEL": "Notiser skickas för dessa områden:", @@ -1392,12 +1546,10 @@ "GROUP_ALARM_TYPES": "Larmtyper", "GROUP_FEATURES": "Funktioner", "GROUP_ADMINISTRATION": "Administration", - "GROUP_COMMANDS": "Kommandon", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", - "GROUP_MAPS_ASSETS": "Kartor & resurser", + "GROUP_OIDC": "Extern SSO", "GROUP_ANALYTICS_LINKS": "Analys & länkar", - "GROUP_DEBUG": "Felsökning", "GROUP_ICON_REPO": "Ikon-repository", "GROUP_OTHER": "Övrigt", "CUSTOM_TITLE_LABEL": "Webbplatstitel", @@ -1411,52 +1563,51 @@ "FAVICON_URL_PREVIEW": "Favicon-förhandsvisning (32×32)", "FAVICON_URL_CACHE_WARNING": "Webbläsare cachar favicons aggressivt. Efter att du sparat måste användare rensa webbläsarens cache eller göra en hård omladdning (Ctrl+F5 / Cmd+Shift+R) för att se den nya ikonen.", "FAVICON_URL_CSP_NOTE": "Om din webbplats använder en Content Security Policy måste favicon-URL:ens ursprung tillåtas av ditt img-src-direktiv; annars blockerar webbläsaren hämtningen och återgår till standardikonen.", + "FORCED_BY_PORACLE": "Inaktiverat i Poracles egen konfiguration. Poracle kastar dessa webhooks och boten avvisar kommandot, så detta kan inte aktiveras här.", + "FORCED_BY_PORACLE_TOOLTIP": "Styrs av Poracles konfiguration, inte av den här sidan.", "CUSTOM_PAGE_NAME_LABEL": "Etikett för navigeringslänk", "CUSTOM_PAGE_NAME_DESC": "Etikett för den anpassade navigeringslänken (t.ex. \"Tillbaka till kartan\").", "CUSTOM_PAGE_URL_LABEL": "URL för navigeringslänk", "CUSTOM_PAGE_URL_DESC": "URL som den anpassade navigeringslänken pekar på.", "CUSTOM_PAGE_ICON_LABEL": "Ikon för navigeringslänk", "CUSTOM_PAGE_ICON_DESC": "FontAwesome-klass för navigeringslänkens ikon (t.ex. \"fas fa-map\").", - "DISABLE_MONS_LABEL": "Inaktivera Pokémon", - "DISABLE_MONS_DESC": "Dölj hantering av Pokémon-larm för alla användare.", - "DISABLE_RAIDS_LABEL": "Inaktivera Raider", - "DISABLE_RAIDS_DESC": "Dölj hantering av Raid-larm för alla användare.", - "DISABLE_QUESTS_LABEL": "Inaktivera Uppdrag", - "DISABLE_QUESTS_DESC": "Dölj hantering av uppdragslarm för alla användare.", - "DISABLE_INVASIONS_LABEL": "Inaktivera Invasioner", - "DISABLE_INVASIONS_DESC": "Dölj hantering av invasionslarm för alla användare.", - "DISABLE_LURES_LABEL": "Inaktivera Lockbeten", - "DISABLE_LURES_DESC": "Dölj hantering av lockbete-larm för alla användare.", - "DISABLE_NESTS_LABEL": "Inaktivera Bon", - "DISABLE_NESTS_DESC": "Dölj hantering av bo-larm för alla användare.", - "DISABLE_GYMS_LABEL": "Inaktivera Gym", - "DISABLE_GYMS_DESC": "Dölj hantering av gymlarm för alla användare.", - "DISABLE_FORT_CHANGES_LABEL": "Inaktivera fort-ändringar", - "DISABLE_FORT_CHANGES_DESC": "Dölj hantering av fort-ändringslarm för alla användare.", - "DISABLE_MAXBATTLES_LABEL": "Inaktivera Max Battles", - "DISABLE_MAXBATTLES_DESC": "Dölj hantering av Max Battle-larm för alla användare.", - "DISABLE_AREAS_LABEL": "Inaktivera områden", - "DISABLE_AREAS_DESC": "Hindra användare från att hantera sina områdesprenumerationer.", - "DISABLE_PROFILES_LABEL": "Inaktivera profiler", - "DISABLE_PROFILES_DESC": "Hindra användare från att skapa och växla mellan larmprofiler.", - "DISABLE_LOCATION_LABEL": "Inaktivera plats", - "DISABLE_LOCATION_DESC": "Hindra användare från att ange en hemplats.", - "DISABLE_NOMINATIM_LABEL": "Inaktivera geokodning", - "DISABLE_NOMINATIM_DESC": "Inaktivera Nominatim-adressökning för platsval.", - "DISABLE_GEOMAP_LABEL": "Inaktivera kartvy", - "DISABLE_GEOMAP_DESC": "Dölj den interaktiva geofence-kartan helt.", - "DISABLE_GEOMAP_SELECT_LABEL": "Inaktivera områdesval på karta", - "DISABLE_GEOMAP_SELECT_DESC": "Hindra användare från att välja områden genom att klicka på kartan.", - "ENABLE_TEMPLATES_LABEL": "Aktivera mallar", + "DISABLE_MONS_LABEL": "Pokémon", + "DISABLE_MONS_DESC": "Låt användare hantera Pokémon-larm.", + "DISABLE_RAIDS_LABEL": "Raider", + "DISABLE_RAIDS_DESC": "Låt användare hantera Raid-larm.", + "DISABLE_QUESTS_LABEL": "Uppdrag", + "DISABLE_QUESTS_DESC": "Låt användare hantera uppdragslarm.", + "DISABLE_INVASIONS_LABEL": "Invasioner", + "DISABLE_INVASIONS_DESC": "Låt användare hantera invasionslarm.", + "DISABLE_LURES_LABEL": "Lockbeten", + "DISABLE_LURES_DESC": "Låt användare hantera lockbete-larm.", + "DISABLE_NESTS_LABEL": "Bon", + "DISABLE_NESTS_DESC": "Låt användare hantera bo-larm.", + "DISABLE_GYMS_LABEL": "Gym", + "DISABLE_GYMS_DESC": "Låt användare hantera gymlarm.", + "DISABLE_FORT_CHANGES_LABEL": "Fort-ändringar", + "DISABLE_FORT_CHANGES_DESC": "Låt användare hantera fort-ändringslarm.", + "DISABLE_MAXBATTLES_LABEL": "Max Battles", + "DISABLE_MAXBATTLES_DESC": "Låt användare hantera Max Battle-larm.", + "DISABLE_AREAS_LABEL": "Områden", + "DISABLE_AREAS_DESC": "Låt användare hantera sina områdesprenumerationer.", + "DISABLE_PROFILES_LABEL": "Profiler", + "DISABLE_PROFILES_DESC": "Låt användare skapa och växla mellan larmprofiler.", + "DISABLE_LOCATION_LABEL": "Plats", + "DISABLE_LOCATION_DESC": "Låt användare ange en hemplats.", + "DISABLE_NOMINATIM_LABEL": "Geokodning", + "DISABLE_NOMINATIM_DESC": "Tillåt Nominatim-adressökning för platsval.", + "DISABLE_USER_GEOFENCES_LABEL": "Egna geofences", + "DISABLE_USER_GEOFENCES_DESC": "Låt användare rita, importera och skicka in egna geofences. Befintliga geofences fortsätter att fungera.", + "ENABLE_TEMPLATES_LABEL": "Mallar", "ENABLE_TEMPLATES_DESC": "Låt användare välja mallar för notifieringsmeddelanden.", "ALLOWED_LANGUAGES_LABEL": "Tillåtna UI-språk", "ALLOWED_LANGUAGES_DESC": "Kommaseparerade språkkoder som ska visas i språkväljaren (t.ex. \"en,de,fr,es\"). Lämna tomt för att visa alla 11 språk.", + "PORACLE_LOCALE_HINT": "Standardspråk för nya användare: {{locale}}, hämtat från Poracles egen konfiguration. Den som väljer ett språk, eller vars webbläsare ber om ett som den här webbplatsen har, får det i stället.", "ENABLE_ROLES_LABEL": "Aktivera rollbaserad åtkomst", "ENABLE_ROLES_DESC": "Tillåt endast användare med specifika Discord-roller att logga in. Kräver Bot Token och Guild ID.", "ALLOWED_ROLE_IDS_LABEL": "Tillåtna roll-ID:n", - "ALLOWED_ROLE_IDS_DESC": "Kommaseparerade Discord-roll-ID:n som ger åtkomst (t.ex. \"123456789,987654321\"). Lämna tomt för att tillåta alla.", - "ADMIN_ALLOWED_LANGUAGES_LABEL": "Tillåtna språk", - "ADMIN_ALLOWED_LANGUAGES_DESC": "Kommaseparerad lista över språkkoder som användare kan välja (t.ex. \"en,de,fr\").", + "ALLOWED_ROLE_IDS_DESC": "Kommaseparerade Discord-roll-ID:n, t.ex. 123456789,987654321. En användare behöver minst en av dessa roller för att logga in. Lämna tomt för att tillåta alla.", "REGISTER_COMMAND_LABEL": "Registreringskommando", "REGISTER_COMMAND_DESC": "Poracle-bot-kommando som användare kör för att registrera sig (t.ex. \"$!register\").", "LOCATION_COMMAND_LABEL": "Platskommando", @@ -1464,9 +1615,30 @@ "ENABLE_TELEGRAM_LABEL": "Aktivera Telegram-inloggning", "ENABLE_TELEGRAM_DESC": "Tillåt Telegram-inloggning på denna webbplats. Kräver TELEGRAM_ENABLED=true, bot token och bot username i .env (serveromstart krävs efter .env-ändringar).", "TELEGRAM_BOT_LABEL": "Bot-användarnamn", - "TELEGRAM_BOT_DESC": "Telegram-bot-användarnamn (utan @).", + "TELEGRAM_BOT_DESC": "Telegram-bot-användarnamn (utan @). Används när TELEGRAM_BOT_USERNAME inte är konfigurerat.", "ENABLE_DISCORD_LABEL": "Aktivera Discord-inloggning", "ENABLE_DISCORD_DESC": "Tillåt Discord-inloggning på denna webbplats. Kräver Discord Client ID och Client Secret i .env (serveromstart krävs efter .env-ändringar). Påverkar inte PoracleNG-bot-leverans.", + "ENABLE_OIDC_LABEL": "Aktivera extern SSO-inloggning", + "ENABLE_OIDC_DESC": "Tillåt inloggning via den konfigurerade externa OIDC/OAuth2-leverantören. Kräver OIDC_*-inställningar (leverantörs-URL:er, client ID och secret) i .env (serveromstart krävs efter .env-ändringar).", + "AUTH_MODE_OIDC": "SSO (OIDC)", + "AUTH_MODE_OIDC_DESC": "Alla användare omdirigeras till den externa SSO-leverantören. Lokal inloggning förbigås.", + "AUTH_MODE_SWITCH_CONFIRM": "Byt till SSO", + "AUTH_MODE_OIDC_CONFIRM_TITLE": "Byta till SSO-inloggning?", + "AUTH_MODE_OIDC_CONFIRM_MSG": "Efter att du sparat omdirigeras alla användare (inklusive administratörer) till {{provider}} för att logga in — den lokala Discord/Telegram-inloggningssidan förbigås. Om leverantören är onåbar kan du bli utelåst; återställ genom att sätta AUTH_FORCE_LOCAL=true i serverns miljö.", + "AUTH_OIDC_NOT_CONFIGURED": "SSO är otillgängligt tills OIDC-leverantören har konfigurerats i serverns miljö (OIDC_*-miljövariabler).", + "AUTH_OIDC_HIDES_LOCAL": "Discord och Telegram döljs medan SSO är det aktiva inloggningsläget.", + "AUTH_SLO_LABEL": "Enkel utloggning", + "AUTH_SLO_DESC": "När detta är aktiverat avslutar \"Logga ut överallt\" även leverantörens session (inte bara denna webbplats). Kräver leverantörens end-session-endpoint (OIDC_END_SESSION_URL).", + "AUTH_SLO_UNAVAILABLE": "Enkel utloggning är otillgänglig tills leverantörens end-session-endpoint har konfigurerats (miljövariabeln OIDC_END_SESSION_URL).", + "OIDC_SERVER_CONFIG": "Konfiguration av OIDC-leverantör", + "OIDC_PROVIDER_LABEL": "Leverantörsnamn", + "OIDC_AUTHORIZATION_URL_LABEL": "Authorization URL", + "OIDC_TOKEN_URL_LABEL": "Token URL", + "OIDC_USERINFO_URL_LABEL": "UserInfo URL", + "OIDC_CLIENT_ID_LABEL": "Client ID", + "OIDC_SCOPES_LABEL": "Scopes", + "OIDC_IDENTITY_CLAIM_LABEL": "Identitetsanspråk", + "OIDC_USE_PKCE_LABEL": "Använd PKCE", "PROVIDER_URL_LABEL": "URL för kartrutor", "PROVIDER_URL_DESC": "URL-mall för kartrute-leverantören (används för statiska kartor).", "GANALYTICSID_LABEL": "Google Analytics-ID", @@ -1498,7 +1670,22 @@ "DISCORD_ADMIN_IDS_LABEL": "Admin-ID:n", "DISCORD_ADMIN_IDS_DESC": "Discord-användar-ID:n med admin-åtkomst (maskerad).", "DISCORD_GEOFENCE_FORUM_LABEL": "Geofence-forumkanal", - "DISCORD_GEOFENCE_FORUM_DESC": "Discord-forumkanal för geofence-inlämningstrådar." + "DISCORD_GEOFENCE_FORUM_DESC": "Discord-forumkanal för geofence-inlämningstrådar.", + "SEARCH_PLACEHOLDER": "Sök inställningar…", + "SEARCH_CLEAR": "Rensa sökning", + "UNSAVED_CHANGES": "{{count}} osparade", + "SAVE_CHANGES": "Spara ändringar", + "DISCARD_CHANGES": "Ångra", + "COLLAPSE_SECTION": "Fäll ihop sektion", + "EXPAND_SECTION": "Expandera sektion", + "SUMMARY_ENABLED": "{{count}} av {{total}} aktiverade", + "GROUP_AUTH": "Autentisering", + "AUTH_MODE_LABEL": "Inloggningsläge", + "AUTH_MODE_LOCAL": "Lokal", + "AUTH_MODE_LOCAL_DESC": "Logga in direkt med Discord eller Telegram.", + "AUTH_FORCE_LOCAL_ACTIVE": "Lokal inloggning tvingas av serverkonfigurationen.", + "DISABLE_UPDATE_CHECK_LABEL": "Sök inte efter uppdateringar", + "DISABLE_UPDATE_CHECK_DESC": "Hindrar webbplatsen från att fråga GitHub om en nyare PoracleWeb eller Poracle har släppts. Det är den enda förfrågan utanför ditt eget nätverk och inget skickas med." }, "GEOFENCE_DETAIL": { "NAME": "Namn", @@ -1561,5 +1748,66 @@ "YOUR_LOCATION": "Din plats", "SELECTED_COUNT": "{{count}} valda:", "AREAS_SELECTED": "{{count}} område(n) valda" + }, + "ALERT_DEFAULTS": { + "TITLE": "Standardinställningar för aviseringar", + "DESC": "Välj hur nya aviseringar levereras som standard. Du kan fortfarande ändra detta för varje avisering när du skapar den.", + "DEFAULT_DISTANCE": "Standardavstånd", + "DEFAULT_DISTANCE_HINT": "Används för att förifylla radien för nya avståndsbaserade aviseringar.", + "FOOTNOTE": "Gäller endast nyskapade aviseringar — befintliga ändras inte.", + "DISTANCE_TOO_SMALL": "Måste vara minst 0,1 km.", + "DISTANCE_TOO_LARGE": "Får vara högst 100 km." + }, + "PAGINATOR": { + "ITEMS_PER_PAGE": "Objekt per sida:", + "RANGE": "{{start}} - {{end}} av {{total}}", + "RANGE_EMPTY": "0 av {{total}}", + "NEXT_PAGE": "Nästa sida", + "PREVIOUS_PAGE": "Föregående sida", + "FIRST_PAGE": "Första sidan", + "LAST_PAGE": "Sista sidan" + }, + "WHERE": { + "SET_PIN": "Ange din position", + "PIN_MISSING_WARNING": "Du har inte angett någon position än, så aviseringen har inget att mäta från.", + "PLACES_EMPTY_TITLE": "Inga platser än", + "PIN_UNSET": "Inte angiven", + "PLACES_PAGE_DESC": "Namngivna punkter som dina aviseringar kan riktas mot i stället för din position.", + "ADD_PLACE": "Lägg till en plats", + "AREAS_LABEL": "Områden", + "AREA_LIST_MORE": "{{areas}} och {{count}} till", + "MEASURED_FROM": "Mätt från", + "MY_PIN": "Min position", + "NAME_PLACE_MESSAGE": "Vad ska platsen heta?", + "NAME_PLACE_TITLE": "Namnge platsen", + "NEAR_PIN": "Inom {{distance}} km från min position", + "NEAR_PLACE": "Inom {{distance}} km från {{place}}", + "NO_PLACES": "Inga platser än. Lägg till en nedan för att rikta aviseringen någon annanstans än din position.", + "ONLY_IN": "Endast i {{areas}}", + "OPTION_AREAS": "Endast i valda områden", + "OPTION_NEAR": "Nära en punkt", + "OPTION_PLACE": "Nära en plats", + "OPTION_PROFILE": "Var som helst i mina områden", + "PIN_NOTE": "Reservvalet för varje avisering utan eget mål.", + "PIN_TITLE": "Min position", + "PLACES_EMPTY": "Lägg till en för att få aviseringar någon annanstans än vid din position: jobbet, gymmet, hos föräldrarna.", + "PLACES_TITLE": "Platser", + "PLACE_DELETED": "{{place}} borttagen.", + "PLACE_DELETE_CONFIRM": "Aviseringar som pekar på {{place}} återgår till din position.", + "PLACE_DELETE_ERROR": "Kunde inte ta bort platsen.", + "PLACE_DELETE_TITLE": "Ta bort platsen?", + "PLACE_IN_USE": "{{place}} används av {{count}} avisering(ar). Peka om dem först.", + "PLACE_LABEL": "Plats", + "PLACE_NAME": "Namn", + "PLACE_SAVED": "{{place}} sparad.", + "PLACE_SAVE_ERROR": "Kunde inte spara platsen.", + "PROFILE_ANYWHERE": "Överallt där jag får aviseringar", + "PROFILE_AREAS": "Var som helst i mina områden", + "RADIUS_KM": "Radie (km)", + "SAVE": "Ange var", + "SCOPE_SAVED": "Uppdaterat.", + "SCOPE_SAVE_ERROR": "Kunde inte uppdatera var aviseringen når dig.", + "SHEET_TITLE": "Var ska aviseringen nå dig?", + "USE_THIS_POINT": "Använd den här punkten" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts index 2da6115c..d0a4da50 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts @@ -1,4 +1,8 @@ +// Dev server runs Angular at :4200 with a proxy (see proxy.conf.json) that +// forwards /api/* and /auth/* to the local API on :8082. Empty `apiUrl` means +// all HTTP calls become same-origin from the browser's view — identical to +// the production single-port setup. export const environment = { - apiUrl: `http://${window.location.hostname}:5048`, + apiUrl: '', production: false, }; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/styles.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/styles.scss index ca8e8d63..dad4794e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/styles.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/styles.scss @@ -431,6 +431,13 @@ router-outlet + * { padding: 16px 24px !important; } +// Help screenshot viewer: the image is the dialog, so drop the surface chrome around it +.image-viewer-panel .mat-mdc-dialog-surface { + background: transparent; + box-shadow: none; + padding: 0; +} + // Geofence detail dialog: remove content max-height so the map container renders at full height .geofence-detail-dialog-panel .mat-mdc-dialog-content { max-height: none; diff --git a/CHANGELOG.md b/CHANGELOG.md index 304c582c..db2ce8e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,454 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.17.1] - 2026-08-21 + +### Changed + +- **A switched-off alarm type disappears completely.** For most of this release's development it stayed half-present -- reachable, listing what you already had, refusing to create more -- so that nobody was left holding rules they could not remove. In practice that put a locked, empty page in front of every user of an instance that never enabled the type, to serve the few who had one. Disabling a type now removes it: the sidebar item, the dashboard card, the page and every endpoint for it. Rules already stored are not deleted -- they cannot fire while the type is off, and they come back exactly as they were if you switch it on again. + + +## [2.17.0] - 2026-08-21 + +### Added + +- **A German server reads as a German site to someone arriving for the first time.** The display language fell back to English for everyone whose browser advertises a language this UI does not ship, which on a German or Italian deployment is most first-time visitors. Poracle's own `locale` was already fetched and projected to the browser and read by nothing; it is now the last fallback ahead of the hardcoded English, in both the display language and the language Poracle writes alerts in. A stored choice still wins, and browser detection still comes second — a French browser on a German server is better served in French — so the server's locale answers only for the visitors the browser cannot place. It has to pass the `allowed_languages` filter, so an admin who restricted the UI to English and French does not get a German default they excluded, and a locale PoracleNG has translations for but this UI does not (`ja`, `ru`, `zh-cn`) falls back to English rather than half-applying. The locale reaches the browser on the anonymous settings endpoint rather than through `/api/config`, which is `[Authorize]` and would have put a 401 on the login page again ([#770](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/770)). +- **The signed-out language menu honours the admin's language restriction.** `allowed_languages` was served only to authenticated sessions, so the login page offered all eleven languages however narrowly the site had been configured ([#770](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/770)). + +### Changed + +- **Quest summary schedules follow the same rule as the alarm types.** Turning quests off used to take the whole schedule with it -- unreadable and unclearable until someone turned quests back on. Now the schedule you already have can be opened and cleared; setting one, and sending one on demand, are refused. It was the last controller still doing it the old way. +- **Switching an alarm type off no longer hides the alarms people already have.** It used to take the whole page with it: rules a user had already created became invisible, and unremovable, until someone switched the type back on. That is the wrong way round -- an alarm of a disabled type can never fire, so deleting it is the one thing still worth doing, and when the type is disabled in Poracle rather than here its bot refuses the matching command too, which leaves this page as the only way to clean up. The page now stays reachable and says which side switched it off. Creating, editing, bulk distance changes and test alerts are gone; the list and both delete paths are untouched, and the nav item keeps a padlock so you know before you click. +### Documentation + +- **Webhooks and delegates have a page.** The feature existed in the docs only as a database table, so the questions it actually raises -- what a delegate may do, whether Poracle's config has to be edited, why someone has access in Discord but not here -- could only be answered by reading the source. It is written down now, including that managing a webhook on this site needs no Poracle config edit and managing it through the bot does. Also: what a quest summary schedule does once quests are switched off, and a troubleshooting entry for a delegate who cannot see their webhooks. +- **The guides describe how language actually works now.** The internationalization page said Pokemon names follow the alert language; they follow the display language, and the page now says so, along with the order a first-time visitor is resolved through and what happens on a Poracle that has no translation for their language. Site settings gains the Poracle-side disable flags, a screenshot of what a forced toggle looks like and why it cannot be switched on here, and a section on values that arrive on the settings response without being stored. Also two new troubleshooting entries -- English names, and a type that will not switch on -- and the in-app help guide updated in all eleven languages. + +### Removed + +- **The orphaned language selector component.** `shared/components/language-selector` stopped being rendered anywhere when the alert-language control moved into the user menu in 2.16.0, but it kept its own copy of the reconcile logic and its own default, so anyone changing how the alert language is chosen found two implementations and one of them lied. Its leftover styling rule went with it ([#774](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/774)). + +### Fixed + +- **The impersonation banner no longer tells a delegate to go "Back to Admin".** That wording dates from when only admins could view another account; a webhook delegate doing the same thing was pointed back to a place they have never been. It now reads "Back to your account", which is true for whoever is looking. +- **A new webhook delegate can reach their webhooks straight away.** Granting someone a delegate wrote the row immediately, but the *My Webhooks* nav item was drawn from a claim stamped into their sign-in token, so nothing appeared until that token refreshed -- up to a day later -- even though the page and the impersonation grant would both already have let them in. The same lag ran the other way on revoking: the item stayed until they next signed in, then led to an empty page. It is resolved live now, like the two checks behind it. If the lookup cannot complete, the token's older list is kept rather than dropping a delegate mid-session. +- **The admin page says which language Poracle is configured for.** A read-only line beside Allowed UI Languages names Poracle's own locale, since that is what a visitor gets when they have never chosen a language and their browser asks for one this site does not have. It sits there rather than in its own section because it is the setting it interacts with, and it cannot be edited -- it is Poracle's to set. +- **`poracle_locale` is no longer presented as something you can set.** It appeared in the admin page's Other section as an ordinary text box, but it is not a stored setting -- it is read from Poracle's own configuration so the site can default your display language. Saving it wrote a real row, and a real row wins, so one edit would have pinned the language default permanently and stopped the site tracking Poracle at all. The write is now refused outright, and the box is gone ([#780](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/780)). +- **Three admin settings sections that opened onto nothing.** Commands, Maps & Assets and Debug each rendered a header and a chevron over an empty panel. Maps & Assets was the residue of #452, which deleted the two toggles it held (`disable_geomap` and `disable_geomap_select`, legacy PoracleJS keys describing a map picker this app does not have) and left the shell behind; the other two never held anything. A group that declares no settings is no longer rendered, and a test now fails the build if one is added, since the way this recurs is someone removing a group's last setting and not noticing. +- **The login page no longer makes a call it cannot make.** Every visit while signed out fired `GET /api/location/language`, which requires a token, so it answered 401 and was thrown away. Nothing showed, because the error was swallowed two files further down -- the same shape as the login-page failure fixed in #426. The alert language is now reconciled only once there is a token to do it with, including immediately after a login completed without a page reload ([#775](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/775)). +- **A type disabled in Poracle is no longer offered here.** An operator who switched raids off in Poracle's `config.toml` got a bot that refused `!raid`, a processor that dropped every raid webhook, and a web UI that carried on drawing the Raids page and saving alarms that could never fire. PoracleWeb.NET now reads Poracle's own `disabledHooks` and treats it as a floor under the existing `disable_*` site settings: a type is off if either source says so, and Poracle's flags can only ever disable, never re-enable something an admin turned off here. Enforcement sits at the shared feature gate, so the controller filter, the service-layer guards, quick-pick apply, profile duplicate and import, and the cleaning toggle are all covered by the one check. Fort changes are read separately from `general.disable_fort_update`, because Poracle honours that flag in both the processor and the bot but leaves it out of the array. `pokestop` is deliberately mapped to nothing — it looks like the parent hook for lures, invasions and quests, but nothing upstream consumes it, so honouring it would take three working types away for a flag that currently does nothing. If Poracle is unreachable, or too old to report the flags at all, the site settings stay in sole charge rather than everything going dark. The admin settings page shows a forced toggle as off and locked, with a line saying where the decision came from ([#769](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/769)). +- **An automatically picked display language no longer pins itself forever.** The language chosen for you — by browser detection, or by falling through to English — was written to `localStorage` as though you had picked it, so the very first page load decided the answer for good. A visitor who arrived while Poracle was unreachable, and so landed on English, would have stayed on English however the server was configured afterwards. Only a language you choose from the menu is stored now; a detected or server-supplied one is re-decided on each visit ([#770](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/770)). +- **Pokemon names, types and forms follow the display language.** Setting the site to German left the selector listing Bulbasaur, Blastoise and Butterfree under English type chips, because the names never came from anything translatable: the API cached WatWowMap's English masterfile, and the browser fetched the same file from GitHub a second time for forms and types. PoracleNG already translates all three and serves them at `/api/masterdata/monsters`, which is where they now come from, per display language and re-fetched when you change it. The English masterfile stays as the fallback for a Poracle too old to serve that route, so nothing empties out. Type chips keep their English identity internally, since the uicons file names and the filter comparison both key on it, and only the label is translated. Items and moves stay English -- Poracle has no translated equivalent to call. Reported on Discord. + +## [2.16.0] - 2026-08-20 + +### Added + +- **The admin page says when you are behind, on either half.** The Poracle server card now compares what is running against what has been published — for this site, its latest GitHub release; for Poracle, the version constant on its main branch, since that project publishes no releases or tags at all. A newer version says so; a version newer than any release is named as a development build rather than reported as out of date, which is also how a Poracle develop build gives itself away, since its branch never leaves the binary. It sends nothing, it is cached for six hours, and “Do not check for updates” switches it off entirely. It is not the only request that leaves your network: the Pokemon master data is fetched from GitHub on any visit ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **PoracleWeb knows which Poracle it is talking to, and says so when that is a problem.** It assumed 5.1.0 and never checked, so on an older server the features that need it — per-alarm delivery scope, the PVP mega filter, the minimum time filter — wrote columns that do not exist: the control saved, nothing changed, and the only clue was silence. The admin panel now opens with the Poracle version, its database schema number, the capabilities it reports and when that was last read, with a red warning when the server is too old for this build and an amber one when it did not answer at all. The same check logs on every start. Detection is by capability rather than by branch: Poracle publishes only a version on its health endpoint, so a development build reports the last release's number, and self-hosters run forks where the branch name would answer the wrong question anyway ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Fort alarms can watch for description changes.** PoracleNG has always accepted `description` as a sixth change type and PoracleWeb drew five checkboxes, so the type was unreachable from the web — and because the list is rebuilt from those checkboxes on every save, a rule set with the bot lost it the next time its owner changed anything else. There is a box for it now, and any change type this UI does not know is carried through untouched rather than dropped ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Quests can ask for stardust, and for more than one of something.** Stardust was the one reward PoracleNG matches that PoracleWeb could not create at all — it could show such a rule but not make one — so the reward picker gains a Stardust tab. Items, candy and mega energy gain a minimum amount, which is what turns "any Rare Candy quest" into "three or more". Stardust keeps its floor in a different field from the other three because that is where PoracleNG looks for it, and the card now says "3x Rare Candy" rather than leaving the number invisible. Stardust also stops borrowing the grey colour every unrecognised reward type gets ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Alerts can skip spawns that will be gone before you get there.** Pokemon rules gain a minimum time-left filter, the one PoracleNG has always had and the bot writes as `t:300`. It is a short list of round durations rather than a seconds box, because seconds is where this goes wrong in both directions — typing 5 and meaning minutes asks for a five-second floor, and typing more than a spawn lives mutes the rule with no error. Every rule in production that uses this field is set to exactly five minutes. A value the bot set that is not one of the presets is kept and offered, so opening the dialog cannot round it or drop it, and the card says the floor rather than leaving a rule mysteriously quiet ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Quick picks can aim their alarms too.** The apply dialog kept its own copy of the old two-option control, and the request it sent had no way to carry a place or a set of areas. Both are fixed, so a quick pick creates alarms with the same delivery scope you would set by hand ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Set your pin without leaving the alarm.** The warning about measuring from a pin you have not set now offers to set it, rather than only naming the problem. Both language menus also carry a line saying what they change, since "display" and "alert" is a distinction worth stating rather than implying ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Editing an alarm uses the same scope control as creating one.** The edit dialogs kept the old two-option version, which could not describe an alarm confined to areas and so showed it read-only. All twenty dialogs now render the one control, so the same three options are available wherever the question is asked ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The Delivery tab and the card's scope picker are the same control.** They were two different shapes of the same question, and the dialog version was missing an option: "only in specific areas" could not be chosen when creating an alarm, only afterwards from the card. Both now render one component, so a new alarm can be confined to areas from the start, and picking your pin when you have not set one says so instead of alerting on nothing ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Alert language shows flag rows, matching display language.** The two menus sit next to each other and now look like siblings, which is the only way the difference between them reads at a glance ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Alert language moved into the user menu, next to display language.** It was a block on the Areas page, which is where it landed for want of anywhere better. The two controls do different jobs and each looked like *the* language setting while they sat apart; side by side and distinctly labelled, the difference is visible in a glance ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Areas and Places are one page.** The sidebar item is now Areas & Places, and your named places sit directly under the card that holds your pin, because they are the same kind of thing: points an alert can measure from. Places is no longer a separate item; `/places` redirects. The notification language setting moved to the end of that page, where it stops interrupting the area picker ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **You can make a place without leaving the alarm you are editing.** The scope picker's "measured from" list ends with Add a place, which opens the map, asks for a name, and selects it. Creating a place was only ever wanted at that exact moment, and the old empty state sent you to a menu that no longer existed ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Places.** A page in the sidebar beside Areas and My Geofences: your pin, plus whatever points you name. Adding one borrows the existing location map as a picker rather than growing a second one, and naming is a separate step because picking a point and naming it are two decisions. Deleting a place that alerts still point at says how many and refuses, instead of quietly widening them back to your pin. The Areas page's location card links to it, since that page already explains that alerts reach you by area or by a radius from a point and named points are more of the second. Shown only where the location feature is enabled, matching the endpoints it calls ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **New alarms can start somewhere other than your pin.** Alert Defaults gains a place alongside the radius, and all ten add dialogs now offer a "measured from" selector, so a preference like "within 2 km of work" applies to everything you create instead of being re-picked each time. A place that gets deleted is forgotten rather than left seeding alarms with a label PoracleNG rejects ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **PVP rules can target mega evolutions.** PoracleNG 5.1.0 ranks megas separately from base forms, so a Pokemon PVP rule now chooses between Base, Mega, Mega X and Mega Y, and the card's PVP badge says which. Works regardless of the server's `include_mega_evolution` setting, which only decides the default for rules that do not ask ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Every alarm type says where it reaches you, in your own language.** The scope chip now appears on raid, egg, quest, invasion, lure, nest, gym, fort-change and max-battle cards as well as Pokemon, and clicking it opens the same picker. Raids, eggs, quests and max battles got it through the shared alarm-info summary, so four types describe themselves correctly from one implementation rather than four. Edit dialogs show the scope read-only and hide the areas-or-distance control for an area-confined alarm, which that control cannot describe and a save would have had refused. All 37 strings are translated into the ten non-English locales ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Alarms can be aimed somewhere other than your pin.** PoracleNG 5.1.0 gives every alarm its own delivery scope, and the API now carries it: an alarm can measure its radius from a saved place ("within 2 km of work") or be confined to a set of areas, instead of inheriting the profile's single pin and area list. Saved places are managed at `GET/POST /api/location/places` and `DELETE /api/location/places/{label}`; deleting a place that alarms still point at answers 409 and names them rather than orphaning the label. The three ways a scope can contradict itself — a place and areas together, areas with a radius, a place without one — are refused before anything is written, with wording that says which one to change ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Your own geofences work as an alarm's areas.** PoracleNG refuses `override_areas` entries whose fence is not user-selectable, and PoracleWeb serves user-drawn geofences that way deliberately, to keep them out of the bot's area picker — so naming one would have failed the whole write with "area not permitted". Matching never consults that flag, so the permitted names are sent to PoracleNG and the full list is written to the alarm afterwards, then state is reloaded. Verified against PoracleNG 5.1.0's matcher rather than inferred. Tagged `HACK: trusted-set-areas` alongside the existing area workarounds, and removable in one piece if PoracleNG grows a trusted override write ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Every alarm can say where it should reach you.** The scope an alarm has always had, inherited and invisible, is now stated on the card and editable per alarm: anywhere in your areas, within a radius of a saved place, or only in specific areas. One shared control reads and writes it wherever it appears, rather than two more fields in each of the nine alarm dialogs. The three options are a radio group because PoracleNG treats them as mutually exclusive, so the combinations it refuses cannot be expressed in the first place ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The Pokemon card says where each alert reaches you, and lets you change it there.** The chip replaces the old areas-or-distance badge and covers the cases it could not say: within a radius of a saved place, or confined to specific areas. Clicking it opens the scope picker without opening the whole edit dialog. The other nine alarm types follow ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **New Pokemon alarms can be aimed at a saved place.** The delivery step's radius gains a "measured from" selector: your pin, as before, or any place you have saved. Editing an alarm shows its scope but sends you to the card to change it, so there is one way to do it rather than two that can disagree ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +### Fixed + +- **Documentation screenshots no longer show a real account.** They were taken against a live deployment, so the signed-in Discord name and picture travelled into the guide with them. The user menu is captured under a placeholder name, and the toolbar avatar in the other ten is the neutral silhouette a user without a Discord picture already sees ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Deleting your own geofence keeps working when custom geofences are switched off, and now says why.** Every other write to that page is refused when an admin disables the feature, so the delete read as an oversight and was reported as one. It is not: fences that already exist keep matching, the bot has no command that manages a drawn polygon, and so this is the only way a user can stop an area alerting them. The alarm types can gate their deletes because `!untrack` still works there. The reason is recorded in the code and pinned by a test ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The documentation describes the site as it is now.** An audit of the README, the docs site and the in-app help against the code turned up 101 problems, 35 of them sentences that were actively wrong rather than merely out of date: instructions that named "Areas & Location" after it became Areas & Places, the two-mode delivery model that the per-alarm scope replaced, a fort change-type list missing an option, and quest filters that were never documented at all. The in-app guide is corrected in all eleven languages, and eleven screenshots were retaken against a running deployment ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)).- **The Delivery tab's Message Settings heading no longer sits flush against the control above it.** The rule that lets the first heading in a tab start flush used `:first-of-type`, which also matched the first heading in a tab that has something above it — leaving exactly zero pixels between the delivery picker and the heading beneath. `:first-child` is the predicate that was meant ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The versions card names this site too, not only Poracle.** It showed the Poracle server's version and schema, and mentioned PoracleWeb's only when there was an update to report — so on a deployment that is current, or on the development channel, the site's own build appeared nowhere. Both halves now have their own section with a version, a build reference and a line saying whether anything newer is out ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The Poracle server card is on a page you can actually open.** It shipped on the admin landing page, which nothing routes to — `/admin` redirects straight to Users, and the sidebar links to the three sections directly. The card now sits at the top of Admin → Settings, and the dead landing page is gone. Its four English-only strings had survived every translation pass for the same reason nobody noticed the page: you could not get to it ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Max Battles and Fort Changes speak your language now.** Nine strings in those two sections were written straight into the templates and so appeared in English whatever the display language was set to — the hints explaining what each Max Battle tab tracks, what the Gigantamax toggle does, and the Fort Type and Change Types headings. All nine are translated into the ten other locales, matching the wording each already uses for "Max Battle" and "fort". The PVP level cap's screen-reader label was English too, and now reuses the translated legend above it ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **A quest's minimum amount can be changed after you set it.** It shipped in the add dialog only, so a card reading "3x Rare Candy" had no route back to the 3 — the same create-only shape as the mega picker, found by sweeping every add dialog against its edit twin. The stardust floor is editable there too. What stays fixed is which reward the alarm is about: changing Rare Candy to a Poke Ball describes a different alarm, not an edit of this one. A test now compares the two dialogs of all nine alarm types and fails on any control that exists in one and not the other, unless the difference is written down with a reason ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The mega evolution picker is reachable when editing an alarm, not just when creating one.** In the edit dialog it sat inside the PVP level-cap fieldset, which renders only on a server that advertises level caps — so on a server without them a mega rule could be created and then never changed, and the card said "Mega X" with no way to get back to it. It is now a sibling of the cap picker, the way the add dialog has always had it ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The new filters, straightened out in a browser.** The time-left hint wraps to two lines at phone width and Material only reserves one, so it overlapped the Size heading beneath it by 4px; quest rewards needed 520px of tab for a 464px rail once Stardust joined them, which hid the newest tab behind a pagination arrow at every window size. Headings in the More Filters panel now leave room for a hint that wraps, and the reward tabs are tight enough for all five ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The PVP tab's mega evolution selector now actually stores your choice.** The control, the Angular request field, the card suffix and four passing component specs all shipped against a backend that had no such property: model binding dropped `pvpRankingEvolution` on the way in and the typed read dropped it on the way back, so the selector changed nothing and the suffix could never appear. The specs passed because they assert the shape of the request the component builds, which says nothing about whether the API accepts it. Existing rules are unaffected — the value was preserved on edits throughout, just never settable. A new test walks the field from the JSON the browser sends to the row PoracleNG stores and back, and a second one fails the build whenever PoracleNG grows a column that PoracleWeb neither writes nor explains ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Two pages no longer run off the side of a phone.** Walking all twenty pages at 390px turned up My Geofences, whose three header buttons measured 404px so Draw Geofence sat 30px past the edge, and Help, whose small screenshots carried a bare 480px cap that beat the 100% one on the base class and spilled 92px. The geofence buttons wrap; the screenshots cap to whichever is smaller ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +- **Layout problems found by actually looking at the running app.** The PVP tab's rank hints collided with the field below them; the scope picker's missing-pin warning crushed its icon and squeezed its action into three lines on a phone; the Set Location dialog kept a 2px horizontal scrollbar because its map's border sat outside its width. The Places empty state also stopped repeating its own title ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +- **The PVP tab's mega evolution control no longer collides with the rank fields.** Its fieldset was given a class that had no styles, so it drew the browser's default border and reserved no space beneath itself. It uses the same classes as the PVP cap control right above it, which already handled this ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +- **The Set Location dialog no longer scrolls sideways.** Giving the component itself a width made it wider than the padded surface it sits in. The size lives on the dialog's content now, the way the app's other dialogs do it ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +- **The scope picker ignored the scope it was given.** It read its input in the constructor, where a signal input is not populated yet, so it took its own default and wrote that straight back — discarding an alarm's real scope when you opened it to edit, and the Alert Defaults preference when creating one. It seeds after the input arrives now ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The location dialog is one size.** Six callers each passed their own width and one said 400px, so the same dialog looked different depending on where you opened it from. The component decides now ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Four snackbars showed a raw translation key instead of a message.** The invasion, quest, raid and max-battle add dialogs referenced `COMMON.SAVED` and `COMMON.ERROR`, which never existed; the per-type keys they should have used were already there and already translated. Also translated 38 strings per language that had been sitting in English across all ten locales: every sign-in error, the test-alert messages, fort-change and max-battle snackbars, the raid level picker and the distance validation ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The place picker opens where you are.** It was seeded with 0,0, so choosing a point for a new place opened the map in the Atlantic. It now starts from your pin, and its title says it is picking a point rather than setting your location ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +- **The map on Areas & Places is the size it should be.** It let the child component pick its own height and came out too small to find an area in. It now matches My Geofences: same height, framing and margins ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +- **Clearing your pin no longer comes back as 0,0.** Poracle stores "no pin" as 0,0 rather than null, so the page cleared correctly and then read those coordinates back at face value on the next visit and rendered them. The rule now lives in one helper instead of being rewritten in half a dozen components. The pin's section is also labelled My pin, matching what the rest of the site calls it ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **The area map opens on your pin.** It ranked the selected areas above it, and a multi-area selection frames a whole region, so the map opened too far out to click anything. Your own drawn shapes still win on My Geofences, where they are what you came for ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +- **Bulk and edit writes no longer erase alarm settings PoracleWeb cannot see.** Every alarm write is built by serializing the typed model, so any column PoracleNG has that PoracleWeb never modelled was absent from the request — and because the write carries a `uid`, PoracleNG upserts the row and stores the column default over whatever was there. PoracleNG 5.1.0 added per-rule location and area overrides (`override_location_label`, `override_areas`) and mega-evolution PVP mode (`pvp_ranking_evolution`) to the tracking API, so setting any of them with the bot and then pressing Update Distance, toggling auto-delete, or editing the alarm on the web silently reset them. Writes now rewrite the stored row in place and change only the fields being edited, rather than round-tripping through the model, so fields PoracleWeb has no concept of survive — including `costume`, which arrives in PoracleNG 5.2.0. All ten alarm types are covered. The collision guards see the merged row too, which closes a related false refusal: an unmodelled field could not tell two alarms apart, so an edit PoracleNG would have accepted was reported as a conflict ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + +## [2.15.3] - 2026-08-18 + +## [2.15.2] - 2026-08-18 + +### Fixed + +- **Discord login no longer shows the "Authorize" button on every sign-in.** Adding `prompt=none` to the OAuth2 redirect tells Discord to skip the consent screen for users who have already granted the app permission. The screen still appears on first-time authorization ([#719](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/719)). + +### Dependencies +- Bump the angular group ([#716](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/716)) +- Bump the eslint group ([#718](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/718)) +- Bump the test group with 2 updates + +## [2.15.1] - 2026-08-13 + +### Fixed + +- **Inspecting a blocked user no longer signs the admin out.** `/api/auth/me` answers 401 for an account an administrator has blocked, which is how a blocked user's session ends — but under impersonation that 401 lands on the admin doing the inspecting, and the SPA discards the stashed admin token along with the rest of the session, so there was no way back. Lapsed subscribers are blocked accounts, and "why did this person's alerts stop?" is the main reason to inspect one at all, so inspection hit it constantly. The blocked state is now reported as data — the banner says so — and inspection works. Any other 401 while inspecting ends the inspection and returns the admin to their own session rather than logging them out ([#706](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/706)). +- **Expired OIDC refresh sessions are actually deleted now.** The background cleanup had never once completed on MariaDB: EF Core's `ExecuteDeleteAsync` emits ``DELETE FROM `oidc_sessions` AS `o` ``, and MariaDB rejects an aliased single-table delete outright, so every pass since the feature shipped threw a 1064 and logged a warning while the table only grew. The delete is now raw SQL with no alias. Nothing needs doing on upgrade — the first pass after startup clears the backlog. The eight sibling deletes in `HumanRepository` would have failed the same way and are gone; they had been dead code since alarm deletion moved to the PoracleNG proxy ([#707](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/707)). +- Dependabot no longer proposes `Microsoft.OpenApi` 3.x every week. The 3.0 object model made `IOpenApiMediaType.Example` read-only, and `Microsoft.AspNetCore.OpenApi` 10.0.10 still generates code that assigns it, so the bump cannot build and no edit in this repository can reach the failure. Minor and patch updates inside 2.x still come through, so a later advisory is not masked ([#702](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/702)). + +### Dependencies +- Bump @types/leaflet ([#705](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/705)) +- Bump the angular group ([#701](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/701)) +- Bump the dotnet group with 12 updates ([#711](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/711)) + +## [2.15.0] - 2026-08-10 + +### Added + +- **`PUBLIC_URL` sets the sign-in callback address directly.** OAuth callback URLs were only ever derived from the incoming request, so the sole way to influence them was to declare your reverse proxy — an indirect lever for something you would rather just state. Setting `PUBLIC_URL=https://poracle.example.com` names the origin outright for both Discord and OIDC. Optional: leave it unset and behaviour is unchanged, which stays correct for a directly-exposed instance, for a declared proxy, and for anyone reaching the instance on several hostnames. An unusable value stops the app at startup instead of producing a callback the provider silently refuses ([#689](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/689)). +- A sign-in that is about to fail this way now says so in the log, naming both fixes, rather than leaving the provider's "invalid redirect_uri" as the only symptom ([#689](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/689)). +- The Discord OAuth2 and external SSO setup guides now say where the callback URL comes from and when to pin it with `PUBLIC_URL`, instead of describing it as always derived from the request. + +### Removed + +- `Discord:RedirectUri` — the setting was read by nothing and had been dead since the callback URL became request-derived. Anyone who had set it was getting silence; `PUBLIC_URL` is the working replacement and covers OIDC too ([#689](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/689)). + +### Fixed + +- The Areas map opens on the areas you have selected instead of on the whole world ([#693](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/693)). It was fitting the bounds of every area in the feed, which on a network spanning Hawaii to Australia is the entire planet — no area was more than a pixel and none of them could be clicked. It now opens on your selection, falling back to your own geofences, then your pinned location, and only then to everything. My Geofences opens on your own geofences for the same reason, rather than on an ocean. +- The Help page screenshots no longer carry one deployment's branding. Every screenshot with the toolbar in it read `PoGO Alerts Network`, and the sign-in screenshot showed it again as the splash heading, so a self-hoster reading Help saw somebody else's network name beside instructions about their own install. They now read `DM Alerts` — the same default the app itself falls back to when `custom_title` is unset ([#694](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/694)). +- Help screenshots open full size when clicked ([#694](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/694)). They are captured at 1440px and drawn in a column roughly half that wide, so the UI detail each one exists to point at was too small to read. They are now focusable and open in a viewer on click, Enter or Space. +- The documentation screenshots are unbranded too, the other half of the same defect: all 41 shots under `docs/screenshots/` that show a toolbar or the sign-in splash now read `DM Alerts` rather than `PoGO Alerts Network`, so the docs no longer illustrate self-hosting with a picture of somebody else's deployment ([#697](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/697)). +- Dependabot now opens its pull requests against `develop` rather than `main`. With no `target-branch` set it defaulted to the repository's default branch, so dependency bumps landed directly on released code without ever being built as `:beta` or running on the dev instance — contradicting the documented rule that `main` only moves when a release is merged. It also put every bump through the merge queue that exists only on `main`. +- **Upgrading to 2.14.0 could break sign-in behind a reverse proxy.** The proxy-trust fix in that release ([#583](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/583)) stopped believing `X-Forwarded-Proto` from undeclared proxies, so instances that had not set `PROXY_KNOWN_PROXIES` / `PROXY_KNOWN_NETWORKS` began building OAuth callback URLs as `http://` and Discord rejected the sign-in with an invalid `redirect_uri`. Both variables are now documented in `.env.example`, the configuration reference, the reverse-proxy setup guide, and troubleshooting. If you are affected, declare your proxy and recreate the container ([#689](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/689)). + +### Dependencies +- Bump jsdom in /Applications/Pgan.PoracleWebNet.App/ClientApp ([#687](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/687)) + +## [2.14.0] - 2026-08-09 + +### Security + +- A failed lookup of PoracleWeb's own delegate table is now reported as unresolved rather than as a confident empty list, so a momentary blip no longer denies a delegate for a full minute (#667) +- A PoracleNG outage during a profile switch no longer strips admin from the session: the role resolver now reports "could not resolve" separately from "not an admin", and a degraded answer is never cached (#656) +- A profile switch during impersonation no longer undoes the deliberate privilege downgrade (#663) +- `PUT /api/fort-changes/{uid}` bounds `changeTypes` the same way the create path does (#660) +- Creating a profile no longer accepts an unbounded location or an area list naming another user's private geofence, both of which were written straight to the database (#647) +- A login that carries no refresh token now clears any refresh token left behind by a previous session, instead of inheriting it and letting the refresh interceptor swap in a JWT minted for the previous user (#625) +- A token re-issue no longer restarts the session clock: profile switches and profile resyncs keep the original expiry instead of applying the 24-hour default, so a short OIDC access token stays short and revocation propagates as documented (#624) +- `isAdmin` is resolved live on every token re-issue rather than copied forward, so removing someone's admin rights takes effect instead of surviving indefinitely while they switch profile (#624) +- Webhook delegates configured in PoracleJS can use the My Webhooks page again: the live resolution now unions PoracleNG's delegate list with PoracleWeb's own table, as the JWT claim always did (#626) +- Pinned `SQLitePCLRaw.bundle_e_sqlite3` forward to 2.1.12 in the test project, clearing the high-severity GHSA-2m69-gcr7-jv3q advisory the build reported +- **Blocking a user was not enforced by the API** ([#609](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/609)). The earlier fix only signed the browser out; the API kept serving a blocked account, and anything not using the web app was unaffected entirely. Blocking now refuses immediately, and unblocking restores access at once. +- **Blocking a user did not block them** ([#597](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/597)). Blocking stopped notifications being delivered and left the web session untouched, so a blocked account kept full access for up to a day, and signing in again issued a fresh token. +- **Rate limits could be bypassed by forging a header** ([#583](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/583)). `X-Forwarded-For` was believed from any caller, so a client could name a different address on each request and hand itself a fresh allowance — including on the sign-in endpoints the limit exists to protect. It is now honoured only from proxies the deployment declares, via `PROXY_KNOWN_PROXIES` / `PROXY_KNOWN_NETWORKS`. +- **Per-user rate limits were shared by everyone behind the same address** ([#581](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/581)). The limiter ran before sign-in was established, so it could not tell users apart — one person on a shared connection could use up the allowance for everyone on it. +- **`GET /api/config` published the Poracle admin id list to anonymous callers** ([#415](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/415)). The route was `[AllowAnonymous]` and returned the upstream config object whole, so an unauthenticated request to an internet-facing host returned the Discord and Telegram admin ids, the webhook delegation map, the internal provider URL and the static map key. The same admin list is `[Authorize]`-and-admin-gated on `GET /api/admin/poracle-admins`, and PoracleNG gates its own copy behind `X-Poracle-Secret` — PoracleWeb was the one place it was public. The route now requires authentication and returns a `PublicPoracleConfig` projection instead of the raw object. The projection is an allowlist, so a field added upstream is not exposed until someone adds it deliberately. Nothing needed the old payload: the only browser consumers are the Pokemon add and edit dialogs, both behind the auth guard, and both read only the PvP cap fields. `GET /api/config/templates` and `GET /api/config/dts` stay anonymous. If your instance has been reachable from the internet, treat the admin ids and the static key as disclosed. + +### Fixed + +- The in-app help's user-menu screenshot is current again: it predated Alert Defaults and the Language submenu, so two menu items were missing from the picture +- The in-app help now mentions **Sign out everywhere**, the single-logout option SSO sessions get, in all eleven locales +- The nightly image prune no longer deletes released Docker images. `ignore-versions` matches a container version's digest rather than its tags, so it protected nothing and `:latest` and the version tags were swept away a few days after each release — leaving every documented `docker compose up` unable to pull (#677) +- Changing a quick pick's alarm type no longer drops its ping target, template or distance either (#674) +- Changing a quick pick's alarm type no longer resets its auto-delete, edit-in-place and summary bits (#671) +- Creating a profile validates the location and area list before the profile is created, instead of leaving an orphan profile behind a 400 (#665) +- Deleting the last quick pick no longer restores all thirty presets in the same session, and installations seeded before the marker existed are backfilled at startup (#666) +- The quick-pick seed marker no longer appears in the admin Settings page as an editable unknown setting (#668) +- Changing a quick pick's alarm type no longer keeps the previous type's filter keys (#669) +- Renaming an approved geofence answers 400 with a reason instead of 500, and the rename button is hidden where it cannot work (#657) +- Creating a profile no longer strips approved geofences, which are public areas anyone may select, and rejects a malformed area list instead of silently emptying it (#658) +- Seeding the built-in quick picks is no longer aborted by a user pick that happens to hold a built-in id (#659) +- The invasion `gruntType` length bound is the column's actual width (255), not an invented 35 (#661) +- Whether the built-in quick picks have been seeded is recorded server-side, so a second admin or a different browser no longer reseeds them and a failed seed retries (#662) +- Editing a quick pick no longer drops filters whose value is 0: the built-in Nundo preset is `minIv: 0, maxIv: 0`, so changing its description silently turned "0% IV only" into "any IV" for everyone who applied it afterwards (#654) +- Rejecting a geofence submission no longer silently stops the owner's alerts: a rejected fence stays in the feed, as "remains private with review notes" always intended (#645) +- An approved or under-review geofence can no longer be renamed, which used to move the area subscription to a name neither Koji nor the feed serves (#646) +- Renaming a geofence keeps its region instead of clearing the Koji parent, and the rename dialog now pre-selects the region it already has (#648) +- Admins can review a rejected geofence submission again, which the API has always allowed (#649) +- The region picker shows the region that is already selected instead of an empty box (#650) +- Deleting a profile refreshes the session, so the JWT stops naming a profile that no longer exists (#651) +- Resetting an alarm's template to Default now sticks; nine of the ten edit dialogs sent a null the mapper skipped, so the choice was accepted and discarded (#639) +- "Update Distance (all)" works on the Pokemon page: it was sending an object to an endpoint that binds a bare number, so it failed every time (#640) +- Bulk "Update Distance" now reports failures instead of doing nothing visible, and shows the server's explanation of which alarm is in the way (#641) +- Select All on the Raids page no longer selects hidden Eggs, which Delete then removed along with the raids (#642) +- The Areas page no longer offers to save when it could not read your current areas, which turned one failed request into the loss of every subscription (#643) +- Seeding the built-in quick picks no longer aborts partway: two invasion presets carry empty filters on purpose and were rejected by the save-time validation added in #604, so both the first-visit auto-seed and Reset to Defaults left a partial preset list (#637) +- The admin Settings page shows every group on a fresh install; Alarm Types, Features, Administration and Analytics were hidden until their rows existed, and this page is the only thing that creates them (#629) +- Resetting quick picks to defaults now clears the applied state of the picks it removes, instead of orphaning a row per user and profile (#630) +- An admin editing their own personal quick pick no longer publishes it to every user, and an admin save can no longer take over a pick belonging to someone else (#631) +- Telegram accounts appear in the admin Users list, so they can be blocked, paused, purged and impersonated like any other account (#632) +- Switching login provider in one save no longer fails on whichever request lands first: enables are applied before disables so the anti-lockout guard never sees a half-applied batch (#633) +- Deleting all global quick picks sticks instead of being undone by an auto-reseed on the next admin visit (#634) +- The impersonation banner no longer survives a 401 with a Stop button that does nothing; with no admin token to restore, it now signs out (#627) +- After a session expires, the login page no longer bounces to the dashboard and back, and the OIDC auto-redirect runs as it should (#628) +- The Add Raid dialog no longer offers a level picker on the By Boss tab, where PoracleNG discards the chosen level and stores "any" (#615) +- A 401 now clears the whole session rather than the access token alone, so the admin impersonation token and a dead refresh token no longer survive it (#616) +- The delivery preview no longer spins forever in every alarm dialog when `disable_location` is on (#617) +- Approving or rejecting a geofence no longer blanks the admin card: the response carries the owner and reviewer names, avatars and polygon the list projection adds (#618) +- The gym picker's rate-limit message is translated instead of hardcoded English (#619) +- The admin "Bot Username" setting is now read as a fallback for the Telegram login widget instead of saving nowhere (#620) +- Invasion alarms no longer accept a grunt type containing control characters, or one longer than the column, which produced an alarm that could never fire or a 500 (#611) +- Fort change alarms now refuse a `changeTypes` list longer than the five legal values, or one that repeats a value, instead of failing in the database (#612) +- An admin can no longer block their own account, which since #609 removed their API access immediately, including the endpoint that would restore it (#613) +- **Ordinary radius and template edits were refused when a similar alarm existed** ([#606](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/606)). The clash check was stricter than Poracle itself, so it blocked edits that would have been perfectly safe — and the message suggested doing the very thing it was blocking. Pokemon edits could never clash at all and were refused anyway. +- **Re-applying a quick pick with an unusable filter deleted its alarms** ([#607](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/607)) for six of the nine alarm types — the check that is supposed to run first only covered three. +- **A quick pick holding a value alarms refuse could still be saved** ([#608](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/608)) for those same six types. +- **Revoking a webhook delegate did not take effect until they signed in again** ([#600](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/600)) — up to a day of continued access, including the ability to act as that webhook. +- **Bulk delete stopped at the first alarm that had already gone** ([#603](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/603)), reporting nothing and leaving the list unrefreshed even though some alarms had been deleted. +- **An admin could save a quick pick holding a value alarms refuse** ([#604](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/604)), so every user who applied it got the error instead. It is now caught when the pick is saved. +- **Adding alarms you already had reported them as newly created** ([#602](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/602)) in seven of the eight add dialogs. +- **A very long quick pick description returned a server error** ([#601](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/601)) instead of saying it was too long. +- **Adding an alarm could overwrite an existing one that had a custom template** ([#593](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/593)). Adding an alarm identical to one you already had, except that yours carried a custom template, replaced that template with the default and reported a new alarm created. +- **A bulk radius change could rewrite an alarm you had not selected** ([#598](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/598)), leaving the one you did select at its old radius while reporting it updated. +- **The PVP league could still be set to an unusable value by editing** ([#594](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/594)) — the check added in v2.12.3 covered adding an alarm but not editing one. +- **A deleted account still got server errors from the alarm lists, dashboard and cleaning** ([#595](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/595)) rather than being signed out. +- **`PROXY_KNOWN_PROXIES` and `PROXY_KNOWN_NETWORKS` did nothing** ([#596](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/596)), so an instance behind a reverse proxy had no way to declare it. +- **A deleted account kept getting server errors instead of being signed out** ([#584](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/584)). Only the session check answered correctly; every other page returned "an unexpected error occurred" until the app happened to re-check. All of them now agree and the session ends. +- **Renaming a geofence skipped the name rules that creating one enforces** ([#585](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/585)), so a name the app refuses at creation could be applied by editing instead. +- **The PVP league accepted values no league uses** ([#586](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/586)), storing a filter that can never match. It is now limited to the four the dropdown offers. +- **Quick-pick apply and profile import discarded the server's explanation** ([#587](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/587), [#588](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/588)) — the instruction telling you what to do, and the message naming the bad alarm in a file. +- **Two retired map-picker settings still appeared in the admin page** ([#589](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/589)). +- **A bulk radius change could leave you with fewer alarms than you selected** ([#580](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/580)). Two alarms that differed only by radius became the same alarm once both were set to the same one, and Poracle merged them — while the app reported every alarm updated. It is now refused with an explanation. +- **The Profiles page was blank for accounts that had never edited a profile** ([#582](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/582)). It reported no alarms across any profile while the alarms were there and firing. +- **Re-applying a quick pick whose type had changed deleted its alarms before refusing** ([#579](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/579)), so the error read as "nothing happened" while the alarms were already gone. +- **Adding a Pokemon alarm at a tighter IV silently replaced the existing one** ([#574](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/574)). Same species, higher minimum IV — about the most ordinary thing you can do — reported a new alarm created and destroyed the old one. +- **An "any gym" alarm was refused when a gym-specific one existed** ([#575](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/575)), and whether it worked depended on which of the two you added first. They are different alarms and both are allowed again. +- **Duplicating a profile from the Profiles page put the Pokemon alarms on the wrong profile** ([#576](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/576)) — back onto the profile being copied, which quietly gained a duplicate each time, while the new profile came up with no Pokemon tracking at all. +- **Adding several alarms at once reported the whole batch as failed if one clashed** ([#577](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/577)), leaving the ones that were created invisible until you reloaded the page. +- **Adding a lure for a type you already track returned a server error** ([#562](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/562)) instead of saying you already have one — for a choice the lure picker actively offers. +- **A new profile started with all your current areas and location** ([#563](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/563)) rather than empty, so it began delivering notifications for areas you never chose for it. +- **A quick pick could still store filter values the add form rejects** ([#565](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/565)), producing an alarm that matches nothing. The check was running against a model that carries none of the rules. +- **Applying a quick pick after changing its alarm type stranded the alarms it had made** ([#557](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/557)). It now says to remove the pick first, rather than quietly losing track of them. +- **The My Webhooks page failed to load for the only people who can see it** ([#564](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/564)). It read from an admin-only list, so every delegate got an empty table and an error. +- **Alarm dialogs and profile import replaced the server's explanation with a generic failure** ([#567](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/567), [#568](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/568)), so you were told something failed but not which alarm was in the way or which field was wrong. +- **The eight withdrawn admin settings reappeared under "Other"** ([#560](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/560)) — the same editable controls one section lower, still promising behaviour the app does not have. +- **Adding an alarm could silently take over one you already had** ([#561](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/561), [#569](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/569)). Adding an alarm that matched an existing one except for its radius reported a new alarm created, and quietly moved the old one instead — leaving one alarm where there had been two. It is now refused, while adding a genuinely new alarm, or re-adding one you already have, behave as before. +- **A quick pick with a long name failed with a server error** ([#555](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/555)). The identifier generated from the name could be longer than the column that stores it. +- **A profile backup containing a fort-change alarm would not import** ([#556](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/556)) — an unmodified export produced by this app, refused by the validation added in v2.12.2. +- **A renamed geofence disappeared from the map and reported no points** ([#559](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/559), [#566](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/566)) until the page was reloaded, because the rename response left the shape out. +- **A renamed geofence showed as "Inactive" while still active** ([#558](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/558)), so it looked as though renaming had switched your alerts off. +- **Some alarms became impossible to edit** ([#553](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/553)). The check added to stop an edit overwriting a different alarm was too broad: two alarms that differ in both radius and template are genuinely separate, but every edit on either was refused — radius, template, auto-delete and clearing the gym — with a message about an alarm that was not in the way. Gyms differing only in their slot and battle toggles had the same problem. Both are editable again, and an edit that really would overwrite another alarm is still refused. +- **Renaming a custom geofence switched it off for your other profiles** ([#543](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/543)). Editing was implemented as delete-and-recreate, and the recreate only re-subscribed the profile you were on. The page still showed it as on, so nothing said those profiles had stopped receiving alerts. Renaming now keeps every subscription. +- **Poracle's explanation of a rejected alarm was replaced with "an unexpected error"** ([#539](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/539)), so you were told the server had broken rather than what was wrong with the alarm. +- **Selecting a raid could select an egg as well** ([#540](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/540)). The two lists shared one set of selections keyed by ID, and raid and egg IDs can be the same number — so a bulk delete or radius change hit the raid twice and left the egg untouched. +- **Editing an alarm onto another one's exact settings could leave two identical alarms** ([#537](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/537), [#538](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/538)) that cannot be told apart on the page, for Pokemon and Max Battles. Adding that same alarm is refused, so editing was the only way to reach it. +- **Too many logins from one network left the browser hanging** ([#546](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/546)) instead of saying so. On a shared connection the 31st person to sign in within a minute waited on a request that was parked rather than refused. +- **Applying a quick pick twice made it forget the alarms it had created** ([#542](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/542)). The second apply adds nothing, and the pick recorded that as owning nothing — so Remove reported success while deleting nothing and the alarms were left with no way to identify them. +- **Changing an applied quick pick's alarm type orphaned its alarms** ([#541](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/541)). The pick went looking for its Pokemon alarms among the raids, concluded they had all been deleted, and dropped its record of them — alarms left behind with no Remove button and nothing saying where they came from. +- **Quick picks accepted names and other fields longer than they can store** ([#549](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/549)), returning a server error instead of saying so. Neither dialog limits the length, so a preset named with a sentence was enough. +- **Importing a profile accepted values every other path refuses** ([#548](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/548)). A hand-edited backup could store a negative radius, an IV window of -999 to 500, or a cleaning value outside its range, and the alarm then matched nothing. Imports are now checked the same way the add form is, and a file that fails is refused whole rather than half-applied. +- **The area list showed other people's private geofence names** ([#544](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/544)). Any signed-in user could read the names everyone else had given their own geofences — "home", "work area" and so on. The page hid them, but the data was being sent. Your own geofences are still listed. +- **A deleted account stayed signed in to an app where nothing worked** ([#545](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/545)). The session kept reporting itself healthy while every page failed with a generic error, for up to a day, with no way out but clearing browser storage. It now signs out. +- **Editing an alarm could silently delete a different one** ([#531](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/531)). Changing a raid's team, an egg's level, a gym's slot or battle toggles, or a fort-change's change types onto settings another alarm already had merged the two: one alarm vanished, the survivor took the edited alarm's radius, and the app reported a successful update. The clash is now refused and both alarms are left alone. +- **Re-applying a quick pick could destroy its alarms** ([#532](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/532)). Re-apply deleted the existing alarms first and only then checked whether the pick could be applied, so a pick that had been disabled or edited into an impossible filter lost its alarms and reported only that it had been refused. Everything that can refuse a re-apply is now checked before anything is deleted. +- **Changing the notification language marked your account as never seen** ([#517](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/517)). The write blanked the last-seen timestamp Poracle and the admin user list use to judge whether an account is still alive, and reported success without a hint that anything else had changed. +- **A quick pick could create an alarm that can never match anything** ([#507](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/507)). Filter values the alarm form rejects were saved through a pick and notified nothing, silently. Quick picks are now held to the same rules as the form. +- **Disabling a quick pick stranded the alarms it had created** ([#508](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/508)). The pick vanished from the list along with its Remove button, leaving alarms with no way to un-apply them and nothing to say where they came from — and an admin disabling a shared pick did that to everyone who had applied it. +- **The Cleaning page offered rows for alarm types the admin had switched off** ([#509](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/509)); pressing one threw you off the page with an error. +- **Adding several Pokemon at once reported alarms it had not created** ([#495](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/495)). Ones you already tracked were counted as new, so the message disagreed with the list. It now says how many were added and how many were already there. +- **Duplicating a profile from the Profiles page copied the wrong areas and location** ([#503](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/503)). The copy inherited whichever profile was active rather than the one being duplicated, so you got the right alarms over the wrong map — the same defect fixed for the other duplicate button in v2.12.1. +- **An imported profile arrived subscribed to areas the file never mentioned** ([#522](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/522)). Copying the alarms carried the active profile's area list and location onto the new profile, so the import delivered notifications for areas you never chose for it. +- **Duplicating a profile with a long or empty name returned a server error** ([#504](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/504), [#519](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/519)) instead of explaining the problem, as creating a profile already did. The duplicate prompt is prefilled with " (Copy)" and has no length limit, so a long name is easy to reach. +- **Adding a fort-change alarm could silently replace an existing one** ([#502](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/502)). If the settings matched an alarm you already had, Poracle overwrote that alarm's radius rather than adding a second one, while the app reported a new alarm created. The clash is now refused with an explanation. +- **Adding the same max battle twice stacked duplicate alarms** ([#521](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/521)), producing two of every notification with no way to tell the rows apart. Max battles are the one type Poracle does not de-duplicate. +- **Clearing the gym on a raid, egg or gym alarm reported success and kept the old gym** ([#497](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/497)). The alarm went on firing for that one gym while the card and the dialog both showed none. +- **Creating an invasion alarm whose grunt type differed only in capitalisation returned a server error** ([#500](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/500)), and **clearing the grunt type on an edit did too** ([#518](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/518)) — the explanation the check was written to give never arrived. Both now say what is wrong. +- **A new raid alarm reported a raid level it had not saved** ([#523](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/523)) when the alarm named a specific boss, so the card disagreed with the same alarm after a refresh. +- **The ping field was accepted everywhere and stored nowhere** ([#494](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/494)). Typing a role to ping saved without complaint and was blank again on reload, on every alarm type. Poracle discards it on write, so the controls have been removed rather than left pretending. +- **A refused Pokemon filter said only "failed"** ([#496](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/496)). The server names the pair that is inverted; the dialogs threw that away and showed a fixed message, so a transposed minimum and maximum gave no clue which field to fix. +- **The custom navigation link was visible only to admins** ([#513](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/513)) — the one group that least needs it. An admin setting it up saw it work and had no way to tell it was missing for everyone else. +- **The user menu always showed "Profile 0" instead of the profile's name** ([#520](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/520)), disagreeing with the Profiles page, which showed it correctly. +- **Turning off area management broke pages and dialogs that had nothing to do with it** ([#506](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/506), [#515](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/515)). Every area lookup was refused, including the ones nobody asked for — the delivery preview inside each add-alarm dialog, the geofence page's map — and the app read those as a dead page and sent the user to the dashboard. Opening an alarm dialog moved the page underneath it, and My Geofences became unreachable despite having its own switch. Areas can now be seen while they cannot be changed. +- **The dashboard raised an error toast on every load when a feature was turned off** ([#516](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/516)) and kept showing buttons that bounced straight back with the same message. A disabled feature is now simply absent from the dashboard. +- **Custom geofences could change area subscriptions while area management was off** ([#505](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/505)). Creating a geofence subscribed the current profile to it regardless of the switch, so drawing fences was a way around it — and a GeoJSON import could add up to 50 in one go. New geofences now arrive inactive while areas are frozen. +- **Deleting a user left almost everything they owned behind** ([#510](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/510), [#511](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/511), [#512](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/512)). Alarms, custom geofences, quick picks and webhook delegate grants all survived the delete. Nothing could reach them, so the account looked gone — until the same ID was created again and inherited the lot, including delegate rights over a recreated webhook URL. A deleted user's geofences also kept being published to Poracle. +- **Webhook delegation accepted IDs that name nothing** ([#514](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/514)). A typo created a grant over a webhook that does not exist, for a user who does not exist, and the admin list then showed it as real. +- **"Delete all alarms" skipped fort changes and max battles** ([#510](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/510)), leaving those two types behind on every path that clears a user's alarms. +- **Saving a raid, egg, quest, gym, nest or fort-change edit without changing anything was refused** ([#498](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/498), [#499](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/499), [#501](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/501)). Pressing Save with nothing edited, or changing only the ping, reported that another alarm already used those settings — the alarm it meant was the one being edited. Introduced by the duplicate detection added in v2.12.1. +- **The quest summary editor said an empty schedule meant a manual profile start** ([#457](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/457)). The schedule editor is shared with profiles, and its empty-state line contradicted the sentence directly above it about quests being delivered individually. +- **A Pokemon alarm could be saved with a filter nothing can match** ([#461](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/461)). Transposing a pair — minimum IV 90 with maximum 10, say — saved without complaint and then went silent, with nothing to explain why the alerts stopped. Each bound was checked against the game's limits but never against its partner. Such a filter is now refused, including when the edit only changes one side of a pair. +- **Deleting a quick pick left a record of it behind** ([#470](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/470)). The definition went and its applied state stayed, unreachable, so a new pick created with the same name inherited an "applied" badge pointing at the old pick's alarms. The delete confirmation now also says that the alarms it created stay, and that Remove is what deletes those. +- **Saving a location without both coordinates moved you to the middle of the Atlantic** ([#480](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/480)). A request that left out latitude or longitude wrote it as 0 over your real location, reported success, and took the profile with it — after which weather returned nothing and distance matching ran against a point you had never set. Both coordinates are now required. +- **Deleting a user left their profiles behind** ([#481](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/481)). The profiles rows survived the delete, invisible everywhere, and re-creating the same webhook or user adopted them verbatim — old areas, old location, old schedule. They are now removed with the user. +- **Re-creating a deleted webhook reported failure after creating it** ([#482](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/482)). The admin saw a server error and no webhook, while the record had been written — and trying again said the webhook already existed. The half-write is now undone so the reported failure is the truth. +- **Webhook delegation accepted a user ID it could not store** ([#483](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/483)). Too long produced a server error rather than an explanation, and an empty one was saved as a delegate granting nothing to nobody, which then appeared in the admin list. It is now checked the same way the webhook ID already was. +- **An admin impersonation session could quietly stop being recorded as one** ([#484](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/484)). When Poracle changed the active profile in the background, the refreshed session dropped the note of which admin had started it, leaving nothing on the server to distinguish it from the user's own session. +- **A custom geofence could take a name a public area already had** ([#475](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/475)). Only your own geofences were checked, so a private one could be created under a public area name. Both then reach Poracle under the same name, and approving the private one overwrote the public area. Names are now checked against both, ignoring case. +- **Importing GeoJSON stopped part-way through on a bad coordinate** ([#473](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/473)). One malformed number ended the whole import with a server error, keeping whatever had already been imported and reporting nothing about the rest. Each shape is now reported on its own and the import finishes. +- **Importing GeoJSON could quietly change the shape you gave it** ([#474](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/474)). Points it could not read were dropped and the result reported as a clean success, so the stored area covered somewhere other than the file described. Such a shape is now refused, and a file holding more than one area says so instead of silently keeping the first. +- **Custom geofences all showed 0 points** ([#477](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/477)) on the geofences page, though the admin list showed the real count. +- **Saving areas could show a different list than was saved** ([#476](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/476)). The page echoed back what you submitted rather than what was stored, so an area Poracle declined still appeared selected until a refresh. +- **Turning off areas or custom geofences did not stop geofences being switched on and off** ([#478](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/478)). Those two buttons wrote area subscriptions without checking either switch, so the Areas page refused while the same change went through elsewhere. Both are now enforced, at the endpoint and in the service. +- **Turning off locations broke the notification language selector** ([#479](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/479)). The language endpoints sat behind the location switch despite having nothing to do with a location, and the Areas page loads the selector on open — so with locations off, opening Areas bounced you to the dashboard. Language is now independent of it. +- **Importing a profile put the alarms on whatever profile the file named** ([#465](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/465)). A backup carrying a profile number wrote its alarms there instead of into the profile just created, leaving them orphaned on a profile that might not exist — and inherited by a future profile created at that number. The file no longer chooses the profile or the owner. +- **Duplicating a profile copied the wrong areas and location** ([#466](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/466)). The copy silently inherited whichever profile was active rather than the one being duplicated, so you got the right alarms over the wrong map — and since the coordinates also drive the active-hours timezone, a schedule on the copy could run against the wrong clock. +- **Blank or over-long profile names returned a server error** ([#467](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/467)) on rename and import, where creating a profile already explained the problem properly. +- **A cleaning toggle broke any quick pick covering those alarms** ([#471](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/471)). Toggling auto-delete rewrites every alarm of that type under a new identifier, and the quick pick that created them kept pointing at the old ones — so the pick showed as never applied, Remove deleted nothing, and re-applying failed because the leftover alarms were still there. Cleaning was the one bulk operation that never followed the rewrite. +- **The cleaning endpoints accepted values that are not on or off** ([#472](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/472)). Any even number silently switched cleaning *off* while reporting the alarms updated, and the write is not free — it rewrites every alarm, and for Max Battles deletes and recreates them. +- **Editing an alarm onto settings another alarm already used destroyed one of them** ([#462](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/462)). Lures and invasions are replaced rather than updated, so changing one onto a combination another alarm already held — flipping an invasion's gender dropdown was enough — deleted the alarm being edited and silently overwrote the other one. The clash is now detected before anything is removed and the edit is refused with an explanation. +- **Edits that clashed were reported as successful** ([#463](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/463)). For gyms, fort changes and the other types, an edit onto settings another alarm already used was declined upstream, but the response echoed the requested values back so it could never disagree with the request. The clash is now reported. +- **Applying a quick pick could delete an alarm you built yourself** ([#469](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/469), [#468](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/468)). If a pick matched an alarm you already had, the pick claimed it as its own — so removing the pick deleted your alarm. It also recorded an unusable reference when the match was exact, which wiped the pick's applied state on the next page load and left no way to remove it. A pick now claims only alarms it actually created. +- **Creating an alarm that already exists no longer reports it as newly created** ([#459](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/459)). The response claimed a created resource and pointed at an address that did not exist. +- **Editing a raid, egg, quest, gym, fort change or nest returned an alarm ID that no longer existed** ([#460](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/460), [#464](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/464)). PoracleNG re-creates the row under a new ID when you edit one of these, while reporting zero inserts. PoracleWeb keyed off that count rather than the ID it was handed, so the response advertised the old ID — which then failed on the next read, edit or delete — and any quick pick tracking the alarm was left pointing at it. Pokemon alarms were unaffected, because PoracleNG genuinely updates those in place. +- **A batch of interface defects** ([#426](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/426)). Deleting a Gym, Lure or Nest alarm asked "Edit … Alarm?" instead of a delete confirmation. A quest tracking any item showed as "Item #0". Hovering a toolbar button silently disabled every keyboard shortcut, because the tooltip counted as a blocking overlay. The Alert Defaults dialog accepted 200 km, showed "200 km" in its preview, then quietly saved 100 — and 0 or a negative saved as 1; it now says what the limits are and refuses instead. The "Set up areas" link inside an add-alarm dialog reloaded the whole page and threw away everything typed. The Cleaning page always greyed out the Max Battles row even when alarms existed. Searching admin settings never filtered the Authentication section. And the login page made a signed-in-only request on every visit, failing with an error in the console. +- **Error messages, admin settings and table pagination stayed in English regardless of your language** ([#425](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/425)). Three separate gaps. Every error toast came from a message table that was never translated, even though a fully translated one sat beside it doing the same job for a different part of the app — both now use the translated one. Twelve strings were missing from every non-English locale, so the Authentication settings group, the PvP level-cap labels and a few hints rendered in English inside otherwise-translated pages; the English fallback made this invisible rather than obviously broken. And the table paginator on the admin pages kept Material's built-in English wording. A test now fails the build if any locale drifts from English again, or if the error toasts go untranslated. +- **A bulk distance change still made a quick pick impossible to remove** ([#443](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/443)). The single-edit case was fixed in [#403](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/403), but the Update All and bulk-distance actions rewrite every row too, so the same stale-uid failure came back through them: Remove reported success, deleted nothing, and the next page load cleared the applied state so there was no way to try again. The replacement rows are now matched by their contents rather than their position, because the batch response comes back in a different order than it was sent — matching by position would have repointed a quick pick at somebody else's alarm and deleted the wrong one. Where two alarms are genuinely indistinguishable, nothing is moved rather than guessed. +- **Creating a profile with a name you already use did nothing at all** ([#427](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/427)). The dialog detected the clash and set an error message, but Angular Material only renders that slot when the field is in a validation error state, which this one never was — so pressing Create produced no message, no toast and no request, and the only visible change was the character counter disappearing. The clash is now flagged as you type and the Create button disables, so the dead click cannot happen. +- **"Disable Geocoding" did not disable geocoding** ([#420](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/420)). The toggle was stored and shown in admin settings but nothing read it, so an operator who switched it off for privacy or OpenStreetMap terms-of-use reasons was still making outbound Nominatim requests on every address search. It now genuinely stops them: the search and reverse-lookup endpoints refuse, and the location dialog hides its search box rather than showing one that fails. Setting a location by coordinates or on the map still works. +- **A geofence approval that Koji rejected returned an unexplained 500** ([#422](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/422)). Approving with a region Koji does not recognise — or approving at all while Koji is unreachable, or after a region was deleted since the picker loaded — produced "An unexpected error occurred." with nothing to act on. The region is now checked before anything is sent, and a bad one is named in a 400. Any other Koji failure returns 502 saying the geofence server rejected the request, rather than looking like a bug in PoracleWeb. The submission is left untouched either way, so it can be retried. +- **Bulk distance updates accepted negative distances on every alarm type** ([#417](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/417)). Creating an alarm with a negative distance has always been rejected, but the two bulk endpoints carried no validation, and the Update All dialog's number input had a minimum that nothing enforced — typing `-5` and pressing the button sent it. PoracleNG treats a distance above zero as "use a radius", so a negative value silently switched the selected alarms to area-based delivery while their cards went on showing a negative radius. Both endpoints now reject it with the same rule the create path uses, and the dialog disables the button and explains why. +- **Saving a location off the Earth was accepted and then failed silently** ([#423](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/423)). Latitude and longitude were stored with no bounds check, so a coordinate like 999/999 persisted to both your account and the active profile. Nothing said so: the weather panel returned nothing, the map returned nothing, distance matching ran against a point that does not exist, and the active-hours scheduler's timezone lookup became meaningless. Out-of-range coordinates are now rejected with a message naming the limit. +- **Renaming a profile did nothing and told you it worked** ([#406](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/406)). PoracleNG's profile update silently ignores the name — it answers success and writes nothing, while honouring a schedule change on the very same request. PoracleWeb never checked, so the rename dialog showed a success message built from the response and the old name reappeared on refresh. Renames are now written directly, and the response carries the new name. +- **Creating, duplicating or importing a profile could produce an empty result, orphaned alarms, or a junk profile** ([#407](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/407)). PoracleWeb guessed the new profile's number as one past the highest, but PoracleNG assigns the lowest free number instead. Anyone who had deleted a profile from the middle therefore had a gap, and four things went wrong: create returned an empty body so the profile list did not update, duplicate copied the alarms to a number with no profile behind it (leaving them to attach themselves to whatever profile was created there later), and a failed duplicate or import left an empty profile behind. PoracleWeb now asks which number was actually assigned, and a failed import is rolled back with a message explaining what was wrong with the file. +- **Alarms created while your session's profile claim was stale landed on the wrong profile, invisible and undeletable** ([#411](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/411)). Alarm writes carried a profile number stamped from the login token, and that token lives four hours — long enough for the active profile to move underneath it via the active-hours scheduler, the bot's `!profile` command, or a second tab. PoracleNG took the submitted number at face value for Pokemon alarms while scoping every read to the live profile, so the alarm saved successfully and then could not be seen, edited or deleted. Writes no longer send a profile number at all: PoracleNG files each alarm under the profile you are actually on, which is what it already did for the other nine alarm types. +- **Approving a geofence accepted any status**, the same gap [#409](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/409) closed on reject. A geofence the owner had never submitted could be approved and made public, and an already-approved one could be approved again under a different name — pushing a second entry into Koji and stranding the first, which is exactly the leak #409 fixed. Approve now accepts only a submission awaiting review, or one previously rejected and being reconsidered. +- **Creating a geofence accepted malformed polygons, which then poisoned the shared geofence feed** ([#410](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/410)). The create endpoint checked only how many points a polygon had, so `[[1],[2],[3]]` and coordinates nowhere on Earth were stored as-is. Those rows were then served by `GET /api/geofence-feed` — the anonymous endpoint that is the single geofence source for PoracleJS — and crashed the owner's GeoJSON export with a 500 that persisted until they worked out which geofence to delete. GeoJSON import had always validated point shape and coordinate range properly; the two write paths into the same table simply disagreed. Both now share one rule, and the read paths skip anything malformed rather than trusting it, so a row written before this existed cannot break a feed everyone depends on. +- **Rejecting or deleting a previously-approved geofence left a public area stranded in Koji forever** ([#409](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/409)). Approval pushes the polygon into the shared Koji project; reject never undid that and accepted any status, so an already-approved fence could be flipped to `rejected` — and admin delete then skipped the Koji cleanup, because that was gated on the status being exactly `approved`. The local row disappeared while a public, selectable fence stayed in the shared project with nothing able to manage it, recoverable only by editing Koji by hand. Reject now refuses anything not awaiting review, and both delete paths clean Koji whenever the geofence was ever promoted, using the promoted name it is actually known by. The owner's own delete had the same hole and is fixed with it. +- **Geofence review endpoints answered 404 for bad input** ([#421](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/421)). Approve, reject and admin delete mapped every failure to 404, so an admin who typed a promoted name containing a slash was told the submission did not exist while it sat visible in the list in front of them. Validation and state failures now return 400 with the reason; 404 means the submission is genuinely gone. The promoted-name field also validates in the browser now, against the same rules the server applies, so ordinary names like `Downtown / Uptown` are caught before the request rather than after it. +- **Approving a custom geofence unsubscribed the owner from it, and from every other custom geofence they had active** ([#408](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/408)). When an admin approved a submission under a different public name, the area swap went through PoracleNG's `setAreas`, which intersects the submitted list against `userSelectable=true` fences for non-admins. Both names failed that test — the original because user-drawn geofences are served `userSelectable=false`, and the promoted one because PoracleNG had not reloaded its fence list yet — so the owner's entire custom-geofence subscription set was stripped while approve still returned 200. Neither the admin nor the owner was told, and the owner simply stopped receiving alerts, including for the area they had just had approved. The swap now writes through `IUserAreaDualWriter`, the same path the Areas page already uses to work around this behaviour, and preserves per-profile activation: a geofence active on one profile and not another stays that way. +- **Editing a quick-pick alarm made that quick pick impossible to remove** ([#403](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/403)). PoracleNG rewrites a tracking row on edit for every alarm type except monsters, so the row comes back with a new uid. Quick-pick applied state stores the uids captured at apply time, so after any edit the stored uid pointed at a row that no longer existed: **Remove** deleted nothing and still reported success, and the next page load — seeing none of the tracked uids alive — decided the user had deleted the alarms by hand and cleared the applied state, so the UI stopped offering to remove it at all. The alarm kept firing with no way to get rid of it short of deleting it manually. Alarm edits now move the stored uid with the row. A regression test fails the build if a new alarm service rotates uids without wiring this up. +- **Editing a Max Battle alarm destroyed its move and evolution filters** ([#412](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/412)). The edit dialog hardcoded `move: 9000` and `evolution: 9000` — the "any" sentinel — while reading every neighbouring field off the existing alarm. Changing the distance, or anything else, therefore reset both filters and widened what the user got alerted on. Neither dialog can set those filters (they come from the bot), so once wiped they could only be restored through the bot or a direct API call, and the card's move pill simply vanished. Both values are now carried through from the existing alarm. The backend was never at fault: `MaxBattleUpdate` treats null as "leave alone", and an update omitting the two fields always preserved them. +- **"Track all invasions" failed every single time, and so did the "All Invasions" quick pick** ([#416](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/416)). Both asked PoracleNG to track invasions with no grunt type — the toggle posted `gruntType: null` and the quick pick shipped with empty filters — and both were coalesced to an empty string on the way out. PoracleNG has no catch-all: it rejects an empty `grunt_type` with `400 Grunt type mandatory`, which PoracleWeb turned into a generic 500 and a "failed to create" toast. The save button was never disabled, so the feature looked available and was not. Both now fan out over the Team Rocket grunt types, which is what the toggle's own hint already promised ("Creates a single alarm for every grunt type, leader, and Giovanni"). Pokestop event types are excluded — they are not Rocket invasions and have their own section. A missing grunt type is now rejected at the API edge with a 400 that says so, instead of being forwarded upstream to fail. +- **`GET /api/masterdata/grunts` returned 500 to every caller** ([#419](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/419)). The proxy requested `/api/config/grunts`, a path neither supported backend serves, so the call always 404'd upstream and `EnsureSuccessStatusCode()` turned that into an unhandled exception — which also left the controller's own "Grunt data not available" branch permanently unreachable. Corrected to `/api/masterdata/grunts`, which PoracleNG does serve; the route now returns the full grunt table. Upstream failures return `null` rather than throwing, matching the sibling calls in the same file, so a real outage produces the intended 404 instead of a 500. +- **The Cleaning page was broken three separate ways** ([#402](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/402)). **Max Battles duplicated every alarm on each click**: cleaning is a fetch-modify-POST that assumes PoracleNG upserts on `uid`, but its maxbattle create is insert-only, so each toggle inserted a full duplicate set while the originals kept `clean=0` — unbounded growth, one set per click. That type now frees its rows before re-creating them, and restores the originals if the re-create fails; the other types still upsert untouched. **Fort changes silently did nothing**: PoracleNG's `FortTracking` has no clean column at all (confirmed in its schema and its struct), yet the toggle returned `{"updated":N}` with a success message and wrote nothing. Fort changes are removed from the cleaning page, the status response and the API, which now returns 400 rather than faking success. **The bulk toggle applied partially and then failed**: every per-type toggle re-checks its own feature gate, so one admin-disabled alarm type threw partway through — a 403 to the caller with the types processed before it already written. It now skips disabled types and reports which were skipped, so the operation is all-or-nothing per type instead of silently half-applied. + +### Removed + +- **Eight admin settings that saved but did nothing** ([#547](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/547)): the Patreon and PayPal links, the analytics ID, the map provider URL, the register and location commands, the HTTPS marker and the debug flag. Each described behaviour the app does not have — the HTTPS one in particular reads as a security control and changed nothing. They are legacy Poracle keys that were never wired to anything; existing values are left in the database, unread. +- **The auto-delete setting on Fort Change alarms** ([#437](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/437)). PoracleNG has no such field for fort changes, so the toggle was discarded on every save and the badge on the card could never be true. Split out of [#402](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/402), which removed the same dead setting from the Cleaning page. +- **Two admin toggles that never did anything** ([#420](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/420)). "Disable Map View" and "Disable Map Area Selection" describe a PoracleJS map picker that PoracleWeb does not have, so there was nothing to connect them to. Guessing at a meaning would have been worse than removing them. Any values already saved are ignored and harmless. + +## [2.13.0] - 2026-08-08 + +### Changed +- **Documented that four admin toggles do nothing.** `disable_nominatim`, `disable_geomap`, `disable_geomap_select` and `disable_userlist` are legacy PoracleJS `pweb_settings` keys carried across by `SettingsMigrationService`; nothing in PoracleWeb.NET reads them, front-end or back-end. They are inert switches that mislead operators — `disable_nominatim` in particular does **not** stop third-party geocoding, because PoracleWeb never calls Nominatim at all (the bot does). Recorded in CLAUDE.md with the recommendation to remove them from the admin settings page rather than invent server-side behaviour for features this application does not have. +- **Geofence review threads now carry enough to decide on** ([#393](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/393)): the Discord submission post showed a name, a region, a vertex count and a map, none of which answer the question a reviewer actually has. The biggest omission was **size** — approving an area publishes it to everyone's area picker, so the real gate is neighbourhood versus whole metro, and the map could not show it: a static map auto-zooms to fit, so a city park and an entire county render as identical pictures at identical dimensions. The card now leads with the area in km² plus a plain-language band (`4.2 km² · neighbourhood`), then the region, then the lowercase name the area would take publicly (previously invisible, though it is what approval actually publishes into a shared namespace). Below that sits the centroid with a maps link, which matters most in the case where region auto-detection found nothing — that now reads `Not detected` rather than a blank field. When the submission's centre falls inside an existing public area, the card names it, so duplicates are visible without opening a map; this is a centroid test against the cached Koji fences (with their precomputed bounding boxes as a pre-filter), not a true overlap measurement. `Points` is gone: vertex count is engineering trivia that never changed an approve/reject decision, and it was holding one of three prime inline slots. The submitter moves from an embed field to the native author block, with the `<@id>` mention kept in the message text so it stays clickable, and the embed carries a native timestamp since staleness is a triage signal in a queue. Two presentation problems go with it. The **embed colour was inert** — the same blue on every post forever, including after a verdict — and now tracks status: amber pending, green approved, red rejected. And the **opening post never updated**: approval posted a *new* message and left the original looking permanently pending, so anyone opening the thread later had to scroll to learn the outcome and the published name. The opening embed is now rewritten in place; because a forum post's starter message shares the thread's ID, this needed no schema change, and the map is re-referenced by attachment ID rather than re-uploaded or pinned to its expiring signed CDN URL. A failed edit still lets the verdict reply through. New optional `PUBLIC_URL` setting links the embed title straight to the admin review page; the link is simply omitted when it is unset. The polygon area, centroid and containment maths are ported from the frontend's `geo.utils.ts` into a new server-side `GeoMath`, with tests deriving expected values from the sphere's radius rather than from the implementation so the two copies cannot silently drift. + +### Fixed +- **Language reads and writes 404'd whenever the JWT profile claim was stale** ([#424](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/424)): both handlers resolved the user with `GetByIdAndProfileAsync`, which fetched the human and then discarded it unless `current_profile_no` matched the token's claim. PoracleNG's active-hours scheduler and the bot's `!profile` command both change that value out of band — the codebase already documents this as normal and `/api/auth/me` exists to resolve it — so the language selector would read null and fall back to `localStorage`, and a save would roll back with `AREAS.SNACK_LANGUAGE_FAILED`, while every other endpoint in the same controller worked fine. +- **Retired the profile-filtered human lookup entirely.** `humans` is keyed on `id` alone, so `GetByIdAndProfileAsync` was a primary-key lookup with an extra predicate that could only ever return a spurious null. It had no correct use, and all three call sites were bugs: the raw Discord ID on the geofence review card (fixed earlier in this release), the language 404 above, and a silent no-op in the geofence approval area-swap fallback where a failed proxy call would fall back to a lookup that returned nothing. The method is removed from `IHumanService`, `IHumanRepository` and both implementations; callers use `GetByIdAsync`. Two unit tests that asserted the profile filter as intended behaviour went with it, and a new guard test fails the build if any `*AndProfile*` human lookup is reintroduced. +- **Ordinary client input returned 500 across nine endpoints** ([#418](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/418)): unknown values, missing fields and over-long strings raised unhandled exceptions instead of a 4xx, so callers got `{"error":"An unexpected error occurred."}` and operators got unhandled-exception log noise that masks real faults. Several were reachable by any authenticated user. Now: an unknown cleaning alarm type returns **400** naming the valid types (`pokemon` is a natural guess for `monsters` and used to 500); applying or re-applying an unknown quick pick returns **404**, matching what its sibling GET and DELETE already did; a quick-pick `clean` outside 0–7 or a negative `distance` is rejected **before** any alarm is created, where the old throw landed after the alarms were written but before the applied-state row, leaving the pick un-applied with no cleanup path; `PUT /api/areas` with a null entry returns **400** rather than throwing a `NullReferenceException`; creating a profile with no name returns **400 "Profile name is required."**, the same message the duplicate endpoint already returned; and over-long `language` and `webhookId` values are rejected instead of overflowing their columns. +- **Summary schedules accepted an uppercase alert type then silently misbehaved** ([#418](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/418)): `ValidAlertTypes` is case-insensitive by design, but the value was forwarded unnormalised to a case-sensitive backend. `PUT`, `DELETE` and trigger all returned 500 for `QUEST`, and — worse — `GET` returned **200 reporting an empty schedule** for a user who had one, so a read-modify-write client would have wiped their schedule. The alert type is now lowercased at all four call sites after validation. +- **Admin ban/unban returned 500 on every call** ([#404](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/404)): `PoracleHumanProxy.AdminDisabledAsync` posted `{"adminDisable": 0|1}`, but PoracleNG's `adminDisabledRequest` is `State *bool` under the key `state` and answers `400 "state is required (true/false)"` to anything else. `EnsureSuccessStatusCode()` turned that into a generic 500, so the Ban and Unban buttons failed against **every** PoracleNG, production included — and failed as a no-op, leaving the flag unchanged. Now sends `{"state": true|false}`. The unit test asserted the wrong key against an always-200 mock, which is exactly why the drift survived; it now asserts the key PoracleNG declares and that the old one is absent. +- **Admin webhook creation returned 500 on every valid request** ([#405](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/405)): PoracleNG's `createHumanRequest` declares `Enabled` and `AdminDisable` as `*bool`, while these are stored here as `int` and were serialized as JSON numbers — rejected with `json: cannot unmarshal number into Go struct field createHumanRequest.enabled of type bool`. Only the validation branches (blank name/url, duplicate) ever worked; the happy path could not succeed. Both fields are now sent as booleans. Confined to admin webhook creation — `IHumanService.CreateAsync` has one non-test caller and the login paths do not use it. +- **New Quick Picks were saved with an empty id** ([#413](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/413)): the create dialog has no id field and sends `""`, which the repository stored verbatim. Every id-bearing route then collapsed to `/api/quick-picks/` and could not match — delete returned 405, apply returned 404, and the pick could not be removed through any API path. Ids are now generated server-side from the name (`"Hundo IV!"` → `hundo-iv`), with a numeric suffix on collision and a guid fallback, so any client that omits one gets a working pick. The related cross-account clobber — a second empty-id pick taking over the first — was already closed by the quick-pick ownership check earlier in this release. **Existing rows with an empty id are not migrated**; they must be removed directly in `poracle_web.quick_pick_definitions`. +- **Editing a lure or an invasion failed with a 500 and silently discarded the change** ([#401](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/401)): these are the only two tracking tables PoracleNG guards with a unique index over a natural key — `lure_tracking(id, profile_no, lure_id)` and `invasion_tracking(id, profile_no, gender, grunt_type)`; every other type is unique on `PRIMARY(uid)` alone. PoracleNG's create handler treats a row as "already present" only when *every* field matches, so changing a field **outside** that key (a lure's distance, template or clean) was not recognised as an existing row and it attempted an `INSERT` that collided with the index. MariaDB raised `Error 1062 Duplicate entry`, PoracleNG answered `500 {"message":"database error"}`, and PoracleWeb surfaced a generic failure while the row stayed exactly as it was. Changing the radius on a lure was simply impossible. Both services now replace the row — delete first so the natural key is free, then create — via the new `NaturalKeyTrackingUpdate`, which also **restores the original row if the re-create fails**, so a failed edit can no longer destroy the alarm. The remaining types keep the cheaper `TrackingUpdateReconciler` path, since their creates cannot collide. A consequence worth knowing: the `uid` now rotates on every lure/invasion edit, which is unavoidable while PoracleNG has no upsert for these types — the underlying gap is written up in `docs/poracleng-enhancement-requests.md` with a reproduction, and closing it upstream would let this workaround and its uid churn go away. +- **Editing an alarm created a duplicate instead of updating it, on seven of ten alarm types.** Updates are sent to PoracleNG as a create carrying the existing `uid`, which it normally treats as an upsert — but it dedups each tracking type by a natural key (egg level, raid team/exclusive, quest reward, lure id, and the nest/gym/fort filter fields), so an edit that changes a field in that key made it **insert a new row** rather than update the one the uid points at. The pre-edit row survived, so the user ended up with two live alarms, one still matching the filter they thought they had changed, and received duplicate DMs. The API compounded it by returning `200` with a body carrying the *old* uid and the *new* values — describing a row that does not exist — so the UI showed a success message and repeated edits grew the list without bound. Raids, eggs, quests, lures, nests, gyms and fort changes were affected; monsters were not. `InvasionService` and `MaxBattleService` already handled this, so the pattern existed in-tree and simply had not been applied to the rest. The reconciliation now lives in one place, `TrackingUpdateReconciler`, which detects an insert from the create response, deletes the superseded row, and returns the uid that actually survived. The upsert path is untouched, so a uid only changes when PoracleNG genuinely made a new row, and a failed cleanup logs a warning rather than failing an edit that already applied. `InvasionService` was collapsed onto the same helper so there is a single implementation for the next alarm type to inherit. Verified live against PoracleNG: editing an egg, raid, quest and gym each leave exactly one row where they previously left two. +- **Discord avatars never loaded on the admin user list** ([#395](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/395)): `DiscordAvatarComponent` batches the user IDs it needs and POSTs them to `/api/admin/users/avatars` — an endpoint that has never existed in the .NET API. `git log -S` finds no trace of it server-side; the client call predates the rewrite and was never ported, so every avatar silently fell back to its placeholder. It went unnoticed because `error.interceptor.ts` lists the path in `SILENT_URL_PATTERNS`, which is correct behaviour for an avatar lookup but makes a permanently dead route indistinguishable from a blip. The endpoint now exists, reading the same `AvatarCacheService` that `GET /api/admin/users` already uses per row, so it is a lookup rather than a fetch and never calls Discord. It is admin-gated (the component is used only on the admin user list), deduplicates IDs, skips blanks, and caps a batch at 200. +- **Max Battle move names never rendered** ([#396](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/396)): the same shape of bug. `SettingsService.getConfig()` called `/api/settings/config`, which does not exist either — the **405** rather than a 404 was the giveaway, since the request matched `PUT api/settings/{key}` with `key = "config"` on path but not on method. The subscription errored, `this.moves` stayed empty, and every charged move rendered as `Move #123`. Move names come from the WatWowMap masterfile that `MasterDataService` already downloads and caches for 24 hours — it parsed `monsters` and `items` out of it and ignored the 390-entry `moves` section sitting alongside them. It now caches moves too and serves them from a new `GET /api/masterdata/moves`, mirroring the existing items endpoint, and the Max Battle list reads names through `MasterDataService.getMoveName()` like every other list reads Pokemon and item names. The dead `getConfig()` method, its unused `PoracleConfig` interface (distinct from the still-used `PoracleServerConfig`), and the spec that asserted the client's shape against a mocked response — passing happily while the endpoint 405'd in production — are all removed. +- **Geofence submission threads lost their map image** ([#391](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/391)): when a user submits a custom geofence for review, the Discord forum post is meant to show a static map of the polygon. It did — for a few hours. `SubmitForReviewAsync` asks PoracleNG for a map (`GET /api/geofence/{area}/map`), which answers with a **pregenerated tileserver-cache URL**, and that URL was set directly as `embed.image.url`. tileserver-cache evicts pregenerated tiles, so the link (and Discord's `images-ext-1` proxy of it, which re-fetches the origin) starts returning 404 and the embed renders with no image. Confirmed on production: a freshly requested area tile returns `200 image/png` while the tile embedded in a June submission thread returns `404`. Nothing was wrong at the PoracleNG end — the endpoint works, the static provider is configured, and user-drawn geofences are present in PoracleNG's fence state. The map bytes are now downloaded at post time and uploaded to Discord as a real message attachment (`multipart/form-data`, `payload_json` + `files[0]`, referenced from the embed as `attachment://geofence-map.png`), so Discord hosts the image with the message and it survives tile eviction. The download uses a separate unauthenticated `HttpClient` so the bot token is never sent to the tileserver, and is capped at 8 MB; if it fails for any reason the post falls back to linking the URL exactly as before, and to no image when there is no URL at all. Two smaller problems found alongside it are fixed too: an unresolved region emitted `{"name": "Region", "value": ""}`, which Discord renders as a blank embed field and now reads `Unassigned`; and a failed map fetch left no trace at all, because `GetAreaMapUrlAsync` returns `null` on any non-2xx while the caller only logged thrown exceptions — a null URL and a failed attachment download are now both logged as warnings. Threads created before the PoracleNG migration have no image object at all (the static provider was unconfigured then) and are not retroactively fixed. + +### Security +- **`GET /api/settings` served credential-bearing settings to every authenticated non-admin.** The endpoint is deliberately not admin-gated (the SPA reads branding and feature flags from it on every login), and filtered secrets with an exact-match denylist. That denylist contained the literal `scan_db`, which matches no real key — the rows are `scan_dbhost` / `scan_dbuser` / `scan_dbpass` / `scan_dbport` / `scan_dbname` — and omitted `cf_id` / `cf_secret` entirely. Only `api_secret` and `telegram_bot_token` were actually withheld, so a scanner-database password and a Cloudflare Access service token were downloaded into every ordinary user's browser at login and held in the `siteSettings` signal. The filter is now an **allowlist**: non-admins receive the eight branding/config keys the SPA reads by name plus the `disable_*`, `enable_*` and `uicons_*` families it reads dynamically, and nothing else. A newly added credential key is therefore hidden by default rather than exposed by default. Admins still receive everything, so the admin settings page is unchanged. **Operators should rotate any scanner-DB and Cloudflare Access credentials that were stored in site settings**, since they have been readable by every logged-in user. Regression tests cover the real key names — the previous test only exercised `api_secret`, which the broken denylist did catch, which is why this shipped. +- **Any authenticated user could overwrite, steal or read any quick pick.** `QuickPickDefinitionRepository.CreateOrUpdateAsync` upserts on `Id` alone, and the id arrives from the request body, so posting a global pick's well-known id (`hundo`, `nundo`, `raid-5star`, …) to `POST /api/quick-picks/user` rewrote that row: scope flipped `global` → `user`, ownership moved to the caller, and the pick vanished from every other user's list. The same worked against another user's private pick. Two related gaps went with it: `GET /api/quick-picks/{id}` returned any pick by id, exposing another user's private pick and their Discord ID in `ownerUserId`; and `LoadDefinitionAsync` resolved the unscoped lookup first, so `POST /api/quick-picks/{id}/apply` on someone else's private pick succeeded and created real alarms. The write path now rejects an id owned by anyone else with 403, the read is ownership-scoped (global picks public, user picks owner-only), and apply resolves through the same scoped path. `DeleteUserPickAsync` already scoped correctly, which is what made the write path's omission clearly unintentional. +- **Three admin feature toggles were enforced only in the browser.** `disable_areas`, `disable_profiles` and `disable_location` hid their nav item and nothing more — `/api/areas`, `/api/profiles`, `/api/profile-overview` and `/api/location` stayed fully open, so any logged-in user could keep using a feature the operator had switched off simply by calling the API directly. No client tampering was even required, though that works too: the settings live in an in-memory Angular signal that devtools can edit. All four controllers now carry `[RequireFeatureEnabled(...)]` and return the standard `403` + `disableKey` body, and `/areas` and `/profiles` gain the `disabledFeatureGuard` they were missing (they had a nav `disableKey` but no route guard). Admins are blocked too, matching the existing rule that a toggle means "nobody uses this feature". `GET /api/auth/me` is deliberately **not** gated by `disable_profiles`, so the JWT profile resync keeps working and PoracleNG's active-hours scheduler can still move a user between profiles. Impact was limited — these gate features users are normally entitled to use, and every endpoint still enforces per-user ownership, so this was an operator-intent bypass rather than privilege escalation. New `FeatureGateCoverageTests` fails the build if a key is added to `DisableFeatureKeys` without a controller enforcing it, so the next toggle cannot ship client-only. + +## [2.12.1] - 2026-08-05 + +### Fixed +- **English UI showed raw translation keys (`NAV.DASHBOARD`, `AUTH.SIGN_IN`) after upgrading to v2.12.0.** The ngx-translate v18 upgrade moved `defaultLanguage` to `fallbackLang`, but v18 loads the fallback language *eagerly from inside the `TranslateService` constructor*. Setting it in `provideTranslateService()` therefore resolved `TranslateLoader` while the injector was still building `TranslateService`, failing with `NG0200: Circular dependency detected`. Only the fallback language was affected, so English rendered raw keys while every other locale — loaded later via `use()` — worked normally. The fallback is now set in `I18nService.init()` after bootstrap, where the injector is complete. Regression tests exercise the real `appConfig` providers, so re-adding `fallbackLang` to the provider config fails CI. + +## [2.12.0] - 2026-08-05 + +### Added +- **`GET /api/version` reports the running build.** Returns `version`, `revision` (git SHA), `revisionShort`, `buildDate` and `environment`, so you can confirm what a deployment is actually serving with a single request. The image's OCI labels already carried this, but labels are only readable via `docker inspect` on the host — no help for checking an instance from outside, and absent entirely from locally-built images. CI now passes `BUILD_VERSION` / `BUILD_REVISION` / `BUILD_DATE` as Docker build args from the same metadata that produces the labels; builds without them report `unknown` rather than failing. The endpoint is anonymous by design (the repository is public, so the commit SHA is not sensitive, and no configuration or secret is exposed). +- **Generic external SSO / OIDC login provider** ([#327](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/327)): PoracleWeb can now delegate login to any external OAuth2/OpenID Connect provider, in addition to the built-in Discord and Telegram methods. This enables single sign-on — e.g. pointing PoracleWeb (`alerts.pogoalerts.net`) at the PogoAlerts OAuth2 server so a user who is already signed into the main site lands in PoracleWeb without re-authenticating — but it is fully **provider-agnostic**: any self-hoster can configure their own IdP. The implementation is a configurable twin of the existing Discord flow. Two new endpoints (`GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback`) handle the authorization-code exchange with **PKCE** (state + verifier persisted in HttpOnly cookies, same CSRF protection as the Discord path), then read a configurable **identity claim** (default `discord_id`, falling back to the standard `sub`) from the provider's UserInfo response and look it up in the Poracle `human` table exactly as a direct Discord login would — so existing admin resolution (`GetRolesAsync`), Discord guild-role gating, and the per-user enable/disable all apply unchanged, and PoracleWeb still mints and validates **its own** JWT (no change to token issuance). Provider config (provider name, authorize/token/userinfo URLs, client id/secret, scopes, claim mapping, PKCE flag) comes from `OIDC_*` env vars / `appsettings` — the secret is never stored in the database — and `OIDC_ENABLED` is auto-inferred when the client id and three URLs are all present (same first-time-setup safeguard as Telegram). A separate `enable_oidc` site setting gives admins a runtime on/off toggle (Features → *External SSO* group on the admin settings page; carried by `SettingsMigrationService`), while admins can always log in even when it's disabled so they can re-enable it. The login page renders a "Sign in with {provider}" button (with the same disabled-by-admin hint pattern as Discord/Telegram) whenever the provider is configured, driven by a new `oidc` block on `GET /api/auth/providers`; a new `/auth/oidc/callback` route reuses the existing token-fragment callback handler. New `OIDC_*` keys documented in `.env.example`, new `AUTH.SIGN_IN_OIDC` / `AUTH.ERR_OIDC_*` and `ADMIN_SETTINGS.*_OIDC` / `GROUP_OIDC` i18n keys added to English (other locales fall back to English until translated). Backend tests cover the `providers` oidc block (configured / not-configured / admin-disabled) and the `/oidc/login` redirect (state + PKCE cookies, provider URL + params); frontend tests cover the OIDC button visibility and click delegation. Wiring ReactMap and the PogoAlerts main site to the same provider, and PogoAlerts-side cross-subdomain session cookies, are separate follow-up work. +- **OIDC refresh-token consumption — silent session renewal + revocation propagation** (opt-in, provider-agnostic): building on the OIDC login above, PoracleWeb can now optionally consume the provider's **refresh token** instead of discarding it, so an SSO session renews silently in the background (no 24-hour hard re-login) and a disable/logout at the provider propagates to PoracleWeb within one short access-token lifetime. It is **off by default** (`OIDC_USE_REFRESH_TOKENS=false`) — existing deployments and providers that don't issue refresh tokens are completely unaffected (the login cleanly falls back to a standard full-lifetime session). The provider refresh token is brokered **entirely server-side**: it's encrypted at rest with DataProtection in a new `oidc_sessions` table (added via EF migration `AddOidcSessions`) and **never sent to the browser**; the browser instead holds an opaque PoracleWeb token in `localStorage` that keys a rotation **family**. A new `POST /api/auth/oidc/refresh` endpoint redeems the stored refresh token against the provider, **re-validates the user live** (existence, `enable_oidc` gate, role access, admin-disable) on every refresh, rotates both tokens, and family-revokes on replay/reuse or when the provider rejects the refresh (revocation propagation); `POST /api/auth/oidc/refresh/revoke` ends a session on logout, and an `OidcSessionCleanupService` reaps expired/stale rows. Refresh-backed OIDC sessions get a short **per-login** JWT (`OIDC_ACCESS_TOKEN_MINUTES`, default 30) while Discord/Telegram/local logins keep the 24-hour JWT — the lifetime override is scoped so non-refresh logins aren't shortened. The implementation is **fully OIDC-provider-agnostic**: `OIDC_OFFLINE_ACCESS_SCOPE` (default `offline_access`) is appended to the authorize request so standards-compliant providers issue a refresh token; `OIDC_TOKEN_AUTH_METHOD` supports both `client_secret_post` and `client_secret_basic`; non-rotating providers (no new refresh token on refresh) are handled by carrying the prior token forward; and nothing relies on discovery/JWKS/`id_token`. The frontend adds a single-flight `TokenStoreService` + an `oidcRefreshInterceptor` (proactive pre-expiry refresh and reactive 401-retry, with a null-refresh-token guard so every non-refresh login keeps the existing "401 → logout" path). Refresh on/off is controlled solely by the `OIDC_USE_REFRESH_TOKENS` env flag — there is intentionally **no** runtime admin toggle, since refresh is coupled to the per-login JWT lifetime (disabling it mid-session would strand already-issued short-lived tokens); its active state is surfaced read-only on `GET /api/auth/providers` (`oidc.refresh`) and `GET /api/settings/oidc-config`. New `OIDC_*` keys documented in `.env.example` with a per-provider config matrix (PogoAlerts, Keycloak, Authentik, Auth0, Google, Azure AD/Entra, Okta), and a full **OIDC Refresh Tokens** documentation page (configuration reference, five Mermaid flow diagrams, the provider matrix, and the security model) added to the docs site. Backend tests cover the session rotation/replay/cap/cleanup mechanics and the provider-agnostic client (auth method, optional/non-rotating refresh tokens); frontend tests cover the token store's single-flight refresh and the interceptor's proactive/reactive/loop-guard behavior. + +### Changed +- **Removed stale AutoMapper references from the docs site** ([#241](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/241)): AutoMapper was dropped in v2.6.0 ([#173](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/173)) in favour of manual mapping extensions, but four docs pages still described it as the live mapping layer — `architecture/backend.md` even carried a `.ForAllMembers(opts => opts.Condition(...))` snippet that exists nowhere in the codebase. The mapping sections in `architecture/overview.md` and `architecture/backend.md` now describe `AlarmMappingExtensions` (`To*()` / `ApplyUpdate()`) and `EntityMappingExtensions` (`ToModel()` / `ToEntity()` / `ApplyTo()`), with a real `ApplyUpdate` snippet showing the explicit null-skip guards; the `Core.Mappings/` line in the solution tree, the test-coverage bullet in `development/testing.md`, and a passing mention in `architecture/poracleng-proxy.md` are corrected to match. The last piece of AutoMapper residue outside the docs goes with it: the mapping test file was still named `PoracleMappingProfileTests.cs` while the class inside it had been renamed to `MappingExtensionTests`, so the file is renamed to match. No behaviour change. +- **Renamed `ScannerDbContext` to `ScannerContext`** ([#240](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/240)): the scanner DB context was the only one of the three `DbContext` subclasses carrying a `Db` in its name, out of step with its siblings `PoracleContext` and `PoracleWebContext`. Renamed the class, its file, the `DbContextOptions<>` type argument, the `ScannerService` constructor parameter and field, and the `AddDbContext<>` registration, plus the two doc mentions. Purely cosmetic — no functional change: `IScannerService` is untouched, the optional `ConnectionStrings:ScannerDb` key is unchanged, no EF migrations are involved (the scanner DB is read-only and never migrated by PoracleWeb), and there is no wire-contract or config impact for self-hosters. +- **Localized the external SSO / OIDC strings** across all bundled locales. The SSO login feature added 30 i18n keys to English only, so every non-English locale fell back to English for the "Sign in with {provider}" button, the signed-out panel, the OIDC error messages, and the admin Authentication / External SSO settings group. These are now translated into Danish, German, Spanish, French, Italian, Dutch, Polish, Portuguese (PT & BR), and Swedish. Translation-only — no code or behavior change. +- **Admin Server Settings page UX overhaul.** Adds a live **search/filter** (sticky bar, match highlighting, `/` or Ctrl/Cmd+K to focus), a **sticky save + discard bar** so saving is always reachable on the long page, **sign-in providers grouped under Authentication** (Telegram/Discord moved up), and **collapsible sections** (persisted) with per-section "unsaved" chips and state summaries (e.g. "7 of 9 enabled"). Headline fix: the alarm-type/feature toggles were a confusing **double negative** ("Disable X", ON = feature off) mixed with positive `enable_*` toggles; they are now **uniformly positive** (ON = enabled, labels are the feature name, descriptions are "Let users …"). The stored `disable_*` keys are **unchanged** — a presentation-only inversion — so backend feature-gating is unaffected. New UX i18n keys and the reframed positive labels/descriptions are translated across all 11 locales. + +### Fixed +- **Containers reported `unhealthy` while serving traffic normally** ([#239](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/239)): the Compose healthcheck probes the app with `curl -sf http://localhost:8080/`, but the `mcr.microsoft.com/dotnet/aspnet:10.0` runtime base (Ubuntu 24.04) ships neither `curl` nor `wget`, so every probe failed with `/bin/sh: 1: curl: not found` and Docker flipped the container to `unhealthy` after three tries. ASP.NET Core was live the whole time, so the impact was cosmetic on a single host — but Swarm, Kubernetes, and auto-healer scripts treat `unhealthy` as a restart/evict signal, so it would misfire in any real deployment. The runtime stage now installs `curl` (`--no-install-recommends`, apt lists removed, ~6.5 MB) before dropping to the `appuser` account. Verified in a built image: `curl 8.5.0` resolves and runs as `appuser`. +- **Role gating required *every* listed role, and quoted values locked everyone out** ([#367](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/367)): `allowed_role_ids` is described everywhere as an allow-list of Discord roles that grant access, but `CheckRoleAccessAsync` compared with `HashSet.IsSubsetOf`, which only returned true when the user held **all** of the listed roles. Configuring `123,456` therefore denied anyone who had just one of them. It now matches on `Overlaps` — holding any one listed role is enough. Two smaller problems around the same setting are fixed with it. The setting's tooltip rendered its example wrapped in quotes (`"123456789,987654321"`), so admins pasted the quotes in and the comma-split produced entries like `"123456789` that can never equal a Discord role ID; every non-admin was then denied with nothing but an info-level log to explain it (admins bypass the role check, which is why the site looked admin-only). Values are now parsed with surrounding quotes stripped — straight, curly, guillemet, and low-9 variants, matching the quote styles used across the translated tooltips — and entries that aren't numeric snowflakes are dropped and logged as a warning instead of being kept as unmatchable garbage. If a non-empty setting yields no usable IDs at all, non-admin logins are refused with `role_check_failed` and an error log rather than silently falling open to "allow everyone". Finally the tooltip copy (all 11 locales) and the settings/SSO docs now drop the misleading quotes and state the any-of semantics outright. Both callers are affected — the Discord OAuth callback and the OIDC/external-SSO path share this check. Unit tests cover the parser (quote styles, whitespace, non-snowflake entries, dedup, empty values) and the any-of grant/deny decision. + +### Security +- **Gym-picker images no longer send a `Referer` header to third-party hosts** ([#242](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/242)): the gym picker renders two kinds of remote image — the scanner DB's `gym.url` photo (a Niantic CDN URL in stock Golbat/RDM deployments, though an operator can rewrite the column to point at a self-hosted mirror) and the team-icon fallback from `raw.githubusercontent.com`. Neither carried a referrer policy, so every image request told the remote host which PoracleWeb instance the user was browsing. All four `` tags in `gym-picker.component.html` now set `referrerpolicy="no-referrer"`. Modern browsers already default to `strict-origin-when-cross-origin`, so the pre-existing leak was the origin rather than the full URL — this closes the remainder. Presentation-only: no API, model, or scanner-query change, and `GymSearchResult.Url` still carries the raw scanner URL as before. The photo-proxy endpoint floated in the original issue was **not** implemented: server-side fetching of a URL supplied by a database PoracleWeb does not own would turn a passive disclosure into an authenticated outbound-request primitive from a host that can reach Poracle, Koji, Golbat, and both MySQL servers. A host allowlist applied at projection remains the cheaper option if a deployment ever needs the mirror case handled. +- **App-wide `Referrer-Policy` tightened to `same-origin`, so no remote host learns the instance origin** ([#383](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/383)): the per-element fix above covered the gym picker, but the same leak existed everywhere else the SPA loads a remote resource — uicons from `raw.githubusercontent.com` (`icon.service.ts`, operator-overridable, so possibly a self-hosted mirror) across the Pokémon/raid/egg/lure/invasion/gym/quick-pick lists and dialogs, Discord avatars from `cdn.discordapp.com`, and the Google Fonts stylesheets in `index.html`. Each request disclosed the origin of the PoracleWeb instance being browsed, which for a private or invite-only deployment is the part worth withholding. The security-headers middleware previously sent `strict-origin-when-cross-origin` (the browser default, which sends the origin cross-origin); it now sends `same-origin` — full referrer within the site, nothing at all to third parties — fixing every case in one place rather than annotating tags individually. `no-referrer` was considered and rejected: `AuthController` reads the `Referer` header on `DiscordLogin`, the OIDC login path, and OIDC RP-initiated logout to recover which frontend origin the user came from, validate it against the configured CORS origins, and redirect back there after the provider callback — blanking the same-origin referrer would degrade all three to this host's own origin and bounce users to the wrong place. The header values moved out of the inline lambda in `Program.cs` into a `SecurityHeaders` class so they're assertable without booting the app; the CSP is carried over byte-identical (a test pins it against the original literal). Tests cover the policy value, a guard that it never becomes `no-referrer` or any of the origin-leaking values, and the previously untested `AuthController` origin recovery it depends on (allowed referer honored, disallowed and non-absolute referers rejected, absent referer falling back to self). The per-element `referrerpolicy` attributes from #242 are left in place as defence-in-depth. + +### Dependencies +- Bump Microsoft.AspNetCore.Authentication.JwtBearer and 6 others ([#363](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/363)) +- Bump Microsoft.EntityFrameworkCore and 4 others ([#366](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/366)) +- Bump Microsoft.NET.Test.Sdk from 18.6.0 to 18.8.1 ([#365](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/365)) +- Bump jest-preset-angular ([#344](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/344)) +- Bump the angular group across 1 directory with 13 updates ([#338](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/338)) +- Bump the angular group across 1 directory with 9 updates ([#359](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/359)) +- Bump the eslint group across 1 directory with 5 updates ([#349](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/349)) +- Upgrade @ngx-translate to v18 and migrate off TranslateModule ([#377](https://github.com/PGAN-Dev/PoracleWeb.NET/pull/377)) + +## [2.11.1] - 2026-06-05 + +### Fixed +- **Base/regional-default Pokémon forms (e.g. Unova Stunfisk) were missing from the form picker** ([#323](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/323)): the *Form & Gender* form picker in the Pokémon add/edit dialogs is built from the WatWowMap masterfile in `MasterDataService.loadForms()`, which discarded every form named `Normal` (alongside the synthetic id-0 "any" pseudo-form). For most Pokémon that's harmless, but for a species with a regional variant the `Normal` entry **is** the original/base form (Stunfisk lists `Normal` id `2246` for Unova and `Galarian` id `2345`), so dropping it left only "All Forms" and "Galarian" — there was no way to alert on Unova Stunfisk alone (e.g. for PVP) without also catching Galarian. The loader now keeps all real forms (`form.id !== 0`, including `Normal`) and only drops a `Normal` form when it's a species' **lone** form — where the existing "All Forms" option already covers it — so base regional forms become selectable when a sibling variant exists, while species with just a base form stay uncluttered. Combined with the multi-select picker (#318), users can now target the base form, a regional variant, or both. Unit tests cover the keep-when-sibling and drop-when-lone cases. + +### Security +- **Patched a high-severity `Microsoft.OpenApi` advisory affecting the API host** ([GHSA-v5pm-xwqc-g5wc](https://github.com/advisories/GHSA-v5pm-xwqc-g5wc)): a circular schema reference could terminate OpenAPI parsing. `Microsoft.OpenApi` is vulnerable from `2.0.0-preview.11` through `2.7.4`, and `Microsoft.AspNetCore.OpenApi` pins the vulnerable `2.0.0` transitively — unchanged between 10.0.8 and 10.0.10, so the recent ASP.NET Core bumps did **not** clear it. Added a direct `PackageReference` to `Microsoft.OpenApi` 2.7.5 (the first patched release) in the API project to raise the resolved version; it can be dropped once `Microsoft.AspNetCore.OpenApi` ships a patched floor of its own. Swagger/OpenAPI generation is development-only, so production deployments were not exposed at runtime. + +## [2.11.0] - 2026-06-05 + +### Added +- **Multi-select Pokémon forms in the alarm add dialog** ([#318](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/318)): the *Form & Gender* form picker in the Pokémon add dialog only let users pick **one** specific form or "All Forms", so tracking (e.g.) Meowth's Alola **and** Galarian forms while ignoring Kanto meant adding each alarm by hand. The picker is now a **multi-select** — selecting two forms creates two alarms, one per form. Because PoracleNG models `form` as a single integer per tracking entry (no array support on the wire), the dialog reuses its existing per-Pokémon fan-out (`forkJoin`) and now emits one `MonsterCreate` per **(Pokémon × selected form)** combination; the success snackbar reports the correct total. An empty selection means "all forms" (form `0`), matching the previous default — there's no separate "All Forms" option to mis-toggle, and a "Leave empty to match all forms" hint makes that explicit. No backend, mapping, DB, or PoracleNG change was needed: each form remains its own independent alarm with its own UID, so editing/deleting per-form afterward works through the normal list. Scope is the **add** dialog only — the edit dialog stays single-form, since splitting one existing alarm into several on edit is a different (create-plus-delete) operation. A new dedicated `forms` form control backs the multi-select, leaving the manual numeric form-id fallback (shown when masterfile form data is unavailable) on the original single-value `form` control. New `POKEMON.FORM_MULTI_HINT` i18n key added and translated across all 11 locales. Unit tests cover the fan-out, the empty=all-forms default, the numeric fallback, and the success-count snackbar. + +### Fixed +- **Unable to create a private geofence when Koji has no region hierarchy** ([#314](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/314)): the "Name Your Geofence" dialog forced the user to pick a **region** before the Save button enabled, but the region list is derived entirely from Koji's parent→child geofence structure (`KojiService.GetRegionsAsync` returns only geofences that are referenced as a `parent` by another geofence). On a *flat* Koji project (no nesting — common in simpler/newer setups) the list is empty, so the dropdown showed only the hardcoded "All Regions" sentinel; selecting it left `selectedRegionId` null and the field blank, an inescapable dead end. Region is in fact only needed when an admin later **promotes** a geofence to a public Koji area — a private geofence is stored in PoracleWeb's DB and served via `/api/geofence-feed` **without** a group, so PoracleNG never uses it. Fixes: + - **Region is now optional at creation.** The draw dialog's validation no longer requires a region; a region-less geofence saves with an empty group / `parentId 0`. The "All Regions" sentinel now clears the selector (instead of rendering a blank chip), and **when Koji defines no regions the picker is hidden entirely** rather than showing an empty dropdown. + - **Admins set the region at approval time.** The geofence-approval dialog gained an optional region selector (defaulting to the submission's existing region, hidden when no regions exist); `AdminGeofenceController` / `IUserGeofenceService.ApproveSubmissionAsync` now accept an optional `parentId`/`groupName` override that is applied before promotion and persisted. This moves the region decision to the person who actually manages the Koji project, and leaves it untouched when omitted. + - **Koji `__parent: 0` no longer 500s.** Probing the live Koji API revealed that `save-koji` with `__parent: 0` returns **HTTP 500 `[GEOFENCE]: Does not exist`** (Koji tries to resolve a non-existent parent id 0) even though it persists the row — which would have made *every* region-less promotion throw at `EnsureSuccessStatusCode`. `KojiService.SaveGeofenceAsync` now sends `__parent: null` (Koji's native "no parent" representation, confirmed to return HTTP 200) whenever `parentId <= 0`. Backend (`KojiServiceTests`, controller + service region-override tests) and frontend (dialog optional-region, hidden-when-empty, approval region override) tests added. + +## [2.10.0] - 2026-06-03 + +### Added +- **Notification-language selector on the Areas & Location page** ([#310](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/310)): the `LanguageSelectorComponent` — the only path from the web app to a user's Poracle DM language (`human.Language`, which controls the language of alert text and Pokémon names) — was imported into `app.ts` and styled in `app.scss` but **never placed in any template**, so it was dead code with no way to reach it. Users who wanted German alerts had only the toolbar language menu, which calls `i18n.use()` and changes the **Angular UI translations**, not the bot's DM language. The selector is now rendered in a labelled "Notification language" section on the **Areas & Location** page (where the reporter looked), clearly distinguished from the toolbar display-language menu. Its language list — previously a stale hardcode of 18 languages (incl. ja/ko/zh/ru/no/fi/th/tr) that didn't match the app's supported set — now reuses `I18nService.allLanguages` (the 11 supported locales), so it can't drift again. The component seeds its value from the persisted `human.Language` via a new `GET /api/location/language` endpoint (and reconciles against the bot, which can change the language out-of-band) instead of trusting only `localStorage`, and shows success/failure feedback on save. The dead import and the now-orphaned `app-language-selector` responsive style were removed from the app shell. New `AREAS.NOTIFICATION_LANGUAGE` / `NOTIFICATION_LANGUAGE_DESC` / `SNACK_LANGUAGE_UPDATED` / `SNACK_LANGUAGE_FAILED` i18n keys added and translated across all 11 locales. Whether German **Pokémon names** actually render still depends on the Poracle server having German name/master data loaded for that language — PoracleWeb's responsibility ends at writing `human.Language` correctly. Service and component tests cover the new GET endpoint and the load/save/revert behavior. + +### Fixed +- **Duplicate `allowed_languages` admin setting** ([#308](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/308)): the admin settings page rendered two separate rows that both wrote to the same `allowed_languages` key — "Allowed UI Languages" in the Features group and "Allowed Languages" in the Administration group. Because `admin-settings.component.ts` keys its value map by the setting `key`, the two rows collapsed onto a single entry: editing one visibly changed the other, and on save one could silently clobber the other with an empty value. Removed the redundant Administration-group row (and its now-unused `ADMIN_SETTINGS.ADMIN_ALLOWED_LANGUAGES_LABEL` / `ADMIN_ALLOWED_LANGUAGES_DESC` keys across all 11 locales), keeping the single Features-group "Allowed UI Languages" control whose description matches the actual behavior (filtering the UI language selector). + +## [2.9.0] - 2026-06-03 + +### Added +- **Quest summary delivery schedule management UI** ([#300](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/300), follow-up to [#292](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/292)): the per-alarm quest "Daily summary" toggle (shipped in #292/#295, sets `clean` bit 4) was **inert** — there was no way to tell PoracleNG *when* to deliver the summary, so buffered quests never fired. A new **Quest summary delivery** dialog (launched from the Quests page toolbar menu) lets users view, edit, clear, and force-deliver ("Send summary now") their summary schedule, wired to PoracleNG's `/api/summaries` endpoints. The schedule is a per-user `active_hours` array (`[{day,hours,mins}]`) — the same shape as a profile's active hours — so the dialog **reuses** the existing `ActiveHoursEditorDialogComponent` and `LocationWarningComponent` (the 0,0 → default-timezone hazard applies identically). Backend adds `IPoracleSummaryProxy`/`PoracleSummaryProxy` (mirrors `PoracleHumanProxy`; raw-JSON `active_hours` pass-through; `404 → null`; `503 → SummaryBackendUnavailableException`, treated as a transient backend fault, **not** "feature off") and a `SummaryScheduleController` whose every action derives the user id from the JWT (`this.UserId`) with **no `{userId}` route segment** (IDOR-safe), gated by `[RequireFeatureEnabled(disable_quests)]`, with the trigger rate-limited (`test-alert`, 5/60s) since it delivers a real DM. Capability comes from PoracleNG's `tracking.quest_summary_enabled` config flag (surfaced as `questSummaryEnabled` on `auth/me`, Golbat-style 200 boolean, `IMemoryCache` 5-min; defaults to **off** when the flag is absent so the UI is only shown when PoracleNG will actually buffer and deliver summaries — avoiding a dead-end — and off on fault) — the menu entry is hidden when off, with a `SUMMARY_DISABLED_HINT` on the quest dialogs. "Send summary now" notes that it only flushes quest matches PoracleNG has buffered since the last summary. `ProfileController.ValidateActiveHours` was extracted into a shared `ActiveHoursValidator` reused by both controllers. New `QUESTS.SUMMARY_SCHEDULE_*` i18n keys added and translated across all 11 locales. Backend (proxy, controller, capability service, re-pointed validator) and frontend (service, dialog) tests included. +- **Admin toggle to disable user-submitted geofences** ([#297](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/297), from discussion [#214](https://github.com/PGAN-Dev/PoracleWeb.NET/discussions/214)): a new `disable_user_geofences` site setting (Features group on the admin settings page) lets operators turn off the custom/user-drawn geofence feature entirely. Reuses the existing `disable_*` feature-gate pattern: the "provide a geofence" endpoints on `UserGeofenceController` (create, submit-for-review, GeoJSON import) are gated with `[RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)]` and a defense-in-depth `IFeatureGate.EnsureEnabledAsync` guard in `UserGeofenceService.CreateAsync` (which also covers import, since `GeoJsonService.ImportAsync` funnels through it) and `SubmitForReviewAsync`. On the frontend both the user-facing *My Geofences* item and the admin *User Geofences* review-queue item are hidden (`disableKey`, with `adminNavItems` now honouring the disable flag like the other nav groups), and the `/geofences` and `/admin/geofence-submissions` routes are guarded (`disabledFeatureGuard`), redirecting to the dashboard with the existing `ERROR.FEATURE_DISABLED` toast; the 403 interceptor handles direct API hits the same way. **Existing user geofences keep working** — they continue to be served by `/api/geofence-feed`, and the read/manage/delete endpoints plus the admin review backend stay ungated, so enabling the toggle hides the whole feature and freezes new submissions without breaking in-flight alerts. Carried by `SettingsMigrationService` (`CategoryMap` + `BooleanKeys`); new `ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_*` label/description keys added and translated across all 11 locales. Admins are also blocked while the toggle is on (consistent with the alarm gates) and re-enable it from Settings. +- **Configurable default delivery scope for new alerts** ([#298](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/298), [discussion #217](https://github.com/PGAN-Dev/PoracleWeb.NET/discussions/217)): new alarms have always opened pre-set to **Areas** (geofence-based, `distance = 0`); users who track by radius had to switch the location mode and re-type a distance on every single add. A new **Alert Defaults** entry in the user menu opens a dialog (cohesive with the existing distance-dialog — selectable Areas/Distance mode cards, a km input, and a live delivery preview) where a user picks whether new alerts default to **Areas** or **Distance** and pins a default radius (0.1–100 km, clamped). The preference is stored client-side in `localStorage` (`poracle-default-alert-mode` / `poracle-default-alert-distance-km`), mirroring the theme/accent/language pattern, and is read by a new `AlertDefaultsService`. All nine add-alarm dialogs (Pokémon, Raids/Eggs, Quests, Invasions, Lures, Nests, Gyms, Fort Changes, Max Battles) **and the quick-pick apply dialog** now seed their `distanceMode`/`distanceKm` form controls from the service instead of the hard-coded `areas`/`1 km`. Applies to **newly created** alerts only — existing alerts and the per-alert override in each dialog are unchanged. New `ALERT_DEFAULTS.*` and `MENU.ALERT_DEFAULTS` i18n keys added and translated across all 11 locales. Unit tests cover the service (read/clamp/persist) and the dialog (init-from-pref, save, clamp). +- **Discord server/category notes on the admin user list** ([#265](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/265)): channel-type users in the admin user list now show the Poracle `notes` value (which PoracleJS/PoracleNG can be configured to auto-fill with the Discord guild name and channel category) as a muted second line under the name, with a tooltip showing the full text. This disambiguates channels that share the same name across different servers. The `notes` column already existed on the `humans` table but was dropped at every layer — it's now surfaced through the existing PoracleNG human JSON (`HumanService.DeserializeHuman`) for single-user reads and through the existing admin bulk read (no new database queries, no live Discord API calls), mapped on the `Human` model and `EntityMappingExtensions`, and projected by both `GET /api/admin/users` and `GET /api/admin/users/by-id`. The admin search box now also matches against notes, so admins can filter channels by server name. +- **Lure edit-in-place and quest daily-summary delivery modes** ([#292](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/292)): surfaces the two remaining meaningful `clean` bitmask bits as user controls (building on the PR1 preservation fix). Lure add/edit dialogs gain an **"Edit message in place"** toggle (sets `clean` bit 2) so a changed lure updates the existing Discord message instead of sending a new one; quest add/edit dialogs gain a **"Daily summary"** toggle (sets bit 4) to collect matching quests into one summary message (requires a configured summary schedule on the bot). Both default off, compose via the `CleanFlags`/`clean-flags` helper so they preserve the auto-delete and any sibling bit, and surface on cards as status badges (edit = `--mat-sys-secondary`, summary = `--mat-sys-tertiary`, mirroring the `.clean-tag` / RSVP-pill pattern). New `LURES.EDIT_*` and `QUESTS.SUMMARY_*` i18n keys added and translated across all 11 locales. Only lure (edit) and quest (summary) get new controls — they're the only types whose PoracleNG processor reads the respective bit, so no dead toggles. Dialog specs cover init-from-bit and save-composes-while-preserving. +- **RSVP notification mode for raid and egg alarms** ([#233](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/233)): the `rsvpChanges` field is now selectable end-to-end via a three-option mode toggle in the raid/egg add and edit dialogs — "Matches only" (`0`, default), "Matches + RSVP updates" (`1`), or "RSVP updates only" (`2`). Surfaced through a new self-contained `` component, with a matching `` badge on raid/egg cards when the mode is non-default. The "RSVP updates only" option warns that the alarm will be silenced without an RSVP-emitting scanner. The server-side `[Range(0, 1)]` on `RsvpChanges` in `RaidCreate` / `RaidUpdate` / `EggCreate` / `EggUpdate` was rejecting the new mode `2` with HTTP 400 before it could reach PoracleNG — widened to `[Range(0, 2)]`. Adds Polish, Swedish, and Danish RSVP translations (previously English fallback). The field, mapping (`AlarmMappingExtensions`), and dialog form binding already existed on `main`; this wires the UI control and the third mode value. Selecting an RSVP mode (`1`/`2`) now also sets PoracleNG's edit-in-place bit (`clean` bit 2) so RSVP count changes **edit the existing alert in place** instead of sending a fresh message each time — matching PoracleNG's intended delivery for its first edit-tracking consumer. The card auto-delete badge now masks `clean` bit 1 so it still shows when the edit bit is also set. ([#237](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/237)): the Poracle wire field `pvp_ranking_cap` is now surfaced end-to-end. When Poracle's config advertises more than one cap via `pvp.levelCaps`, the Pokemon add/edit dialogs show a cap selector (`All` / `L40` / `L50` / `L51`) and new alarms pre-fill from `tracking.defaultUserTrackingLevelCap`. Previously every PvP alarm was tagged "all caps" server-side, which flooded new users with L51 noise when admins only cared about L50. Matches the PoracleWeb PHP passthrough pattern — no new admin setting required; the default lives in Poracle config where it already belongs. The cap field is wired through `Monster` / `MonsterCreate` / `MonsterUpdate` / `MonsterEntity` / `AlarmMappingExtensions`, `PoracleConfig` (`PvpCaps`, `DefaultPvpCap`), a small `PoracleConfigService` (Angular) that caches `/api/config`, and `QuickPickService.SafeMonsterFilterKeys` so quick-pick definitions can pin a cap too. A hint — italic "Default · from Poracle config" — appears under the toggle group on add-dialog until the user touches it; the hint is hidden once the user makes a selection. The picker is hidden entirely when Poracle offers only one cap. + +### Fixed +- **Alarm `clean` bitmask was clobbered and validation-capped** ([#292](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/292)): PoracleNG reads `clean` as a 3-bit bitmask (bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary), but PoracleWeb treated it as a boolean for 8 of 10 alarm types. Two harms fixed: (1) the `[Range(0, 1)]` on `Clean` across 16 `Create`/`Update` models 400'd any bot-set value > 1 on a round-trip — widened to `[Range(0, 7)]` (Raid/Egg were already done by #233); (2) every dialog/card/service rebuilt `clean` from a boolean (`clean === 1` / `clean ? 1 : 0`), silently zeroing bits 2/4 a user never saw — worst at `CleaningService` which overwrote the whole value and mis-reported multi-bit alarms as "not clean". Added a `CleanFlags` helper (C#) + `clean-flags.ts` twin with a `Preserve(existing, mask, changes)` read-modify-write, and made every clean read/write across all 10 alarm types, the cleaning service, the quick-pick apply path, and the profile overview bit-aware so bot-set edit/summary bits survive a web edit. Also closes a latent gap where the raid/egg RSVP save dropped bit 4. This is the invisible correctness fix; the user-facing lure edit-in-place and quest summary controls follow in a separate change. +- **Docker image build failed at `npm ci`**: the `Dockerfile`'s Angular stage uses `node:22-alpine`, which bundles npm 10.9.x. That npm rejects the npm-11-generated `package-lock.json` with `EUSAGE` (it strictly requires the nested `chokidar`/`readdirp` optional-peer entries that npm 11 prunes) — the same failure the frontend CI job hit and fixed by pinning npm 11. The Dockerfile never got the same treatment, so `docker build` / `docker compose up --build` failed for everyone building from source. Added `RUN npm install -g npm@11` before `npm ci` in the `angular-build` stage so the in-container install resolution matches the committed lockfile. +- **Gym search failed with a MariaDB SQL syntax error** ([#260](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/260)): the `LikeEscape` helper added in #232 used `\` as the LIKE-escape character, and `ScannerService.SearchGymsAsync` passed `\` to `EF.Functions.Like(name, pattern, "\\")`. MariaDB's default mode (`NO_BACKSLASH_ESCAPES=OFF`) treats `\` as a string-literal escape too, so any escaped backslash in the pattern (which `LikeEscape` itself produced for user-supplied backslashes) left an unbalanced quote and broke the query with `near ''\')`. Switched the escape character to `|` (added `LikeEscape.EscapeChar` constant) — it has no special meaning in MariaDB string literals so the LIKE pattern can no longer interact with quote escaping. Tests updated to match the new escape sequences. +- **Raid/Egg level selector hardcoded to 1–6** ([#259](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/259)): the raid/egg add dialog's three level-pickers (raid checkboxes, egg checkboxes, boss-level dropdown) were all driven by a hardcoded `levels = [1, 2, 3, 4, 5, 6]` array, even though PoracleNG accepts arbitrary positive integers and Pokémon GO actually defines 19 named raid types in the WatWowMap masterfile. Replaced the three sites with a new `` shared component (Material 3 chip listbox with `+ Add` and a "More raid types…" overflow menu) backed by a new `RaidLevelService` that fetches the canonical list from `GET /api/masterdata/raid-levels` on app load, with a baked-in fallback so the UI always works offline. Correctness: level 7 is **Mega Legendary Raid** (not "Elite" as the prior UI labeled it); Elite Raid is at level 9. All 19 masterfile-defined raid types are now surfaced (1–5 Star, Mega, Mega Legendary, Ultra Beast, Elite, Primal, 1–5 Shadow, 4–5 Super Mega, Coordinated 1–2). New API endpoint: `GET /api/masterdata/raid-levels` returns the canonical list with categories and English singular/plural names; future work can swap the baked-in source for a live WatWowMap masterfile fetch without changing the wire contract. Per-type custom palette (`raid`/`egg`/`boss`) backed by separate localStorage slots so adding a custom level on one picker doesn't leak into the others. Egg picker only surfaces star tiers (1–5) since Pokémon GO has no Mega/Shadow/Primal/Coordinated eggs; raid + boss pickers get the full list. Boss tab now defaults to the canonical `9000` "any" sentinel (was `0`). Server-side `[Range(0, 10)]` on `RaidCreate.Level`, `RaidUpdate.Level`, `EggCreate.Level`, `EggUpdate.Level` was rejecting custom integers (8+) and the 9000 wildcard with HTTP 400 before they could reach PoracleNG — relaxed to `[Range(0, int.MaxValue)]` matching PoracleNG's actual range. Card star icons capped to the literal 1–5 "N Star Raid" tier (was 1–7, rendering ~23 stars for custom-level alarms). Edit dialog adopts the same label resolver as the cards (an alarm at level 7 reads "Mega Legendary Raid" in both card and edit dialog, not "Level 7"). New i18n keys `RAIDS.LEVEL.RAID_1`–`RAID_19` (singular + `_PLURAL` variants) added to all 11 locales with English placeholders; volunteers can localize in a follow-up per discussion #211. Existing alarms saved with `level: 0` continue to render and edit fine; new alarms use the canonical sentinels. +- **Dependabot auto-merge workflow never fired on PRs**: `auto-merge-deps.yml` listed both `pull_request_target` and `push` as triggers, but in practice the workflow only ever ran for `push` events — every PR-event run for the last 100+ workflow runs was a `push` event, none were `pull_request_target`. Result: Dependabot PRs were never auto-approved (each one needed manual approval), and every push recorded a `failure` conclusion because the job's `if: github.event_name == 'pull_request_target'` gate skipped all steps. Removed the `push` trigger (matching `pr-labeler.yml`, which fires correctly with `pull_request_target` alone), dropped the job-level `if:`, and added a sentinel "Workflow ran" first step so non-Dependabot PRs record as success rather than zero-step failure. Follow-up to #231: that fix moved the gate to job level on the assumption GitHub would record skipped runs as success, but it records 0-job runs as failure regardless. +- **Frontend CI `npm ci` failures on Dependabot PRs**: CI used Node 22's bundled npm 10.9.7, which strictly requires nested `chokidar@4.0.3` / `readdirp@4.1.2` lockfile entries that `@angular-devkit/*` packages declare as optional peers. Dependabot regenerates `package-lock.json` with a newer npm that prunes those entries, producing lockfiles npm 10.9.7's `npm ci` rejected with `EUSAGE`. Pinned npm 11 in the `frontend` CI job so the install resolution matches what Dependabot produces. Affects PRs #248, #250, #256, #261, #262. + ### Changed - **Scanner types renamed from `Rdm*` to generic `Scanner*`** ([#232](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/232)): the scanner DB context and entities were named `RdmScannerContext` / `Rdm{Gym,Pokestop,Station,Weather}Entity` / `RdmScannerService`, but the schema is backend-agnostic. Renamed to `ScannerDbContext` / `Scanner*Entity` / `ScannerService` and updated example connection strings and prose to reference **Golbat** (the currently supported scanner backend). No behavior change; `IScannerService` interface unchanged; no migrations or `[Table]` mappings affected. Impacts only consumers that reference the implementation types directly — standard DI registration uses the `IScannerService` interface and is unaffected. - `IScannerService.PointInPolygon` (static) and `ScannerService.EscapeLikePattern` (static) were moved to dedicated `GeometryHelpers` and `LikeEscape` utility classes in `Core.Services`. The interface no longer carries unrelated geometry helpers; the LIKE-escape helper is reusable by any future repository that needs dialect-safe wildcard escaping. @@ -543,38 +991,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rate limiting (per-IP) on auth endpoints - Docker deployment with Watchtower auto-updates -[Unreleased]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.8.0...HEAD -[2.8.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.6.0...v2.8.0 +[Unreleased]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.17.1...HEAD +[2.17.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.17.0...v2.17.1 +[2.17.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.16.0...v2.17.0 +[2.16.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.15.3...v2.16.0 +[2.15.3]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.15.2...v2.15.3 +[2.15.2]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.15.1...v2.15.2 +[2.15.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.15.0...v2.15.1 +[2.15.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.14.0...v2.15.0 +[2.14.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.13.0...v2.14.0 +[2.13.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.12.1...v2.13.0 +[2.12.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.12.0...v2.12.1 +[2.12.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.11.1...v2.12.0 +[2.11.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.11.0...v2.11.1 +[2.11.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.10.0...v2.11.0 +[2.10.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.9.0...v2.10.0 +[2.9.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.8.0...v2.9.0 +[2.8.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.7.0...v2.8.0 [2.7.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.6.0...v2.7.0 -[2.6.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.4.1...v2.6.0 -[2.5.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.4.0...v2.5.0 -[2.4.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.3.0...v2.4.1 +[2.6.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.5.0...v2.6.0 +[2.5.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.4.1...v2.5.0 +[2.4.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.4.0...v2.4.1 [2.4.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.3.0...v2.4.0 [2.3.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.2.0...v2.3.0 -[2.3.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.2.0...v2.3.0 [2.2.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.1.3...v2.2.0 [2.1.3]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.1.2...v2.1.3 -[2.1.3]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.1.2...v2.1.3 [2.1.2]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.1.1...v2.1.2 -[2.1.2]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.1.1...v2.1.2 -[2.1.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.1.0...v2.1.1 [2.1.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.1.0...v2.1.1 [2.1.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.3.1...v2.0.0 [1.3.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.3.0...v1.3.1 -[1.3.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.1.2...v1.3.0 -[1.2.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.1.1...v1.2.0 -[1.1.2]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.1.0...v1.1.2 -[1.1.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.0.2...v1.1.1 +[1.3.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.2.0...v1.3.0 +[1.2.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.1.2...v1.2.0 +[1.1.2]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.1.1...v1.1.2 +[1.1.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.0.2...v1.1.0 [1.0.2]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.0.1...v1.0.2 [1.0.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.6.4...v1.0.0 [0.6.4]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.6.3...v0.6.4 -[0.6.3]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.6.1...v0.6.3 +[0.6.3]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.6.2...v0.6.3 [0.6.2]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.6.1...v0.6.2 [0.6.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.6.0...v0.6.1 -[0.6.1]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.5.5...v0.6.0 [0.5.5]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.5.4...v0.5.5 [0.5.4]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v0.5.3...v0.5.4 diff --git a/CLAUDE.md b/CLAUDE.md index 67bff09e..dcb232db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Pgan.PoracleWebNet.slnx | | WebhookDelegateEntity, QuickPickDefinitionEntity, | | QuickPickAppliedStateEntity), | | Configurations/ (EF Core entity type configurations) -| +-- Data.Scanner/ ScannerDbContext for optional scanner DB +| +-- Data.Scanner/ ScannerContext for optional scanner DB | +-- Applications/ | +-- Web.Api/ ASP.NET Core host @@ -197,6 +197,56 @@ Pgan.PoracleWebNet.slnx - **Location warning**: `LocationWarningComponent` shows an inline red warning when active hours are set but the profile has 0,0 coordinates, since PoracleNG uses the profile's location for timezone calculations and 0,0 defaults to UTC. - **JWT profile resync**: PoracleNG can change `current_profile_no` out-of-band (active-hours scheduler, bot `!profile` command). `GET /api/auth/me` detects when the JWT's `profileNo` claim differs from the DB value and returns a refreshed JWT with the corrected profile number, preventing alarm CRUD from targeting a stale profile. The dashboard shows a snackbar notification when this resync occurs. +### Profile Numbering and Rename (PoracleNG quirks) + +Two upstream behaviours that PoracleWeb has to work around. Both verified directly against PoracleNG. + +**PoracleNG assigns the lowest free profile number, not `max + 1`.** With profiles 0, 1, 3 a new profile is created at **2**. Its `/add` endpoint returns only `{"status":"ok"}` -- no number -- so the assigned number cannot be predicted and must be discovered. `ProfileController.Create`/`Duplicate` and `ProfileOverviewController.DuplicateProfile`/`ImportProfile` snapshot the profile list, create, re-read, and diff (`ProfileNumbering.ResolveCreated`). Diffing rather than matching on name, because profile names are not unique. Predicting `max + 1` produced empty create responses and copied duplicate alarms to a `profile_no` with no profile row -- orphans that later attached themselves to whatever profile was eventually created at that number. See #407. + +**PoracleNG's profile update silently ignores `name`.** `POST /api/profiles/{id}/update` answers `{"status":"ok"}` and writes nothing for a rename, while honouring `active_hours` on the same request. Rename therefore goes through `IProfileRepository.RenameAsync`, a direct DB write scoped to `profiles.name` only. Verified that PoracleNG serves the new name on its very next read, so nothing needs invalidating. This is the same class of workaround as `HACK: trusted-set-areas`. See #406. + +**Alarm writes never send `profile_no`.** PoracleNG takes a submitted `profile_no` at face value for the pokemon type -- `profile_no: 9` creates a row on a profile that does not exist -- while scoping every read to `current_profile_no`. Since the JWT claim can be stale (see "JWT profile resync"), stamping it onto writes stranded alarms that were invisible and undeletable. `PoracleJsonHelper.SerializeToElement` strips it, so PoracleNG files each alarm under the live active profile. See #411. + +### Webhook Delegation Resolves Live, From Three Sources + +A webhook is a Poracle human whose id is a Discord webhook URL. A **delegate** is a non-admin allowed +to manage one, which they do by impersonating it — so "who may manage what" is an authorisation +question asked on three surfaces: the `/my-webhooks` list, `POST /api/admin/impersonate`, and +`/api/auth/me`, which decides whether the nav item renders at all. + +`IUserRoleResolver.ResolveAsync` is the single answer, unioning: + +1. PoracleNG's `getAdministrationRoles` → `admin.discord.webhooks` (covers `discord.webhook_admins` + **and** guild-role delegation), +2. `poracle_web.webhook_delegates` (what the admin dialog writes), +3. admins from `Poracle:AdminIds` or Poracle's config, who short-circuit before any network call. + +Cached one minute per user; a degraded answer is never cached. + +**All three surfaces must resolve live, and the same union.** This has now broken four separate ways, +each time because one surface disagreed with another: + +| Fix | What it broke | +|---|---| +| #564 | `/my-webhooks` loaded from the admin user list, which 403s non-admins — the only people who could see the page | +| #601 | reading the claim let a revoked delegate keep access for 24 hours | +| #626 | resolving from the local table alone refused a PoracleJS-configured delegate the nav item had just offered | +| #786 | `/api/auth/me` still read the claim, so a *new* delegate could not find a page that would have let them in | + +The `managedWebhooks` JWT claim is now a **cold fallback only**, used when a resolve is degraded so an +outage does not strip a delegate mid-session. Nothing authorises off it. Do not reintroduce it as a +source of truth. + +Two carve-outs worth keeping: an impersonation session resolves nothing (`this.UserId` is the +impersonated account, so its own delegations answer a different question — the shape of #663), and +`IsAdmin` deliberately still comes from the claim, because every admin endpoint authorises off the +claim too and resolving one live without the other puts the UI and the API into disagreement. + +Deleting a human purges grants in both directions (`RemoveAllForIdAsync` matches `WebhookId` **or** +`UserId`), so a recreated webhook URL does not adopt the old delegates. See #510-#512. + +User-facing documentation: `docs/features/webhooks.md`. + ### Rate Limiting - Auth endpoints use **per-IP** partitioned rate limiting (not global). - `auth` policy: 30 requests per 60s per IP (login, callback, token exchange). @@ -208,6 +258,34 @@ Pgan.PoracleWebNet.slnx ### Feature Gating (`disable_*` Site Settings) The `disable_mons` / `disable_raids` / `disable_quests` / `disable_invasions` / `disable_lures` / `disable_nests` / `disable_gyms` / `disable_maxbattles` / `disable_fort_changes` site settings disable entire alarm types for everyone, including admins. Eggs share `disable_raids` (no separate `disable_eggs` exists; eggs share the raid UI in the SPA). See #236 for the original bug. +**A disabled alarm type disappears completely.** The gate is **class-level** on the ten alarm +controllers and on `SummaryScheduleController`, so every action answers 403 — reads and deletes +included — and the SPA hides the sidebar item, the dashboard card and the route. + +This was briefly the other way round. #784 moved the gate onto the write actions so users could still +see and delete rules of a switched-off type; #792 reverted it. An operator disabling a type means it +should be gone, and dormant rules are harmless: they cannot fire, they are not deleted, and they come +back intact when the type is re-enabled. `DisabledAlarmTypeGatingTests` fails the build if the gate +moves back onto individual actions. + +The corollary is that there is nothing read-only to build around it: no banner, no `writesDisabled` +computed, no per-control `@if`. If you find yourself adding one, the page it would live on is +unreachable. + +**Any `disable_*` toggle must be enforced server-side.** A toggle wired only into the SPA (nav item, route guard) is decoration, not a gate: the endpoints stay reachable by direct API call, and client state — the `siteSettings` signal — is trivially tampered with. `disable_areas`, `disable_profiles` and `disable_location` all shipped that way and were only closed later. `FeatureGateCoverageTests` now fails the build if a key is added to `DisableFeatureKeys` without a controller enforcing it. + +Non-alarm features follow the same rules, minus the tracking-type dictionary: add the constant, apply `[RequireFeatureEnabled(...)]` to the controller (or to individual actions, as `UserGeofenceController` does so its reads stay open), add a `disabledFeatureGuard` to the route in `app.routes.ts` **and** the `disableKey` to the nav item — the nav entry alone is not enough. + +Deliberately **not** gated: `/api/auth/me` under `disable_profiles`, so the JWT profile resync keeps working and PoracleNG's active-hours scheduler can still move a user between profiles. + +`disable_nominatim` **is** implemented (#420): it gates the two geocode actions on `LocationController`, so switching it off genuinely stops the outbound Nominatim/OpenStreetMap calls, and the location dialog hides its address search rather than firing a request that would 403 and bounce the user to the dashboard. It is gated per-action rather than per-controller because the controller itself is already gated by `disable_location`. + +`disable_geomap` and `disable_geomap_select` were removed from the admin UI and from `SettingsMigrationService` in the same change. They are legacy PoracleJS keys describing a map picker PoracleWeb does not have, so there was nothing to wire them to and inventing a meaning would have been worse than deleting them. Any rows left in `site_settings` are harmless -- nothing reads them. `disable_userlist` was never a toggle in this UI (the migration carries `admin_disable_userlist` as a legacy key only). + +**Poracle's own flags are a floor under these (#769).** `UpstreamFeatureFlagService` reads `disabledHooks` from `/api/config/poracleWeb` plus `general.disable_fort_update` from `/api/config/values`, maps them to `disable_*` keys via `PoracleDisabledHookMap`, and `FeatureGate` treats a type as off if **either** source disables it. Cached 5 min. It **fails open**: any fault, timeout or absent field yields an empty set, because a Poracle outage disabling every alarm type for everyone is worse than the problem being solved. `GET /api/settings/upstream-disabled` exposes the resolved keys so nav, route guards and the admin toggles agree with the API. + +Two traps, both verified against 5.1.0 and both load-bearing: `pokestop` is in `disabledHooks` but `DisablePokestop` has no consumer in the processor, so it maps to **nothing** — mapping it to lures/invasions/quests would disable three working types; and `disable_fort_update` is enforced upstream but omitted from the array, which is the only reason the second config call exists. Both filed upstream (jfberry/PoracleNG#195). + **Adding a new alarm type? Wire it through all four layers:** 1. **Constant** — add to `Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs` (both the `const string` field and the `ByTrackingType` dictionary entry). @@ -237,6 +315,19 @@ The `disable_mons` / `disable_raids` / `disable_quests` / `disable_invasions` / - `ScannerGymEntity` maps the `url` column for gym photo thumbnails from the scanner DB. - The scanner DB is optional -- if not configured, the gym picker is hidden and `gym_id` can still be entered manually. +### Localized Game Data + +Pokemon names, types, form names and evolution chains come from PoracleNG, which translates them from its own i18n bundle: `GET /api/masterdata/monsters?locale={code}`, proxied through `MasterDataController.GetMonsters` and consumed by the SPA's `MasterDataService` in the same `forkJoin` as items and moves. The locale is the **display** language, so switching it re-fetches and re-emits on `ready$`; an open species picker updates in place. See #771. + +- **Fallback, not failure.** A Poracle too old for the route, or unreachable, falls back to the cached English WatWowMap masterfile (`IMasterDataService.GetMonsterDataAsync`). Moves and items have no translated equivalent upstream and stay English. +- **Type names are identity, not display text.** `IconService` keys uicons on the English name and the filter chips track by it, so `applyMonsters` resolves the English name from the stable type id (`shared/utils/pokemon-types.ts`) and keeps the translated string beside it as a label, read via `getTypeLabel()`. Substituting the localized name blanks every type icon. +- **Untranslated keys are ignored.** If Poracle's game-data locale download failed it returns the key itself (`poke_25`); those are skipped so the English name survives rather than a raw key reaching the UI. +- The lone-base-form drop rule matches `/^normal/i` because form names are now translated ("Normale" in Italian). Do **not** simplify it to "species with exactly one form": 856 species qualify and two of them, Koraidon and Miraidon, are not base forms. + +### Settings That Are Projections, Not Rows + +`poracle_locale` is synthesized onto the settings response from Poracle's `general.locale`; the SPA uses it as the last display-language fallback. It is **not stored**, `SettingsController.Upsert` refuses to write it, and it is declared in `PROJECTED_KEYS` so it never reaches the admin page's "Other" catch-all as an editable box. A stored row would win over the projected value, so one accidental save would pin the language default permanently. Any future projection needs the same two halves — the write refusal is the guarantee, the declaration is cosmetics. See #780, and #560 for the same mistake with retired keys. + ### Service Lifetimes - Most services are **scoped** (per-request). `MasterDataService` is a **singleton** (cached game data). - `DashboardService` now uses a single `GetAllTrackingAsync` call to PoracleNG instead of 8 separate DB count queries. @@ -248,11 +339,13 @@ The `disable_mons` / `disable_raids` / `disable_quests` / `disable_invasions` / - Uses Angular signals for reactive state where applicable. - Lazy-loaded routes in `app.routes.ts`. - Services in `core/services/` use `HttpClient` to call the .NET API (including `ScannerService` for gym search, `TestAlertService` for test notifications). +- `MasterDataService` fetches monster data per display language and refetches on a language change; it no longer fetches the masterfile from GitHub in the browser. See "Localized Game Data". - `TestAlertService` manages per-UID cooldown tracking (15s Map-based TTL) and in-flight request deduplication to prevent duplicate API calls. - `GymPickerComponent` is a shared autocomplete component used in gym/raid/egg dialogs for gym selection with photo thumbnails and area names. - `ActiveHoursEditorDialogComponent` is a shared dialog for editing profile schedule rules with day/time pickers and a weekly preview grid. - `ActiveHoursChipComponent` renders compact amber schedule pills summarizing active hours on profile cards. - `LocationWarningComponent` displays an inline warning when a profile has active hours but missing coordinates. +- `AlertDefaultsService` (`core/services/alert-defaults.service.ts`) persists the user's preferred default delivery scope for **new** alarms -- mode (`areas`/`distance`) and default radius (km, clamped 0.1-100) -- to `localStorage` (`poracle-default-alert-mode` / `poracle-default-alert-distance-km`), mirroring the theme/accent/language pattern. All nine add-alarm dialogs and the quick-pick apply dialog seed their `distanceMode`/`distanceKm` form controls from it; the `AlertDefaultsDialogComponent` (user menu -> Alert Defaults) edits it. Client-side only -- no backend/API change; existing alarms are unaffected. ### UI Patterns - **Alarm lists**: Card grid with filter pills showing IV/CP/Level/PVP/Gender at a glance. Test button in card actions sends a sample notification via PoracleNG. @@ -286,6 +379,73 @@ The `disable_mons` / `disable_raids` / `disable_quests` / `disable_invasions` / - **DataProtection**: Keys are persisted to `DATA_DIR/dataprotection-keys` (Docker: `/app/data/dataprotection-keys`, standalone: `./data/dataprotection-keys`). Configured in `ServiceCollectionExtensions.cs` via `AddDataProtection().PersistKeysToFileSystem().SetApplicationName("Pgan.PoracleWebNet.Api")`. Uses the existing `DATA_DIR` env var (set in Dockerfile, read via `configuration["DATA_DIR"]`) with a fallback to `Path.Combine(Directory.GetCurrentDirectory(), "data")` for local dev. No additional env vars or NuGet packages needed. - **PoracleJS config**: `geofence.path` in PoracleJS config is a single URL pointing to the PoracleWeb unified feed endpoint (e.g., `"http://poracleweb:8082/api/geofence-feed"`). PoracleWeb fetches admin geofences from Koji internally and merges them with user geofences. +## Fixing Defects Without Causing Them + +About one in five defects found in this codebase's audit sweeps was caused by an earlier fix in the +same campaign. They are not random -- they cluster into two shapes, and both are cheap to prevent. + +### Shape 1: tightening a rule without enumerating who depended on the loose one + +The repeated failure. A constraint gets added, the bad case is verified refused, and nobody checks +which legitimate cases are now refused too. + +| The fix | What it broke | +|---|---| +| #601 resolved delegated webhooks live | delegates configured in PoracleJS got an empty page and a 403 (#626) | +| #604 validated quick-pick filters at save time | seeding died on two built-ins that carry empty filters on purpose (#637) | +| #616 cleared the admin token on a 401 | left an impersonation banner whose Stop button did nothing (#627) | +| #531 guarded alarm-edit collisions | refused uneditable alarms (#553), then swallowed `gym_id` (#575), then ignored first-match ordering (#606) | + +**Before adding any validation, allowlist, guard or refusal:** + +1. **Query production for what currently satisfies the loose rule.** An invasion `gruntType` allowlist + built from `InvasionGruntTypes.All` would have refused `blanche`, `candela`, `spark`, `npc 0`-`npc 10` + and `player team leader` -- every one of them live. Connection details are in `.env`; query with + `docker run --rm mariadb:latest mariadb --skip-ssl -h HOST -P PORT -u USER -pPASS DB -e "SQL"`. + If you cannot enumerate what the loose rule permits, you cannot safely tighten it. +2. **Prefer refusing the impossible over allowing only the known.** "No control characters, no more than + the column holds" ages well. "One of these thirty values" does not. +3. **Find the other callers.** `SeedDefaultsAsync` reaches `SaveAdminPickAsync`; quick-pick apply reaches + `BulkCreateAsync`. A guard added for the interactive path fires on every internal path too. + +### Shape 2: fixing one path and leaving its siblings + +`bulkDelete` was hardened in #603 and `bulkUpdateDistance` was not (#641). The single-pick delete cleared +applied state and the reseed did not (#630). One edit dialog sent `''` and nine sent `null` (#639). + +**Before committing, grep for the shape you just fixed.** There are ten alarm types, ten list components, +ten edit dialogs, eleven locale files. A fix that touches one of a set of ten is incomplete until you have +looked at the other nine and can say in the PR why they are fine, or fixed them too. + +### Tests: assert what must still work, not only what must now fail + +A test written alongside a fix encodes the fix's own model of the world, so it passes whether or not the +fix is right. Three fixes validated the domain model instead of the `*Create` DTO the controller binds, +passed their tests, and did nothing (#548, #555, #565). `monster.service.spec.ts` went further and +**asserted the broken request shape**, so the suite was actively defending the bug (#640). + +- Every guard needs a **legitimate-case-still-passes** test alongside the refusal test. The grunt-type + theory names five values found in live data; the seeding test names the two presets that were being + dropped. Those are the tests that catch shape 1. +- **Watch a new test fail before you fix the code.** A test that has never been red proves nothing. +- When an existing test contradicts a fix, work out which one is wrong before changing either. Sometimes + the test is enshrining the defect. + +### Verify upstream behaviour by calling it, not by reading it + +Three fixes were derived from a PoracleNG checkout four months adrift from production (#521, #531, #553). +Pin the checkout first -- see the memory note `project_poracleng_checkout_must_match_prod` -- and then, +where it is cheap, confirm by POSTing to the running instance rather than trusting the source read. +Reading `DiffAndClassify` incrementally produced a guard that was right about the part just read and wrong +about the part not yet read, twice. + +### What "clean" means + +A sweep returning nothing does not mean the code is defect-free -- it means that set of lenses is +exhausted. Every sweep should include a **regression lens**: an agent that reads the diffs merged since +the last sweep and asks only "what did these fixes break, and which siblings did they miss?" That is the +lens which catches the one-in-five, and it is the one most easily forgotten. + ## Common Issues ### MySQL Provider @@ -310,6 +470,15 @@ When updating alarms, the frontend sends `*Update` DTOs to `PUT /{uid}`. The con ### Discord API Version for Geofence Notifications Use `discordapp.com/api/v9` (not v10) -- v10 is not supported on the `discordapp.com` domain. The `DiscordNotificationService` HttpClient is configured with base address `https://discordapp.com/api/v9/`. +### Geofence Review Embeds (Discord) +- **Never link a Poracle static map URL into an embed.** `GET /api/geofence/{area}/map` returns a *pregenerated tileserver-cache* URL that the tile cache evicts, so the embed's image 404s within hours (Discord stores only the link, and its `images-ext-1` proxy re-fetches the dead origin). Download the bytes and upload them as a message attachment (`multipart/form-data`, `payload_json` + `files[0]`, embed image `attachment://geofence-map.png`). See #391. +- **Discord folds an `attachment://` attachment into the embed**, so the message's `attachments` array comes back **empty**. There is no attachment ID to carry forward when editing, and the embed's resolved `cdn.discordapp.com` URL is a *signed, expiring* link. Editing a card therefore re-downloads and re-uploads the map rather than trying to retain it. +- **A forum post's starter message shares the thread's ID**, so the opening embed is edited with `PATCH /channels/{threadId}/messages/{threadId}` -- no separate message ID is stored. +- The map download uses a **separate unauthenticated named HttpClient** (`DiscordNotificationService.MapImageHttpClientName`) so the bot token never reaches the tileserver. +- One `BuildEmbed` builds the pending/approved/rejected card so the states can't drift. Colour is status-bearing (amber/green/red). Every piece degrades independently: failed download links the URL, failed card rewrite still posts the verdict reply, Koji outage drops the overlap line. +- `GeoMath` (area/centroid/containment) is a **hand-port of the frontend's `shared/utils/geo.utils.ts`**. Keep the two in sync -- tests derive expected values from the sphere's radius (`2πR/360`), not from the implementation. +- Use `IHumanRepository.GetByIdAsync` for name lookups, **not** `GetByIdAndProfileAsync(id, 1)` -- the latter also filters on `current_profile_no`, so it finds nobody whose active profile isn't #1 (the default is 0). The same suspect pattern still exists in the approval area-swap fallback in `UserGeofenceService`. + ### Poracle Area Case Sensitivity Poracle does **case-sensitive** area matching. Geofence names stored in `humans.area`, `profiles.area`, and the `kojiName` field in `user_geofences` must always be lowercase. The `UserGeofenceService.CreateAsync()` method enforces this with `ToLowerInvariant()`. Area updates via `IPoracleHumanProxy.SetAreasAsync()` normalize to lowercase before sending to PoracleNG. @@ -327,9 +496,50 @@ On first startup after upgrade, the `SettingsMigrationStartupService` automatica ### MariaDB GET_LOCK Compatibility `MySql.EntityFrameworkCore`'s `MigrateAsync()` uses `GET_LOCK('__EFMigrationsLock', -1)` which returns NULL on MariaDB (infinite timeout not supported), causing `System.InvalidCastException`. The `MariaDbHistoryRepository` class overrides the lock acquisition to use `GET_LOCK(3600)` instead. This is registered via `ReplaceService()` on `PoracleWebContext`. +### `ExecuteDeleteAsync` Is Unusable On MariaDB +`MySql.EntityFrameworkCore` emits the aliased single-table form, ``DELETE FROM `t` AS `x` WHERE …``, and MariaDB answers 1064 — it requires the multi-table ``DELETE x FROM t AS x`` once an alias is present. `ExecuteUpdateAsync` is fine; MariaDB accepts `UPDATE t AS x SET x.c = …`. Verified against MariaDB 10.8.2. + +Nothing catches this before production. It compiles, and the repository tests pass because they run on **SQLite**, whose provider emits the same alias and accepts it. The OIDC session cleanup shipped this way and had never once run (#707); `QuickPickAppliedStateRepository` hit it earlier and quietly grew a load-and-`RemoveRange` workaround. + +Use raw SQL with **unquoted** identifiers (so the statement also parses on SQLite for the tests), or load and `RemoveRange` when the row count is small. `NoAliasedDeleteTests` fails the build if `.ExecuteDeleteAsync(` reappears anywhere under `Core/`, `Data/` or the API project. + ### Gym ID NULL vs Empty String The `gym_id` column in Poracle alarm tables (gym, raid, egg) is a `NOT NULL` string that defaults to `""` (empty string) meaning "any gym". PoracleNG handles the null-to-empty normalization on its side. The `GymPickerComponent` emits `null` when cleared and the gym's `id` string when selected. +### Clean Field Bitmask +The alarm `clean` field is a **3-bit bitmask** in PoracleNG, not a boolean: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary (`db.IsClean/IsEdit/IsSummary` in PoracleNG; quest summary is gated by `summary_schedules`). Use `CleanFlags` (`Core.Models/CleanFlags.cs`) and the frontend twin `shared/utils/clean-flags.ts` (`AUTO_DELETE`/`EDIT`/`SUMMARY`, `isAutoDelete/isEdit/isSummary`, `compose`, `preserve(existing, mask, changes)`) for all reads/writes so bits set elsewhere (e.g. via the bot) survive a web edit. Models cap `Clean` at `[Range(0, 7)]`. UI controls exist only where PoracleNG acts on the bit: auto-delete (all types), edit-in-place (lures + raids/eggs via RSVP `rsvpChanges`), and daily summary (quests). **Angular templates can't parse bitwise `&`** — gate card badges via a component method (e.g. `isAutoDelete(clean)`), not inline `clean & 1`. See #292. + +### PoracleNG Decides Insert vs Update vs Duplicate — And PoracleWeb Must Mirror It + +Every alarm write is a POST. PoracleNG diffs the submitted row against the existing ones (`DiffTracking`, `processor/internal/db/diff.go`) using `diff` struct tags, and the outcome is not obvious: + +```go +totalDiffs == 0 -> duplicate: nothing written, "alreadyPresent" +totalDiffs == 1 && nonUpdatableDiffs == 0 -> UPDATE of that existing row, re-keyed to a new uid +otherwise -> new insert +``` + +`diff:"update"` fields are `clean`, `distance`, `template`, plus `slot_changes` and `battle_changes` on gyms. `diff:"match"` and untagged fields identify the alarm. + +The consequence that keeps biting: **an Add or an Edit that differs from a *different* alarm by exactly one updatable field takes that alarm over.** The user gets 201/200, and one alarm exists where there were two. `TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync` mirrors the rule and refuses before the write — on create and update alike. Two or more updatable differences genuinely coexist and must stay editable; refusing those made alarms permanently uneditable (#553). + +When comparing, **a field PoracleWeb does not supply cannot be compared** — PoracleNG fills it with its own default (`template` becomes the configured name, `"1"` when unset), so a null here says nothing about what will be stored. Counting it as a difference is what made the create-path check miss every collision (#561). Some values are also rewritten on the way in: raids and max battles force `level` to 9000 unless `pokemon_id` is 9000, so compare the value that will be **stored**, not the one sent (#521, #531). + +See #462, #463, #531, #553, #561. + +### Keep the PoracleNG Checkout Pinned To What Prod Runs + +`E:/PGAN/pogogit/PoracleNG` drifts. On 2026-08-08 it was four months behind prod, and its `DiffTracking` lacked the `totalDiffs == 1` clause entirely — reading it produced three wrong fixes in one day. + +PoracleNG has no version endpoint (`/health` only). To establish what prod runs: `ss -lntp | grep 3030` for the pid, `/proc//exe` for the binary, and `git -C /source/PoracleNG rev-parse HEAD`; compare the binary mtime against the commit date. Then check out that commit locally. + +**Even then, verify against the live instance.** POST directly to `/api/tracking//?silent=true` with the `X-Poracle-Secret` header and read the row back. Source and running binary have disagreed before, and a unit test cannot tell you which is right. + +### Validation Attributes Live On The `*Create` DTOs, Not The Domain Models + +`Monster`, `Raid`, `Gym` and friends carry **no** `[Range]` or `[StringLength]` attributes; `MonsterCreate` and its siblings do. Anything that validates an alarm outside the normal model-binding path must bind or deserialize into the `*Create` DTO, or it will validate nothing and pass silently. + +This has cost three separate fixes: profile import (#548), quick-pick apply (#565), and the quick-pick id length check that bounded the name but not the id generated from it (#555). All three passed their unit tests while doing nothing, because the tests asserted the code ran rather than that it rejected anything. ### Monster Filter Defaults PoracleNG applies `cleanRow` defaults (template, PVP ranking, size, max values, etc.) on every create/update, so PoracleWeb no longer needs to maintain its own set of `*Create` model defaults for alarm filter fields. The `*Create` models still exist for DTO mapping (via `AlarmMappingExtensions.To*()` methods) but their field defaults are no longer critical -- PoracleNG is the authoritative source for filter defaults. @@ -354,6 +564,17 @@ PoracleNG can change `current_profile_no` outside of PoracleWeb — the active-h ### JWT Generation (IJwtService) JWT token generation is centralized in `IJwtService` / `JwtService` (singleton). Three methods: `GenerateToken(UserInfo)` for fresh tokens, `GenerateImpersonationToken(UserInfo, impersonatedBy)` for admin impersonation, and `GenerateTokenWithReplacedProfile(ClaimsPrincipal, profileNo)` for profile switches. The latter filters out registered JWT claims (`exp`, `nbf`, `iat`, `iss`, `aud`) before copying to prevent stale claim duplication. All controllers (`AuthController`, `ProfileController`, `ProfileOverviewController`, `AdminController`) use this service — no inline JWT generation. +## Branching + +| Branch | Purpose | +|---|---| +| `main` | Released code. Only moves when a release is merged. Publishing the release builds `:latest`, which prod's watchtower auto-deploys within its 60s poll -- the merge itself ships nothing. `docker-publish.yml` also has an SSH deploy step, but it is inert here: it exits early unless `DEPLOY_HOST` and `DEPLOY_SSH_KEY` are set, and this repo has neither. A plain `git clone` lands here, so self-hosters get released code. | +| `develop` | Integration. **Open pull requests against this.** Publishes `:beta` on every merge, which the dev instance auto-deploys. | + +Cutting a release: merge `develop` into `main`, then publish a GitHub release. `release-changelog.yml` opens a PR promoting `[Unreleased]` to the new version section, and `docker-publish.yml` pushes `:latest`. + +`ci.yml` and `changelog.yml` run for pushes and PRs on **both** branches -- a workflow filtered to one branch means PRs into the other run with no checks at all and merge looking green (this happened to #394 while it was stacked on another branch). + ## Build & Run ### Using convenience scripts (recommended) @@ -517,6 +738,10 @@ dotnet ef migrations script \ | Geo Utilities | `Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/geo.utils.ts` | | Alarm Mapping Extensions | `Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs` | | Entity Mapping Extensions | `Core/Pgan.PoracleWebNet.Core.Mappings/EntityMappingExtensions.cs` | +| IUpstreamFeatureFlagService | `Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUpstreamFeatureFlagService.cs` | +| UpstreamFeatureFlagService | `Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs` | +| PoracleDisabledHookMap | `Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs` | +| Pokemon type id-to-name table | `Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/pokemon-types.ts` | | IPoracleTrackingProxy | `Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleTrackingProxy.cs` | | IPoracleHumanProxy | `Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleHumanProxy.cs` | | PoracleTrackingProxy | `Core/Pgan.PoracleWebNet.Core.Services/PoracleTrackingProxy.cs` | @@ -537,6 +762,8 @@ dotnet ef migrations script \ | PwebSettingService (deprecated) | `Core/Pgan.PoracleWebNet.Core.Services/PwebSettingService.cs` | | KojiService | `Core/Pgan.PoracleWebNet.Core.Services/KojiService.cs` | | DiscordNotificationService | `Core/Pgan.PoracleWebNet.Core.Services/DiscordNotificationService.cs` | +| GeoMath (area/centroid/containment) | `Core/Pgan.PoracleWebNet.Core.Services/GeoMath.cs` | +| GeofenceSubmissionPost Model | `Core/Pgan.PoracleWebNet.Core.Models/GeofenceSubmissionPost.cs` | | IPwebSettingService (deprecated) | `Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPwebSettingService.cs` | | IScannerService | `Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IScannerService.cs` | | ScannerService | `Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs` | @@ -570,5 +797,5 @@ dotnet ef migrations script \ ## Testing - **Frontend**: Jest with jest-preset-angular. Run with `npm test` from `ClientApp/`. Tests cover services, pipes, components, dialogs, and utilities (including `geo.utils.spec.ts`, `user-geofence.service.spec.ts`, `admin-geofence.service.spec.ts`, `region-selector.component.spec.ts`, `geofence-name-dialog.component.spec.ts`, `geofence-approval-dialog.component.spec.ts`, `geofence-submissions.component.spec.ts`, `test-alert.service.spec.ts`, `active-hours.utils.spec.ts`, `active-hours-chip.component.spec.ts`, `active-hours-editor-dialog.component.spec.ts`, `location-warning.component.spec.ts`). -- **Backend**: xUnit with Moq. Run with `dotnet test` from solution root. Tests cover controllers, services, and manual mapping extensions. Alarm service tests mock `IPoracleTrackingProxy` (returning `JsonElement` payloads) instead of repositories. Human/Profile/Area controller tests mock `IPoracleHumanProxy`. Key test classes: `MonsterServiceTests`, `RaidServiceTests`, `EggServiceTests`, `QuestServiceTests`, `InvasionServiceTests`, `LureServiceTests`, `NestServiceTests`, `GymServiceTests`, `HumanServiceTests`, `DashboardServiceTests`, `CleaningServiceTests`, `AreaControllerTests`, `ProfileControllerTests`, `AdminControllerTests`, `UserGeofenceControllerTests`, `AdminGeofenceControllerTests`, `GeofenceFeedControllerTests`, `UserGeofenceServiceTests`, `SettingsControllerTests`, `PwebSettingServiceTests`, `QuickPickServiceSecurityTests`, `SiteSettingServiceTests`, `WebhookDelegateServiceTests`, `SettingsMigrationServiceTests`, `TestAlertControllerTests`, `TestAlertServiceTests`, `ActiveHoursValidationTests`, `MappingExtensionTests`, `DataProtectionConfigurationTests`. +- **Backend**: xUnit with Moq. Run with `dotnet test` from solution root. Tests cover controllers, services, and manual mapping extensions. Alarm service tests mock `IPoracleTrackingProxy` (returning `JsonElement` payloads) instead of repositories. Human/Profile/Area controller tests mock `IPoracleHumanProxy`. Key test classes: `MonsterServiceTests`, `RaidServiceTests`, `EggServiceTests`, `QuestServiceTests`, `InvasionServiceTests`, `LureServiceTests`, `NestServiceTests`, `GymServiceTests`, `HumanServiceTests`, `DashboardServiceTests`, `CleaningServiceTests`, `AreaControllerTests`, `ProfileControllerTests`, `AdminControllerTests`, `UserGeofenceControllerTests`, `AdminGeofenceControllerTests`, `GeofenceFeedControllerTests`, `UserGeofenceServiceTests`, `SettingsControllerTests`, `PwebSettingServiceTests`, `QuickPickServiceSecurityTests`, `SiteSettingServiceTests`, `WebhookDelegateServiceTests`, `SettingsMigrationServiceTests`, `TestAlertControllerTests`, `TestAlertServiceTests`, `ActiveHoursValidationTests`, `MappingExtensionTests`, `DataProtectionConfigurationTests`, `DiscordNotificationServiceTests`, `GeoMathTests`. - **CI**: Both test suites run automatically on push/PR to main via GitHub Actions. diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IHumanRepository.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IHumanRepository.cs index edc006fb..94553568 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IHumanRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IHumanRepository.cs @@ -6,11 +6,9 @@ public interface IHumanRepository { public Task> GetAllAsync(); public Task GetByIdAsync(string id); - public Task GetByIdAndProfileAsync(string id, int profileNo); public Task CreateAsync(Human human); public Task UpdateAsync(Human human); public Task> GetByIdsAsync(IEnumerable ids); public Task ExistsAsync(string id); - public Task DeleteAllAlarmsByUserAsync(string userId); public Task DeleteUserAsync(string userId); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IOidcSessionRepository.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IOidcSessionRepository.cs new file mode 100644 index 00000000..3cfd2d4c --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IOidcSessionRepository.cs @@ -0,0 +1,37 @@ +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Abstractions.Repositories; + +/// +/// Persistence for server-side OIDC refresh sessions (rotation families). All bulk revoke/cleanup +/// methods commit immediately: the revokes via EF Core's set-based ExecuteUpdateAsync, the +/// cleanup via raw SQL because the aliased delete EF generates is invalid on MariaDB (see #707). +/// +public interface IOidcSessionRepository +{ + /// Loads a session by the SHA-256 hash of the presented opaque token (no tracking). + public Task GetByHashAsync(string sessionTokenHash); + + /// Inserts a new session row (issuance or rotation successor). + public Task AddAsync(OidcSession session); + + /// + /// Atomic rotation guard: revokes the presented row only if it is currently active + /// (not revoked, not expired), stamping rotation + the successor hash. Returns the + /// number of rows affected — exactly 1 on success, 0 if it was already rotated/expired + /// (which the caller classifies as replay/expiry). + /// + public Task TryRevokeForRotationAsync(string sessionTokenHash, string newHash); + + /// Revokes every still-active row in a family (replay/logout/cap/provider revoke). + public Task RevokeFamilyAsync(string familyId, string reason); + + /// Revokes every still-active session for a user (admin disable / logout-everywhere). + public Task RevokeAllForUserAsync(string userId, string reason); + + /// + /// Set-based delete of expired rows and revoked rows older than the retention window. + /// Returns the number of rows removed. + /// + public Task DeleteExpiredAndStaleAsync(TimeSpan revokedRetention); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IPoracleSchemaVersionReader.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IPoracleSchemaVersionReader.cs new file mode 100644 index 00000000..1a638e4a --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IPoracleSchemaVersionReader.cs @@ -0,0 +1,19 @@ +namespace Pgan.PoracleWebNet.Core.Abstractions.Repositories; + +/// +/// Reads the migration number PoracleNG has applied to its own database. +/// +/// +/// PoracleNG's /health capability map describes bot and template-editor features; nothing in it +/// says which alarm columns exist. The applied migration does, and PoracleNG runs its migrations at +/// startup, so the number tracks the binary. Reading it is a stopgap until upstream publishes it — +/// see the note on . +/// +public interface IPoracleSchemaVersionReader +{ + /// + /// The applied migration number, or null when it cannot be read — no table, no permission, no + /// database. Never throws: an unknown schema is a valid answer that simply unlocks nothing. + /// + Task GetAppliedMigrationAsync(CancellationToken cancellationToken = default); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IProfileRepository.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IProfileRepository.cs index f9d8c979..0f511667 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IProfileRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IProfileRepository.cs @@ -8,5 +8,18 @@ public interface IProfileRepository public Task GetByUserAndProfileNoAsync(string userId, int profileNo); public Task CreateAsync(Profile profile); public Task UpdateAsync(Profile profile); + /// + /// Renames a profile, touching only profiles.name. + /// + /// + /// PoracleNG's profile update handler silently ignores the name key — it answers + /// {"status":"ok"} and writes nothing, while honouring active_hours on the same request. + /// So rename cannot go through the proxy. Verified that a direct write is served by PoracleNG's own + /// read immediately afterwards, so nothing else needs invalidating. Deliberately narrower than + /// , which also rewrites area and coordinates. See #406. + /// + /// false if no such profile exists. + public Task RenameAsync(string userId, int profileNo, string name); + public Task DeleteAsync(string userId, int profileNo); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IQuickPickAppliedStateRepository.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IQuickPickAppliedStateRepository.cs index 695b8682..ca9f4e44 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IQuickPickAppliedStateRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IQuickPickAppliedStateRepository.cs @@ -6,6 +6,25 @@ public interface IQuickPickAppliedStateRepository { public Task GetAsync(string userId, int profileNo, string quickPickId); public Task> GetByUserAndProfileAsync(string userId, int profileNo); + + /// + /// Every applied state for the user across all profiles. Used by the uid remapper, which cannot know + /// which profile an edited alarm belongs to. + /// + public Task> GetByUserAsync(string userId); public Task CreateOrUpdateAsync(QuickPickAppliedState state); public Task DeleteAsync(string userId, int profileNo, string quickPickId); + + /// + /// Removes every applied-state row for a quick pick, optionally narrowed to one user. + /// + /// + /// Deleting a definition left its applied state behind. Nothing could reach it -- the listing walks + /// definitions -- so it leaked, and re-creating a pick under the same id resurrected a stale "applied" + /// badge pointing at alarm uids from the old one. See #470. + /// + public Task DeleteByQuickPickIdAsync(string quickPickId, string? userId = null); + + /// Removes every applied-state row belonging to a user. See #510. + public Task DeleteByUserAsync(string userId); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IUserAreaDualWriter.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IUserAreaDualWriter.cs index af251d2f..86daa917 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IUserAreaDualWriter.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IUserAreaDualWriter.cs @@ -54,4 +54,51 @@ public interface IUserAreaDualWriter /// /// true if at least one row was actually modified. public Task RemoveAreaFromAllProfilesAsync(string humanId, string areaName); + + + /// + /// Replaces with in humans.area and in + /// every row of profiles.area for , committed in a single + /// SaveChangesAsync. Only rows that actually held the old name are touched, so per-profile + /// activation survives the rename: a geofence active on profile 2 but not profile 3 stays that way. + /// + /// + /// Used when an admin approves a custom geofence under a different public name. Going through + /// SetAreasAsync there loses the subscription entirely — the old name is + /// userSelectable=false so PoracleNG strips it, and the promoted name is not yet in + /// PoracleNG's fence list because the reload happens afterwards, so it is stripped too. See #408. + /// + /// true if at least one row was actually modified. + public Task RenameAreaInAllProfilesAsync(string humanId, string oldName, string newName); + + /// + /// Writes into the override_areas column of one alarm row, + /// scoped to so a caller cannot reach another user's alarms. + /// An empty collection clears the column to NULL. + /// + /// + /// HACK: trusted-set-areas. Same root cause as the rest of this interface, one layer along. + /// PoracleNG's tracking write validates every entry of override_areas against + /// GetAvailableAreas, which filters on userSelectable for non-admins, so a user's own + /// drawn geofence is refused outright with 400 "area not permitted" — the whole request fails, it is + /// not silently stripped as setAreas does. + /// + /// Matching, by contrast, never consults userSelectable: resolveOverride hands the + /// rule's areas to areaOverlap, which compares names against the fences the spawn fell in + /// (processor/internal/matching/generic.go). Verified at PoracleNG 5.1.0. So a name written straight + /// into the column matches exactly like a permitted one, which is what makes this workaround correct + /// rather than merely convenient. + /// + /// + /// Callers must trigger ReloadStateAsync afterwards: PoracleNG reloads tracking state on its + /// own mutations, and a direct write is not one of them. Without it the override waits for the + /// periodic reload (tuning.reload_interval_secs, 60s by default). + /// + /// + /// PoracleNG's name for the type: pokemon, raid, egg, quest, invasion, + /// lure, nest, gym, fort or maxbattle. + /// The tracking type is not one of the ten. + /// true if the row existed and was updated. + public Task SetAlarmOverrideAreasAsync( + string humanId, string trackingType, int uid, IReadOnlyCollection areaNames); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IWebhookDelegateRepository.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IWebhookDelegateRepository.cs index 1b236320..b66dd6fe 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IWebhookDelegateRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IWebhookDelegateRepository.cs @@ -10,4 +10,7 @@ public interface IWebhookDelegateRepository public Task AddAsync(string webhookId, string userId); public Task RemoveAsync(string webhookId, string userId); public Task RemoveAllForWebhookAsync(string webhookId); + + /// Removes every grant naming this id, as the webhook or as the delegate. See #512. + public Task RemoveAllForIdAsync(string id); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ICleaningService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ICleaningService.cs index 2de55f94..1e3a1a9e 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ICleaningService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ICleaningService.cs @@ -11,6 +11,5 @@ public interface ICleaningService public Task ToggleCleanLuresAsync(string userId, int profileNo, int clean); public Task ToggleCleanNestsAsync(string userId, int profileNo, int clean); public Task ToggleCleanGymsAsync(string userId, int profileNo, int clean); - public Task ToggleCleanFortChangesAsync(string userId, int profileNo, int clean); public Task ToggleCleanMaxBattlesAsync(string userId, int profileNo, int clean); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IDiscordNotificationService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IDiscordNotificationService.cs index cb1f3fa5..4e27035c 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IDiscordNotificationService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IDiscordNotificationService.cs @@ -1,9 +1,17 @@ +using Pgan.PoracleWebNet.Core.Models; + namespace Pgan.PoracleWebNet.Core.Abstractions.Services; public interface IDiscordNotificationService { - public Task CreateGeofenceSubmissionPostAsync(string userId, string userName, string geofenceName, string groupName, int polygonPoints, string? mapImageUrl); - public Task PostApprovalMessageAsync(string threadId, string geofenceName, string promotedName); - public Task PostRejectionMessageAsync(string threadId, string geofenceName, string reason); + /// Opens the review thread. Returns the thread ID, which is also the starter message ID. + public Task CreateGeofenceSubmissionPostAsync(GeofenceSubmissionPost post); + + /// + /// Rewrites the opening embed so the thread reflects its outcome, then posts the verdict as a reply and + /// locks the thread. + /// + public Task PostReviewOutcomeAsync(string threadId, GeofenceSubmissionPost post); + public Task EnsureForumTagsExistAsync(); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IHumanService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IHumanService.cs index 3987a779..ea1cd6a1 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IHumanService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IHumanService.cs @@ -5,8 +5,7 @@ namespace Pgan.PoracleWebNet.Core.Abstractions.Services; public interface IHumanService { public Task> GetAllAsync(); - public Task GetByIdAsync(string id); - public Task GetByIdAndProfileAsync(string id, int profileNo); + public Task GetByIdAsync(string id); public Task CreateAsync(Human human); public Task UpdateAsync(Human human); public Task ExistsAsync(string id); diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IMasterDataService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IMasterDataService.cs index 8404b726..87384eb7 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IMasterDataService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IMasterDataService.cs @@ -6,6 +6,16 @@ public interface IMasterDataService { public Task GetPokemonDataAsync(); public Task GetItemDataAsync(); + + /// Move ID to name map (e.g. {"13":"Wrap"}), sourced from the masterfile. + public Task GetMoveDataAsync(); + + /// + /// The raw masterfile monster map keyed "{pokemonId}_{formId}" (names, types, forms, + /// stats, evolutions). English only - it is the fallback for when PoracleNG cannot serve its + /// localized equivalent. + /// + public Task GetMonsterDataAsync(); public Task RefreshCacheAsync(); /// diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs index 37a4839e..5ac323d4 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs @@ -4,18 +4,37 @@ namespace Pgan.PoracleWebNet.Core.Abstractions.Services; public interface IPoracleApiProxy { - public Task GetConfigAsync(); - public Task GetAreasAsync(string userId); - public Task GetTemplatesAsync(); - public Task GetAdminRolesAsync(string userId); - public Task GetGruntsAsync(); - public Task GetGeofenceAsync(); - public Task GetAreasWithGroupsAsync(string userId); - public Task GetAreaMapUrlAsync(string areaName); - public Task GetAllGeofenceDataAsync(); - public Task GetLocationMapUrlAsync(double lat, double lon); - public Task GetDistanceMapUrlAsync(double lat, double lon, int distance); - public Task ReloadGeofencesAsync(); - public Task SendTestAlertAsync(TestAlertRequest request); - public Task GetGeofencesGeoJsonAsync(); + Task GetConfigAsync(); + Task GetQuestSummaryEnabledAsync(); + + /// + /// Reads general.disable_fort_update from PoracleNG's config-values endpoint. PoracleNG + /// honours this flag in the processor and the bot but leaves it out of the disabledHooks + /// array on /api/config/poracleWeb, so fort changes have to be asked about separately. + /// Returns null when the value cannot be determined (older Poracle, PoracleJS, endpoint + /// shape changed) so the caller can leave the site setting in sole charge. + /// + Task GetFortUpdateDisabledAsync(); + Task GetAreasAsync(string userId); + Task GetTemplatesAsync(); + Task GetAdminRolesAsync(string userId); + Task GetGruntsAsync(); + + /// + /// Localized monster master data: names, types and form names in . + /// PoracleNG translates these from its own i18n bundle, which is why they are fetched from it + /// rather than from the English-only WatWowMap masterfile. + /// + /// The raw JSON map keyed "{pokemonId}_{formId}", or null when upstream + /// is unreachable or does not serve it. + Task GetMonstersAsync(string locale); + Task GetGeofenceAsync(); + Task GetAreasWithGroupsAsync(string userId); + Task GetAreaMapUrlAsync(string areaName); + Task GetAllGeofenceDataAsync(); + Task GetLocationMapUrlAsync(double lat, double lon); + Task GetDistanceMapUrlAsync(double lat, double lon, int distance); + Task ReloadGeofencesAsync(); + Task SendTestAlertAsync(TestAlertRequest request); + Task GetGeofencesGeoJsonAsync(); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleHumanProxy.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleHumanProxy.cs index ab90c48d..2d71a9e6 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleHumanProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleHumanProxy.cs @@ -1,3 +1,4 @@ +using Pgan.PoracleWebNet.Core.Models; using System.Text.Json; namespace Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -99,4 +100,25 @@ public interface IPoracleHumanProxy /// Maps to POST /api/profiles/{userId}/copy/{fromProfileNo}/{toProfileNo} /// public Task CopyProfileAsync(string userId, int fromProfileNo, int toProfileNo); + + /// + /// The user's saved places, plus the profile pin every alarm falls back to. + /// Maps to GET /api/humans/{id}/locations + /// + public Task GetPlacesAsync(string userId); + + /// + /// Saves a place. PoracleNG reports per-row outcomes rather than failing the request, so a + /// duplicate label comes back as a message here rather than an exception. + /// Maps to POST /api/humans/{id}/locations/add + /// + /// Null on success, or PoracleNG's reason for refusing this label. + public Task AddPlaceAsync(string userId, SavedPlace place); + + /// + /// Deletes a saved place. + /// Maps to POST /api/humans/{id}/locations/{label}/delete + /// + /// Alarms still point at this place. + public Task DeletePlaceAsync(string userId, string label); } diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleServerProfileService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleServerProfileService.cs new file mode 100644 index 00000000..ca2eacfd --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleServerProfileService.cs @@ -0,0 +1,19 @@ +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Reads which PoracleNG this instance is talking to, and what it can store. +/// +public interface IPoracleServerProfileService +{ + /// + /// The current profile, cached briefly. Never throws: a server that cannot be reached comes back as + /// rather than an exception, because every caller is + /// asking "may I offer this feature", and the answer when nobody knows is no. + /// + Task GetAsync(CancellationToken cancellationToken = default); + + /// Drops the cached profile so the next read probes again. + void Invalidate(); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleSummaryProxy.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleSummaryProxy.cs new file mode 100644 index 00000000..ab502964 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleSummaryProxy.cs @@ -0,0 +1,22 @@ +using System.Text.Json; + +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +public interface IPoracleSummaryProxy +{ + // GET /api/summaries/{id} -> unwraps { "schedules":[...] }. Returns the schedules JsonElement (array), or null on non-success. + Task GetSchedulesAsync(string userId); + + // GET /api/summaries/{id}/{alertType} -> unwraps { "schedule":{...} }. Returns null on 404. + Task GetScheduleAsync(string userId, string alertType); + + // POST /api/summaries/{id}/{alertType}; body { "active_hours": }. Upsert. + // activeHoursJson is an ALREADY-VALIDATED raw JSON array literal ("[]" or "[{...}]"). + Task SetScheduleAsync(string userId, string alertType, string activeHoursJson); + + // DELETE /api/summaries/{id}/{alertType} -- idempotent (200 on missing). + Task DeleteScheduleAsync(string userId, string alertType); + + // POST /api/summaries/{id}/{alertType}/trigger -- synchronous flush-and-deliver, always 200. + Task TriggerAsync(string userId, string alertType); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleTrackingProxy.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleTrackingProxy.cs index 106a8643..89539990 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleTrackingProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleTrackingProxy.cs @@ -57,8 +57,25 @@ public interface IPoracleTrackingProxy /// /// Result from PoracleNG's tracking create endpoint. /// +/// +/// PoracleNG's answer to a tracking create. It reports exactly what it did; PoracleWeb used to read +/// almost none of it, which is the shared root of #459, #462, #463, #468 and #469. +/// public record TrackingCreateResult( List NewUids, int AlreadyPresent, int Updates, - int Inserts); + int Inserts) +{ + /// The uid the row now lives under, or null when PoracleNG named none. + public int? PrimaryUid => this.NewUids.Count > 0 ? (int)this.NewUids[0] : null; + + /// + /// PoracleNG wrote no new row: it either matched an existing one or found the submission + /// already present. On a create that means the alarm was NOT created by this call. + /// + public bool InsertedNothing => this.Inserts == 0; + + /// The submission duplicated an existing row exactly, so nothing was named or written. + public bool WasRejectedAsDuplicate => this.AlreadyPresent > 0 && this.Inserts == 0 && this.NewUids.Count == 0; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuickPickService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuickPickService.cs index e99262f1..de617ea4 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuickPickService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IQuickPickService.cs @@ -6,7 +6,14 @@ public interface IQuickPickService { public Task> GetAllAsync(string userId, int profileNo); public Task GetByIdAsync(string id); - public Task SaveAdminPickAsync(QuickPickDefinition definition); + + /// Ownership-scoped read: global picks are public, user picks are visible only to their owner. + public Task GetVisibleByIdAsync(string userId, string id); + /// + /// Saves a global quick pick. skips the ownership guard, which the + /// seeding path shares and would otherwise trip over a user pick holding a built-in id. See #659. + /// + public Task SaveAdminPickAsync(QuickPickDefinition definition, bool isSeeding = false); public Task SaveUserPickAsync(string userId, QuickPickDefinition definition); public Task DeleteAdminPickAsync(string id); public Task DeleteUserPickAsync(string userId, string id); diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs new file mode 100644 index 00000000..f93b5ba5 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs @@ -0,0 +1,18 @@ +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Source of the 19 (currently) known Pokémon GO raid levels, sourced from the +/// WatWowMap masterfile. Served as a structured list to the frontend so the +/// level selector and alarm cards stay aligned with the canonical vocabulary +/// even as new raid types ship. +/// +/// Implementations should cache the result and fall back to a baked-in list +/// when the upstream masterfile is unreachable. +/// +public interface IRaidLevelService +{ + /// Returns the canonical raid-level list. Never throws; falls back to defaults on error. + Task> GetAllAsync(); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ISummaryCapabilityService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ISummaryCapabilityService.cs new file mode 100644 index 00000000..1a08c7a1 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ISummaryCapabilityService.cs @@ -0,0 +1,6 @@ +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +public interface ISummaryCapabilityService +{ + Task IsQuestSummaryEnabledAsync(); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ITrackedUidRemapper.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ITrackedUidRemapper.cs new file mode 100644 index 00000000..4073e095 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ITrackedUidRemapper.cs @@ -0,0 +1,27 @@ +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Keeps quick-pick applied state pointing at live alarm rows when a uid rotates. +/// +/// +/// +/// PoracleNG implements a tracking edit as delete-and-insert for every type except monsters, so the row +/// comes back with a new uid. Quick-pick applied state persists the uids captured at apply time, and +/// removal deletes by those uids — so after any edit, removal deleted nothing, reported 204, and the +/// alarm kept firing. Worse, the summary read then saw zero surviving uids, concluded the user had +/// deleted the alarms by hand, and cleared the applied state, leaving no way to remove it from the UI +/// at all. See #403. +/// +/// +/// Alarm services call this whenever they observe a rotation, so the stored uid follows the row. +/// It is deliberately best-effort: a failure here must not fail an edit that already succeeded. +/// +/// +public interface ITrackedUidRemapper +{ + /// + /// Rewrites to in every applied state belonging to + /// for . A no-op when no quick pick tracks the uid. + /// + public Task RemapAsync(string userId, string alarmType, int oldUid, int newUid); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUpdateCheckService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUpdateCheckService.cs new file mode 100644 index 00000000..494c046a --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUpdateCheckService.cs @@ -0,0 +1,21 @@ +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Compares what this deployment runs against what has been published. +/// +public interface IUpdateCheckService +{ + /// + /// Whether either component is behind. Never throws and never blocks on the network for long: a + /// failed or disabled check reports Unknown, which the UI renders as nothing rather than as news. + /// + Task<(UpdateStatus PoracleWeb, UpdateStatus PoracleNg)> CheckAsync( + string? runningPoracleWeb, + string? runningPoracleNg, + CancellationToken cancellationToken = default); + + /// Drops the cached answer so the next check asks GitHub again. + void Invalidate(); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUpstreamFeatureFlagService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUpstreamFeatureFlagService.cs new file mode 100644 index 00000000..f479fb19 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUpstreamFeatureFlagService.cs @@ -0,0 +1,20 @@ +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Resolves which alarm types the upstream Poracle deployment has switched off in its own config, +/// expressed as this application's disable_* keys. +/// +/// +/// These act as a floor under the disable_* site settings rather than a replacement: +/// a type is off if either source says so. The site settings still gate UI Poracle has no opinion +/// about (areas, profiles, geocoding), so they cannot simply be swapped out. See #769. +/// +public interface IUpstreamFeatureFlagService +{ + /// + /// The disable_* keys Poracle forces off. Empty when Poracle is unreachable, is too old to + /// report the flags, or genuinely disables nothing — the caller must not be able to tell those + /// apart, because all three mean "leave the site settings in charge". + /// + Task> GetDisabledKeysAsync(); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs index a205eb55..883e57f3 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs @@ -6,13 +6,23 @@ public interface IUserGeofenceService { public Task> GetByUserAsync(string humanId); public Task CreateAsync(string humanId, int profileNo, UserGeofenceCreate model); + /// + /// Renames a geofence and moves its area subscriptions with it. + /// + /// + /// The Geofences page used to implement editing as delete-then-recreate, and the recreate + /// re-subscribed only the ACTIVE profile — so renaming from profile 0 quietly switched the geofence + /// off everywhere else, while the page still showed it on. See #543. + /// + public Task RenameAsync(string humanId, int id, string displayName, string? groupName, int? parentId); + public Task DeleteAsync(string humanId, int profileNo, int id); public Task SubmitForReviewAsync(string humanId, string kojiName); public Task> GetAllAsync(); public Task> GetAllWithDetailsAsync(); public Task> GetPendingSubmissionsAsync(); public Task AdminDeleteAsync(string adminId, int id); - public Task ApproveSubmissionAsync(string adminId, int id, string? promotedName); + public Task ApproveSubmissionAsync(string adminId, int id, string? promotedName, int? parentId = null, string? groupName = null); public Task RejectSubmissionAsync(string adminId, int id, string reviewNotes); public Task AddToProfileAsync(string humanId, int profileNo, int geofenceId); public Task RemoveFromProfileAsync(string humanId, int profileNo, int geofenceId); diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserPurgeService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserPurgeService.cs new file mode 100644 index 00000000..89040452 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserPurgeService.cs @@ -0,0 +1,20 @@ +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Removes everything PoracleWeb holds about a user when their account is deleted. +/// +/// +/// The delete used to remove the humans row alone. Everything else stayed: alarms in the Poracle DB, +/// and geofences, webhook delegate grants, quick picks and their applied state in poracle_web. None of +/// it was reachable through any API surface afterwards, so it looked deleted — until the same id was created +/// again, which adopted the lot. The delegate grants are the sharp end: re-creating a webhook URL silently +/// restored impersonation rights over it, and a deleted user's geofences kept being published in the feed +/// PoracleJS reads. See #510, #511, #512. +/// +public interface IUserPurgeService +{ + /// + /// Erases the user's data everywhere PoracleWeb stores it. Returns false when no such user exists. + /// + public Task PurgeAsync(string userId); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs b/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs index e224e6b1..672e66e1 100644 --- a/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs +++ b/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs @@ -29,12 +29,17 @@ public static class AlarmMappingExtensions PvpRankingBest = src.PvpRankingBest, PvpRankingMinCp = src.PvpRankingMinCp, PvpRankingLeague = src.PvpRankingLeague, + PvpRankingCap = src.PvpRankingCap, + PvpRankingEvolution = src.PvpRankingEvolution, + MinTime = src.MinTime, Form = src.Form, Size = src.Size, MaxSize = src.MaxSize, Gender = src.Gender, Clean = src.Clean, Template = src.Template, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this MonsterUpdate src, Monster dest) @@ -59,12 +64,17 @@ public static void ApplyUpdate(this MonsterUpdate src, Monster dest) if (src.PvpRankingBest != null) dest.PvpRankingBest = src.PvpRankingBest.Value; if (src.PvpRankingMinCp != null) dest.PvpRankingMinCp = src.PvpRankingMinCp.Value; if (src.PvpRankingLeague != null) dest.PvpRankingLeague = src.PvpRankingLeague.Value; + if (src.PvpRankingCap != null) dest.PvpRankingCap = src.PvpRankingCap.Value; + if (src.PvpRankingEvolution != null) dest.PvpRankingEvolution = src.PvpRankingEvolution.Value; + if (src.MinTime != null) dest.MinTime = src.MinTime.Value; if (src.Form != null) dest.Form = src.Form.Value; if (src.Size != null) dest.Size = src.Size.Value; if (src.MaxSize != null) dest.MaxSize = src.MaxSize.Value; if (src.Gender != null) dest.Gender = src.Gender.Value; if (src.Clean != null) dest.Clean = src.Clean.Value; if (src.Template != null) dest.Template = src.Template; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── Raid ───────────────────────────────────────────────── @@ -84,6 +94,8 @@ public static void ApplyUpdate(this MonsterUpdate src, Monster dest) Exclusive = src.Exclusive, GymId = src.GymId, RsvpChanges = src.RsvpChanges, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this RaidUpdate src, Raid dest) @@ -100,6 +112,8 @@ public static void ApplyUpdate(this RaidUpdate src, Raid dest) if (src.Exclusive != null) dest.Exclusive = src.Exclusive.Value; if (src.GymId != null) dest.GymId = src.GymId; if (src.RsvpChanges != null) dest.RsvpChanges = src.RsvpChanges.Value; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── Egg ────────────────────────────────────────────────── @@ -115,6 +129,8 @@ public static void ApplyUpdate(this RaidUpdate src, Raid dest) Exclusive = src.Exclusive, GymId = src.GymId, RsvpChanges = src.RsvpChanges, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this EggUpdate src, Egg dest) @@ -128,6 +144,8 @@ public static void ApplyUpdate(this EggUpdate src, Egg dest) if (src.Exclusive != null) dest.Exclusive = src.Exclusive.Value; if (src.GymId != null) dest.GymId = src.GymId; if (src.RsvpChanges != null) dest.RsvpChanges = src.RsvpChanges.Value; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── Quest ──────────────────────────────────────────────── @@ -137,11 +155,14 @@ public static void ApplyUpdate(this EggUpdate src, Egg dest) Ping = src.Ping, Distance = src.Distance, Reward = src.Reward, + Amount = src.Amount, RewardType = src.RewardType, Shiny = src.Shiny, Clean = src.Clean, Template = src.Template, Form = src.Form, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this QuestUpdate src, Quest dest) @@ -149,11 +170,14 @@ public static void ApplyUpdate(this QuestUpdate src, Quest dest) if (src.Ping != null) dest.Ping = src.Ping; if (src.Distance != null) dest.Distance = src.Distance.Value; if (src.Reward != null) dest.Reward = src.Reward.Value; + if (src.Amount != null) dest.Amount = src.Amount.Value; if (src.RewardType != null) dest.RewardType = src.RewardType.Value; if (src.Shiny != null) dest.Shiny = src.Shiny.Value; if (src.Clean != null) dest.Clean = src.Clean.Value; if (src.Template != null) dest.Template = src.Template; if (src.Form != null) dest.Form = src.Form.Value; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── Invasion ───────────────────────────────────────────── @@ -166,6 +190,8 @@ public static void ApplyUpdate(this QuestUpdate src, Quest dest) GruntType = src.GruntType, Clean = src.Clean, Template = src.Template, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this InvasionUpdate src, Invasion dest) @@ -176,6 +202,8 @@ public static void ApplyUpdate(this InvasionUpdate src, Invasion dest) if (src.GruntType != null) dest.GruntType = src.GruntType; if (src.Clean != null) dest.Clean = src.Clean.Value; if (src.Template != null) dest.Template = src.Template; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── Lure ───────────────────────────────────────────────── @@ -187,6 +215,8 @@ public static void ApplyUpdate(this InvasionUpdate src, Invasion dest) LureId = src.LureId, Clean = src.Clean, Template = src.Template, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this LureUpdate src, Lure dest) @@ -196,6 +226,8 @@ public static void ApplyUpdate(this LureUpdate src, Lure dest) if (src.LureId != null) dest.LureId = src.LureId.Value; if (src.Clean != null) dest.Clean = src.Clean.Value; if (src.Template != null) dest.Template = src.Template; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── Nest ───────────────────────────────────────────────── @@ -209,6 +241,8 @@ public static void ApplyUpdate(this LureUpdate src, Lure dest) Form = src.Form, Clean = src.Clean, Template = src.Template, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this NestUpdate src, Nest dest) @@ -219,6 +253,8 @@ public static void ApplyUpdate(this NestUpdate src, Nest dest) if (src.Form != null) dest.Form = src.Form.Value; if (src.Clean != null) dest.Clean = src.Clean.Value; if (src.Template != null) dest.Template = src.Template; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── Gym ────────────────────────────────────────────────── @@ -233,6 +269,8 @@ public static void ApplyUpdate(this NestUpdate src, Nest dest) Template = src.Template, BattleChanges = src.BattleChanges, GymId = src.GymId, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this GymUpdate src, Gym dest) @@ -245,6 +283,8 @@ public static void ApplyUpdate(this GymUpdate src, Gym dest) if (src.Template != null) dest.Template = src.Template; if (src.BattleChanges != null) dest.BattleChanges = src.BattleChanges.Value; if (src.GymId != null) dest.GymId = src.GymId; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── FortChange ─────────────────────────────────────────── @@ -256,8 +296,9 @@ public static void ApplyUpdate(this GymUpdate src, Gym dest) FortType = src.FortType, IncludeEmpty = src.IncludeEmpty, ChangeTypes = src.ChangeTypes, - Clean = src.Clean, Template = src.Template, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this FortChangeUpdate src, FortChange dest) @@ -267,8 +308,9 @@ public static void ApplyUpdate(this FortChangeUpdate src, FortChange dest) if (src.FortType != null) dest.FortType = src.FortType; if (src.IncludeEmpty != null) dest.IncludeEmpty = src.IncludeEmpty.Value; if (src.ChangeTypes != null) dest.ChangeTypes = src.ChangeTypes; - if (src.Clean != null) dest.Clean = src.Clean.Value; if (src.Template != null) dest.Template = src.Template; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } // ── MaxBattle ──────────────────────────────────────────── @@ -286,6 +328,8 @@ public static void ApplyUpdate(this FortChangeUpdate src, FortChange dest) Move = src.Move, Evolution = src.Evolution, StationId = src.StationId, + OverrideLocationLabel = src.OverrideLocationLabel, + OverrideAreas = src.OverrideAreas, }; public static void ApplyUpdate(this MaxBattleUpdate src, MaxBattle dest) @@ -300,5 +344,7 @@ public static void ApplyUpdate(this MaxBattleUpdate src, MaxBattle dest) if (src.Move != null) dest.Move = src.Move.Value; if (src.Evolution != null) dest.Evolution = src.Evolution.Value; if (src.StationId != null) dest.StationId = src.StationId; + if (src.OverrideLocationLabel != null) dest.OverrideLocationLabel = src.OverrideLocationLabel; + if (src.OverrideAreas != null) dest.OverrideAreas = src.OverrideAreas; } } diff --git a/Core/Pgan.PoracleWebNet.Core.Mappings/EntityMappingExtensions.cs b/Core/Pgan.PoracleWebNet.Core.Mappings/EntityMappingExtensions.cs index 55434e37..bcd6df89 100644 --- a/Core/Pgan.PoracleWebNet.Core.Mappings/EntityMappingExtensions.cs +++ b/Core/Pgan.PoracleWebNet.Core.Mappings/EntityMappingExtensions.cs @@ -29,6 +29,7 @@ public static class EntityMappingExtensions DisabledDate = e.DisabledDate, CurrentProfileNo = e.CurrentProfileNo, CommunityMembership = e.CommunityMembership, + Notes = e.Notes, }; public static HumanEntity ToEntity(this Human m) => new() @@ -47,6 +48,7 @@ public static class EntityMappingExtensions DisabledDate = m.DisabledDate, CurrentProfileNo = m.CurrentProfileNo, CommunityMembership = m.CommunityMembership ?? string.Empty, + Notes = m.Notes ?? string.Empty, }; public static void ApplyTo(this Human src, HumanEntity dest) @@ -64,6 +66,7 @@ public static void ApplyTo(this Human src, HumanEntity dest) dest.DisabledDate = src.DisabledDate; dest.CurrentProfileNo = src.CurrentProfileNo; dest.CommunityMembership = src.CommunityMembership ?? string.Empty; + dest.Notes = src.Notes ?? string.Empty; } // ── Profile ────────────────────────────────────────────── diff --git a/Core/Pgan.PoracleWebNet.Core.Models/AccountGoneException.cs b/Core/Pgan.PoracleWebNet.Core.Models/AccountGoneException.cs new file mode 100644 index 00000000..f2d357f4 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/AccountGoneException.cs @@ -0,0 +1,29 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// The account the request is authenticated as no longer exists. +/// +/// +/// A JWT outlives the account it names. After an admin deletes a user, PoracleNG answers 404 "User not +/// found" for every lookup, and EnsureSuccessStatusCode turned that into an unhandled 500 — so the +/// deleted user sat in a fully rendered app throwing "an unexpected error occurred" on every page, because +/// the SPA only signs out on 401. /api/auth/me alone got this right (#545). Raised here and mapped to +/// 401 so every endpoint answers the same way and the session ends. See #584. +/// +public sealed class AccountGoneException : Exception +{ + public AccountGoneException() + : base("This account no longer exists.") + { + } + + public AccountGoneException(string message) + : base(message) + { + } + + public AccountGoneException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/ActiveHoursValidator.cs b/Core/Pgan.PoracleWebNet.Core.Models/ActiveHoursValidator.cs new file mode 100644 index 00000000..fb7b9f72 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/ActiveHoursValidator.cs @@ -0,0 +1,94 @@ +using System.Text.Json; + +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Shared validator for the active_hours JSON shape used by both profile schedules and +/// quest summary schedules. The schedule is a JSON array of {day:1-7, hours:0-23, mins:0-59} +/// entries (max 28). Extracted from ProfileController.ValidateActiveHours so the profile and +/// summary controllers share one implementation rather than risking drift between two copies. +/// +public static class ActiveHoursValidator +{ + public static (bool IsValid, string? Error) Validate(string? activeHours) + { + if (string.IsNullOrWhiteSpace(activeHours)) + { + return (true, null); + } + + activeHours = activeHours.Trim(); + + JsonElement arr; + try + { + arr = JsonSerializer.Deserialize(activeHours); + } + catch (JsonException) + { + return (false, "active_hours must be a valid JSON array."); + } + + if (arr.ValueKind != JsonValueKind.Array) + { + return (false, "active_hours must be a JSON array."); + } + + if (arr.GetArrayLength() > 28) + { + return (false, "active_hours may contain at most 28 entries."); + } + + foreach (var entry in arr.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) + { + return (false, "Each active_hours entry must be an object."); + } + + if (!entry.TryGetProperty("day", out var dayProp) || !TryGetIntValue(dayProp, out var day) || day < 1 || day > 7) + { + return (false, "Each active_hours entry must have a 'day' between 1 and 7."); + } + + if (!entry.TryGetProperty("hours", out var hoursProp)) + { + return (false, "Each active_hours entry must have an 'hours' property."); + } + + if (!TryGetIntValue(hoursProp, out var hours) || hours < 0 || hours > 23) + { + return (false, "Each active_hours entry must have 'hours' between 0 and 23."); + } + + if (!entry.TryGetProperty("mins", out var minsProp)) + { + return (false, "Each active_hours entry must have a 'mins' property."); + } + + if (!TryGetIntValue(minsProp, out var mins) || mins < 0 || mins > 59) + { + return (false, "Each active_hours entry must have 'mins' between 0 and 59."); + } + } + + return (true, null); + } + + private static bool TryGetIntValue(JsonElement element, out int value) + { + if (element.ValueKind == JsonValueKind.Number) + { + return element.TryGetInt32(out value); + } + + if (element.ValueKind == JsonValueKind.String && + int.TryParse(element.GetString(), out value)) + { + return true; + } + + value = 0; + return false; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/AlarmValidationException.cs b/Core/Pgan.PoracleWebNet.Core.Models/AlarmValidationException.cs new file mode 100644 index 00000000..7169a90d --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/AlarmValidationException.cs @@ -0,0 +1,27 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// A service-layer rejection of an alarm the caller cannot fix by retrying: the request itself is wrong. +/// +/// +/// Service-layer guards used to throw , which nothing maps, so a request the +/// guard was written to explain came back as a bare 500 and the explanation never left the building. The +/// create paths get the same rejection as a 400 from model validation, so the update paths were reporting a +/// server fault for a request the API already knew how to describe. See #518. +/// +public sealed class AlarmValidationException : Exception +{ + public AlarmValidationException(string message) + : base(message) + { + } + + public AlarmValidationException() + { + } + + public AlarmValidationException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/BulkDistanceRequest.cs b/Core/Pgan.PoracleWebNet.Core.Models/BulkDistanceRequest.cs index 213d499a..1f6710b5 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/BulkDistanceRequest.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/BulkDistanceRequest.cs @@ -1,8 +1,22 @@ +using System.ComponentModel.DataAnnotations; + namespace Pgan.PoracleWebNet.Core.Models; public class BulkDistanceRequest { public List Uids { get; set; } = []; + + /// + /// Metres. Zero means area-based delivery. + /// + /// + /// Every *Create and *Update model has carried [Range(0, int.MaxValue)] on Distance since the + /// beginning; this one did not, so the bulk endpoints accepted negatives that the create path rejects. + /// PoracleNG clamps the upper bound but not the lower, and it gates radius matching on + /// distance > 0 — so a negative value silently switched those alarms to area-based delivery + /// while the card went on showing a negative radius. See #417. + /// + [Range(0, int.MaxValue)] public int Distance { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/CleanFlags.cs b/Core/Pgan.PoracleWebNet.Core.Models/CleanFlags.cs new file mode 100644 index 00000000..b1df1ec1 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/CleanFlags.cs @@ -0,0 +1,42 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Helpers for the PoracleNG alarm clean column, which is a 3-bit bitmask: +/// bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. Mirrors PoracleNG's +/// db.IsClean / db.IsEdit / db.IsSummary (processor/internal/db/clean.go), +/// so reads and writes preserve bits the web UI does not surface. +/// +public static class CleanFlags +{ + /// Auto-delete bit (bit 1). PoracleNG db.IsClean. + public const int AutoDelete = 1; + + /// Edit-in-place bit (bit 2). PoracleNG db.IsEdit. + public const int Edit = 2; + + /// Summary bit (bit 4). PoracleNG db.IsSummary. + public const int Summary = 4; + + /// All known bits combined (7). + public const int All = AutoDelete | Edit | Summary; + + /// True when the auto-delete bit (bit 1) is set. + public static bool IsAutoDelete(int clean) => (clean & AutoDelete) != 0; + + /// True when the edit-in-place bit (bit 2) is set. + public static bool IsEdit(int clean) => (clean & Edit) != 0; + + /// True when the summary bit (bit 4) is set. + public static bool IsSummary(int clean) => (clean & Summary) != 0; + + /// Composes a clean bitmask from the three known flags. + public static int Compose(bool autoDelete, bool edit, bool summary) => + (autoDelete ? AutoDelete : 0) | (edit ? Edit : 0) | (summary ? Summary : 0); + + /// + /// Returns with only the bits in + /// replaced by the corresponding bits from . Bits outside the + /// mask are left untouched, so bot-set bits the web UI does not edit survive a save. + /// + public static int Preserve(int existing, int mask, int changes) => (existing & ~mask) | (changes & mask); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs b/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs index 3980901d..9eef9ae6 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs @@ -29,6 +29,46 @@ public static class DisableFeatureKeys public const string MaxBattles = "disable_maxbattles"; public const string FortChanges = "disable_fort_changes"; + /// + /// Disables the user-submitted custom-geofence feature (drawing/creating, submitting for review, + /// and GeoJSON import). Not an alarm type — gates UserGeofenceController directly. Existing + /// user geofences keep being served by the geofence feed so in-flight alerts don't break. + /// + public const string UserGeofences = "disable_user_geofences"; + + /// + /// Disables area (geofence subscription) management. Not an alarm type — gates + /// AreaController. Existing subscriptions keep working; only changing them is blocked. + /// + public const string Areas = "disable_areas"; + + /// + /// Disables profile management and switching. Gates ProfileController and + /// ProfileOverviewController. The user stays on whichever profile is currently active — + /// /api/auth/me is deliberately not gated, so the JWT profile resync keeps working and + /// PoracleNG's active-hours scheduler can still move a user between profiles. + /// + public const string Profiles = "disable_profiles"; + + /// + /// Disables setting a home location and its distance radius. Gates LocationController. + /// A location already set stays set. + /// + public const string Location = "disable_location"; + + /// + /// Disables outbound geocoding - the address search and reverse lookup that call the configured + /// Nominatim/OpenStreetMap provider. Gates the two geocode actions on LocationController + /// rather than the whole controller, which is already gated by . + /// + /// + /// This toggle shipped in the admin UI with no consumer anywhere in the codebase, so an operator + /// who switched it off for privacy or OSM terms-of-use reasons was still making outbound Nominatim + /// calls. The most misleading of the four inert toggles: the others merely did nothing, while this + /// one implied a guarantee it did not provide. See #420. + /// + public const string Geocoding = "disable_nominatim"; + /// /// Tracking-type string (as used in PoracleNG's /api/tracking/{type} URLs and /// ProfileOverviewService's alarm-type loop) → matching disable_* key. @@ -38,9 +78,9 @@ public static class DisableFeatureKeys /// fort-changes and the disable key is disable_fort_changes — three /// different spellings of the same concept, baked into the upstream API. /// - public static IReadOnlyDictionary ByTrackingType - { - get; + public static IReadOnlyDictionary ByTrackingType + { + get; } = new Dictionary(StringComparer.Ordinal) { ["pokemon"] = Pokemon, diff --git a/Core/Pgan.PoracleWebNet.Core.Models/DistinctValuesAttribute.cs b/Core/Pgan.PoracleWebNet.Core.Models/DistinctValuesAttribute.cs new file mode 100644 index 00000000..73e04e7f --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/DistinctValuesAttribute.cs @@ -0,0 +1,35 @@ +using System.Collections; +using System.ComponentModel.DataAnnotations; + +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Refuses a collection that repeats a value. +/// +/// +/// A repeated entry in a set-like field cannot mean anything, and the ones we have reach a column that +/// stores them as JSON text -- so a long enough repeat became a database error rather than a 400. +/// See #612. +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class DistinctValuesAttribute : ValidationAttribute +{ + public override bool IsValid(object? value) + { + if (value is not IEnumerable items) + { + return true; + } + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in items) + { + if (!seen.Add(item?.ToString() ?? string.Empty)) + { + return false; + } + } + + return true; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs b/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs index de3031f4..ae9c1a1a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Egg.cs @@ -1,47 +1,72 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class Egg -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public string? Ping - { - get; set; - } - public int Distance - { - get; set; - } - public int Team { get; set; } = 4; - public int Level - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int Exclusive - { - get; set; - } - public string? GymId - { - get; set; - } - public int RsvpChanges - { - get; set; - } - public int ProfileNo - { - get; set; - } -} +namespace Pgan.PoracleWebNet.Core.Models; + +public class Egg +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int Team { get; set; } = 4; + public int Level + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int Exclusive + { + get; set; + } + public string? GymId + { + get; set; + } + public int RsvpChanges + { + get; set; + } + public int ProfileNo + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs index 61527641..13a3c054 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs @@ -19,13 +19,15 @@ public int Distance [Range(0, 4)] public int Team { get; set; } = 4; - [Range(0, 10)] + // PoracleNG accepts any positive integer as an egg level. See #259. + [Range(0, int.MaxValue)] public int Level { get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -49,9 +51,23 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int RsvpChanges { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs index 2356e53f..c6fb0355 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs @@ -22,13 +22,15 @@ public int? Team get; set; } - [Range(0, 10)] + // See EggCreate.Level — PoracleNG accepts arbitrary positive integers. + [Range(0, int.MaxValue)] public int? Level { get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -52,9 +54,23 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int? RsvpChanges { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs b/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs index 22a1dcc5..64eaae21 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs @@ -38,15 +38,35 @@ public int IncludeEmpty /// [JsonConverter(typeof(StringOrArrayConverter))] public List ChangeTypes { get; set; } = []; - public int Clean + public string? Template { get; set; } - public string? Template + public int ProfileNo { get; set; } - public int ProfileNo + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas { get; set; } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeCreate.cs index f63979b4..b98b0adc 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeCreate.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using System.ComponentModel.DataAnnotations; namespace Pgan.PoracleWebNet.Core.Models; @@ -35,16 +36,36 @@ public int IncludeEmpty FortChangeOptions.ChangeTypeImageUrl, FortChangeOptions.ChangeTypeRemoval, FortChangeOptions.ChangeTypeNew)] + // PoracleNG stores this as its JSON text, so an export carries change_types as the STRING + // ["name"] rather than an array -- and a genuine, unmodified backup then failed to re-import once + // #548 started binding this DTO. The domain model has carried the converter for exactly this + // reason; the Create DTO needs it too. See #556. + // There are exactly five legal change types, so anything longer is impossible input and was reaching + // the database as an over-long JSON string -- a 500 for a request the API can describe. Duplicates + // are refused for the same reason: they cannot mean anything. See #612. + [MaxLength(5, ErrorMessage = "changeTypes may contain at most 5 entries.")] + [DistinctValues(ErrorMessage = "changeTypes must not repeat a value.")] + [JsonConverter(typeof(StringOrArrayConverter))] public List ChangeTypes { get; set; } = []; - [Range(0, 1)] - public int Clean + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + + [StringLength(256)] + public string? Template { get; set; } - [StringLength(256)] - public string? Template + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas { get; set; } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeOptions.cs b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeOptions.cs index 3f5b6658..e6148dcf 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeOptions.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeOptions.cs @@ -15,6 +15,7 @@ public static class FortChangeOptions public const string ChangeTypeImageUrl = "image_url"; public const string ChangeTypeRemoval = "removal"; public const string ChangeTypeNew = "new"; + public const string ChangeTypeDescription = "description"; public static readonly IReadOnlySet ValidFortTypes = new HashSet(StringComparer.Ordinal) { @@ -30,5 +31,6 @@ public static class FortChangeOptions ChangeTypeImageUrl, ChangeTypeRemoval, ChangeTypeNew, + ChangeTypeDescription, }; } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs index fe8138cf..eb6ab824 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs @@ -29,6 +29,10 @@ public int? IncludeEmpty get; set; } + // The same two bounds the create DTO carries (#612). Left off here, an update could still push an + // unbounded or repeating list into the JSON text column. See #660. + [MaxLength(5, ErrorMessage = "changeTypes may contain at most 5 entries.")] + [DistinctValues(ErrorMessage = "changeTypes must not repeat a value.")] [AllowedStringValues( FortChangeOptions.ChangeTypeName, FortChangeOptions.ChangeTypeLocation, @@ -40,14 +44,24 @@ public List? ChangeTypes get; set; } - [Range(0, 1)] - public int? Clean + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + + [StringLength(256)] + public string? Template { get; set; } - [StringLength(256)] - public string? Template + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas { get; set; } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/GeoJsonImportResult.cs b/Core/Pgan.PoracleWebNet.Core.Models/GeoJsonImportResult.cs index 83cbf58e..67ad57e1 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/GeoJsonImportResult.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/GeoJsonImportResult.cs @@ -4,6 +4,13 @@ public class GeoJsonImportResult { public List Created { get; set; } = []; public List Errors { get; set; } = []; + + /// + /// Features that were imported, but not exactly as the file described them. Separate from + /// because these did produce a geofence - the user still needs telling, + /// since the stored shape is what PoracleJS matches alerts against. See #474. + /// + public List Warnings { get; set; } = []; } public class GeoJsonImportError diff --git a/Core/Pgan.PoracleWebNet.Core.Models/GeofenceNotFoundException.cs b/Core/Pgan.PoracleWebNet.Core.Models/GeofenceNotFoundException.cs new file mode 100644 index 00000000..9270504f --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/GeofenceNotFoundException.cs @@ -0,0 +1,44 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// The requested geofence does not exist. +/// +/// +/// Distinct from the the geofence service throws for validation +/// and state-machine failures, so controllers can answer 404 for a missing record and 400 for bad input. +/// Previously both were the same type and every one of them became a 404 — so an admin who typed a +/// promoted name containing a slash was told the submission did not exist, while it sat visible in the +/// list in front of them. See #421. +/// +/// +/// Derives from on purpose: several controllers already catch +/// that type from these same service calls, and a sibling type would have turned every one of those +/// into an unhandled 500. Handlers that want to distinguish the two catch this first. +/// +public sealed class GeofenceNotFoundException : InvalidOperationException +{ + public GeofenceNotFoundException(int id) + : base($"Geofence with ID {id} not found.") => this.GeofenceId = id; + + public GeofenceNotFoundException(string kojiName) + : base($"Geofence '{kojiName}' not found.") => this.KojiName = kojiName; + + public GeofenceNotFoundException() + { + } + + public GeofenceNotFoundException(string message, Exception innerException) + : base(message, innerException) + { + } + + public int? GeofenceId + { + get; + } + + public string? KojiName + { + get; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/GeofenceSubmissionPost.cs b/Core/Pgan.PoracleWebNet.Core.Models/GeofenceSubmissionPost.cs new file mode 100644 index 00000000..9a9fed2e --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/GeofenceSubmissionPost.cs @@ -0,0 +1,54 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Everything the Discord review post shows about a submitted geofence. Built once at submission time and +/// rebuilt on approval/rejection so the opening post can be edited in place to reflect the outcome. +/// +public sealed record GeofenceSubmissionPost +{ + /// Discord user ID of the submitter, used for the clickable mention. + public required string UserId { get; init; } + + /// + /// Display name of the submitter, shown in the embed author block. Null when it could not be resolved, + /// in which case the author block is omitted rather than showing a raw Discord ID. + /// + public string? UserName { get; init; } + + /// Name the submitter gave the area. + public required string DisplayName { get; init; } + + /// Lowercase name the area would take in the shared public list on approval. + public required string PublicName { get; init; } + + /// Auto-detected Koji parent region, or empty when detection found nothing. + public string GroupName { get; init; } = string.Empty; + + public double AreaSqKm { get; init; } + + public double CentroidLat { get; init; } + + public double CentroidLon { get; init; } + + /// Name of an existing public area containing this one's centroid, when there is one. + public string? OverlapsArea { get; init; } + + /// Static map URL from Poracle; downloaded and attached to the message. + public string? MapImageUrl { get; init; } + + /// Review state driving the embed colour and status line: pending, approved or rejected. + public GeofenceReviewState State { get; init; } = GeofenceReviewState.Pending; + + /// Admin's reason, shown on rejection. + public string? ReviewNotes { get; init; } + + /// Deep link to the admin review page, when a public site URL is configured. + public string? ReviewUrl { get; init; } +} + +public enum GeofenceReviewState +{ + Pending, + Approved, + Rejected, +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs b/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs index 1c19deb2..22dbfca1 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Gym.cs @@ -1,46 +1,71 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class Gym -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public string? Ping - { - get; set; - } - public int Distance - { - get; set; - } - public int Team - { - get; set; - } - public int SlotChanges - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int BattleChanges - { - get; set; - } - public string? GymId - { - get; set; - } - public int ProfileNo - { - get; set; - } -} +namespace Pgan.PoracleWebNet.Core.Models; + +public class Gym +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int Team + { + get; set; + } + public int SlotChanges + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int BattleChanges + { + get; set; + } + public string? GymId + { + get; set; + } + public int ProfileNo + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/GymCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/GymCreate.cs index 4a3b7a81..1a155a2f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/GymCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/GymCreate.cs @@ -25,7 +25,8 @@ public int SlotChanges get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -48,4 +49,18 @@ public string? GymId { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/GymUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/GymUpdate.cs index cb96c8df..226ed03d 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/GymUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/GymUpdate.cs @@ -28,7 +28,8 @@ public int? SlotChanges get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -51,4 +52,18 @@ public string? GymId { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Helpers/PolygonValidation.cs b/Core/Pgan.PoracleWebNet.Core.Models/Helpers/PolygonValidation.cs new file mode 100644 index 00000000..9a3c1fd0 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/Helpers/PolygonValidation.cs @@ -0,0 +1,77 @@ +namespace Pgan.PoracleWebNet.Core.Models.Helpers; + +/// +/// The one definition of a well-formed user geofence polygon. +/// +/// +/// +/// There are two write paths into user_geofences — the create endpoint and GeoJSON import — and +/// they enforced different rules. Import checked point arity and coordinate range; create checked only +/// the point count, so [[1],[2],[3]] and [[999,-999],...] were stored verbatim. Those rows +/// then reached GET /api/geofence-feed, the anonymous endpoint that is the single geofence source +/// for PoracleJS, and crashed the owner's GeoJSON export with an IndexOutOfRangeException. See #410. +/// +/// +/// Read paths use to skip bad rows rather than trust them: rows written before +/// this validation existed are still in the database, and one of them must not be able to break a feed +/// that every other user depends on. +/// +/// Points are internal order — [latitude, longitude], not GeoJSON's [lon, lat]. +/// +public static class PolygonValidation +{ + public const int MinPoints = 3; + public const int MaxPoints = 500; + + /// + /// Validates a polygon for storage. is a message suitable for returning to + /// the caller, phrased the same way the import path phrases its rejections. + /// + public static bool TryValidate(double[][]? polygon, out string error) + { + if (polygon is null || polygon.Length < MinPoints) + { + error = $"Polygon must have at least {MinPoints} points."; + return false; + } + + if (polygon.Length > MaxPoints) + { + error = $"Polygon cannot exceed {MaxPoints} points."; + return false; + } + + for (var i = 0; i < polygon.Length; i++) + { + var point = polygon[i]; + + if (point is null || point.Length != 2) + { + error = $"Polygon point {i} must be a [latitude, longitude] pair."; + return false; + } + + if (double.IsNaN(point[0]) || double.IsNaN(point[1]) || + double.IsInfinity(point[0]) || double.IsInfinity(point[1])) + { + error = $"Polygon point {i} is not a finite coordinate."; + return false; + } + + if (point[0] is < -90 or > 90 || point[1] is < -180 or > 180) + { + error = "Coordinates out of valid range (lat: -90 to 90, lon: -180 to 180)."; + return false; + } + } + + error = string.Empty; + return true; + } + + /// + /// Whether a polygon already in storage is safe to serve or project. Used by read paths, which cannot + /// assume the row was written after existed. + /// + public static bool IsWellFormed(double[][]? polygon) => TryValidate(polygon, out _); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Helpers/ProfileNameRules.cs b/Core/Pgan.PoracleWebNet.Core.Models/Helpers/ProfileNameRules.cs new file mode 100644 index 00000000..d9c4cdbd --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/Helpers/ProfileNameRules.cs @@ -0,0 +1,31 @@ +namespace Pgan.PoracleWebNet.Core.Models.Helpers; + +/// +/// The one place that decides whether a profile name is acceptable. +/// +/// +/// Creating a profile checked the name against the profiles.name varchar(255) column and answered a +/// clear 400 (#467). Neither duplicate endpoint repeated the check, so the same name reached the database +/// and came back as an opaque 500 — and the Profile Overview page's duplicate prompt is a free-text input +/// with no maxlength, prefilled with "<source> (Copy)", so a long name is an ordinary thing to type. +/// Shared rather than copied a fourth time. See #504, #519. +/// +public static class ProfileNameRules +{ + public const int MaxLength = 255; + + /// + /// Returns the message to report, or null when the name is acceptable. + /// + public static string? Validate(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return "Profile name is required."; + } + + return name.Trim().Length > MaxLength + ? $"Profile name must be {MaxLength} characters or fewer." + : null; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Helpers/ProfileNumbering.cs b/Core/Pgan.PoracleWebNet.Core.Models/Helpers/ProfileNumbering.cs new file mode 100644 index 00000000..4f941b98 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/Helpers/ProfileNumbering.cs @@ -0,0 +1,53 @@ +namespace Pgan.PoracleWebNet.Core.Models.Helpers; + +/// +/// Works out which profile number PoracleNG actually assigned to a newly created profile. +/// +/// +/// +/// PoracleWeb used to predict the number as max(existing) + 1. PoracleNG assigns the +/// lowest free number instead (verified: with profiles 0, 1, 3 a new profile is created at 2), +/// so any user who had ever deleted a non-last profile got a different number than PoracleWeb expected. +/// The create then re-read the wrong number and returned an empty body, and duplicate copied the alarms +/// to a profile number with no profile row — orphaned alarms that later attached themselves to whatever +/// profile was eventually created at that number. See #407. +/// +/// +/// The number is resolved by diffing the profile list either side of the create, rather than by matching +/// on name, because names are not unique — two profiles may legitimately share one. +/// +/// +public static class ProfileNumbering +{ + /// + /// The profile number that appeared between and . + /// + /// + /// Used only to disambiguate if more than one profile appeared, which would mean something else + /// created a profile concurrently. + /// + /// The new profile number, or null if none appeared — the create silently failed. + public static int? ResolveCreated( + IEnumerable before, + IEnumerable after, + string? name = null) + { + var existing = before.Select(p => p.ProfileNo).ToHashSet(); + var added = after.Where(p => !existing.Contains(p.ProfileNo)).ToList(); + + if (added.Count == 0) + { + return null; + } + + if (added.Count == 1) + { + return added[0].ProfileNo; + } + + // A concurrent create. Prefer one matching the name we asked for; failing that the lowest, which + // is the one PoracleNG would have assigned first. + var named = added.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal)); + return named?.ProfileNo ?? added.Min(p => p.ProfileNo); + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Human.cs b/Core/Pgan.PoracleWebNet.Core.Models/Human.cs index e1e73922..9fe4e8c1 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Human.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Human.cs @@ -55,4 +55,14 @@ public string? CommunityMembership { get; set; } + + /// + /// Free-text notes on the human record. PoracleJS/PoracleNG can be configured to auto-fill this + /// with the Discord guild (server) name and channel category for channel-type users, which the + /// admin user list surfaces to disambiguate channels that share the same name. + /// + public string? Notes + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs b/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs index 36e6031c..c9fec400 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Invasion.cs @@ -1,38 +1,63 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class Invasion -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public string? Ping - { - get; set; - } - public int Distance - { - get; set; - } - public int Gender - { - get; set; - } - public string? GruntType - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int ProfileNo - { - get; set; - } -} +namespace Pgan.PoracleWebNet.Core.Models; + +public class Invasion +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int Gender + { + get; set; + } + public string? GruntType + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int ProfileNo + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.cs index 13e974e8..5b3a6bd2 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.cs @@ -22,13 +22,20 @@ public int Gender get; set; } + /// + /// Required. PoracleNG has no catch-all — an empty value is rejected upstream, so accepting one + /// here only converted a clear 400 into an opaque 500. Track everything by posting one alarm per + /// grunt type. See #416. + /// + [Required(AllowEmptyStrings = false)] [StringLength(256)] public string? GruntType { get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -39,4 +46,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/InvasionGruntTypes.cs b/Core/Pgan.PoracleWebNet.Core.Models/InvasionGruntTypes.cs new file mode 100644 index 00000000..075ba235 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/InvasionGruntTypes.cs @@ -0,0 +1,45 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// The Team Rocket grunt_type values PoracleNG accepts for invasion tracking. +/// +/// +/// +/// PoracleNG has no catch-all: grunt_type must be a non-empty string, and an empty one is +/// rejected with 400 {"message":"Grunt type mandatory"}. "Track everything" therefore has to be +/// a fan-out over this list — one row per type — which is also how the invasion table's natural key +/// (id, profile_no, gender, grunt_type) expects rows to be shaped. See #416. +/// +/// +/// This is the twin of GRUNT_TYPES in +/// ClientApp/src/app/modules/invasions/invasion-add-dialog.component.ts. The two must stay in +/// sync; InvasionGruntTypesTests pins the contents so a one-sided edit fails the build. +/// The frontend list additionally splits mixed into male and female rows for display — the +/// same grunt_type, differing only by gender — so it has one more entry than this one. +/// +/// +/// Pokestop event types (kecleon, gold-stop, showcase) are deliberately absent. +/// They are not Team Rocket invasions and are offered separately in the UI. +/// +/// +public static class InvasionGruntTypes +{ + /// The eighteen elemental grunt types. + public static IReadOnlyList Elemental { get; } = + [ + "bug", "dark", "dragon", "electric", "fairy", "fighting", "fire", "flying", "ghost", + "grass", "ground", "ice", "metal", "normal", "poison", "psychic", "rock", "water" + ]; + + /// Grunts that are not tied to a single element. + public static IReadOnlyList Special { get; } = ["mixed", "darkness", "decoy"]; + + /// The three Rocket leaders. Giovanni is separate — he needs a Super Rocket Radar. + public static IReadOnlyList Leaders { get; } = ["cliff", "arlo", "sierra"]; + + public const string Giovanni = "giovanni"; + + /// Every Team Rocket grunt type: elemental, special, leaders and Giovanni. + public static IReadOnlyList All { get; } = + [.. Elemental, .. Special, .. Leaders, Giovanni]; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/InvasionUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/InvasionUpdate.cs index 066a9490..c5ef8779 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/InvasionUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/InvasionUpdate.cs @@ -28,7 +28,8 @@ public string? GruntType get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -39,4 +40,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/KojiOperationException.cs b/Core/Pgan.PoracleWebNet.Core.Models/KojiOperationException.cs new file mode 100644 index 00000000..11c0d6b9 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/KojiOperationException.cs @@ -0,0 +1,53 @@ +using System.Net; + +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// A call to the Koji geofence server failed. +/// +/// +/// EnsureSuccessStatusCode() threw a bare , which no controller +/// caught, so any Koji-side failure during a geofence approval — Koji down, a region deleted between the +/// cached region list loading and the admin clicking approve, an unknown parent id — surfaced as +/// 500 {"error":"An unexpected error occurred."} with nothing to act on. This carries the status +/// and body so the caller can answer 502 and say which upstream failed. See #422. +/// +public sealed class KojiOperationException : Exception +{ + public KojiOperationException(string operation, HttpStatusCode statusCode, string? responseBody) + : base($"Koji rejected the {operation} request with {(int)statusCode} {statusCode}.") + { + this.Operation = operation; + this.StatusCode = statusCode; + this.ResponseBody = responseBody; + } + + public KojiOperationException() + { + } + + public KojiOperationException(string message) + : base(message) + { + } + + public KojiOperationException(string message, Exception innerException) + : base(message, innerException) + { + } + + public string? Operation + { + get; + } + + public HttpStatusCode StatusCode + { + get; + } + + public string? ResponseBody + { + get; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs b/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs index b8a8db9e..52b2cef9 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Lure.cs @@ -1,34 +1,59 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class Lure -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public string? Ping - { - get; set; - } - public int Distance - { - get; set; - } - public int LureId - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int ProfileNo - { - get; set; - } -} +namespace Pgan.PoracleWebNet.Core.Models; + +public class Lure +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int LureId + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int ProfileNo + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/LureCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/LureCreate.cs index 3bb18280..2d013954 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/LureCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/LureCreate.cs @@ -22,7 +22,8 @@ public int LureId get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -33,4 +34,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/LureUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/LureUpdate.cs index d3654064..a3b45a8f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/LureUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/LureUpdate.cs @@ -22,7 +22,8 @@ public int? LureId get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -33,4 +34,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs index 934eefc2..17358dd2 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattle.cs @@ -1,46 +1,71 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class MaxBattle -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public int PokemonId { get; set; } = 9000; - public string? Ping - { - get; set; - } - public int Distance - { - get; set; - } - public int Gmax - { - get; set; - } - public int Level { get; set; } = 9000; - public int Form - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int Move { get; set; } = 9000; - public int Evolution { get; set; } = 9000; - public string? StationId - { - get; set; - } - public int ProfileNo - { - get; set; - } -} +namespace Pgan.PoracleWebNet.Core.Models; + +public class MaxBattle +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public int PokemonId { get; set; } = 9000; + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int Gmax + { + get; set; + } + public int Level { get; set; } = 9000; + public int Form + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int Move { get; set; } = 9000; + public int Evolution { get; set; } = 9000; + public string? StationId + { + get; set; + } + public int ProfileNo + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleCreate.cs index 405bcf43..19794647 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleCreate.cs @@ -34,7 +34,8 @@ public int Form get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -57,4 +58,18 @@ public string? StationId { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleUpdate.cs index 3bc037fe..d0f07cfd 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleUpdate.cs @@ -34,7 +34,8 @@ public int? Form get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -63,4 +64,18 @@ public string? StationId { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs b/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs index e7f7dec8..d493ee1e 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs @@ -67,10 +67,38 @@ public int PvpRankingLeague { get; set; } + public int PvpRankingCap + { + get; set; + } + + /// + /// Which form of the pokemon the PVP ranks are read from: 0 base, 1 any mega, 2 Mega X, 3 Mega Y. + /// + /// + /// Only consulted when a league is set. 0 means base ranks, and whether mega entries also match is + /// then the server's include_mega_evolution default. PoracleNG 5.1.0. + /// + public int PvpRankingEvolution + { + get; set; + } public int Form { get; set; } + /// + /// Seconds a spawn must still have left when it is found, or the alert is skipped. + /// + /// + /// PoracleNG compares this against the spawn's time-to-hidden, so it answers "is this still worth + /// walking to?". 0 means any. + /// + public int MinTime + { + get; set; + } + public int Size { get; set; } = -1; public int MaxSize { get; set; } = 5; public int Gender @@ -89,4 +117,29 @@ public int ProfileNo { get; set; } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs index 23e28df9..7e7bc2b0 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs @@ -100,18 +100,44 @@ public int PvpRankingMinCp get; set; } - [Range(0, int.MaxValue)] + // The league is a CP cap, and the dropdown offers exactly four: none, Little (500), Great (1500), + // Ultra (2500). [Range(0, int.MaxValue)] accepted any positive integer, so a value no league uses + // stored a PVP filter that can never match -- the one unbounded field among Best/Worst/Cap. + // See #586. + [AllowedValues(0, 500, 1500, 2500)] public int PvpRankingLeague { get; set; } + [Range(0, 55)] + public int PvpRankingCap + { + get; set; + } + + // 0 base, 1 any mega, 2 Mega X, 3 Mega Y. PoracleNG reads no other value, and a fifth would rank + // against a form that does not exist, so the rule would never match. + [Range(0, 3)] + public int PvpRankingEvolution + { + get; set; + } + [Range(0, int.MaxValue)] public int Form { get; set; } + // Seconds of remaining despawn time. A pokemon spawn lasts an hour at the very most, and a value + // above its lifetime is a rule that can never match. + [Range(0, 3600)] + public int MinTime + { + get; set; + } + [Range(-1, 5)] public int Size { get; set; } = -1; @@ -124,7 +150,8 @@ public int Gender get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -135,4 +162,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MonsterRangeValidator.cs b/Core/Pgan.PoracleWebNet.Core.Models/MonsterRangeValidator.cs new file mode 100644 index 00000000..3b54d9fe --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/MonsterRangeValidator.cs @@ -0,0 +1,39 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Cross-field checks for a Pokemon alarm's min/max pairs. +/// +/// +/// The models carry per-property [Range] attributes only, so each bound was checked against the +/// game's limits and never against its partner. PoracleNG's matcher ANDs every range as a sequential gate, +/// so a window like minIv 90 / maxIv 10 can never match anything: the alarm saves clean and then goes +/// silent, with no error to explain it. A transposed pair in the edit dialog was enough. Validation has to +/// run on the merged alarm rather than the request, because a PUT carrying only minIv inverts the +/// window against the value already stored. Same shape as the distance guard added in #417. See #461. +/// +public static class MonsterRangeValidator +{ + /// + /// Returns a message naming the first inverted pair, or null when every window is satisfiable. + /// + public static string? Validate(Monster monster) + { + ArgumentNullException.ThrowIfNull(monster); + + // PVP rankings count upward from the best, so "best" is the LOWER bound of the pair. + return Check(monster.MinIv, monster.MaxIv, "minIv", "maxIv") + ?? Check(monster.MinCp, monster.MaxCp, "minCp", "maxCp") + ?? Check(monster.MinLevel, monster.MaxLevel, "minLevel", "maxLevel") + ?? Check(monster.MinWeight, monster.MaxWeight, "minWeight", "maxWeight") + ?? Check(monster.Atk, monster.MaxAtk, "atk", "maxAtk") + ?? Check(monster.Def, monster.MaxDef, "def", "maxDef") + ?? Check(monster.Sta, monster.MaxSta, "sta", "maxSta") + ?? Check(monster.Size, monster.MaxSize, "size", "maxSize") + ?? Check(monster.PvpRankingBest, monster.PvpRankingWorst, "pvpRankingBest", "pvpRankingWorst"); + } + + private static string? Check(int min, int max, string minName, string maxName) => + min > max + ? $"{minName} ({min}) is greater than {maxName} ({max}), so no Pokemon can match this alarm." + : null; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs index f6da1c7b..b03e6ecf 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs @@ -118,12 +118,34 @@ public int? PvpRankingMinCp get; set; } - [Range(0, int.MaxValue)] + // The same four the create DTO allows (#586). Left as an unbounded range here, an edit could store a + // CP cap no league uses -- the exact state that fix was written to prevent, reached from the edit + // path instead. See #594. + [AllowedValues(null, 0, 500, 1500, 2500)] public int? PvpRankingLeague { get; set; } + [Range(0, 55)] + public int? PvpRankingCap + { + get; set; + } + + // 0 base, 1 any mega, 2 Mega X, 3 Mega Y. + [Range(0, 3)] + public int? PvpRankingEvolution + { + get; set; + } + + [Range(0, 3600)] + public int? MinTime + { + get; set; + } + [Range(0, int.MaxValue)] public int? Form { @@ -148,7 +170,8 @@ public int? Gender get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -159,4 +182,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs b/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs index 2a1abb36..2c4757c0 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Nest.cs @@ -1,42 +1,67 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class Nest -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public string? Ping - { - get; set; - } - public int Distance - { - get; set; - } - public int PokemonId - { - get; set; - } - public int MinSpawnAvg - { - get; set; - } - public int Form - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int ProfileNo - { - get; set; - } -} +namespace Pgan.PoracleWebNet.Core.Models; + +public class Nest +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int PokemonId + { + get; set; + } + public int MinSpawnAvg + { + get; set; + } + public int Form + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int ProfileNo + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/NestCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/NestCreate.cs index 406d91c5..c8bc680d 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/NestCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/NestCreate.cs @@ -34,7 +34,8 @@ public int Form get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -45,4 +46,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/NestUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/NestUpdate.cs index e36d1244..bf64b86a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/NestUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/NestUpdate.cs @@ -28,7 +28,8 @@ public int? Form get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -39,4 +40,18 @@ public string? Template { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/OidcRefreshRequest.cs b/Core/Pgan.PoracleWebNet.Core.Models/OidcRefreshRequest.cs new file mode 100644 index 00000000..354b08a0 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/OidcRefreshRequest.cs @@ -0,0 +1,11 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// Body for POST /api/auth/oidc/refresh and /oidc/refresh/revoke: the opaque +/// PoracleWeb refresh token the browser holds in localStorage. +public sealed class OidcRefreshRequest +{ + public string? RefreshToken + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/OidcSession.cs b/Core/Pgan.PoracleWebNet.Core.Models/OidcSession.cs new file mode 100644 index 00000000..02b31273 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/OidcSession.cs @@ -0,0 +1,58 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Domain view of a server-side OIDC refresh session (one link in a rotation family). +/// The opaque token itself is never stored — only its SHA-256 hash — and the provider refresh +/// token in stays encrypted at rest. +/// +public class OidcSession +{ + public int Id + { + get; set; + } + + public string SessionTokenHash { get; set; } = string.Empty; + public string FamilyId { get; set; } = string.Empty; + public DateTime FamilyIssuedAt + { + get; set; + } + + public string UserId { get; set; } = string.Empty; + public string EncryptedRefreshToken { get; set; } = string.Empty; + public DateTime ExpiresAt + { + get; set; + } + + public DateTime CreatedUtc + { + get; set; + } + + public DateTime? RevokedAt + { + get; set; + } + + public string? RevokedReason + { + get; set; + } + + public string? ReplacedByHash + { + get; set; + } + + public string? IpAddress + { + get; set; + } + + public string? UserAgent + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs b/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs index 5cbc373a..5e395c3a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs @@ -34,12 +34,44 @@ public bool PvpLittleLeagueAllowed { get; set; } + + /// + /// PvP level caps offered by Poracle (e.g. [50] or [50, 51]). + /// Sourced from Poracle's pvp.levelCaps config and exposed via /api/config/poracleWeb. + /// + public List PvpCaps { get; set; } = []; + + /// + /// Default cap pre-selected when a user creates a new PvP-tracked monster alarm. + /// 0 = match all caps. Sourced from Poracle's tracking.defaultUserTrackingLevelCap. + /// + public int DefaultPvpCap + { + get; set; + } + public string DefaultTemplateName { get; set; } = string.Empty; public string EverythingFlagPermissions { get; set; } = string.Empty; public int MaxDistance { get; set; } + /// + /// Webhook types the upstream Poracle deployment has switched off, as reported by + /// GET /api/config/poracleWeb (e.g. ["raid", "quest"]). + /// + /// + /// null means the field was absent — an older Poracle or PoracleJS, which has no opinion — + /// and is deliberately distinct from an empty list, which means "nothing is disabled upstream". + /// Only the latter is safe to enforce on. Translate to disable_* keys with + /// ; note that disable_fort_update is + /// enforced upstream but never appears here. + /// + public List? DisabledHooks + { + get; set; + } + public PoracleAdmins? Admins { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs b/Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs new file mode 100644 index 00000000..ea3e7201 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/PoracleDisabledHookMap.cs @@ -0,0 +1,81 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Translates PoracleNG's own per-webhook-type disable flags into the disable_* keys this +/// application gates on, so a type an operator switched off in Poracle's config.toml stops +/// being offered here too. +/// +/// +/// +/// The upstream names come from the hookTypes list in +/// processor/internal/api/config.go, which is what GET /api/config/poracleWeb reports +/// as disabledHooks. PoracleNG enforces the same flags in two other places — the processor +/// drops the webhook and the bot refuses the command — so honouring them here makes the web UI +/// agree with the two surfaces that already do. See #769. +/// +/// +/// Two entries in the upstream array deliberately map to nothing: +/// +/// +/// +/// pokestop looks like the parent hook for lures, invasions and quests, but +/// DisablePokestop appears nowhere in the PoracleNG 5.1.0 processor outside the +/// disabledHooks list itself. Mapping it would take three working alarm types away from any +/// server that sets a flag which currently does nothing. +/// +/// +/// weather has no counterpart because PoracleWeb has no weather alarms. +/// +/// +/// +/// disable_fort_update is the mirror-image case: PoracleNG honours it in both the processor +/// and the bot, but omits it from the hookTypes list, so it never appears in +/// disabledHooks. It is read separately from general.disable_fort_update on +/// GET /api/config/values — see IPoracleApiProxy.GetFortUpdateDisabledAsync. +/// +/// +public static class PoracleDisabledHookMap +{ + /// + /// Upstream disabledHooks entry → the value it forces off. + /// Entries absent from this map (pokestop, weather) disable nothing. + /// + public static IReadOnlyDictionary ByHookName + { + get; + } = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["pokemon"] = DisableFeatureKeys.Pokemon, + // Eggs share the raid key here for the same reason they share it everywhere else: one raid UI. + ["raid"] = DisableFeatureKeys.Raids, + ["quest"] = DisableFeatureKeys.Quests, + ["invasion"] = DisableFeatureKeys.Invasions, + ["lure"] = DisableFeatureKeys.Lures, + ["nest"] = DisableFeatureKeys.Nests, + ["gym"] = DisableFeatureKeys.Gyms, + ["maxbattle"] = DisableFeatureKeys.MaxBattles, + }; + + /// + /// Maps an upstream disabledHooks array to the set of disable_* keys it forces off. + /// Unknown or unmapped hook names are ignored rather than guessed at. + /// + public static IReadOnlySet ToDisableKeys(IEnumerable? disabledHooks) + { + var keys = new HashSet(StringComparer.Ordinal); + if (disabledHooks is null) + { + return keys; + } + + foreach (var hook in disabledHooks) + { + if (!string.IsNullOrWhiteSpace(hook) && ByHookName.TryGetValue(hook.Trim(), out var key)) + { + keys.Add(key); + } + } + + return keys; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/PoracleServerProfile.cs b/Core/Pgan.PoracleWebNet.Core.Models/PoracleServerProfile.cs new file mode 100644 index 00000000..a81ee99a --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/PoracleServerProfile.cs @@ -0,0 +1,118 @@ +using System.Text.Json.Serialization; + +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// What PoracleWeb knows about the PoracleNG instance it is pointed at. +/// +/// +/// +/// PoracleWeb assumed 5.1.0 and never checked, so on an older server the features that need it — +/// per-alarm scope, the PVP mega picker, the minimum time filter — wrote fields nothing stored and +/// failed silently. This is the check. +/// +/// +/// Deliberately not a branch. PoracleNG stamps its branch into the binary but publishes only the +/// version on /health, so a develop build between releases reports the last release's number and +/// cannot be told apart. Branch would be the wrong question anyway: self-hosters run forks and +/// cherry-picks, and what matters is whether this server can store a given field, not what it is called. +/// +/// +public sealed class PoracleServerProfile +{ + /// The oldest PoracleNG this build of PoracleWeb is written against. + /// + /// 5.1.0 is where override_location_label, override_areas and + /// pvp_ranking_evolution arrive. Below it those columns do not exist, so the controls that + /// write them do nothing at all. + /// + public static readonly System.Version MinimumSupported = new(5, 1, 0); + + /// Version string as reported, e.g. 5.1.0. Null when the server could not be reached. + public string? Version + { + get; init; + } + + /// + /// The feature map from /health, verbatim. Absent key means unsupported — PoracleNG's own + /// documented contract for this map, so clients may default-false rather than probe. + /// + public IReadOnlyDictionary Capabilities { get; init; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Applied migration number from PoracleNG's schema_migrations table, or null when it could + /// not be read. + /// + /// + /// The capability map covers bot and template-editor features only; nothing in it describes alarm + /// columns. The migration number does, which is what makes it the signal for "can this server store + /// that filter". 5.1.0 sits at 5; costume arrives at 6 and 7. + /// + public long? SchemaVersion + { + get; init; + } + + /// True when PoracleNG answered at all. + public bool Reachable + { + get; init; + } + + /// When this was last read from the server. + public DateTimeOffset CheckedAt + { + get; init; + } + + /// The parsed , or null when it is missing or unparseable. + [JsonIgnore] + public System.Version? ParsedVersion => TryParse(this.Version); + + /// + /// True only when the server is known to be older than . + /// + /// + /// Unreachable or unparseable is not "too old" — it is unknown, and shouting about a version nobody + /// has established would train admins to ignore the banner. carries that + /// case instead. + /// + [JsonIgnore] + public bool IsBelowMinimum => this.ParsedVersion is { } v && v < MinimumSupported; + + /// The profile for a server that did not answer: nothing known, nothing assumed. + public static PoracleServerProfile Unknown(DateTimeOffset checkedAt) => new() + { + Reachable = false, + CheckedAt = checkedAt, + }; + + /// + /// True when the named capability is present and on. Missing keys are false, per PoracleNG's map + /// contract, so a server that predates a capability behaves like one that switched it off. + /// + public bool Supports(string capability) => + this.Capabilities.TryGetValue(capability, out var enabled) && enabled; + + /// True when the applied schema is at least . + /// Unknown schema answers false: an unread migration number must not unlock a column. + public bool HasSchema(long migration) => this.SchemaVersion >= migration; + + /// + /// Parses the version PoracleNG reports. It ships "0.0.0" when the build flags are not injected, and + /// that is not a real version — a locally built binary is treated as unknown rather than ancient. + /// + private static System.Version? TryParse(string? raw) + { + if (string.IsNullOrWhiteSpace(raw) || raw.StartsWith("0.0.0", StringComparison.Ordinal)) + { + return null; + } + + // Tolerate a suffix: the version may be stamped as 5.2.0-rc1 by a build script. + var numeric = new string(raw.TakeWhile(c => char.IsAsciiDigit(c) || c == '.').ToArray()).TrimEnd('.'); + + return System.Version.TryParse(numeric, out var parsed) ? parsed : null; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/PublicPoracleConfig.cs b/Core/Pgan.PoracleWebNet.Core.Models/PublicPoracleConfig.cs new file mode 100644 index 00000000..e7805fe3 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/PublicPoracleConfig.cs @@ -0,0 +1,53 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// The subset of that is safe to hand to a browser. +/// +/// +/// +/// GET /api/config used to return whole, which carried the +/// Poracle admin Discord/Telegram id lists, the webhook delegation map, the internal provider URL +/// and the static map key. Upstream PoracleNG gates that same payload behind +/// X-Poracle-Secret; PoracleWeb republished it on an internet-facing route. The identical +/// admin list is [Authorize]-and-admin-gated on GET /api/admin/poracle-admins, so the +/// data was protected on one route and public on another. +/// +/// +/// This type is an allowlist, not a denylist: a new field added to is +/// not exposed until it is deliberately added here. The omitted fields have no browser consumer — +/// admins and delegateAdministration are read server-side only (by +/// AuthController and AdminController), and the SPA never read providerURL or +/// staticKey beyond carrying them in its fallback object. +/// +/// +public class PublicPoracleConfig +{ + public string Locale { get; set; } = string.Empty; + public string PoracleVersion { get; set; } = string.Empty; + public int PvpFilterMaxRank { get; set; } + public int PvpFilterLittleMinCp { get; set; } + public int PvpFilterGreatMinCp { get; set; } + public int PvpFilterUltraMinCp { get; set; } + public bool PvpLittleLeagueAllowed { get; set; } + public List PvpCaps { get; set; } = []; + public int DefaultPvpCap { get; set; } + public string DefaultTemplateName { get; set; } = string.Empty; + public string EverythingFlagPermissions { get; set; } = string.Empty; + public int MaxDistance { get; set; } + + public static PublicPoracleConfig From(PoracleConfig source) => new() + { + Locale = source.Locale, + PoracleVersion = source.PoracleVersion, + PvpFilterMaxRank = source.PvpFilterMaxRank, + PvpFilterLittleMinCp = source.PvpFilterLittleMinCp, + PvpFilterGreatMinCp = source.PvpFilterGreatMinCp, + PvpFilterUltraMinCp = source.PvpFilterUltraMinCp, + PvpLittleLeagueAllowed = source.PvpLittleLeagueAllowed, + PvpCaps = source.PvpCaps, + DefaultPvpCap = source.DefaultPvpCap, + DefaultTemplateName = source.DefaultTemplateName, + EverythingFlagPermissions = source.EverythingFlagPermissions, + MaxDistance = source.MaxDistance + }; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs b/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs index 0b4b8ec2..ec3fc17f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Quest.cs @@ -1,46 +1,84 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class Quest -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public string? Ping +namespace Pgan.PoracleWebNet.Core.Models; + +public class Quest +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int Reward + { + get; set; + } + /// + /// Fewest of the reward the quest must give, for the rewards that come in quantities: items, candy + /// and mega energy. 0 means any. + /// + /// + /// Stardust does not use this — PoracleNG reads as the stardust floor for + /// reward type 3 — and a pokemon encounter has no quantity to compare. + /// + public int Amount { get; set; } - public int Distance - { - get; set; - } - public int Reward - { - get; set; - } - public int RewardType - { - get; set; - } - public int Shiny - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int ProfileNo - { - get; set; - } - public int Form - { - get; set; - } -} + + public int RewardType + { + get; set; + } + public int Shiny + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int ProfileNo + { + get; set; + } + public int Form + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs index d57f59a4..08815d55 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs @@ -22,6 +22,14 @@ public int Reward get; set; } + // Fewest of the reward the quest must give — items, candy and mega energy come in quantities. + // Stardust is not one of them: PoracleNG reads Reward as the stardust floor for reward type 3. + [Range(0, int.MaxValue)] + public int Amount + { + get; set; + } + [Range(0, int.MaxValue)] public int RewardType { @@ -34,7 +42,8 @@ public int Shiny get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -51,4 +60,18 @@ public int Form { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs index 7d405c2c..8fa95500 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs @@ -22,6 +22,12 @@ public int? Reward get; set; } + [Range(0, int.MaxValue)] + public int? Amount + { + get; set; + } + [Range(0, int.MaxValue)] public int? RewardType { @@ -34,7 +40,8 @@ public int? Shiny get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -51,4 +58,18 @@ public int? Form { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/QuickPickApplyRequest.cs b/Core/Pgan.PoracleWebNet.Core.Models/QuickPickApplyRequest.cs index 626c1aa7..7e570b04 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/QuickPickApplyRequest.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/QuickPickApplyRequest.cs @@ -1,36 +1,58 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -/// -/// Request body for applying a quick pick with optional exclusions and delivery overrides. -/// -public class QuickPickApplyRequest -{ - /// - /// Pokemon IDs to exclude when applying (for monster-type picks). - /// - public List ExcludePokemonIds { get; set; } = []; - - /// - /// Override distance (in meters). Null = use default (0 = areas mode). - /// - public int? Distance - { - get; set; - } - - /// - /// Override clean flag. Null = use default. - /// - public int? Clean - { - get; set; - } - - /// - /// Override template name. Null = use default. - /// - public string? Template - { - get; set; - } -} +using System.ComponentModel.DataAnnotations; + +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Request body for applying a quick pick with optional exclusions and delivery overrides. +/// +public class QuickPickApplyRequest +{ + /// + /// Pokemon IDs to exclude when applying (for monster-type picks). + /// + public List ExcludePokemonIds { get; set; } = []; + + /// + /// Saved place the created alarms measure their radius from. Empty means the profile pin. + /// + /// + /// A quick pick creates ordinary alarms, so it can carry the same delivery scope any other alarm + /// can. Without these two the apply dialog could offer a scope it had no way to send. See #730. + /// + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas the created alarms are confined to. Mutually exclusive with a distance. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } + + /// + /// Override distance (in meters). Null = use default (0 = areas mode). + /// + public int? Distance + { + get; set; + } + + /// + /// Override clean flag. Null = use default. + /// + public int? Clean + { + get; set; + } + + /// + /// Override template name. Null = use default. + /// + public string? Template + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/QuickPickDefinition.cs b/Core/Pgan.PoracleWebNet.Core.Models/QuickPickDefinition.cs index 67b554a8..f041310f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/QuickPickDefinition.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/QuickPickDefinition.cs @@ -1,38 +1,50 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -/// -/// A reusable alarm preset that can be applied to create tracking entries. -/// -public class QuickPickDefinition -{ - public string Id { get; set; } = Guid.NewGuid().ToString("N"); - public string Name { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public string Icon { get; set; } = "bolt"; - public string Category { get; set; } = "Common"; - public string AlarmType { get; set; } = "monster"; // monster, raid, egg, quest, invasion, lure, nest, gym, maxbattle - public int SortOrder - { - get; set; - } - public bool Enabled { get; set; } = true; - - /// - /// Scope: "global" for admin-defined, "user" for user-defined. - /// - public string Scope { get; set; } = "global"; - - /// - /// The Discord/Telegram user ID that owns this definition. Null for global (admin) picks. - /// - public string? OwnerUserId - { - get; set; - } - - /// - /// The alarm filter parameters as a flexible dictionary. - /// Keys match the alarm model properties (e.g., minIv, maxIv, pokemonId, level, etc.). - /// - public Dictionary Filters { get; set; } = []; -} +using System.ComponentModel.DataAnnotations; +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// A reusable alarm preset that can be applied to create tracking entries. +/// +public class QuickPickDefinition +{ + // Every one of these is bounded in the database (QuickPickDefinitionConfiguration) and was bounded + // nowhere else, so an over-long value reached MySQL and came back as an opaque 500. Neither quick- + // pick dialog sets a maxlength, so naming a preset with a sentence was enough. See #549. + [StringLength(50, ErrorMessage = "id must be 50 characters or fewer.")] + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + [Required(AllowEmptyStrings = false, ErrorMessage = "A name is required.")] + [StringLength(200, ErrorMessage = "name must be 200 characters or fewer.")] + public string Name { get; set; } = string.Empty; + // TEXT, so 65535. The only sibling #549 missed, and the only one that still answered 500. + [StringLength(65535, ErrorMessage = "description must be 65535 characters or fewer.")] + public string Description { get; set; } = string.Empty; + [StringLength(50, ErrorMessage = "icon must be 50 characters or fewer.")] + public string Icon { get; set; } = "bolt"; + [StringLength(50, ErrorMessage = "category must be 50 characters or fewer.")] + public string Category { get; set; } = "Common"; + [StringLength(20, ErrorMessage = "alarmType must be 20 characters or fewer.")] + public string AlarmType { get; set; } = "monster"; // monster, raid, egg, quest, invasion, lure, nest, gym, maxbattle + public int SortOrder + { + get; set; + } + public bool Enabled { get; set; } = true; + + /// + /// Scope: "global" for admin-defined, "user" for user-defined. + /// + public string Scope { get; set; } = "global"; + + /// + /// The Discord/Telegram user ID that owns this definition. Null for global (admin) picks. + /// + public string? OwnerUserId + { + get; set; + } + + /// + /// The alarm filter parameters as a flexible dictionary. + /// Keys match the alarm model properties (e.g., minIv, maxIv, pokemonId, level, etc.). + /// + public Dictionary Filters { get; set; } = []; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs b/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs index 4acfaf28..493236f9 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Raid.cs @@ -1,57 +1,82 @@ -namespace Pgan.PoracleWebNet.Core.Models; - -public class Raid -{ - public int Uid - { - get; set; - } - public string Id { get; set; } = string.Empty; - public int PokemonId - { - get; set; - } - public string? Ping - { - get; set; - } - public int Distance - { - get; set; - } - public int Team { get; set; } = 4; - public int Level - { - get; set; - } - public int Form - { - get; set; - } - public int Clean - { - get; set; - } - public string? Template - { - get; set; - } - public int Move { get; set; } = 9000; - public int Evolution { get; set; } = 9000; - public int Exclusive - { - get; set; - } - public string? GymId - { - get; set; - } - public int RsvpChanges - { - get; set; - } - public int ProfileNo - { - get; set; - } -} +namespace Pgan.PoracleWebNet.Core.Models; + +public class Raid +{ + public int Uid + { + get; set; + } + public string Id { get; set; } = string.Empty; + public int PokemonId + { + get; set; + } + public string? Ping + { + get; set; + } + public int Distance + { + get; set; + } + public int Team { get; set; } = 4; + public int Level + { + get; set; + } + public int Form + { + get; set; + } + public int Clean + { + get; set; + } + public string? Template + { + get; set; + } + public int Move { get; set; } = 9000; + public int Evolution { get; set; } = 9000; + public int Exclusive + { + get; set; + } + public string? GymId + { + get; set; + } + public int RsvpChanges + { + get; set; + } + public int ProfileNo + { + get; set; + } + + /// + /// Saved-place label this alarm measures its radius from, instead of the profile's pin. + /// + /// + /// Mutually exclusive with , and meaningless without a distance — + /// PoracleNG refuses both combinations. A label that no longer exists is not an error: PoracleNG + /// falls through to the profile pin, so deleting a place widens its alarms rather than breaking them. + /// + public string? OverrideLocationLabel + { + get; set; + } + + /// + /// Areas this alarm is confined to, instead of the profile's area list. + /// + /// + /// Replaces the profile's areas outright rather than intersecting with them, and is mutually + /// exclusive with a distance. Names are lowercase with spaces, matching the geofence convention. + /// + public List? OverrideAreas + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs index b2a05a83..d95d4aca 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs @@ -25,7 +25,11 @@ public int Distance [Range(0, 4)] public int Team { get; set; } = 4; - [Range(0, 10)] + // PoracleNG accepts any positive integer as a raid level, plus 9000 as the + // "any level" wildcard. The previous [Range(0, 10)] rejected the wildcard + // and any custom server-defined tiers (Elite at 7+, custom 8+) before they + // could reach PoracleNG. See #259. + [Range(0, int.MaxValue)] public int Level { get; set; @@ -37,7 +41,8 @@ public int Form get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int Clean { get; set; @@ -67,9 +72,23 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int RsvpChanges { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidLevelInfo.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidLevelInfo.cs new file mode 100644 index 00000000..43cc3ed3 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidLevelInfo.cs @@ -0,0 +1,25 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Canonical raid-level metadata served to the frontend so the level selector +/// and alarm cards can render the masterfile vocabulary. +/// +/// Source of truth: WatWowMap masterfile (raid_{N} / raid_{N}_plural keys). +/// +public class RaidLevelInfo +{ + /// Backend integer matched against PoracleNG webhook level. 1-19 currently named. + public int Value + { + get; set; + } + + /// Coarse grouping: star, mega, special, shadow, superMega, coordinated. + public string Category { get; set; } = string.Empty; + + /// Singular English name from the masterfile, e.g. "1 Star Raid", "Mega Legendary Raid". + public string Name { get; set; } = string.Empty; + + /// Plural English name from the masterfile, e.g. "1 Star Raids". + public string NamePlural { get; set; } = string.Empty; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs index 4df5f239..14c29241 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs @@ -22,7 +22,9 @@ public int? Team get; set; } - [Range(0, 10)] + // See RaidCreate.Level — PoracleNG accepts arbitrary positive integers + // (plus 9000 as the wildcard). + [Range(0, int.MaxValue)] public int? Level { get; set; @@ -34,7 +36,8 @@ public int? Form get; set; } - [Range(0, 1)] + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary. + [Range(0, 7)] public int? Clean { get; set; @@ -70,9 +73,23 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int? RsvpChanges { get; set; } + + /// Saved-place label this alarm measures its radius from. See the domain model. + [StringLength(64)] + public string? OverrideLocationLabel + { + get; set; + } + + /// Areas this alarm is confined to. See the domain model. + [MaxLength(32)] + public List? OverrideAreas + { + get; set; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Models/SavedPlace.cs b/Core/Pgan.PoracleWebNet.Core.Models/SavedPlace.cs new file mode 100644 index 00000000..8bd05ab2 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/SavedPlace.cs @@ -0,0 +1,59 @@ +using System.ComponentModel.DataAnnotations; + +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// A named coordinate a user can point an alarm at, instead of their profile pin. +/// +/// +/// Stored by PoracleNG in user_locations, keyed by (human, label). Labels are the user's own +/// words — "home", "work" — and are what an alarm's override_location_label refers to. +/// +public class SavedPlace +{ + [Required] + [StringLength(64, MinimumLength = 1)] + public string Label { get; set; } = string.Empty; + + [Range(-90, 90)] + public double Latitude + { + get; set; + } + + [Range(-180, 180)] + public double Longitude + { + get; set; + } +} + +/// +/// Everywhere a user's alarms can be anchored: the profile pin, plus whatever they have named. +/// +public class SavedPlaces +{ + /// + /// The profile pin, which every alarm falls back to. Null when the user has never set a location. + /// + public SavedPlace? Default + { + get; set; + } + + public List Named { get; set; } = []; +} + +/// +/// Why a place could not be deleted: the alarms still pointing at it. +/// +/// +/// PoracleNG answers 409 with a referencing_rules list rather than orphaning the label. Worth +/// surfacing rather than flattening into "could not delete": the useful thing to tell someone is which +/// alarms they need to repoint first. +/// +public class PlaceInUseException(IReadOnlyList referencingRules) + : Exception($"That place is still used by {referencingRules.Count} alarm(s).") +{ + public IReadOnlyList ReferencingRules { get; } = referencingRules; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/SummaryBackendUnavailableException.cs b/Core/Pgan.PoracleWebNet.Core.Models/SummaryBackendUnavailableException.cs new file mode 100644 index 00000000..100855fb --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/SummaryBackendUnavailableException.cs @@ -0,0 +1,6 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +public class SummaryBackendUnavailableException : Exception +{ + public SummaryBackendUnavailableException() : base("Quest summary service unavailable.") { } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/SummarySchedule.cs b/Core/Pgan.PoracleWebNet.Core.Models/SummarySchedule.cs new file mode 100644 index 00000000..a8ffd29e --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/SummarySchedule.cs @@ -0,0 +1,18 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +public class SummarySchedule +{ + public string AlertType { get; set; } = "quest"; + + // Raw JSON array literal; "[]" when cleared. Never project the upstream "id" (it is the user id — IDOR leak). + public string ActiveHours { get; set; } = "[]"; +} + +public class SummaryScheduleRequest +{ + // Accepts the SPA's JSON.stringify(entries); null/whitespace = clear. + public string? ActiveHours + { + get; set; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/TrackingConflictException.cs b/Core/Pgan.PoracleWebNet.Core.Models/TrackingConflictException.cs new file mode 100644 index 00000000..cb8ceb98 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/TrackingConflictException.cs @@ -0,0 +1,40 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// The write was refused because it collides with an alarm that already exists. +/// +/// +/// PoracleNG dedups on a per-type natural key. When an edit moves an alarm onto a key another alarm +/// already holds, PoracleNG declines to write and answers 200 with alreadyPresent set. PoracleWeb +/// used to return that as success while echoing the requested values back from its in-memory model, so +/// the response could not disagree with the request and the user believed the edit had applied. For the +/// natural-key types the same collision was actively destructive: the row was deleted before the +/// colliding create, so the alarm vanished. See #462 and #463. +/// +public sealed class TrackingConflictException : Exception +{ + public TrackingConflictException(string trackingType, string detail) + : base(detail) + { + this.TrackingType = trackingType; + } + + public TrackingConflictException() + { + } + + public TrackingConflictException(string message) + : base(message) + { + } + + public TrackingConflictException(string message, Exception innerException) + : base(message, innerException) + { + } + + public string? TrackingType + { + get; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/UpdateStatus.cs b/Core/Pgan.PoracleWebNet.Core.Models/UpdateStatus.cs new file mode 100644 index 00000000..b1a8432d --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/UpdateStatus.cs @@ -0,0 +1,97 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// How a running component compares to what has been published. +public enum UpdateState +{ + /// Nothing could be established — no network, check switched off, unparseable answer. + Unknown = 0, + + /// Running exactly what is published. + UpToDate = 1, + + /// Something newer has been released. + Behind = 2, + + /// + /// Running something newer than the latest release: a development build. + /// + /// + /// This is what makes the branch answerable after all. PoracleNG bumps processor/version.go + /// at the start of a cycle — main reads 5.1.0 while develop already reads 5.2.0 — so a + /// binary reporting more than the released version is by definition not built from a release. + /// + PreRelease = 3, +} + +/// Whether one component is behind, and what it would be moving to. +public sealed class UpdateStatus +{ + /// The version this instance is running, as it reports itself. + public string? Running + { + get; init; + } + + /// The newest published version, or null when it could not be read. + public string? Latest + { + get; init; + } + + public UpdateState State + { + get; init; + } + + /// Nothing is known: the check is off, or it failed. + public static UpdateStatus Unknown(string? running = null) => new() + { + Running = running, + State = UpdateState.Unknown, + }; + + /// + /// Compares two version strings, tolerating a leading v and a suffix. + /// + /// + /// A version that will not parse leaves the state Unknown rather than guessing a direction. Telling + /// somebody they are behind when they are not is worse than saying nothing, because the next real + /// warning gets ignored too. + /// + public static UpdateStatus Compare(string? running, string? latest) + { + var runningVersion = Parse(running); + var latestVersion = Parse(latest); + + if (runningVersion is null || latestVersion is null) + { + return new UpdateStatus { Running = running, Latest = latest, State = UpdateState.Unknown }; + } + + var state = runningVersion.CompareTo(latestVersion) switch + { + 0 => UpdateState.UpToDate, + < 0 => UpdateState.Behind, + _ => UpdateState.PreRelease, + }; + + return new UpdateStatus { Running = running, Latest = latest, State = state }; + } + + private static Version? Parse(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var trimmed = raw.TrimStart('v', 'V'); + var numeric = new string(trimmed.TakeWhile(c => char.IsAsciiDigit(c) || c == '.').ToArray()).TrimEnd('.'); + + // "0.0.0" is what an un-stamped local build reports, and "beta" is PoracleWeb's own rolling + // channel. Neither is a point on the release line, so neither gets compared to one. + return numeric.Length == 0 || numeric.StartsWith("0.0.0", StringComparison.Ordinal) + ? null + : Version.TryParse(numeric, out var parsed) ? parsed : null; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Models/UserInfo.cs b/Core/Pgan.PoracleWebNet.Core.Models/UserInfo.cs index 893b00d9..4c73df73 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/UserInfo.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/UserInfo.cs @@ -27,6 +27,19 @@ public string[]? ManagedWebhooks get; set; } + /// + /// Name of the active profile. + /// + /// + /// The SPA has always rendered this in the user menu with a "Profile {n}" fallback, and the property + /// did not exist here -- so the fallback fired every time and the menu disagreed with the Profiles + /// page, which looks the name up properly. See #520. + /// + public string? ProfileName + { + get; set; + } + /// /// Optional refreshed JWT token. Returned by /api/auth/me when the JWT's /// profileNo claim is stale (e.g. PoracleNG changed the active profile via diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/HumanRepository.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/HumanRepository.cs index 1826cac9..021a88b3 100644 --- a/Core/Pgan.PoracleWebNet.Core.Repositories/HumanRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/HumanRepository.cs @@ -51,13 +51,6 @@ public async Task> GetByIdsAsync(IEnumerable ids) return results.Select(e => e.ToModel()); } - public async Task GetByIdAndProfileAsync(string id, int profileNo) - { - var entity = await this._context.Humans - .FirstOrDefaultAsync(h => h.Id == id && h.CurrentProfileNo == profileNo); - return entity is null ? null : entity.ToModel(); - } - public async Task CreateAsync(Human human) { var entity = human.ToEntity(); @@ -80,19 +73,10 @@ public async Task UpdateAsync(Human human) public async Task ExistsAsync(string id) => await this._context.Humans.AnyAsync(h => h.Id == id); - public async Task DeleteAllAlarmsByUserAsync(string userId) - { - var count = 0; - count += await this._context.Monsters.Where(m => m.Id == userId).ExecuteDeleteAsync(); - count += await this._context.Raids.Where(r => r.Id == userId).ExecuteDeleteAsync(); - count += await this._context.Eggs.Where(e => e.Id == userId).ExecuteDeleteAsync(); - count += await this._context.Quests.Where(q => q.Id == userId).ExecuteDeleteAsync(); - count += await this._context.Invasions.Where(i => i.Id == userId).ExecuteDeleteAsync(); - count += await this._context.Lures.Where(l => l.Id == userId).ExecuteDeleteAsync(); - count += await this._context.Nests.Where(n => n.Id == userId).ExecuteDeleteAsync(); - count += await this._context.Gyms.Where(g => g.Id == userId).ExecuteDeleteAsync(); - return count; - } + // DeleteAllAlarmsByUserAsync lived here and was dead: HumanService has looped the tracking proxy + // since the PoracleNG migration, so nothing reached it. Its eight ExecuteDeleteAsync calls would + // each have emitted the aliased DELETE that MariaDB rejects (#707), so it could not have worked + // had anything called it. Alarm deletion belongs to PoracleNG, which reloads its own state. public async Task DeleteUserAsync(string userId) { @@ -102,7 +86,12 @@ public async Task DeleteUserAsync(string userId) return false; } + // The profiles rows outlived the human: invisible to every API surface, but re-creating the same + // id adopted them verbatim -- old areas, old coordinates, old active_hours -- and PoracleNG's + // human-create then collided on the surviving (id, profile_no) and errored after committing the + // human (#482). Removed in the same SaveChangesAsync so the two cannot part company. See #481. this._context.Humans.Remove(entity); + this._context.Profiles.RemoveRange(this._context.Profiles.Where(p => p.Id == userId)); await this._context.SaveChangesAsync(); return true; } diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/OidcSessionRepository.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/OidcSessionRepository.cs new file mode 100644 index 00000000..6dc0a1f0 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/OidcSessionRepository.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Data; +using Pgan.PoracleWebNet.Data.Entities; + +namespace Pgan.PoracleWebNet.Core.Repositories; + +public class OidcSessionRepository(PoracleWebContext context) : IOidcSessionRepository +{ + private readonly PoracleWebContext _context = context; + + public async Task GetByHashAsync(string sessionTokenHash) + { + var entity = await this._context.OidcSessions + .AsNoTracking() + .FirstOrDefaultAsync(s => s.SessionTokenHash == sessionTokenHash); + + return entity is null ? null : ToModel(entity); + } + + public async Task AddAsync(OidcSession session) + { + this._context.OidcSessions.Add(ToEntity(session)); + await this._context.SaveChangesAsync(); + } + + public async Task TryRevokeForRotationAsync(string sessionTokenHash, string newHash) + { + // EF query lambdas require == null / != null (translates to IS NULL); `is null` throws. + DateTime now = DateTime.UtcNow; + return await this._context.OidcSessions + .Where(s => s.SessionTokenHash == sessionTokenHash && s.RevokedAt == null && s.ExpiresAt > now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(s => s.RevokedAt, now) + .SetProperty(s => s.RevokedReason, "rotation") + .SetProperty(s => s.ReplacedByHash, newHash)); + } + + public async Task RevokeFamilyAsync(string familyId, string reason) + { + DateTime now = DateTime.UtcNow; + return await this._context.OidcSessions + .Where(s => s.FamilyId == familyId && s.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(s => s.RevokedAt, now) + .SetProperty(s => s.RevokedReason, reason)); + } + + public async Task RevokeAllForUserAsync(string userId, string reason) + { + DateTime now = DateTime.UtcNow; + return await this._context.OidcSessions + .Where(s => s.UserId == userId && s.RevokedAt == null) + .ExecuteUpdateAsync(setters => setters + .SetProperty(s => s.RevokedAt, now) + .SetProperty(s => s.RevokedReason, reason)); + } + + public async Task DeleteExpiredAndStaleAsync(TimeSpan revokedRetention) + { + DateTime now = DateTime.UtcNow; + DateTime revokedCutoff = now - revokedRetention; + + // Raw SQL rather than ExecuteDeleteAsync: MySql.EntityFrameworkCore emits the aliased + // single-table form -- DELETE FROM `oidc_sessions` AS `o` WHERE ... -- and MariaDB rejects + // that outright (1064; it wants the multi-table `DELETE o FROM ... AS o` when an alias is + // present). Every cleanup pass since the feature shipped threw and logged a warning, so the + // table only ever grew. Verified against MariaDB 10.8.2. Identifiers are left bare and + // unquoted so the same statement parses on MariaDB, MySQL and the SQLite the repository + // tests run against. See #707. + return await this._context.Database.ExecuteSqlInterpolatedAsync( + $"DELETE FROM oidc_sessions WHERE expires_at < {now} OR (revoked_at IS NOT NULL AND revoked_at < {revokedCutoff})"); + } + + private static OidcSession ToModel(OidcSessionEntity e) => new() + { + Id = e.Id, + SessionTokenHash = e.SessionTokenHash, + FamilyId = e.FamilyId, + FamilyIssuedAt = e.FamilyIssuedAt, + UserId = e.UserId, + EncryptedRefreshToken = e.EncryptedRefreshToken, + ExpiresAt = e.ExpiresAt, + CreatedUtc = e.CreatedUtc, + RevokedAt = e.RevokedAt, + RevokedReason = e.RevokedReason, + ReplacedByHash = e.ReplacedByHash, + IpAddress = e.IpAddress, + UserAgent = e.UserAgent, + }; + + private static OidcSessionEntity ToEntity(OidcSession m) => new() + { + SessionTokenHash = m.SessionTokenHash, + FamilyId = m.FamilyId, + FamilyIssuedAt = m.FamilyIssuedAt, + UserId = m.UserId, + EncryptedRefreshToken = m.EncryptedRefreshToken, + ExpiresAt = m.ExpiresAt, + CreatedUtc = m.CreatedUtc, + RevokedAt = m.RevokedAt, + RevokedReason = m.RevokedReason, + ReplacedByHash = m.ReplacedByHash, + IpAddress = m.IpAddress, + UserAgent = m.UserAgent, + }; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/PoracleSchemaVersionReader.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/PoracleSchemaVersionReader.cs new file mode 100644 index 00000000..77385e29 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/PoracleSchemaVersionReader.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Data; + +namespace Pgan.PoracleWebNet.Core.Repositories; + +/// +public partial class PoracleSchemaVersionReader(PoracleContext context, ILogger logger) + : IPoracleSchemaVersionReader +{ + private readonly PoracleContext _context = context; + private readonly ILogger _logger = logger; + + /// + public async Task GetAppliedMigrationAsync(CancellationToken cancellationToken = default) + { + try + { + // golang-migrate's table: one row, version plus a dirty flag. Identifiers stay unquoted so + // the statement also parses on SQLite, which is what the repository tests run against. + // Aliased to Value because that is the column name EF Core's scalar SqlQuery expects. + var applied = await this._context.Database + .SqlQueryRaw("SELECT version AS Value FROM schema_migrations LIMIT 1") + .FirstOrDefaultAsync(cancellationToken); + + return applied; + } + catch (Exception ex) + { + // A missing table is the ordinary case on a Poracle database that predates golang-migrate, + // and a permission error is plausible on a locked-down deployment. Neither is worth an + // exception the caller has to think about: the profile simply reports an unknown schema, + // which unlocks nothing. + LogSchemaUnreadable(this._logger, ex); + return null; + } + } + + [LoggerMessage( + EventId = 6101, + Level = LogLevel.Debug, + Message = "Could not read PoracleNG's applied migration; features gated on schema version stay off.")] + private static partial void LogSchemaUnreadable(ILogger logger, Exception exception); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/ProfileRepository.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/ProfileRepository.cs index 75a35f01..d63d9bc2 100644 --- a/Core/Pgan.PoracleWebNet.Core.Repositories/ProfileRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/ProfileRepository.cs @@ -49,6 +49,21 @@ public async Task UpdateAsync(Profile profile) return entity.ToModel(); } + public async Task RenameAsync(string userId, int profileNo, string name) + { + var entity = await this._context.Profiles + .FirstOrDefaultAsync(p => p.Id == userId && p.ProfileNo == profileNo); + + if (entity is null) + { + return false; + } + + entity.Name = name; + await this._context.SaveChangesAsync(); + return true; + } + public async Task DeleteAsync(string userId, int profileNo) { var entity = await this._context.Profiles diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/QuickPickAppliedStateRepository.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/QuickPickAppliedStateRepository.cs index 6a710e47..7e28fb0f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Repositories/QuickPickAppliedStateRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/QuickPickAppliedStateRepository.cs @@ -35,6 +35,16 @@ public async Task> GetByUserAndProfileAsync(string u return [.. entities.Select(MapToModel)]; } + public async Task> GetByUserAsync(string userId) + { + var entities = await this._context.QuickPickAppliedStates + .AsNoTracking() + .Where(s => s.UserId == userId) + .ToListAsync(); + + return [.. entities.Select(MapToModel)]; + } + public async Task CreateOrUpdateAsync(QuickPickAppliedState state) { var entity = await this._context.QuickPickAppliedStates @@ -76,6 +86,41 @@ public async Task DeleteAsync(string userId, int profileNo, string quickPickId) } } + public async Task DeleteByQuickPickIdAsync(string quickPickId, string? userId = null) + { + var query = this._context.QuickPickAppliedStates.Where(s => s.QuickPickId == quickPickId); + + if (userId is not null) + { + query = query.Where(s => s.UserId == userId); + } + + // Loaded and removed rather than ExecuteDeleteAsync: the provider emits an aliased + // DELETE ... AS `q`, which MariaDB rejects outright. + var rows = await query.ToListAsync(); + + if (rows.Count == 0) + { + return; + } + + this._context.QuickPickAppliedStates.RemoveRange(rows); + await this._context.SaveChangesAsync(); + } + + public async Task DeleteByUserAsync(string userId) + { + var rows = await this._context.QuickPickAppliedStates.Where(s => s.UserId == userId).ToListAsync(); + + if (rows.Count == 0) + { + return; + } + + this._context.QuickPickAppliedStates.RemoveRange(rows); + await this._context.SaveChangesAsync(); + } + private static QuickPickAppliedState MapToModel(QuickPickAppliedStateEntity entity) => new() { UserId = entity.UserId, diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs index 69f1a1cc..8447540c 100644 --- a/Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Pgan.PoracleWebNet.Core.Abstractions.Repositories; using Pgan.PoracleWebNet.Core.Models.Helpers; @@ -191,4 +192,138 @@ public async Task RemoveAreaFromAllProfilesAsync(string humanId, string ar return false; } + + public async Task RenameAreaInAllProfilesAsync(string humanId, string oldName, string newName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(humanId); + ArgumentException.ThrowIfNullOrWhiteSpace(oldName); + ArgumentException.ThrowIfNullOrWhiteSpace(newName); + + var lowerOld = oldName.ToLowerInvariant(); + var lowerNew = newName.ToLowerInvariant(); + + if (string.Equals(lowerOld, lowerNew, StringComparison.Ordinal)) + { + return false; + } + + var human = await this._context.Humans.FirstOrDefaultAsync(h => h.Id == humanId); + var humanChanged = false; + if (human is not null) + { + var humanAreas = AreaListJson.Parse(human.Area); + if (RenameCaseInsensitive(humanAreas, lowerOld, lowerNew)) + { + human.Area = AreaListJson.Serialize(humanAreas); + humanChanged = true; + } + } + + var profiles = await this._context.Profiles + .Where(p => p.Id == humanId) + .ToListAsync(); + var anyProfileChanged = false; + foreach (var profile in profiles) + { + var profileAreas = AreaListJson.Parse(profile.Area); + if (RenameCaseInsensitive(profileAreas, lowerOld, lowerNew)) + { + profile.Area = AreaListJson.Serialize(profileAreas); + anyProfileChanged = true; + } + } + + if (humanChanged || anyProfileChanged) + { + await this._context.SaveChangesAsync(); + return true; + } + + return false; + } + + /// + /// Swaps for in place. A row that does not + /// hold the old name is left alone, which is what preserves per-profile activation. If the new name + /// is somehow already present the old one is just dropped, so the rename cannot produce a duplicate. + /// + private static bool RenameCaseInsensitive(List list, string lowerOld, string lowerNew) + { + var index = list.FindIndex(a => string.Equals(a, lowerOld, StringComparison.OrdinalIgnoreCase)); + if (index < 0) + { + return false; + } + + if (list.Any(a => string.Equals(a, lowerNew, StringComparison.OrdinalIgnoreCase))) + { + list.RemoveAt(index); + } + else + { + list[index] = lowerNew; + } + + return true; + } + + /// + /// The Poracle table behind each tracking type, transcribed from PoracleNG migration + /// 000004_per_rule_overrides. Four of the ten are not the type name: pokemon lives in + /// monsters, lure in lures, nest in nests, fort in forts. + /// + /// + /// A table name cannot be a SQL parameter, so it is interpolated — which is safe only because it + /// comes from this fixed map and an unknown key throws rather than falling through to the caller's + /// string. Never widen this to accept a caller-supplied table. + /// + private static readonly Dictionary AlarmTables = new(StringComparer.Ordinal) + { + ["pokemon"] = "monsters", + ["raid"] = "raid", + ["egg"] = "egg", + ["quest"] = "quest", + ["invasion"] = "invasion", + ["lure"] = "lures", + ["nest"] = "nests", + ["gym"] = "gym", + ["fort"] = "forts", + ["maxbattle"] = "maxbattle", + }; + + public async Task SetAlarmOverrideAreasAsync( + string humanId, string trackingType, int uid, IReadOnlyCollection areaNames) + { + ArgumentException.ThrowIfNullOrWhiteSpace(humanId); + + if (!AlarmTables.TryGetValue(trackingType, out var table)) + { + throw new ArgumentOutOfRangeException( + nameof(trackingType), trackingType, "Not a PoracleNG tracking type."); + } + + // Lowercase with spaces, matching parseOverrideAreas in PoracleNG and the geofence naming + // convention. Blank entries are dropped rather than stored as empty strings, which would match + // no fence and read as a corrupt list. + var normalized = areaNames + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Select(a => a.Replace('_', ' ').ToLowerInvariant()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + // NULL, not "[]" — marshalOverrideAreas stores nil for an empty list, and parseOverrideAreas + // reads "" back as no override. Writing "[]" would be a list that matches nothing. + object? value = normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized); + + // Raw SQL, and deliberately unquoted identifiers: the repository tests run on SQLite, which + // rejects MySQL backticks. Scoped by id as well as uid so a mis-supplied uid cannot reach + // another user's alarm. + var rows = await this._context.Database.ExecuteSqlRawAsync( + $"UPDATE {table} SET override_areas = {{0}} WHERE id = {{1}} AND uid = {{2}}", + value ?? DBNull.Value, + humanId, + uid); + + return rows > 0; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/UserGeofenceRepository.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/UserGeofenceRepository.cs index 9596c072..66c4af95 100644 --- a/Core/Pgan.PoracleWebNet.Core.Repositories/UserGeofenceRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/UserGeofenceRepository.cs @@ -60,7 +60,10 @@ public async Task> GetAllActiveAsync() { var entities = await this._context.UserGeofences .AsNoTracking() - .Where(g => g.Status == "active" || g.Status == "pending_review") + // "rejected" is served too. Rejection means "not public", not "switched off": it writes only + // status, notes and reviewer, so dropping the row from the feed stopped the owner's alerts + // while their area lists still named the fence and the UI still showed it Active. See #645. + .Where(g => g.Status == "active" || g.Status == "pending_review" || g.Status == "rejected") .OrderBy(g => g.KojiName) .ToListAsync(); diff --git a/Core/Pgan.PoracleWebNet.Core.Repositories/WebhookDelegateRepository.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/WebhookDelegateRepository.cs index 1e67748c..bfaec18d 100644 --- a/Core/Pgan.PoracleWebNet.Core.Repositories/WebhookDelegateRepository.cs +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/WebhookDelegateRepository.cs @@ -90,4 +90,20 @@ public async Task RemoveAllForWebhookAsync(string webhookId) await this._context.SaveChangesAsync(); return true; } + + public async Task RemoveAllForIdAsync(string id) + { + var entities = await this._context.WebhookDelegates + .Where(d => d.WebhookId == id || d.UserId == id) + .ToListAsync(); + + if (entities.Count == 0) + { + return 0; + } + + this._context.WebhookDelegates.RemoveRange(entities); + await this._context.SaveChangesAsync(); + return entities.Count; + } } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/BulkUidRemap.cs b/Core/Pgan.PoracleWebNet.Core.Services/BulkUidRemap.cs new file mode 100644 index 00000000..ea04dd37 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/BulkUidRemap.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Follows quick-pick tracked uids through a bulk distance update. +/// +/// +/// +/// The bulk distance endpoints are a fetch-mutate-repost, and PoracleNG rewrites each row, so every uid +/// changes. #403 fixed this for single edits, where the create response gives an unambiguous 1:1 mapping. +/// It cannot be done that way here: the batch response comes back reordered. Submitting lures +/// 161, 162, 163 returned 164, 165, 166 mapping to 163, 162, 161 — so pairing by position would repoint a +/// quick pick at somebody else's alarm and its Remove would then delete an alarm the user still wanted. +/// That is worse, and quieter, than the bug being fixed. See #443. +/// +/// +/// Pairing is therefore done on content. A bulk distance update changes exactly one field, so two rows are +/// the same alarm when every other field matches. Where a signature is ambiguous — two rows identical +/// apart from distance — nothing is remapped and it is logged, because guessing is the failure mode this +/// exists to avoid. Such rows are interchangeable anyway once both carry the same distance. +/// +/// +public static partial class BulkUidRemap +{ + /// + /// Fields that differ between the two snapshots by definition, so they cannot identify a row. + /// profile_no is here because outgoing payloads no longer carry it (#411) while rows read back + /// from PoracleNG do - without this the two signatures never match and nothing is ever remapped. + /// description is PoracleNG's rendered summary of the row, not part of its identity: it embeds + /// the distance AND the clean flags, so it changes whenever either does. Leaving it in meant the + /// cleaning toggle could never pair a row with its replacement. + /// + private static readonly HashSet IgnoredForIdentity = + new(StringComparer.Ordinal) { "uid", "distance", "profile_no", "description" }; + + /// + /// Re-reads the tracking rows and moves any quick-pick tracked uid onto its replacement. + /// Best-effort: the distance update itself has already succeeded, so nothing here may throw. + /// + /// The rows as they were posted, still carrying their pre-write uids. + public static async Task ApplyAsync( + IPoracleTrackingProxy proxy, + string trackingType, + string userId, + JsonElement submitted, + ITrackedUidRemapper uidRemapper, + ILogger logger, + IReadOnlyCollection? mutatedFields = null) + { + try + { + // Whatever the caller just changed cannot take part in identity, or the before and after + // snapshots never match. Distance is always excluded because that is what the distance + // endpoints change; cleaning passes "clean" for the same reason. + var identityKeys = IdentityKeys(submitted, mutatedFields); + if (identityKeys.Count == 0) + { + return; + } + + var before = BuildIndex(submitted, identityKeys); + if (before.Count == 0) + { + return; + } + + var after = BuildIndex(await proxy.GetByUserAsync(trackingType, userId), identityKeys); + + foreach (var (signature, oldUid) in before) + { + if (oldUid is null) + { + // Ambiguous before the write, so there is nothing safe to move. + LogAmbiguous(logger, trackingType, signature); + continue; + } + + if (!after.TryGetValue(signature, out var newUid) || newUid is null) + { + LogNoMatch(logger, trackingType, oldUid.Value); + continue; + } + + if (newUid.Value != oldUid.Value) + { + await uidRemapper.RemapAsync(userId, trackingType, oldUid.Value, newUid.Value); + } + } + } + catch (Exception ex) + { + // The distance change is already applied upstream. Failing here would fail a request that + // worked, to protect a quick pick's Remove button. + LogRemapFailed(logger, ex, trackingType); + } + } + + /// + /// Maps each row's identity signature to its uid. A signature seen more than once maps to + /// null, which means "ambiguous — do not touch". + /// + private static Dictionary BuildIndex(JsonElement rows, IReadOnlyCollection identityKeys) + { + var index = new Dictionary(StringComparer.Ordinal); + + if (rows.ValueKind != JsonValueKind.Array) + { + return index; + } + + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!row.TryGetProperty("uid", out var uidProp) || uidProp.ValueKind != JsonValueKind.Number) + { + continue; + } + + var signature = Signature(row, identityKeys); + index[signature] = index.ContainsKey(signature) ? null : uidProp.GetInt32(); + } + + return index; + } + + /// + /// The property names that identify a row, taken from what we submitted. + /// + /// + /// Derived from the outgoing payload rather than fixed, because PoracleNG returns fields we never + /// send - a rendered description, and profile_no which outgoing writes deliberately omit + /// (#411). Comparing full property sets therefore never matched anything, which is how the first + /// version of this shipped green unit tests and did nothing at all in practice. + /// + private static IReadOnlyCollection IdentityKeys(JsonElement rows, IReadOnlyCollection? mutatedFields) + { + var keys = new HashSet(StringComparer.Ordinal); + + if (rows.ValueKind != JsonValueKind.Array) + { + return keys; + } + + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + continue; + } + + foreach (var prop in row.EnumerateObject()) + { + if (!IgnoredForIdentity.Contains(prop.Name) && mutatedFields?.Contains(prop.Name) != true) + { + keys.Add(prop.Name); + } + } + } + + return keys; + } + + /// The identity fields, name-sorted so property order cannot affect the result. + private static string Signature(JsonElement row, IReadOnlyCollection identityKeys) => + string.Join( + "|", + identityKeys + .OrderBy(k => k, StringComparer.Ordinal) + .Select(k => row.TryGetProperty(k, out var v) ? $"{k}={v.GetRawText()}" : $"{k}=")); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Two {TrackingType} rows share an identity after a bulk distance update; leaving quick-pick uids alone rather than guessing.")] + private static partial void LogAmbiguous(ILogger logger, string trackingType, string signature); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "No replacement row found for {TrackingType} uid {OldUid} after a bulk distance update.")] + private static partial void LogNoMatch(ILogger logger, string trackingType, int oldUid); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not follow quick-pick tracked uids through a bulk {TrackingType} distance update; removing an affected quick pick may leave its alarms behind.")] + private static partial void LogRemapFailed(ILogger logger, Exception exception, string trackingType); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs b/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs index e542672f..a5013546 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -8,10 +9,22 @@ namespace Pgan.PoracleWebNet.Core.Services; /// /// Manages the "clean" flag on tracking alarms via the PoracleNG REST API proxy. /// -public class CleaningService(IPoracleTrackingProxy trackingProxy, IFeatureGate featureGate) : ICleaningService +public class CleaningService( + IPoracleTrackingProxy trackingProxy, + IFeatureGate featureGate, + ITrackedUidRemapper uidRemapper, + ILogger logger) : ICleaningService { private readonly IPoracleTrackingProxy _trackingProxy = trackingProxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; + private readonly ILogger _logger = logger; + + /// + /// Tracking types whose PoracleNG create only ever inserts, so a re-POST duplicates rather than + /// updates. Everything else upserts on uid. + /// + private static readonly HashSet InsertOnlyTypes = new(StringComparer.Ordinal) { "maxbattle" }; public async Task> GetCleanStatusAsync(string userId, int profileNo) { @@ -27,7 +40,6 @@ public async Task> GetCleanStatusAsync(string userId, i ["lures"] = AllClean(allTracking, "lure"), ["nests"] = AllClean(allTracking, "nest"), ["gyms"] = AllClean(allTracking, "gym"), - ["fortChanges"] = AllClean(allTracking, "fort"), ["maxbattles"] = AllClean(allTracking, "maxbattle"), }; } @@ -58,9 +70,7 @@ public async Task ToggleCleanGymsAsync(string userId, int profileNo, int cl public async Task ToggleCleanMaxBattlesAsync(string userId, int profileNo, int clean) => await this.ToggleCleanAsync("maxbattle", userId, clean); - - public async Task ToggleCleanFortChangesAsync(string userId, int profileNo, int clean) => - await this.ToggleCleanAsync("fort", userId, clean); + /// /// Workaround: PoracleNG has no bulk clean toggle endpoint. We fetch all alarms of the type, @@ -95,13 +105,61 @@ private async Task ToggleCleanAsync(string type, string userId, int clean) foreach (var alarm in trackingJson.EnumerateArray()) { var dict = JsonSerializer.Deserialize>(alarm.GetRawText())!; - dict["clean"] = JsonSerializer.SerializeToElement(clean); + + // The clean toggle only owns the auto-delete bit (bit 1). Read-modify-write so any + // bot-set edit-in-place (bit 2) / summary (bit 4) bits survive the bulk toggle. (#292) + var existing = dict.TryGetValue("clean", out var c) && c.ValueKind == JsonValueKind.Number ? c.GetInt32() : 0; + dict["clean"] = JsonSerializer.SerializeToElement(CleanFlags.Preserve(existing, CleanFlags.AutoDelete, clean)); updatedAlarms.Add(JsonSerializer.SerializeToNode(dict)); } var body = JsonSerializer.SerializeToElement(updatedAlarms); + + // PoracleNG's maxbattle create is insert-only -- it has no upsert path -- so re-POSTing the + // modified set inserted a full duplicate of every alarm and left the originals untouched. + // One click per duplicate set, unbounded. Free the rows first for that type only; the others + // upsert on uid and must not be deleted. + if (InsertOnlyTypes.Contains(type)) + { + var uids = trackingJson.EnumerateArray() + .Where(a => a.TryGetProperty("uid", out var u) && u.ValueKind == JsonValueKind.Number) + .Select(a => a.GetProperty("uid").GetInt32()) + .ToList(); + + await this._trackingProxy.BulkDeleteByUidsAsync(type, userId, uids); + + try + { + await this._trackingProxy.CreateAsync(type, userId, body); + } + catch + { + // Put the originals back rather than leaving the user with no alarms at all. + await this._trackingProxy.CreateAsync(type, userId, trackingJson); + throw; + } + + // Every non-monster type comes back under a new uid, so any quick pick tracking these rows + // would be left pointing at dead uids -- Remove then deletes nothing and the next page load + // wipes the applied state. This was the one bulk-repost path that never got the remapper the + // distance endpoints already use. "clean" is the field being mutated, so it cannot take part + // in row identity. See #471. + await BulkUidRemap.ApplyAsync( + this._trackingProxy, type, userId, body, this._uidRemapper, this._logger, ["clean"]); + + return count; + } + await this._trackingProxy.CreateAsync(type, userId, body); + // Every non-monster type comes back under a new uid, so any quick pick tracking these rows + // would be left pointing at dead uids -- Remove then deletes nothing and the next page load + // wipes the applied state. This was the one bulk-repost path that never got the remapper the + // distance endpoints already use. "clean" is the field being mutated, so it cannot take part + // in row identity. See #471. + await BulkUidRemap.ApplyAsync( + this._trackingProxy, type, userId, body, this._uidRemapper, this._logger, ["clean"]); + return count; } @@ -126,7 +184,7 @@ private static bool AllClean(JsonElement root, string key) var isClean = cleanVal.ValueKind switch { JsonValueKind.True => true, - JsonValueKind.Number => cleanVal.GetInt32() == 1, + JsonValueKind.Number => CleanFlags.IsAutoDelete(cleanVal.GetInt32()), JsonValueKind.Undefined => throw new NotImplementedException(), JsonValueKind.Object => throw new NotImplementedException(), JsonValueKind.Array => throw new NotImplementedException(), diff --git a/Core/Pgan.PoracleWebNet.Core.Services/DiscordNotificationService.cs b/Core/Pgan.PoracleWebNet.Core.Services/DiscordNotificationService.cs index 71be16df..e5cc2a0b 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/DiscordNotificationService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/DiscordNotificationService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; @@ -5,15 +6,29 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; public partial class DiscordNotificationService( HttpClient httpClient, + IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) : IDiscordNotificationService { + /// + /// Named HttpClient used to download the static map image. Deliberately separate from the Discord + /// client so the bot token is never sent to the tileserver. + /// + public const string MapImageHttpClientName = "geofence-map-image"; + + private const string MapAttachmentFileName = "geofence-map.png"; + + /// Discord's per-attachment limit on the free tier is 25 MB; a static map is ~100 KB. + private const int MaxMapImageBytes = 8 * 1024 * 1024; + private readonly HttpClient _httpClient = httpClient; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; private readonly ILogger _logger = logger; private readonly string _forumChannelId = configuration["Discord:GeofenceForumChannelId"] ?? string.Empty; @@ -23,6 +38,15 @@ public partial class DiscordNotificationService( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; + /// + /// Discord payloads are authored with literal wire names (auto_archive_duration), so no naming + /// policy is applied. Nulls are dropped -- Discord rejects attachments: null. + /// + private static readonly JsonSerializerOptions DiscordPayloadOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + // Cached tag IDs (static so they persist across transient HttpClient instances) private static string? s_pendingTagId; private static string? s_approvedTagId; @@ -168,7 +192,7 @@ public async Task EnsureForumTagsExistAsync() } } - public async Task CreateGeofenceSubmissionPostAsync(string userId, string userName, string geofenceName, string groupName, int polygonPoints, string? mapImageUrl) + public async Task CreateGeofenceSubmissionPostAsync(GeofenceSubmissionPost post) { if (string.IsNullOrEmpty(this._forumChannelId)) { @@ -180,118 +204,281 @@ public async Task EnsureForumTagsExistAsync() try { - var appliedTags = s_pendingTagId != null ? [s_pendingTagId] : Array.Empty(); - - var embeds = new List - { - new - { - title = $"Geofence: {geofenceName}", - color = 2196944, // #2196f3 as decimal - fields = new object[] - { - new { name = "Region", value = groupName, inline = true }, - new { name = "Points", value = polygonPoints.ToString(System.Globalization.CultureInfo.InvariantCulture), inline = true }, - new { name = "Submitted By", value = $"<@{userId}>", inline = true }, - }, - image = mapImageUrl != null ? new { url = mapImageUrl } : null, - }, - }; + // PoracleNG hands back a pregenerated tileserver URL that the tile cache eventually evicts, + // which leaves the embed with a dead image. Upload the bytes as a real Discord attachment so + // the map stays with the message. Fall back to the raw URL when the download fails. + var mapImageBytes = post.MapImageUrl != null ? await this.TryDownloadMapImageAsync(post.MapImageUrl) : null; var body = new { - name = $"Geofence Request: {geofenceName}", + name = $"Geofence Request: {post.DisplayName}", auto_archive_duration = 10080, - applied_tags = appliedTags, + applied_tags = TagsFor(post.State), message = new { - content = "A custom geofence has been submitted for review.\n\n" - + "Please share any context about this area (community day spot, park, popular route, etc.)", - embeds, + content = $"<@{post.UserId}> submitted an area for review.\n\n" + + "Please share any context about it (community day spot, park, popular route, etc.)", + embeds = new object[] { BuildEmbed(post, mapImageBytes != null) }, + attachments = AttachmentsFor(mapImageBytes), }, }; - var response = await this._httpClient.PostAsJsonAsync($"channels/{this._forumChannelId}/threads", body); + var payloadJson = JsonSerializer.Serialize(body, DiscordPayloadOptions); + using HttpContent content = mapImageBytes != null + ? BuildMultipartBody(payloadJson, mapImageBytes) + : new StringContent(payloadJson, System.Text.Encoding.UTF8, "application/json"); + + var response = await this._httpClient.PostAsync($"channels/{this._forumChannelId}/threads", content); response.EnsureSuccessStatusCode(); var threadJson = await response.Content.ReadFromJsonAsync(); var threadId = threadJson.GetProperty("id").GetString(); - LogForumPostCreated(this._logger, geofenceName, threadId); + LogForumPostCreated(this._logger, post.DisplayName, threadId); return threadId; } catch (Exception ex) { - LogForumPostFailed(this._logger, ex, geofenceName); + LogForumPostFailed(this._logger, ex, post.DisplayName); return null; } } - public async Task PostApprovalMessageAsync(string threadId, string geofenceName, string promotedName) + public async Task PostReviewOutcomeAsync(string threadId, GeofenceSubmissionPost post) { + await this.EnsureForumTagsExistAsync(); + + // A forum post's starter message shares the thread's ID, so the opening embed can be rewritten + // without storing a separate message ID. Best-effort: a failed edit must not stop the verdict reply. try { - // Post approval message - var messageBody = new - { - content = $"\u2705 **Approved!** This geofence has been published as **{promotedName}** and is now available to all users on the Areas page.", - }; + await this.UpdateOpeningEmbedAsync(threadId, post); + } + catch (Exception ex) + { + LogOpeningEmbedUpdateFailed(this._logger, ex, threadId); + } + + try + { + var messageBody = new { content = VerdictText(post) }; var messageResponse = await this._httpClient.PostAsJsonAsync($"channels/{threadId}/messages", messageBody); messageResponse.EnsureSuccessStatusCode(); - // Update tags and lock/archive the thread - await this.EnsureForumTagsExistAsync(); - var appliedTags = s_approvedTagId != null ? [s_approvedTagId] : Array.Empty(); var patchBody = new { - applied_tags = appliedTags, + applied_tags = TagsFor(post.State), locked = true, archived = true, }; var patchResponse = await this._httpClient.PatchAsJsonAsync($"channels/{threadId}", patchBody); patchResponse.EnsureSuccessStatusCode(); - LogApprovalPosted(this._logger, threadId, geofenceName); + LogOutcomePosted(this._logger, threadId, post.DisplayName, post.State.ToString()); } catch (Exception ex) { - LogApprovalFailed(this._logger, ex, threadId); + LogOutcomeFailed(this._logger, ex, threadId); + } + } + + /// + /// Rewrites the starter message's embed in place. + /// + /// The map is re-uploaded rather than retained by ID. Discord consumes an attachment referenced by + /// attachment:// into the embed, so the edited message reports an empty attachments array + /// and there is no ID to carry forward. Reusing the embed's resolved cdn.discordapp.com URL is + /// not an option either -- it is a signed link that expires, which is the rot this whole feature exists + /// to avoid. Falls back to linking the tileserver URL only when the re-download fails. + /// + /// + private async Task UpdateOpeningEmbedAsync(string threadId, GeofenceSubmissionPost post) + { + var mapImageBytes = post.MapImageUrl != null ? await this.TryDownloadMapImageAsync(post.MapImageUrl) : null; + + var body = new + { + embeds = new object[] { BuildEmbed(post, mapImageBytes != null) }, + attachments = AttachmentsFor(mapImageBytes) ?? [], + }; + + var payloadJson = JsonSerializer.Serialize(body, DiscordPayloadOptions); + using HttpContent content = mapImageBytes != null + ? BuildMultipartBody(payloadJson, mapImageBytes) + : new StringContent(payloadJson, System.Text.Encoding.UTF8, "application/json"); + + var response = await this._httpClient.PatchAsync($"channels/{threadId}/messages/{threadId}", content); + response.EnsureSuccessStatusCode(); + } + + /// + /// Builds the review card. Field order is the reading order a reviewer needs: how big, where, what it + /// would be called. Vertex count is deliberately absent -- it never changes an approve/reject decision. + /// + private static object BuildEmbed(GeofenceSubmissionPost post, bool hasAttachment) + { + var fields = new List + { + new + { + name = "Size", + value = $"{post.AreaSqKm.ToString("0.##", CultureInfo.InvariantCulture)} km²\n{GeoMath.DescribeArea(post.AreaSqKm)}", + inline = true, + }, + new + { + name = "Region", + value = string.IsNullOrWhiteSpace(post.GroupName) ? "Not detected" : post.GroupName, + inline = true, + }, + new + { + name = post.State == GeofenceReviewState.Approved ? "Published as" : "Publishes as", + value = $"`{post.PublicName}`", + inline = true, + }, + }; + + var lat = post.CentroidLat.ToString("0.0000", CultureInfo.InvariantCulture); + var lon = post.CentroidLon.ToString("0.0000", CultureInfo.InvariantCulture); + fields.Add(new + { + name = "Location", + value = $"{lat}, {lon} · [Open in maps](https://www.google.com/maps/search/?api=1&query={lat},{lon})", + inline = false, + }); + + if (!string.IsNullOrWhiteSpace(post.OverlapsArea)) + { + fields.Add(new + { + name = "Already covered by", + value = $"{post.OverlapsArea} — this area's centre already falls inside a public area.", + inline = false, + }); + } + + if (post.State == GeofenceReviewState.Rejected && !string.IsNullOrWhiteSpace(post.ReviewNotes)) + { + fields.Add(new { name = "Reason", value = Truncate(post.ReviewNotes, 1024), inline = false }); } + + object? image = hasAttachment ? new { url = $"attachment://{MapAttachmentFileName}" } + : post.MapImageUrl != null ? new { url = post.MapImageUrl } + : null; + + return new + { + title = post.DisplayName, + url = post.ReviewUrl, + color = ColorFor(post.State), + author = string.IsNullOrWhiteSpace(post.UserName) ? null : new { name = post.UserName }, + fields = fields.ToArray(), + image, + footer = new { text = FooterFor(post.State) }, + timestamp = DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture), + }; } - public async Task PostRejectionMessageAsync(string threadId, string geofenceName, string reason) + private static int ColorFor(GeofenceReviewState state) => state switch + { + GeofenceReviewState.Approved => 2278750, // #22c55e + GeofenceReviewState.Rejected => 15680580, // #ef4444 + _ => 16096779, // #f59e0b + }; + + private static string FooterFor(GeofenceReviewState state) => state switch + { + GeofenceReviewState.Approved => "Approved", + GeofenceReviewState.Rejected => "Rejected", + _ => "Awaiting review", + }; + + private static string[] TagsFor(GeofenceReviewState state) + { + var tagId = state switch + { + GeofenceReviewState.Approved => s_approvedTagId, + GeofenceReviewState.Rejected => s_rejectedTagId, + _ => s_pendingTagId, + }; + + return tagId != null ? [tagId] : []; + } + + private static object[]? AttachmentsFor(byte[]? mapImageBytes) => + mapImageBytes != null ? [new { id = "0", filename = MapAttachmentFileName }] : null; + + private static string VerdictText(GeofenceSubmissionPost post) => post.State switch + { + GeofenceReviewState.Approved => + $"✅ **Approved.** Published as **{post.PublicName}** and now selectable by everyone on the Areas page.", + GeofenceReviewState.Rejected => + $"❌ **Rejected.** {post.ReviewNotes}\n\nThe area keeps working privately for your own alerts.", + _ => "This submission is awaiting review.", + }; + + private static string Truncate(string value, int max) => + value.Length <= max ? value : string.Concat(value.AsSpan(0, max - 1), "…"); + + /// + /// Downloads the static map so it can be uploaded to Discord as an attachment. Returns null on any + /// failure -- the caller falls back to linking the URL directly. + /// + private async Task TryDownloadMapImageAsync(string mapImageUrl) { try { - // Post rejection message - var messageBody = new + var client = this._httpClientFactory.CreateClient(MapImageHttpClientName); + using var response = await client.GetAsync(mapImageUrl, HttpCompletionOption.ResponseHeadersRead); + + if (!response.IsSuccessStatusCode) { - content = $"\u274C **Rejected.** {reason}\n\nYour geofence will continue to work privately for your own alerts.", - }; - var messageResponse = await this._httpClient.PostAsJsonAsync($"channels/{threadId}/messages", messageBody); - messageResponse.EnsureSuccessStatusCode(); + LogMapImageDownloadFailed(this._logger, mapImageUrl, (int)response.StatusCode); + return null; + } - // Update tags and lock/archive the thread - await this.EnsureForumTagsExistAsync(); - var appliedTags = s_rejectedTagId != null ? [s_rejectedTagId] : Array.Empty(); - var patchBody = new + if (response.Content.Headers.ContentLength > MaxMapImageBytes) { - applied_tags = appliedTags, - locked = true, - archived = true, - }; - var patchResponse = await this._httpClient.PatchAsJsonAsync($"channels/{threadId}", patchBody); - patchResponse.EnsureSuccessStatusCode(); + LogMapImageTooLarge(this._logger, mapImageUrl, response.Content.Headers.ContentLength ?? 0); + return null; + } + + var bytes = await response.Content.ReadAsByteArrayAsync(); + if (bytes.Length == 0 || bytes.Length > MaxMapImageBytes) + { + LogMapImageTooLarge(this._logger, mapImageUrl, bytes.Length); + return null; + } - LogRejectionPosted(this._logger, threadId, geofenceName); + return bytes; } catch (Exception ex) { - LogRejectionFailed(this._logger, ex, threadId); + LogMapImageDownloadError(this._logger, ex, mapImageUrl); + return null; } } + /// + /// Builds the multipart body Discord expects for a message with an uploaded file: the JSON payload + /// under payload_json and the file under files[0], matched to attachments[0].id. + /// + private static MultipartFormDataContent BuildMultipartBody(string payloadJson, byte[] mapImageBytes) + { + var multipart = new MultipartFormDataContent + { + { new StringContent(payloadJson, System.Text.Encoding.UTF8, "application/json"), "payload_json" }, + }; + + var fileContent = new ByteArrayContent(mapImageBytes); + fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + multipart.Add(fileContent, "files[0]", MapAttachmentFileName); + + return multipart; + } + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to initialize Discord forum tags")] private static partial void LogForumTagInitFailed(ILogger logger, Exception ex); @@ -304,21 +491,27 @@ public async Task PostRejectionMessageAsync(string threadId, string geofenceName [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to create Discord forum post for geofence '{GeofenceName}'")] private static partial void LogForumPostFailed(ILogger logger, Exception ex, string geofenceName); - [LoggerMessage(Level = LogLevel.Information, Message = "Posted approval to Discord thread {ThreadId} for geofence '{GeofenceName}'")] - private static partial void LogApprovalPosted(ILogger logger, string threadId, string geofenceName); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to post approval to Discord thread {ThreadId}")] - private static partial void LogApprovalFailed(ILogger logger, Exception ex, string threadId); + [LoggerMessage(Level = LogLevel.Information, Message = "Posted {State} outcome to Discord thread {ThreadId} for geofence '{GeofenceName}'")] + private static partial void LogOutcomePosted(ILogger logger, string threadId, string geofenceName, string state); - [LoggerMessage(Level = LogLevel.Information, Message = "Posted rejection to Discord thread {ThreadId} for geofence '{GeofenceName}'")] - private static partial void LogRejectionPosted(ILogger logger, string threadId, string geofenceName); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to post review outcome to Discord thread {ThreadId}")] + private static partial void LogOutcomeFailed(ILogger logger, Exception ex, string threadId); - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to post rejection to Discord thread {ThreadId}")] - private static partial void LogRejectionFailed(ILogger logger, Exception ex, string threadId); + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not rewrite the opening embed on Discord thread {ThreadId}; posting the verdict anyway")] + private static partial void LogOpeningEmbedUpdateFailed(ILogger logger, Exception ex, string threadId); [LoggerMessage(Level = LogLevel.Warning, Message = "Discord GeofenceForumChannelId is not configured; skipping forum tag setup")] private static partial void LogForumChannelNotConfiguredForTags(ILogger logger); [LoggerMessage(Level = LogLevel.Information, Message = "Discord forum tags initialized: Pending={PendingId}, Approved={ApprovedId}, Rejected={RejectedId}")] private static partial void LogForumTagsInitialized(ILogger logger, string? pendingId, string? approvedId, string? rejectedId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Static map download for the geofence embed returned HTTP {StatusCode} for {MapImageUrl}; linking the URL instead")] + private static partial void LogMapImageDownloadFailed(ILogger logger, string mapImageUrl, int statusCode); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Static map at {MapImageUrl} is {ByteCount} bytes; skipping the attachment upload")] + private static partial void LogMapImageTooLarge(ILogger logger, string mapImageUrl, long byteCount); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Static map download for the geofence embed failed for {MapImageUrl}; linking the URL instead")] + private static partial void LogMapImageDownloadError(ILogger logger, Exception ex, string mapImageUrl); } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/EggService.cs b/Core/Pgan.PoracleWebNet.Core.Services/EggService.cs index b2d6e72c..431ba563 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/EggService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/EggService.cs @@ -1,14 +1,17 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; -public class EggService(IPoracleTrackingProxy proxy, IFeatureGate featureGate) : IEggService +public class EggService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IEggService { private const string TrackingType = "egg"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -29,6 +32,11 @@ public async Task CreateAsync(string userId, Egg model) // (eggs and raids share UI in the SPA). See DisableFeatureKeys.Raids comment. await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Raids); model.Id = userId; + + // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: + // 201 Created, and the user quietly loses the one they had. See #561. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, 0, SerializeToElement(model)); var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -43,8 +51,25 @@ public async Task CreateAsync(string userId, Egg model) public async Task UpdateAsync(string userId, Egg model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Raids); + var oldUid = model.Uid; var body = SerializeToElement(model); - await this._proxy.CreateAsync(TrackingType, userId, body); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Refuse before writing: PoracleNG would satisfy this by merging into the other alarm and + // the reconciler would then delete this one, losing a row the user never touched. See #531. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, oldUid, body); + + var result = await this._proxy.CreateAsync(TrackingType, userId, body); + + // PoracleNG inserts instead of upserting when the edit changes a dedup-key field, + // leaving the pre-edit row behind as a duplicate. Drop it and report the surviving uid. + model.Uid = await TrackingUpdateReconciler.ReconcileAsync( + this._proxy, TrackingType, userId, oldUid, result, this._logger, body, this._uidRemapper); + return model; } @@ -72,43 +97,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) diff --git a/Core/Pgan.PoracleWebNet.Core.Services/FeatureGate.cs b/Core/Pgan.PoracleWebNet.Core.Services/FeatureGate.cs index 63f90c17..3aaeb84f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/FeatureGate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/FeatureGate.cs @@ -4,16 +4,39 @@ namespace Pgan.PoracleWebNet.Core.Services; -public sealed partial class FeatureGate(ISiteSettingService siteSettings, ILogger logger) : IFeatureGate +public sealed partial class FeatureGate( + ISiteSettingService siteSettings, + IUpstreamFeatureFlagService upstreamFlags, + ILogger logger) : IFeatureGate { private readonly ISiteSettingService _siteSettings = siteSettings; + private readonly IUpstreamFeatureFlagService _upstreamFlags = upstreamFlags; private readonly ILogger _logger = logger; - public async Task IsEnabledAsync(string disableKey) => !await this._siteSettings.GetBoolAsync(disableKey); + public async Task IsEnabledAsync(string disableKey) => !await this.IsDisabledAsync(disableKey); - public async Task EnsureEnabledAsync(string disableKey) + /// + /// A feature is off if either source says so: the local disable_* site setting, or + /// Poracle's own config. Poracle's flags are a floor, not a replacement — its processor already + /// drops the webhook and its bot already refuses the command, so a type it has switched off can + /// never fire, and offering it here only produces alarms that save and then do nothing (#769). + /// The site setting is checked first because it is the cheaper of the two and the one an operator + /// sets deliberately. + /// + private async Task IsDisabledAsync(string disableKey) { if (await this._siteSettings.GetBoolAsync(disableKey)) + { + return true; + } + + var upstreamDisabled = await this._upstreamFlags.GetDisabledKeysAsync(); + return upstreamDisabled.Contains(disableKey); + } + + public async Task EnsureEnabledAsync(string disableKey) + { + if (await this.IsDisabledAsync(disableKey)) { // Audit trail: a service-layer caller hit a disabled feature. Either a controller // path didn't have the [RequireFeatureEnabled] attribute, or a service-to-service diff --git a/Core/Pgan.PoracleWebNet.Core.Services/FortChangeService.cs b/Core/Pgan.PoracleWebNet.Core.Services/FortChangeService.cs index e95944e8..e1c8be5f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/FortChangeService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/FortChangeService.cs @@ -1,14 +1,17 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; -public class FortChangeService(IPoracleTrackingProxy proxy, IFeatureGate featureGate) : IFortChangeService +public class FortChangeService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IFortChangeService { private const string TrackingType = "fort"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -27,6 +30,25 @@ public async Task CreateAsync(string userId, FortChange model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.FortChanges); model.Id = userId; + + // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: + // 201 Created, and the user quietly loses the one they had. See #561. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, 0, SerializeToElement(model)); + + // PoracleNG's dedup key for this type ignores distance, so a create matching an existing alarm's + // fort type, include-empty flag and change types OVERWRITES that alarm's radius instead of adding a + // second one -- while PoracleWeb answered 201 with a fresh uid, so the user believed they had two + // alarms and the configured radius was gone. Refuse it and say which alarm is in the way, the same + // way the natural-key types do. See #502. + var siblings = await this.GetByUserAsync(userId, model.ProfileNo); + if (siblings.Any(x => SameDedupKey(x, model))) + { + throw new TrackingConflictException( + TrackingType, + "You already have a fort-change alarm for those settings. Edit its radius instead of adding another."); + } + var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -41,8 +63,25 @@ public async Task CreateAsync(string userId, FortChange model) public async Task UpdateAsync(string userId, FortChange model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.FortChanges); + var oldUid = model.Uid; var body = SerializeToElement(model); - await this._proxy.CreateAsync(TrackingType, userId, body); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Refuse before writing: PoracleNG would satisfy this by merging into the other alarm and + // the reconciler would then delete this one, losing a row the user never touched. See #531. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, oldUid, body); + + var result = await this._proxy.CreateAsync(TrackingType, userId, body); + + // PoracleNG inserts instead of upserting when the edit changes a dedup-key field, + // leaving the pre-edit row behind as a duplicate. Drop it and report the surviving uid. + model.Uid = await TrackingUpdateReconciler.ReconcileAsync( + this._proxy, TrackingType, userId, oldUid, result, this._logger, body, this._uidRemapper); + return model; } @@ -70,43 +109,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) @@ -137,6 +204,18 @@ public async Task> BulkCreateAsync(string userId, IEnume return modelList; } + /// + /// Whether two fort-change alarms occupy the same slot as far as PoracleNG is concerned. + /// + /// Distance is excluded deliberately: upstream ignores it when deduping. See #502. + private static bool SameDedupKey(FortChange existing, FortChange candidate) => + string.Equals(existing.FortType, candidate.FortType, StringComparison.OrdinalIgnoreCase) + && existing.IncludeEmpty == candidate.IncludeEmpty + && existing.ChangeTypes.OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .SequenceEqual( + candidate.ChangeTypes.OrderBy(x => x, StringComparer.OrdinalIgnoreCase), + StringComparer.OrdinalIgnoreCase); + private static List DeserializeItems(JsonElement json) => PoracleJsonHelper.DeserializeList(json); diff --git a/Core/Pgan.PoracleWebNet.Core.Services/GeoJsonService.cs b/Core/Pgan.PoracleWebNet.Core.Services/GeoJsonService.cs index e80404cc..9c10a045 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/GeoJsonService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/GeoJsonService.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Models.Helpers; namespace Pgan.PoracleWebNet.Core.Services; @@ -121,6 +122,19 @@ public async Task ImportAsync(string userId, int profileNo, break; case "MultiPolygon": ring = ExtractMultiPolygonRing(coordinates); + + // A geofence is one ring, so only the first polygon can be kept. Saying so + // beats discarding the rest in silence: the stored shape is what PoracleJS + // matches against. See #474. + if (coordinates.ValueKind == JsonValueKind.Array && coordinates.GetArrayLength() > 1) + { + result.Warnings.Add(new GeoJsonImportError + { + FeatureName = featureName, + Reason = $"Only the first of {coordinates.GetArrayLength()} polygons was imported; a geofence is a single shape." + }); + } + break; default: result.Errors.Add(new GeoJsonImportError @@ -226,7 +240,10 @@ public async Task ImportAsync(string userId, int profileNo, private static GeoJsonFeature? BuildFeature(double[][] path, string name, string group, string source, string displayName) { - if (path.Length == 0) + // Rows written before create validated point arity can still be in the database. Projecting one + // used to throw IndexOutOfRangeException and 500 the caller's entire export until they worked out + // which geofence to delete. Skip it instead. See #410. + if (!PolygonValidation.IsWellFormed(path)) { return null; } @@ -358,9 +375,20 @@ private static string ExtractFeatureName(JsonElement feature, int autoIndex) var points = new List(); foreach (var point in ringElement.EnumerateArray()) { + // Skipping a malformed point stored a DIFFERENT shape than the file described, reported + // as a clean success - and that shape is then served to PoracleJS for real alert + // matching. Refuse the ring so the feature is reported as an error instead. See #474. if (point.ValueKind != JsonValueKind.Array || point.GetArrayLength() < 2) { - continue; + return null; + } + + // A non-numeric coordinate used to throw straight out of the import loop, past the + // per-feature error collection, keeping whatever had already been committed and leaking + // the .NET exception text. Treat it as a malformed ring instead. See #473. + if (point[0].ValueKind != JsonValueKind.Number || point[1].ValueKind != JsonValueKind.Number) + { + return null; } var lon = point[0].GetDouble(); diff --git a/Core/Pgan.PoracleWebNet.Core.Services/GeoMath.cs b/Core/Pgan.PoracleWebNet.Core.Services/GeoMath.cs new file mode 100644 index 00000000..8dde7647 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/GeoMath.cs @@ -0,0 +1,107 @@ +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Server-side twin of the frontend's shared/utils/geo.utils.ts. Kept in sync by hand -- the two +/// implementations must agree, or the area a user sees while drawing will differ from the one a reviewer +/// sees in Discord. +/// +public static class GeoMath +{ + private const double EarthRadiusKm = 6371; + + /// Arithmetic mean of the vertices. Good enough to place a marker and pick a containing region. + public static (double Lat, double Lon) Centroid(IReadOnlyList polygon) + { + if (polygon.Count == 0) + { + return (0, 0); + } + + double latSum = 0; + double lonSum = 0; + + foreach (var point in polygon) + { + if (point.Length < 2) + { + continue; + } + + latSum += point[0]; + lonSum += point[1]; + } + + return (latSum / polygon.Count, lonSum / polygon.Count); + } + + /// Area in square kilometres via the spherical excess (Girard's theorem) formula. + public static double AreaSqKm(IReadOnlyList polygon) + { + if (polygon.Count < 3) + { + return 0; + } + + double total = 0; + for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++) + { + if (polygon[i].Length < 2 || polygon[j].Length < 2) + { + continue; + } + + var lat1 = ToRadians(polygon[j][0]); + var lon1 = ToRadians(polygon[j][1]); + var lat2 = ToRadians(polygon[i][0]); + var lon2 = ToRadians(polygon[i][1]); + + total += (lon2 - lon1) * (2 + Math.Sin(lat1) + Math.Sin(lat2)); + } + + return Math.Abs(total * EarthRadiusKm * EarthRadiusKm / 2); + } + + /// Ray-casting containment test. points are [lat, lon]. + public static bool Contains(IReadOnlyList polygon, double lat, double lon) + { + if (polygon.Count < 3) + { + return false; + } + + var inside = false; + for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++) + { + if (polygon[i].Length < 2 || polygon[j].Length < 2) + { + continue; + } + + double latI = polygon[i][0], lonI = polygon[i][1]; + double latJ = polygon[j][0], lonJ = polygon[j][1]; + + if (latI > lat != latJ > lat && + lon < ((lonJ - lonI) * (lat - latI) / (latJ - latI)) + lonI) + { + inside = !inside; + } + } + + return inside; + } + + /// + /// Plain-language band for an area, so a reviewer does not have to hold a sense of scale in their head. + /// Advisory only: the exact figure is always shown next to it. + /// + public static string DescribeArea(double areaSqKm) => areaSqKm switch + { + < 1 => "a block or two", + < 10 => "neighbourhood", + < 50 => "district", + < 200 => "city-sized", + _ => "very large", + }; + + private static double ToRadians(double degrees) => degrees * Math.PI / 180; +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/GymService.cs b/Core/Pgan.PoracleWebNet.Core.Services/GymService.cs index b5b0f59e..e4467547 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/GymService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/GymService.cs @@ -1,14 +1,17 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; -public class GymService(IPoracleTrackingProxy proxy, IFeatureGate featureGate) : IGymService +public class GymService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IGymService { private const string TrackingType = "gym"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -27,6 +30,11 @@ public async Task CreateAsync(string userId, Gym model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Gyms); model.Id = userId; + + // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: + // 201 Created, and the user quietly loses the one they had. See #561. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, 0, SerializeToElement(model)); var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -41,8 +49,25 @@ public async Task CreateAsync(string userId, Gym model) public async Task UpdateAsync(string userId, Gym model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Gyms); + var oldUid = model.Uid; var body = SerializeToElement(model); - await this._proxy.CreateAsync(TrackingType, userId, body); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Refuse before writing: PoracleNG would satisfy this by merging into the other alarm and + // the reconciler would then delete this one, losing a row the user never touched. See #531. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, oldUid, body); + + var result = await this._proxy.CreateAsync(TrackingType, userId, body); + + // PoracleNG inserts instead of upserting when the edit changes a dedup-key field, + // leaving the pre-edit row behind as a duplicate. Drop it and report the surviving uid. + model.Uid = await TrackingUpdateReconciler.ReconcileAsync( + this._proxy, TrackingType, userId, oldUid, result, this._logger, body, this._uidRemapper); + return model; } @@ -70,43 +95,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) diff --git a/Core/Pgan.PoracleWebNet.Core.Services/HumanService.cs b/Core/Pgan.PoracleWebNet.Core.Services/HumanService.cs index b8f1699c..83d124a8 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/HumanService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/HumanService.cs @@ -20,7 +20,10 @@ public class HumanService( private readonly IPoracleHumanProxy _humanProxy = humanProxy; private readonly IPoracleTrackingProxy _trackingProxy = trackingProxy; - private static readonly string[] AlarmTypes = ["pokemon", "raid", "egg", "quest", "invasion", "lure", "nest", "gym"]; + // fort and maxbattle were missing, so "delete all alarms" left those two types behind and a deleted + // account kept them. Every tracking type PoracleWeb can create belongs here. See #510. + private static readonly string[] AlarmTypes = + ["pokemon", "raid", "egg", "quest", "invasion", "lure", "nest", "gym", "fort", "maxbattle"]; // TODO: Migrate once PoracleNG adds a "get all humans" endpoint. // See: docs/poracleng-enhancement-requests.md @@ -31,18 +34,7 @@ public class HumanService( var json = await this._humanProxy.GetHumanAsync(id); return json is not null ? DeserializeHuman(json.Value) : null; } - - public async Task GetByIdAndProfileAsync(string id, int profileNo) - { - var json = await this._humanProxy.GetHumanAsync(id); - if (json is null) - { - return null; - } - - var human = DeserializeHuman(json.Value); - return human.CurrentProfileNo == profileNo ? human : null; - } + public async Task CreateAsync(Human human) { @@ -97,8 +89,16 @@ public async Task DeleteAllAlarmsByUserAsync(string userId) Fails = json.GetIntProp("fails"), Language = json.GetStringPropOrNull("language"), AdminDisable = json.GetIntProp("admin_disable"), + // Read back because the record is written back whole: EntityMappingExtensions.ApplyTo copies every + // column, so leaving these unmapped stamped default(DateTime) over the real values on any + // direct-DB write -- changing the notification language marked the account as never seen, and + // last_checked is what Poracle and the admin user list use to judge whether it is still alive. + // See #517. + LastChecked = json.GetDateTimePropOrNull("last_checked") ?? default, + DisabledDate = json.GetDateTimePropOrNull("disabled_date"), CurrentProfileNo = json.GetIntProp("current_profile_no"), CommunityMembership = json.GetStringPropOrNull("community_membership"), + Notes = json.GetStringPropOrNull("notes"), }; private static JsonElement SerializeHumanForCreate(Human human) @@ -108,8 +108,11 @@ private static JsonElement SerializeHumanForCreate(Human human) id = human.Id, name = human.Name ?? human.Id, type = human.Type ?? "discord:user", - enabled = human.Enabled, - admin_disable = human.AdminDisable, + // PoracleNG's createHumanRequest declares both as *bool. Sending the int these are stored as + // fails with "json: cannot unmarshal number into Go struct field createHumanRequest.enabled + // of type bool", so webhook creation could never succeed. + enabled = human.Enabled != 0, + admin_disable = human.AdminDisable != 0, }); using var doc = JsonDocument.Parse(json); diff --git a/Core/Pgan.PoracleWebNet.Core.Services/InvasionService.cs b/Core/Pgan.PoracleWebNet.Core.Services/InvasionService.cs index f31faf0f..09be4f29 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/InvasionService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/InvasionService.cs @@ -5,12 +5,13 @@ namespace Pgan.PoracleWebNet.Core.Services; -public partial class InvasionService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger) : IInvasionService +public partial class InvasionService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IInvasionService { private const string TrackingType = "invasion"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -29,7 +30,20 @@ public async Task CreateAsync(string userId, Invasion model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Invasions); model.Id = userId; - model.GruntType ??= ""; + RequireGruntType(model); + + // PoracleNG's natural key on (id, profile_no, gender, grunt_type) is case-insensitive at the + // database, so creating "Water" alongside an existing "water" hit a duplicate-key error and came + // back as a 500. The update path already refuses this; the create path did not. See #500. + var siblings = await this.GetByUserAsync(userId, model.ProfileNo); + if (siblings.Any(x => x.Gender == model.Gender + && string.Equals(x.GruntType, model.GruntType, StringComparison.OrdinalIgnoreCase))) + { + throw new TrackingConflictException( + TrackingType, + "You already have an invasion alarm for that grunt type and gender. Edit or remove that one instead."); + } + var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -44,41 +58,49 @@ public async Task CreateAsync(string userId, Invasion model) public async Task UpdateAsync(string userId, Invasion model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Invasions); - model.GruntType ??= ""; + RequireGruntType(model); var oldUid = model.Uid; - var body = SerializeToElement(model); - var result = await this._proxy.CreateAsync(TrackingType, userId, body); - // PoracleNG dedups invasion tracking by the natural key (grunt_type, gender). - // When an edit changes either field, PoracleNG inserts a new row instead of - // updating the one referenced by uid — leaving the original row as a stale duplicate. - // Detect that case via the insert/newUids response and delete the old row. - if (oldUid > 0 && result.Inserts > 0 && result.NewUids.Count > 0) + // PoracleNG guards this type with a natural unique key and its create has no upsert path, so + // changing a field outside that key collides (Error 1062) and returns 500. Replace the row instead. + // Refuse a collision BEFORE the delete. PoracleNG dedups invasions on + // (id, profile_no, gender, grunt_type), so editing one onto a pair another alarm already + // holds made the replace merge into that alarm - this one deleted, the other one silently + // overwritten. Changing the gender dropdown is enough to trigger it. See #462. + if (oldUid > 0) { - var newUid = (int)result.NewUids[0]; - if (newUid != oldUid) + var siblings = await this.GetByUserAsync(userId, model.ProfileNo); + if (siblings.Any(x => x.Uid != oldUid + && x.Gender == model.Gender + && string.Equals(x.GruntType, model.GruntType, StringComparison.OrdinalIgnoreCase))) { - try - { - await this._proxy.DeleteByUidAsync(TrackingType, userId, oldUid); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) - { - // Stale duplicate left behind — surface for triage but don't fail the update; - // the new row already carries the user's intended settings. - LogStaleDeleteFailed(this._logger, ex, oldUid, newUid); - } - - model.Uid = newUid; + throw new TrackingConflictException( + TrackingType, + "You already have an invasion alarm for that grunt type and gender. Edit or remove that one instead."); } } + var original = oldUid > 0 ? await this.GetByUidAsync(userId, oldUid) : null; + + var body = SerializeToElement(model); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, oldUid, body); + + model.Uid = await NaturalKeyTrackingUpdate.ReplaceAsync( + this._proxy, + TrackingType, + userId, + oldUid, + original is null ? null : SerializeToElement(original), + body, + this._logger, + this._uidRemapper); + return model; } - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to delete stale invasion uid {OldUid} after gender/grunt_type change created new uid {NewUid}; duplicate row may remain.")] - private static partial void LogStaleDeleteFailed(ILogger logger, Exception exception, int oldUid, int newUid); - public async Task DeleteAsync(string userId, int uid) { await this._proxy.DeleteByUidAsync(TrackingType, userId, uid); @@ -103,43 +125,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) @@ -157,7 +207,7 @@ public async Task> BulkCreateAsync(string userId, IEnumera foreach (var model in modelList) { model.Id = userId; - model.GruntType ??= ""; + RequireGruntType(model); } var body = SerializeToElement(modelList); @@ -171,6 +221,52 @@ public async Task> BulkCreateAsync(string userId, IEnumera return modelList; } + /// + /// PoracleNG rejects an empty grunt_type with 400 "Grunt type mandatory" and has no + /// catch-all keyword, so coalescing a missing value to "" guaranteed a failure that surfaced + /// as a generic 500. Fail here instead, where the message says what is actually wrong. Callers that + /// want "everything" must fan out over . See #416. + /// + /// + /// The grunt_type column width upstream: varchar(255), per PoracleNG's initial schema + /// migration at the commit production runs. This said 35, which was an invented limit wearing a + /// factual justification -- in a fix whose whole point was refusing the impossible rather than + /// allowing only the known. See #661. + /// + private const int MaxGruntTypeLength = 255; + + private static void RequireGruntType(Invasion model) + { + // Deliberately NOT an allowlist. The live database holds grunt types this codebase does not model -- + // blanche, candela, spark, "npc 0" through "npc 10", "player team leader" -- so validating against + // InvasionGruntTypes.All would have refused edits to alarms that work today. What is checked instead + // is what cannot be a grunt type under any upstream: control characters, and a value longer than the + // column. See #611. + if (!string.IsNullOrEmpty(model.GruntType)) + { + if (model.GruntType.Any(char.IsControl)) + { + throw new AlarmValidationException("gruntType must not contain control characters."); + } + + if (model.GruntType.Length > MaxGruntTypeLength) + { + throw new AlarmValidationException( + $"gruntType must be {MaxGruntTypeLength} characters or fewer."); + } + } + + if (string.IsNullOrWhiteSpace(model.GruntType)) + { + // AlarmValidationException rather than ArgumentException: nothing maps the latter, so this + // message -- written precisely to explain the problem -- came back as a bare 500 on the + // update path while the create path answered 400. See #518. + throw new AlarmValidationException( + "grunt_type is required — PoracleNG has no catch-all value. To track everything, " + + "create one alarm per InvasionGruntTypes.All entry."); + } + } + private static List DeserializeItems(JsonElement json) => PoracleJsonHelper.DeserializeList(json); diff --git a/Core/Pgan.PoracleWebNet.Core.Services/KojiService.cs b/Core/Pgan.PoracleWebNet.Core.Services/KojiService.cs index 854269b3..db70848f 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/KojiService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/KojiService.cs @@ -39,12 +39,16 @@ public async Task SaveGeofenceAsync(string geofenceName, string displayName, str type = "Polygon", coordinates = new[] { coordinates } }, - properties = new Dictionary + properties = new Dictionary { ["__name"] = geofenceName, ["__mode"] = "unset", ["__projects"] = new[] { this._projectId }, - ["__parent"] = parentId, + // Koji resolves __parent as a geofence id. Sending 0 makes Koji try to look up a + // non-existent parent and return HTTP 500 ("[GEOFENCE]: Does not exist"), even though + // it still persists the row. A region-less geofence (parentId 0, see issue #314) must + // send null — Koji's native "no parent" representation — to save cleanly. + ["__parent"] = parentId > 0 ? parentId : null, ["name"] = displayName, ["group"] = group, ["parent"] = group, @@ -60,7 +64,7 @@ public async Task SaveGeofenceAsync(string geofenceName, string displayName, str var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var response = await this._httpClient.PostAsync($"{this._apiAddress}/api/v1/geofence/save-koji", content); - response.EnsureSuccessStatusCode(); + await EnsureKojiSucceededAsync(response, "geofence save"); LogGeofenceSaved(this._logger, geofenceName, this._projectId); } @@ -108,7 +112,7 @@ public async Task RemoveGeofenceFromProjectAsync(string geofenceName) var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var response = await this._httpClient.PostAsync($"{this._apiAddress}/api/v1/geofence/save-koji", content); - response.EnsureSuccessStatusCode(); + await EnsureKojiSucceededAsync(response, "geofence save"); LogGeofenceRemoved(this._logger, geofenceName); } @@ -295,12 +299,13 @@ public async Task PromoteGeofenceAsync(string currentName, string? newName, stri type = "Polygon", coordinates = new[] { geoJsonCoords } }, - properties = new Dictionary + properties = new Dictionary { ["__name"] = targetName, ["__mode"] = "unset", ["__projects"] = new[] { this._projectId }, - ["__parent"] = parentId, + // Same null-parent guard as SaveGeofenceAsync: Koji 500s on __parent 0 ("does not exist"). + ["__parent"] = parentId > 0 ? parentId : null, ["userSelectable"] = true, ["displayInMatches"] = false, ["name"] = displayName, @@ -316,7 +321,7 @@ public async Task PromoteGeofenceAsync(string currentName, string? newName, stri var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); var response = await this._httpClient.PostAsync($"{this._apiAddress}/api/v1/geofence/save-koji", content); - response.EnsureSuccessStatusCode(); + await EnsureKojiSucceededAsync(response, "geofence save"); // If renaming, remove the old geofence from the project if (newName != null && !string.Equals(newName, currentName, StringComparison.Ordinal)) @@ -586,4 +591,32 @@ private async Task> FetchAdminGeofencesFromKojiAsync() [LoggerMessage(Level = LogLevel.Warning, Message = "Koji Poracle endpoint returned no data array for project '{ProjectName}'")] private static partial void LogPoracleEndpointNoData(ILogger logger, string projectName); + + /// + /// Turns a failed Koji response into a typed error carrying the status and body. + /// + /// + /// EnsureSuccessStatusCode() threw a bare that no caller + /// caught, so a Koji failure during approve reached the admin as an opaque 500. See #422. + /// + private static async Task EnsureKojiSucceededAsync(HttpResponseMessage response, string operation) + { + if (response.IsSuccessStatusCode) + { + return; + } + + string? body = null; + try + { + body = await response.Content.ReadAsStringAsync(); + } + catch (Exception) + { + // The status code is the useful part; a body we cannot read must not mask it. + } + + throw new KojiOperationException(operation, response.StatusCode, body); + } + } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/LikeEscape.cs b/Core/Pgan.PoracleWebNet.Core.Services/LikeEscape.cs index 077a5b8d..054e54b3 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/LikeEscape.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/LikeEscape.cs @@ -2,8 +2,13 @@ namespace Pgan.PoracleWebNet.Core.Services; public static class LikeEscape { + // Use `|` instead of the more conventional `\` because MariaDB's default + // mode treats `\` as a string-literal escape too — a user-supplied `\` in + // the search term left an unbalanced quote and broke gym search (#260). + public const string EscapeChar = "|"; + public static string Escape(string input) => input - .Replace("\\", "\\\\", StringComparison.Ordinal) - .Replace("%", "\\%", StringComparison.Ordinal) - .Replace("_", "\\_", StringComparison.Ordinal); + .Replace("|", "||", StringComparison.Ordinal) + .Replace("%", "|%", StringComparison.Ordinal) + .Replace("_", "|_", StringComparison.Ordinal); } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/LureService.cs b/Core/Pgan.PoracleWebNet.Core.Services/LureService.cs index 70be6a3e..45aad22d 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/LureService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/LureService.cs @@ -1,14 +1,17 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; -public class LureService(IPoracleTrackingProxy proxy, IFeatureGate featureGate) : ILureService +public class LureService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : ILureService { private const string TrackingType = "lure"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -27,6 +30,20 @@ public async Task CreateAsync(string userId, Lure model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Lures); model.Id = userId; + + // PoracleNG guards this type with a unique key on (id, profile_no, lure_id), so adding a lure + // type already tracked hit a duplicate-key error and surfaced as a 500 -- for a submission the + // lure picker actively invites, and which the frontend then reported as a generic "failed to + // create" with no clue which lure caused it. The update path has refused this since #462. + // See #562. + var siblings = await this.GetByUserAsync(userId, model.ProfileNo); + if (siblings.Any(x => x.LureId == model.LureId)) + { + throw new TrackingConflictException( + TrackingType, + "You already have a lure alarm for that lure type. Edit or remove that one instead."); + } + var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -41,8 +58,42 @@ public async Task CreateAsync(string userId, Lure model) public async Task UpdateAsync(string userId, Lure model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Lures); + var oldUid = model.Uid; + + // PoracleNG guards this type with a natural unique key and its create has no upsert path, so + // changing a field outside that key collides (Error 1062) and returns 500. Replace the row instead. + // Refuse a collision BEFORE the delete. PoracleNG dedups lures on (id, profile_no, lure_id), + // so editing one onto a lure_id another alarm already holds made the replace merge into that + // alarm - this one deleted, the other one silently overwritten. See #462. + if (oldUid > 0) + { + var siblings = await this.GetByUserAsync(userId, model.ProfileNo); + if (siblings.Any(x => x.Uid != oldUid && x.LureId == model.LureId)) + { + throw new TrackingConflictException( + TrackingType, + "You already have a lure alarm for that lure type. Edit or remove that one instead."); + } + } + + var original = oldUid > 0 ? await this.GetByUidAsync(userId, oldUid) : null; + var body = SerializeToElement(model); - await this._proxy.CreateAsync(TrackingType, userId, body); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, oldUid, body); + + model.Uid = await NaturalKeyTrackingUpdate.ReplaceAsync( + this._proxy, + TrackingType, + userId, + oldUid, + original is null ? null : SerializeToElement(original), + body, + this._logger, + this._uidRemapper); + return model; } @@ -70,43 +121,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) diff --git a/Core/Pgan.PoracleWebNet.Core.Services/MasterDataService.cs b/Core/Pgan.PoracleWebNet.Core.Services/MasterDataService.cs index d3224c97..f73c3180 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/MasterDataService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/MasterDataService.cs @@ -17,6 +17,8 @@ public partial class MasterDataService( private const string PokemonCacheKey = "MasterData_Pokemon"; private const string ItemCacheKey = "MasterData_Items"; + private const string MoveCacheKey = "MasterData_Moves"; + private const string MonsterCacheKey = "MasterData_Monsters"; private const string BaseStatsCacheKey = "MasterData_BaseStats"; private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24); @@ -39,6 +41,20 @@ public partial class MasterDataService( return data; } + public async Task GetMoveDataAsync() + { + await this.EnsureInitializedAsync(); + this._cache.TryGetValue(MoveCacheKey, out string? data); + return data; + } + + public async Task GetMonsterDataAsync() + { + await this.EnsureInitializedAsync(); + this._cache.TryGetValue(MonsterCacheKey, out string? data); + return data; + } + public async Task GetBaseStatsAsync(int pokemonId, int form) { await this.EnsureInitializedAsync(); @@ -105,6 +121,13 @@ public async Task RefreshCacheAsync() } } } + // The whole monster map is kept verbatim as the English fallback for + // GET /api/masterdata/monsters, which normally serves PoracleNG's localized version. + if (monsters.ValueKind == JsonValueKind.Object) + { + this._cache.Set(MonsterCacheKey, monsters.GetRawText(), CacheDuration); + } + this._cache.Set(PokemonCacheKey, JsonSerializer.Serialize(pokemonMap), CacheDuration); this._cache.Set(BaseStatsCacheKey, baseStatsMap, CacheDuration); LogCachedPokemonEntries(this._logger, pokemonMap.Count); @@ -132,6 +155,30 @@ public async Task RefreshCacheAsync() } this._cache.Set(ItemCacheKey, JsonSerializer.Serialize(itemMap), CacheDuration); LogCachedItemEntries(this._logger, itemMap.Count); + + // Build move name map. Masterfile entries are { "13": { "name": "Wrap", "type": "Normal" } }; + // only the name is needed, so this collapses to id -> name like the item map. + var moveMap = new Dictionary(); + if (root.TryGetProperty("moves", out var moves)) + { + foreach (var entry in moves.EnumerateObject()) + { + var id = entry.Name; + var name = id; + if (entry.Value.TryGetProperty("name", out var nameProp)) + { + name = nameProp.GetString() ?? id; + } + else if (entry.Value.ValueKind == JsonValueKind.String) + { + name = entry.Value.GetString() ?? id; + } + + moveMap[id] = name; + } + } + this._cache.Set(MoveCacheKey, JsonSerializer.Serialize(moveMap), CacheDuration); + LogCachedMoveEntries(this._logger, moveMap.Count); } catch (Exception ex) { @@ -159,6 +206,9 @@ private async Task EnsureInitializedAsync() [LoggerMessage(Level = LogLevel.Information, Message = "Cached {Count} base stat entries.")] private static partial void LogCachedBaseStats(ILogger logger, int count); + [LoggerMessage(Level = LogLevel.Information, Message = "Cached {Count} move entries.")] + private static partial void LogCachedMoveEntries(ILogger logger, int count); + [LoggerMessage(Level = LogLevel.Information, Message = "Cached {Count} item entries.")] private static partial void LogCachedItemEntries(ILogger logger, int count); diff --git a/Core/Pgan.PoracleWebNet.Core.Services/MaxBattleService.cs b/Core/Pgan.PoracleWebNet.Core.Services/MaxBattleService.cs index bfbe8b12..f7463d2d 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/MaxBattleService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/MaxBattleService.cs @@ -5,10 +5,11 @@ namespace Pgan.PoracleWebNet.Core.Services; -public partial class MaxBattleService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger) : IMaxBattleService +public partial class MaxBattleService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IMaxBattleService { private const string TrackingType = "maxbattle"; private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; @@ -29,6 +30,20 @@ public async Task CreateAsync(string userId, MaxBattle model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.MaxBattles); model.Id = userId; + + // Max battles are insert-only upstream: PoracleNG dedups every other type and this one not at all, + // so pressing Add twice stacked identical alarms forever and the user got two of every + // notification, with no way to tell the rows apart in the list. An exact duplicate is refused + // rather than filed again. Alarms that differ in any field -- including radius -- still stack, + // because upstream has no key that would merge them. See #521. + var siblings = await this.GetByUserAsync(userId, model.ProfileNo); + if (siblings.Any(x => IsSameAlarm(x, model))) + { + throw new TrackingConflictException( + TrackingType, + "You already have an identical max battle alarm."); + } + var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -45,9 +60,25 @@ public async Task UpdateAsync(string userId, MaxBattle model) await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.MaxBattles); // MaxBattle is insert-only in PoracleNG (no dedup/upsert). // Delete the old alarm first, then create a replacement. - await this._proxy.DeleteByUidAsync(TrackingType, userId, model.Uid); - + var oldUid = model.Uid; var body = SerializeToElement(model); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Create refuses an exact duplicate (#521) and this path did not, so editing one max battle onto + // another's settings left two identical rows -- and it is checked BEFORE the delete, because the + // delete is what makes this path destructive. See #538. + var siblings = await this.GetByUserAsync(userId, model.ProfileNo); + if (siblings.Any(x => x.Uid != oldUid && IsSameAlarm(x, model))) + { + throw new TrackingConflictException( + TrackingType, + "You already have an identical max battle alarm."); + } + + await this._proxy.DeleteByUidAsync(TrackingType, userId, oldUid); var result = await this._proxy.CreateAsync(TrackingType, userId, body); if (result.NewUids.Count > 0) @@ -55,6 +86,9 @@ public async Task UpdateAsync(string userId, MaxBattle model) model.Uid = (int)result.NewUids[0]; } + // Quick-pick applied state stores uids captured at apply time; follow the row. See #403. + await this._uidRemapper.RemapAsync(userId, TrackingType, oldUid, model.Uid); + return model; } @@ -82,72 +116,78 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } // MaxBattle is insert-only — bulk delete then re-create with updated distance. // If the re-create fails after delete, alarms are lost. Log for recovery. - var uids = itemList.Select(x => x.Uid).ToList(); + var uids = body.EnumerateArray().Select(PoracleJsonHelper.UidOf).OfType().ToList(); await this._proxy.BulkDeleteByUidsAsync(TrackingType, userId, uids); - foreach (var item in itemList) - { - item.Distance = distance; - } - try { - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); + + // The rows were deleted and re-made, so every uid changed. Follow any quick pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); } catch (Exception ex) { LogRecreateFailed(this._logger, ex, - itemList.Count, userId, string.Join(", ", uids)); + count, userId, string.Join(", ", uids)); throw; } - return itemList.Count; + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } // MaxBattle is insert-only — bulk delete then re-create with updated distance. - var matchingUids = matching.Select(x => x.Uid).ToList(); + var matchingUids = body.EnumerateArray().Select(PoracleJsonHelper.UidOf).OfType().ToList(); await this._proxy.BulkDeleteByUidsAsync(TrackingType, userId, matchingUids); - foreach (var item in matching) - { - item.Distance = distance; - } - try { - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); + + // The rows were deleted and re-made, so every uid changed. Follow any quick pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); } catch (Exception ex) { LogRecreateFailed(this._logger, ex, - matching.Count, userId, string.Join(", ", matchingUids)); + count, userId, string.Join(", ", matchingUids)); throw; } - return matching.Count; + return count; } public async Task CountByUserAsync(string userId, int profileNo) @@ -178,6 +218,33 @@ public async Task> BulkCreateAsync(string userId, IEnumer return modelList; } + /// + /// Whether two max battle alarms are the same alarm: every field a user can set, radius included. + /// + /// + /// Deliberately strict. Upstream has no key that would merge two alarms differing by radius, so + /// refusing those would block something that does work. Only an exact repeat is refused. See #521. + /// + private static bool IsSameAlarm(MaxBattle existing, MaxBattle candidate) => + existing.PokemonId == candidate.PokemonId + && existing.Form == candidate.Form + && StoredLevel(existing) == StoredLevel(candidate) + && existing.Move == candidate.Move + && existing.Gmax == candidate.Gmax + && existing.Evolution == candidate.Evolution + && existing.Distance == candidate.Distance + && string.Equals(existing.StationId ?? string.Empty, candidate.StationId ?? string.Empty, StringComparison.OrdinalIgnoreCase); + + /// + /// The level PoracleNG will actually store: it forces 9000 unless the alarm tracks any boss. + /// + /// + /// Comparing the submitted level instead made every duplicate look different, because the stored row + /// already carried the rewritten value. Mirrors trackingMaxbattle.go, which sets level = 9000 whenever + /// pokemon_id names a specific boss. + /// + private static int StoredLevel(MaxBattle alarm) => alarm.PokemonId == 9000 ? alarm.Level : 9000; + private static List DeserializeItems(JsonElement json) => PoracleJsonHelper.DeserializeList(json); diff --git a/Core/Pgan.PoracleWebNet.Core.Services/MonsterService.cs b/Core/Pgan.PoracleWebNet.Core.Services/MonsterService.cs index 9ff0064e..6f9227f6 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/MonsterService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/MonsterService.cs @@ -10,9 +10,11 @@ public class MonsterService(IPoracleTrackingProxy proxy, IFeatureGate featureGat private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; - // Note: profileNo is kept for interface compatibility but PoracleNG scopes to the user's - // active profile (humans.current_profile_no) automatically. The JWT profileNo and the - // active profile should always match because SwitchProfile updates both. + // profileNo is kept for interface compatibility only. PoracleNG scopes reads to the user's active + // profile (humans.current_profile_no), and writes no longer carry profile_no at all — see + // PoracleJsonHelper.SerializeToElement. The previous claim here, that the JWT profileNo and the + // active profile can never diverge, was wrong: the active-hours scheduler and the bot's !profile + // command both move it out of band, and JWTs live four hours. See #411. public async Task> GetByUserAsync(string userId, int profileNo) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); @@ -31,6 +33,11 @@ public async Task CreateAsync(string userId, Monster model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Pokemon); model.Id = userId; + + // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: + // 201 Created, and the user quietly loses the one they had. See #561. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, 0, SerializeToElement(model)); var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -47,6 +54,18 @@ public async Task UpdateAsync(string userId, Monster model) await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Pokemon); // PoracleNG's POST endpoint handles updates when the body includes a uid field. var body = SerializeToElement(model); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Pokemon is the one type with no collision guard: PoracleNG updates it in place rather than + // merging, so an edit onto another alarm's exact settings wrote a byte-identical twin -- two rows + // on the page that cannot be told apart and must each be deleted. Creating that state directly is + // refused, so the edit path was the only way to reach it. See #537. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, model.Uid, body); + await this._proxy.CreateAsync(TrackingType, userId, body); return model; } @@ -78,43 +97,41 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var monsters = DeserializeMonsters(json); - var monsterList = monsters.ToList(); - if (monsterList.Count == 0) - { - return 0; - } + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - foreach (var monster in monsterList) + if (count == 0) { - monster.Distance = distance; + return 0; } - var body = SerializeToElement(monsterList); await this._proxy.CreateAsync(TrackingType, userId, body); - return monsterList.Count; + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var monsters = DeserializeMonsters(json); - var matching = monsters.Where(m => uids.Contains(m.Uid)).ToList(); - if (matching.Count == 0) - { - return 0; - } + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); - foreach (var monster in matching) + if (count == 0) { - monster.Distance = distance; + return 0; } - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + return count; } public async Task CountByUserAsync(string userId, int profileNo) diff --git a/Core/Pgan.PoracleWebNet.Core.Services/NaturalKeyTrackingUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Services/NaturalKeyTrackingUpdate.cs new file mode 100644 index 00000000..744fd699 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/NaturalKeyTrackingUpdate.cs @@ -0,0 +1,121 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Update strategy for the tracking types PoracleNG protects with a natural unique key — +/// lure_tracking(id, profile_no, lure_id) and +/// invasion_tracking(id, profile_no, gender, grunt_type). +/// +/// PoracleNG's create endpoint has no upsert path for these: it treats a row as "already present" only when +/// every field matches, so changing a field outside the natural key makes it attempt an INSERT that +/// collides with the existing row. MariaDB raises +/// Error 1062 Duplicate entry '<id>-<profile>-<key>', PoracleNG answers +/// 500 {"message":"database error"}, and the user's edit is silently discarded. Editing the distance +/// or template of a lure was therefore impossible. +/// +/// +/// The other types carry no natural unique index (only PRIMARY(uid)), so their creates cannot collide +/// and handles them. +/// +/// +internal static partial class NaturalKeyTrackingUpdate +{ + /// + /// Replaces the existing row: delete first so the natural key is free, then create. + /// + /// If the create fails the original is restored, so a failed edit leaves the alarm intact rather than + /// destroying it — the risk inherent in a bare delete-then-create. + /// + /// + /// The uid of the surviving row. + public static async Task ReplaceAsync( + IPoracleTrackingProxy proxy, + string trackingType, + string userId, + int oldUid, + JsonElement? original, + JsonElement updated, + ILogger logger, + ITrackedUidRemapper? uidRemapper = null) + { + // Not an edit: nothing to free up, so this is an ordinary create. + if (oldUid <= 0) + { + var created = await proxy.CreateAsync(trackingType, userId, updated); + return created.NewUids.Count > 0 ? (int)created.NewUids[0] : oldUid; + } + + await proxy.DeleteByUidAsync(trackingType, userId, oldUid); + + try + { + var result = await proxy.CreateAsync(trackingType, userId, updated); + + // The row was deleted a moment ago, so its natural key is free and a genuine replace + // always inserts. Inserting nothing means the edited values collide with a DIFFERENT + // alarm, and PoracleNG merged into that one instead - leaving this alarm deleted and the + // other one silently overwritten. Put ours back and refuse. The services pre-check for + // this so it should not be reachable; it is here because the cost of missing it is a + // destroyed alarm. See #462. + if (result.InsertedNothing) + { + if (original.HasValue) + { + await proxy.CreateAsync(trackingType, userId, original.Value); + } + + throw new TrackingConflictException( + trackingType, + "Another alarm of this type already uses those settings. Edit or remove that one instead."); + } + + var newUid = result.NewUids.Count > 0 ? (int)result.NewUids[0] : oldUid; + + // Quick-pick applied state stores uids captured at apply time; follow the row. See #403. + if (uidRemapper != null) + { + await uidRemapper.RemapAsync(userId, trackingType, oldUid, newUid); + } + + return newUid; + } + catch (TrackingConflictException) + { + // Already restored above; re-restoring would duplicate the row. + throw; + } + catch (Exception ex) + { + LogReplaceFailed(logger, ex, trackingType, oldUid); + + // Put the original back so the edit fails without losing the user's alarm. + if (original.HasValue) + { + try + { + await proxy.CreateAsync(trackingType, userId, original.Value); + } + catch (Exception restoreEx) + { + LogRestoreFailed(logger, restoreEx, trackingType, oldUid); + } + } + + throw; + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Re-creating {TrackingType} uid {OldUid} failed after its row was removed for the edit; restoring the original.")] + private static partial void LogReplaceFailed(ILogger logger, Exception exception, string trackingType, int oldUid); + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not restore the original {TrackingType} uid {OldUid} after a failed edit; the alarm is gone.")] + private static partial void LogRestoreFailed(ILogger logger, Exception exception, string trackingType, int oldUid); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/NestService.cs b/Core/Pgan.PoracleWebNet.Core.Services/NestService.cs index c4e76a1b..5d66bc8c 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/NestService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/NestService.cs @@ -1,14 +1,17 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; -public class NestService(IPoracleTrackingProxy proxy, IFeatureGate featureGate) : INestService +public class NestService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : INestService { private const string TrackingType = "nest"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -27,6 +30,11 @@ public async Task CreateAsync(string userId, Nest model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Nests); model.Id = userId; + + // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: + // 201 Created, and the user quietly loses the one they had. See #561. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, 0, SerializeToElement(model)); var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -41,8 +49,25 @@ public async Task CreateAsync(string userId, Nest model) public async Task UpdateAsync(string userId, Nest model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Nests); + var oldUid = model.Uid; var body = SerializeToElement(model); - await this._proxy.CreateAsync(TrackingType, userId, body); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Refuse before writing: PoracleNG would satisfy this by merging into the other alarm and + // the reconciler would then delete this one, losing a row the user never touched. See #531. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, oldUid, body); + + var result = await this._proxy.CreateAsync(TrackingType, userId, body); + + // PoracleNG inserts instead of upserting when the edit changes a dedup-key field, + // leaving the pre-edit row behind as a duplicate. Drop it and report the surviving uid. + model.Uid = await TrackingUpdateReconciler.ReconcileAsync( + this._proxy, TrackingType, userId, oldUid, result, this._logger, body, this._uidRemapper); + return model; } @@ -70,43 +95,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) diff --git a/Core/Pgan.PoracleWebNet.Core.Services/Pgan.PoracleWebNet.Core.Services.csproj b/Core/Pgan.PoracleWebNet.Core.Services/Pgan.PoracleWebNet.Core.Services.csproj index f936286f..fea15703 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/Pgan.PoracleWebNet.Core.Services.csproj +++ b/Core/Pgan.PoracleWebNet.Core.Services/Pgan.PoracleWebNet.Core.Services.csproj @@ -7,10 +7,10 @@ - - - - + + + + diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs index cfb85ba5..3a56b613 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs @@ -83,6 +83,33 @@ public class PoracleApiProxy(HttpClient httpClient, IConfiguration configuration config.PvpLittleLeagueAllowed = pvpLittle.GetBoolean(); } + if (root.TryGetProperty("pvpCaps", out var pvpCaps) && pvpCaps.ValueKind == JsonValueKind.Array) + { + foreach (var cap in pvpCaps.EnumerateArray()) + { + if (cap.ValueKind == JsonValueKind.Number && cap.TryGetInt32(out var capInt)) + { + config.PvpCaps.Add(capInt); + } + else if (cap.ValueKind == JsonValueKind.String && int.TryParse(cap.GetString(), out var capStr)) + { + config.PvpCaps.Add(capStr); + } + } + } + + if (root.TryGetProperty("defaultPvpCap", out var defaultPvpCap)) + { + if (defaultPvpCap.ValueKind == JsonValueKind.Number && defaultPvpCap.TryGetInt32(out var defInt)) + { + config.DefaultPvpCap = defInt; + } + else if (defaultPvpCap.ValueKind == JsonValueKind.String && int.TryParse(defaultPvpCap.GetString(), out var defStr)) + { + config.DefaultPvpCap = defStr; + } + } + if (root.TryGetProperty("defaultTemplateName", out var templateName)) { config.DefaultTemplateName = templateName.ValueKind == JsonValueKind.String @@ -102,6 +129,20 @@ public class PoracleApiProxy(HttpClient httpClient, IConfiguration configuration config.MaxDistance = maxDist.GetInt32(); } + if (root.TryGetProperty("disabledHooks", out var disabledHooks) && disabledHooks.ValueKind == JsonValueKind.Array) + { + // Absent (older Poracle, PoracleJS) stays null so callers can tell "upstream has no + // opinion" apart from "upstream disables nothing". Only the former may be inferred from. + config.DisabledHooks = []; + foreach (var hook in disabledHooks.EnumerateArray()) + { + if (hook.ValueKind == JsonValueKind.String && hook.GetString() is { Length: > 0 } name) + { + config.DisabledHooks.Add(name); + } + } + } + if (root.TryGetProperty("admins", out var admins)) { config.Admins = new PoracleAdmins(); @@ -167,6 +208,46 @@ public class PoracleApiProxy(HttpClient httpClient, IConfiguration configuration return config; } + /// + /// Reads the effective tracking.quest_summary_enabled flag from PoracleNG's config-values + /// endpoint (/api/config/values, which exposes the merged-with-defaults config — the + /// poracleWeb config view does not include the tracking section). Returns null when + /// the value cannot be determined (endpoint shape changed, feature unknown), so the caller can + /// degrade safely. + /// + public Task GetQuestSummaryEnabledAsync() => + this.ReadConfigValueBoolAsync("tracking", "quest_summary_enabled"); + + /// + public Task GetFortUpdateDisabledAsync() => + this.ReadConfigValueBoolAsync("general", "disable_fort_update"); + + /// + /// Reads a single boolean out of GET /api/config/values, whose body is shaped + /// { "values": { "<section>": { "<key>": true } } }. Returns null when the + /// section or key is missing, or the value is not a boolean — an answer of "cannot determine", + /// distinct from false. + /// + private async Task ReadConfigValueBoolAsync(string section, string key) + { + var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/config/values"); + var response = await this._httpClient.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + if (doc.RootElement.TryGetProperty("values", out var values) + && values.TryGetProperty(section, out var sectionElement) + && sectionElement.TryGetProperty(key, out var value) + && value.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return value.GetBoolean(); + } + + return null; + } + public async Task GetAreasAsync(string userId) { var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/humans/{userId}"); @@ -197,11 +278,39 @@ public class PoracleApiProxy(HttpClient httpClient, IConfiguration configuration return await response.Content.ReadAsStringAsync(); } + /// + /// Invasion grunt master data. The path is /api/masterdata/grunts/api/config/grunts + /// exists in neither supported backend, so this call could only ever 404 and throw. + /// + /// The raw JSON, or null when upstream is unreachable or does not serve it. public async Task GetGruntsAsync() { - var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/config/grunts"); + var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/masterdata/grunts"); var response = await this._httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); + + if (!response.IsSuccessStatusCode) + { + return null; + } + + return await response.Content.ReadAsStringAsync(); + } + + /// + /// Localized monster master data from /api/masterdata/monsters. Older PoracleJS builds do + /// not serve this route, so a 404 is a normal outcome and yields null rather than throwing. + /// + public async Task GetMonstersAsync(string locale) + { + var request = this.CreateRequest(HttpMethod.Get, + $"{this._apiAddress}/api/masterdata/monsters?locale={Uri.EscapeDataString(locale)}"); + var response = await this._httpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + return null; + } + return await response.Content.ReadAsStringAsync(); } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleHumanProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleHumanProxy.cs index cc8b7d8a..b760d57a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/PoracleHumanProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleHumanProxy.cs @@ -1,154 +1,290 @@ -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Configuration; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Core.Services; - -public class PoracleHumanProxy(HttpClient httpClient, IConfiguration configuration) : IPoracleHumanProxy -{ - private readonly HttpClient _httpClient = httpClient; - private readonly string _apiAddress = configuration["Poracle:ApiAddress"] ?? string.Empty; - private readonly string _apiSecret = configuration["Poracle:ApiSecret"] ?? string.Empty; - - /// - /// URL-encodes a userId for safe path construction. Webhook IDs are full URLs - /// containing slashes that would break routing without encoding. - /// - private static string Encode(string userId) => Uri.EscapeDataString(userId); - - public async Task GetHumanAsync(string userId) - { - var response = await this.SendAsync(HttpMethod.Get, $"/api/humans/one/{Encode(userId)}"); - if (!response.IsSuccessStatusCode) - { - return null; - } - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - - // PoracleNG wraps the response: { "human": { ... }, "status": "ok" } - if (doc.RootElement.TryGetProperty("human", out var human)) - { - return human.Clone(); - } - - return doc.RootElement.Clone(); - } - - public async Task CreateHumanAsync(JsonElement body) - { - var response = await this.SendAsync(HttpMethod.Post, "/api/humans", body.GetRawText()); - response.EnsureSuccessStatusCode(); - } - - public async Task StartAsync(string userId) - { - var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/start"); - response.EnsureSuccessStatusCode(); - } - - public async Task StopAsync(string userId) - { - var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/stop"); - response.EnsureSuccessStatusCode(); - } - - public async Task AdminDisabledAsync(string userId, bool disabled) - { - var body = JsonSerializer.Serialize(new - { - adminDisable = disabled ? 1 : 0 - }); - var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/adminDisabled", body); - response.EnsureSuccessStatusCode(); - } - - public async Task SetLocationAsync(string userId, double lat, double lon) - { - var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/setLocation/{lat}/{lon}"); - response.EnsureSuccessStatusCode(); - } - - public async Task SetAreasAsync(string userId, string[] areas) - { - var body = JsonSerializer.Serialize(areas); - var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/setAreas", body); - response.EnsureSuccessStatusCode(); - } - - public async Task GetAreasAsync(string userId) => - // User's selected areas are in GET /api/humans/one/{id} → human.area (JSON string). - // GET /api/humans/{id} returns the available area list, not the user's selection. - await this.GetHumanAsync(userId); - - public async Task SwitchProfileAsync(string userId, int profileNo) - { - var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/switchProfile/{profileNo}"); - response.EnsureSuccessStatusCode(); - } - - public async Task GetProfilesAsync(string userId) - { - var response = await this.SendAsync(HttpMethod.Get, $"/api/profiles/{Encode(userId)}"); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } - - public async Task AddProfileAsync(string userId, JsonElement body) - { - var response = await this.SendAsync(HttpMethod.Post, $"/api/profiles/{Encode(userId)}/add", body.GetRawText()); - response.EnsureSuccessStatusCode(); - } - - public async Task UpdateProfileAsync(string userId, JsonElement body) - { - var response = await this.SendAsync(HttpMethod.Post, $"/api/profiles/{Encode(userId)}/update", body.GetRawText()); - response.EnsureSuccessStatusCode(); - } - - public async Task DeleteProfileAsync(string userId, int profileNo) - { - var response = await this.SendAsync(HttpMethod.Delete, $"/api/profiles/{Encode(userId)}/byProfileNo/{profileNo}"); - response.EnsureSuccessStatusCode(); - } - - public async Task CopyProfileAsync(string userId, int fromProfileNo, int toProfileNo) - { - var response = await this.SendAsync(HttpMethod.Post, $"/api/profiles/{Encode(userId)}/copy/{fromProfileNo}/{toProfileNo}"); - response.EnsureSuccessStatusCode(); - } - - public async Task CheckLocationAsync(string userId, double lat, double lon) - { - var response = await this.SendAsync(HttpMethod.Get, $"/api/humans/{Encode(userId)}/checkLocation/{lat}/{lon}"); - if (!response.IsSuccessStatusCode) - { - return null; - } - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } - - private async Task SendAsync(HttpMethod method, string path, string? body = null) - { - var request = new HttpRequestMessage(method, $"{this._apiAddress}{path}"); - if (!string.IsNullOrEmpty(this._apiSecret)) - { - request.Headers.Add("X-Poracle-Secret", this._apiSecret); - } - - if (body != null) - { - request.Content = new StringContent(body, Encoding.UTF8, "application/json"); - } - - return await this._httpClient.SendAsync(request); - } -} +using Pgan.PoracleWebNet.Core.Models; +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +public class PoracleHumanProxy(HttpClient httpClient, IConfiguration configuration) : IPoracleHumanProxy +{ + private readonly HttpClient _httpClient = httpClient; + private readonly string _apiAddress = configuration["Poracle:ApiAddress"] ?? string.Empty; + private readonly string _apiSecret = configuration["Poracle:ApiSecret"] ?? string.Empty; + + /// + /// URL-encodes a userId for safe path construction. Webhook IDs are full URLs + /// containing slashes that would break routing without encoding. + /// + /// + /// Turns PoracleNG's "user not found" into something the API can answer 401 to. + /// + /// + /// A JWT outlives the account it names. Without this, every lookup for a deleted user threw an + /// HttpRequestException that the global handler flattened into a 500, so the SPA -- which signs out + /// only on 401 -- left the user in an app where every page failed. See #584. + /// + private static void EnsureAccountStillExists(HttpResponseMessage response) + { + if (response.StatusCode == HttpStatusCode.NotFound) + { + throw new AccountGoneException(); + } + } + + private static string Encode(string userId) => Uri.EscapeDataString(userId); + + public async Task GetHumanAsync(string userId) + { + var response = await this.SendAsync(HttpMethod.Get, $"/api/humans/one/{Encode(userId)}"); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + // PoracleNG wraps the response: { "human": { ... }, "status": "ok" } + if (doc.RootElement.TryGetProperty("human", out var human)) + { + return human.Clone(); + } + + return doc.RootElement.Clone(); + } + + public async Task CreateHumanAsync(JsonElement body) + { + var response = await this.SendAsync(HttpMethod.Post, "/api/humans", body.GetRawText()); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task StartAsync(string userId) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/start"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task StopAsync(string userId) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/stop"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task AdminDisabledAsync(string userId, bool disabled) + { + // PoracleNG's adminDisabledRequest is `State *bool \`json:"state"\`` -- it rejects any other key + // with 400 "state is required (true/false)", including the `adminDisable` this used to send, so + // ban/unban failed on every call against every PoracleNG. + var body = JsonSerializer.Serialize(new + { + state = disabled + }); + var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/adminDisabled", body); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task SetLocationAsync(string userId, double lat, double lon) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/setLocation/{lat}/{lon}"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task SetAreasAsync(string userId, string[] areas) + { + var body = JsonSerializer.Serialize(areas); + var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/setAreas", body); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task GetAreasAsync(string userId) => + // User's selected areas are in GET /api/humans/one/{id} → human.area (JSON string). + // GET /api/humans/{id} returns the available area list, not the user's selection. + await this.GetHumanAsync(userId); + + public async Task SwitchProfileAsync(string userId, int profileNo) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/humans/{Encode(userId)}/switchProfile/{profileNo}"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task GetProfilesAsync(string userId) + { + var response = await this.SendAsync(HttpMethod.Get, $"/api/profiles/{Encode(userId)}"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + public async Task AddProfileAsync(string userId, JsonElement body) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/profiles/{Encode(userId)}/add", body.GetRawText()); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task UpdateProfileAsync(string userId, JsonElement body) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/profiles/{Encode(userId)}/update", body.GetRawText()); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task DeleteProfileAsync(string userId, int profileNo) + { + var response = await this.SendAsync(HttpMethod.Delete, $"/api/profiles/{Encode(userId)}/byProfileNo/{profileNo}"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task CopyProfileAsync(string userId, int fromProfileNo, int toProfileNo) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/profiles/{Encode(userId)}/copy/{fromProfileNo}/{toProfileNo}"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task CheckLocationAsync(string userId, double lat, double lon) + { + var response = await this.SendAsync(HttpMethod.Get, $"/api/humans/{Encode(userId)}/checkLocation/{lat}/{lon}"); + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + + public async Task GetPlacesAsync(string userId) + { + var response = await this.SendAsync(HttpMethod.Get, $"/api/humans/{Encode(userId)}/locations"); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + // PoracleNG wraps this one as {"locations": {...}, "status": "ok"} -- reading the root as the + // payload returns an empty set rather than an error, which is the whole reason this note exists. + if (!doc.RootElement.TryGetProperty("locations", out var locations)) + { + return new SavedPlaces(); + } + + var result = new SavedPlaces(); + + if (locations.TryGetProperty("default", out var def) && def.ValueKind == JsonValueKind.Object) + { + result.Default = new SavedPlace + { + Label = string.Empty, + Latitude = def.GetDoubleProp("latitude"), + Longitude = def.GetDoubleProp("longitude"), + }; + } + + if (locations.TryGetProperty("named", out var named) && named.ValueKind == JsonValueKind.Array) + { + foreach (var place in named.EnumerateArray()) + { + result.Named.Add(new SavedPlace + { + Label = place.GetStringProp("label"), + Latitude = place.GetDoubleProp("latitude"), + Longitude = place.GetDoubleProp("longitude"), + }); + } + } + + return result; + } + + public async Task AddPlaceAsync(string userId, SavedPlace place) + { + var body = JsonSerializer.Serialize(new + { + label = place.Label, + latitude = place.Latitude, + longitude = place.Longitude, + }); + + var response = await this.SendAsync( + HttpMethod.Post, $"/api/humans/{Encode(userId)}/locations/add", body); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + + // A rejected label is reported inside a 200: PoracleNG answers per row so a batch can partly + // succeed. Treating the 200 as success stored nothing and told the user it worked. + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + if (!doc.RootElement.TryGetProperty("results", out var results) + || results.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var row in results.EnumerateArray()) + { + var error = row.GetStringPropOrNull("error"); + if (!string.IsNullOrEmpty(error)) + { + return error; + } + } + + return null; + } + + public async Task DeletePlaceAsync(string userId, string label) + { + var response = await this.SendAsync( + HttpMethod.Post, $"/api/humans/{Encode(userId)}/locations/{Encode(label)}/delete"); + EnsureAccountStillExists(response); + + if (response.StatusCode == HttpStatusCode.Conflict) + { + var conflict = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(conflict); + var rules = doc.RootElement.TryGetProperty("referencing_rules", out var refs) + && refs.ValueKind == JsonValueKind.Array + ? refs.EnumerateArray().Select(r => r.ToString()).ToList() + : []; + + throw new PlaceInUseException(rules); + } + + response.EnsureSuccessStatusCode(); + } + + private async Task SendAsync(HttpMethod method, string path, string? body = null) + { + var request = new HttpRequestMessage(method, $"{this._apiAddress}{path}"); + if (!string.IsNullOrEmpty(this._apiSecret)) + { + request.Headers.Add("X-Poracle-Secret", this._apiSecret); + } + + if (body != null) + { + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + + return await this._httpClient.SendAsync(request); + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs index 0221210f..7a6e1103 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleJsonHelper.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Text.Json; namespace Pgan.PoracleWebNet.Core.Services; @@ -20,29 +21,62 @@ internal static class PoracleJsonHelper public static readonly JsonElement EmptyArray = JsonDocument.Parse("[]").RootElement.Clone(); /// - /// Serializes a value to a JsonElement using snake_case naming. - /// Strips "uid":0 from the output — PoracleNG treats uid=0 as an update target - /// instead of a new insert. Omitting uid tells PoracleNG to create a new row. + /// Serializes an alarm payload for PoracleNG, using snake_case naming and removing two properties + /// that do more harm than good on the wire. /// + /// + /// + /// uid: 0 is stripped because PoracleNG reads a present uid as "update this row", so a new + /// alarm with the default uid became an update against a row that does not exist instead of an insert. + /// + /// + /// profile_no is stripped because it was stamped from the caller's JWT claim, and that claim + /// goes stale whenever current_profile_no changes out of band — the active-hours scheduler, the + /// bot's !profile command, or a second tab. PoracleNG honours a submitted profile_no + /// verbatim for the pokemon type while scoping every other type, and every read path, to the live + /// current_profile_no. A stale claim therefore wrote a monster onto a profile the user was no + /// longer on: the POST returned 201 with a real uid, and the row was then invisible to reads and + /// undeletable. Confirmed against PoracleNG that a submitted profile_no is taken at face value + /// even when no such profile exists — profile_no: 9 creates an orphan — and that omitting it + /// makes PoracleNG use current_profile_no. Omitting it is therefore both the safe option and + /// the one that matches how the other nine types already behave. See #411. + /// + /// public static JsonElement SerializeToElement(T value) { var bytes = JsonSerializer.SerializeToUtf8Bytes(value, SnakeCaseOptions); using var doc = JsonDocument.Parse(bytes); var root = doc.RootElement; - if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("uid", out var uid) && uid.GetInt32() == 0) + if (root.ValueKind == JsonValueKind.Array) { - return StripProperty(root, "uid"); + return StripAlarmMetadataFromArray(root); } - if (root.ValueKind == JsonValueKind.Array) + if (root.ValueKind == JsonValueKind.Object) { - return StripZeroUidsFromArray(root); + return StripAlarmMetadata(root); } return root.Clone(); } + /// Names that must never reach PoracleNG on an alarm write. See . + private static bool ShouldStrip(JsonProperty prop) => + prop.NameEquals("profile_no") || + (prop.NameEquals("uid") && prop.Value.ValueKind == JsonValueKind.Number && prop.Value.GetInt32() == 0); + + private static JsonElement StripAlarmMetadata(JsonElement obj) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + WriteStripped(writer, obj); + } + + return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); + } + /// /// Removes a named property from a JSON object, returning a new JsonElement without it. /// @@ -68,7 +102,7 @@ public static JsonElement StripProperty(JsonElement obj, string propertyName) return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); } - private static JsonElement StripZeroUidsFromArray(JsonElement array) + private static JsonElement StripAlarmMetadataFromArray(JsonElement array) { using var stream = new MemoryStream(); using (var writer = new Utf8JsonWriter(stream)) @@ -76,9 +110,9 @@ private static JsonElement StripZeroUidsFromArray(JsonElement array) writer.WriteStartArray(); foreach (var item in array.EnumerateArray()) { - if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("uid", out var uid) && uid.GetInt32() == 0) + if (item.ValueKind == JsonValueKind.Object) { - StripPropertyTo(writer, item, "uid"); + WriteStripped(writer, item); } else { @@ -92,12 +126,12 @@ private static JsonElement StripZeroUidsFromArray(JsonElement array) return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); } - private static void StripPropertyTo(Utf8JsonWriter writer, JsonElement obj, string propertyName) + private static void WriteStripped(Utf8JsonWriter writer, JsonElement obj) { writer.WriteStartObject(); foreach (var prop in obj.EnumerateObject()) { - if (prop.NameEquals(propertyName)) + if (ShouldStrip(prop)) { continue; } @@ -108,6 +142,176 @@ private static void StripPropertyTo(Utf8JsonWriter writer, JsonElement obj, stri writer.WriteEndObject(); } + + /// + /// Rewrites stored rows for a write-back, changing only the named properties and passing every + /// other property through byte-for-byte. + /// + /// + /// + /// The bulk paths used to deserialize into the typed alarm model, mutate one field and serialize the + /// model again. That silently dropped every property PoracleWeb does not model, and because the POST + /// carries a uid PoracleNG upserts the row — so the dropped values were not orphaned, they were + /// erased. PoracleNG 5.1.0 added override_location_label, override_areas and + /// pvp_ranking_evolution; 5.2.0 adds costume. Enumerating them here would only work + /// until the next one lands, so nothing is enumerated: the stored row is the source of truth and the + /// caller states the few properties it means to change. See #730. + /// + /// + /// The same uid/profile_no stripping as applies, so a rewritten + /// row lands on the live active profile rather than a stale one. See #411. + /// + /// + /// The array PoracleNG returned from a tracking read. + /// Rows to write back. Rows it rejects are left out of the result entirely. + /// Properties to set. A name the row does not carry is appended. + public static JsonElement RewriteRows( + JsonElement rows, + Func include, + params (string Name, int Value)[] changes) + { + if (rows.ValueKind != JsonValueKind.Array) + { + return EmptyArray; + } + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartArray(); + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object || !include(row)) + { + continue; + } + + WriteRowWithChanges(writer, row, changes); + } + + writer.WriteEndArray(); + } + + return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); + } + + private static void WriteRowWithChanges( + Utf8JsonWriter writer, JsonElement row, (string Name, int Value)[] changes) + { + writer.WriteStartObject(); + var written = new HashSet(StringComparer.Ordinal); + + foreach (var prop in row.EnumerateObject()) + { + if (ShouldStrip(prop)) + { + continue; + } + + var change = Array.FindIndex(changes, c => string.Equals(c.Name, prop.Name, StringComparison.Ordinal)); + if (change >= 0) + { + writer.WriteNumber(changes[change].Name, changes[change].Value); + written.Add(prop.Name); + continue; + } + + prop.WriteTo(writer); + } + + foreach (var (name, value) in changes) + { + if (written.Add(name)) + { + writer.WriteNumber(name, value); + } + } + + writer.WriteEndObject(); + } + + /// + /// Reads the uid of a row PoracleNG returned, or null when it carries none. + /// + public static int? UidOf(JsonElement row) => + row.ValueKind == JsonValueKind.Object + && row.TryGetProperty("uid", out var uid) + && uid.ValueKind == JsonValueKind.Number + ? uid.GetInt32() + : null; + + /// + /// Adds back every property the stored row carries that the written body does not state, so a + /// single-alarm edit preserves fields the caller had no value for. See . + /// + /// + /// + /// A null in the written body counts as "not stated", not as "clear this". Once the models + /// gained OverrideLocationLabel and OverrideAreas, every serialized alarm carried them + /// as explicit nulls whether or not the caller had read them, and treating that as a value meant an + /// edit wiped the stored override — the exact defect this helper exists to prevent, reintroduced by + /// modelling the field. + /// + /// + /// Clearing is still expressible, and matches what PoracleNG reads as empty: an empty array for + /// override_areas (normalizeOverrideAreas maps it to nil) and an empty string for + /// override_location_label (nullIfEmpty). Same null-versus-empty split the models + /// already use for gym_id. See #730. + /// + /// + public static JsonElement PreserveUnmodelled(JsonElement stored, JsonElement written) + { + if (stored.ValueKind != JsonValueKind.Object || written.ValueKind != JsonValueKind.Object) + { + return written; + } + + var stated = new HashSet(StringComparer.Ordinal); + foreach (var prop in written.EnumerateObject()) + { + if (prop.Value.ValueKind != JsonValueKind.Null) + { + stated.Add(prop.Name); + } + } + + var fromStored = stored.EnumerateObject() + .Where(p => !stated.Contains(p.Name) && !ShouldStrip(p)) + .ToList(); + + if (fromStored.Count == 0) + { + return written; + } + + var supplied = new HashSet(fromStored.Select(p => p.Name), StringComparer.Ordinal); + + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (var prop in written.EnumerateObject()) + { + // The stored row is about to supply this one; writing the null too would duplicate the key. + if (supplied.Contains(prop.Name)) + { + continue; + } + + prop.WriteTo(writer); + } + + foreach (var prop in fromStored) + { + prop.WriteTo(writer); + } + + writer.WriteEndObject(); + } + + return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); + } + /// /// Deserializes a JsonElement array to a typed list using snake_case naming. /// @@ -130,4 +334,17 @@ public static int GetIntProp(this JsonElement el, string name) => public static double GetDoubleProp(this JsonElement el, string name) => el.TryGetProperty(name, out var prop) && prop.TryGetDouble(out var val) ? val : 0.0; + + /// + /// Reads a timestamp PoracleNG may send as null, as an empty string, or not at all. + /// + public static DateTime? GetDateTimePropOrNull(this JsonElement el, string name) => + el.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String + && DateTime.TryParse( + prop.GetString(), + CultureInfo.InvariantCulture, + DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, + out var parsed) + ? parsed + : null; } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleServerProfileService.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleServerProfileService.cs new file mode 100644 index 00000000..2ec0c57a --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleServerProfileService.cs @@ -0,0 +1,149 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Asks PoracleNG what it is and what it can store. +/// +/// +/// +/// Two reads, because they answer different questions. /health gives the release number and +/// PoracleNG's own capability map — which covers bot and template-editor features, and nothing about +/// alarm columns. The applied migration number covers the columns. +/// +/// +/// /health is unauthenticated, so this carries no secret and works even when the API key is +/// wrong — which is itself worth knowing, since "reachable but every write 401s" and "not running at +/// all" look identical from the dashboard otherwise. +/// +/// +public partial class PoracleServerProfileService( + HttpClient httpClient, + IPoracleSchemaVersionReader schemaReader, + IMemoryCache cache, + IConfiguration configuration, + ILogger logger) : IPoracleServerProfileService +{ + private const string CacheKey = "poracle:server-profile"; + + /// + /// Long enough that a dashboard load costs nothing, short enough that an upgrade shows up without a + /// restart. An admin who wants it sooner has the refresh button. + /// + private static readonly TimeSpan CacheFor = TimeSpan.FromMinutes(5); + + private readonly HttpClient _httpClient = httpClient; + private readonly IPoracleSchemaVersionReader _schemaReader = schemaReader; + private readonly IMemoryCache _cache = cache; + private readonly string _apiAddress = configuration["Poracle:ApiAddress"] ?? string.Empty; + private readonly ILogger _logger = logger; + + /// + public async Task GetAsync(CancellationToken cancellationToken = default) + { + if (this._cache.TryGetValue(CacheKey, out PoracleServerProfile? cached) && cached is not null) + { + return cached; + } + + var profile = await this.ProbeAsync(cancellationToken); + this._cache.Set(CacheKey, profile, CacheFor); + + return profile; + } + + /// + public void Invalidate() => this._cache.Remove(CacheKey); + + private async Task ProbeAsync(CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + + if (string.IsNullOrWhiteSpace(this._apiAddress)) + { + return PoracleServerProfile.Unknown(now); + } + + // The schema read is independent of whether PoracleNG answers, and is worth having either way: + // a stopped process still leaves a migrated database behind. + var schemaVersion = await this._schemaReader.GetAppliedMigrationAsync(cancellationToken); + + try + { + using var response = await this._httpClient.GetAsync( + $"{this._apiAddress.TrimEnd('/')}/health", cancellationToken); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var (version, capabilities) = ParseHealth(json); + + return new PoracleServerProfile + { + Version = version, + Capabilities = capabilities, + SchemaVersion = schemaVersion, + Reachable = true, + CheckedAt = now, + }; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + LogProbeFailed(this._logger, this._apiAddress, ex); + + return new PoracleServerProfile + { + SchemaVersion = schemaVersion, + Reachable = false, + CheckedAt = now, + }; + } + } + + /// + /// Pulls the version and the capability map out of the health payload. + /// + /// + /// Every key is read as it comes rather than into a fixed type, because the map is upstream's and it + /// grows: derivedDtsTypes exists on their develop branch and not in any release. A fixed set + /// would silently discard whatever lands next, which is the opposite of what this is for. + /// + private static (string? Version, Dictionary Capabilities) ParseHealth(string json) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + var version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() + : null; + + var capabilities = new Dictionary(StringComparer.Ordinal); + + if (root.TryGetProperty("capabilities", out var caps) && caps.ValueKind == JsonValueKind.Object) + { + foreach (var capability in caps.EnumerateObject()) + { + // Only booleans. Upstream states the map is booleans-only and that anything with shape + // to it lives on its own endpoint, so a non-boolean here is a payload we do not + // understand rather than a feature to guess at. + if (capability.Value.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + capabilities[capability.Name] = capability.Value.GetBoolean(); + } + } + } + + return (version, capabilities); + } + + [LoggerMessage( + EventId = 6102, + Level = LogLevel.Warning, + Message = "Could not read PoracleNG's health at {ApiAddress}. Version-gated features stay off until it answers.")] + private static partial void LogProbeFailed(ILogger logger, string apiAddress, Exception exception); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleSummaryProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleSummaryProxy.cs new file mode 100644 index 00000000..f98e4d17 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleSummaryProxy.cs @@ -0,0 +1,117 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +public class PoracleSummaryProxy(HttpClient httpClient, IConfiguration configuration) : IPoracleSummaryProxy +{ + private readonly HttpClient _httpClient = httpClient; + private readonly string _apiAddress = configuration["Poracle:ApiAddress"] ?? string.Empty; + private readonly string _apiSecret = configuration["Poracle:ApiSecret"] ?? string.Empty; + + /// + /// URL-encodes a path segment for safe path construction. User IDs can be full webhook URLs + /// containing slashes that would break routing; alert types are server-validated but encoded + /// too for defense-in-depth consistency. + /// + private static string Encode(string segment) => Uri.EscapeDataString(segment); + + public async Task GetSchedulesAsync(string userId) + { + var response = await this.SendAsync(HttpMethod.Get, $"/api/summaries/{Encode(userId)}"); + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + throw new SummaryBackendUnavailableException(); + } + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + // PoracleNG wraps the response: { "status": "ok", "schedules": [ ... ] } + return doc.RootElement.TryGetProperty("schedules", out var schedules) ? schedules.Clone() : doc.RootElement.Clone(); + } + + public async Task GetScheduleAsync(string userId, string alertType) + { + var response = await this.SendAsync(HttpMethod.Get, $"/api/summaries/{Encode(userId)}/{Encode(alertType)}"); + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + throw new SummaryBackendUnavailableException(); + } + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (!response.IsSuccessStatusCode) + { + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + // PoracleNG wraps the response: { "status": "ok", "schedule": { ... } } + return doc.RootElement.TryGetProperty("schedule", out var schedule) ? schedule.Clone() : doc.RootElement.Clone(); + } + + public async Task SetScheduleAsync(string userId, string alertType, string activeHoursJson) + { + var body = $"{{\"active_hours\":{(string.IsNullOrWhiteSpace(activeHoursJson) ? "[]" : activeHoursJson)}}}"; + var response = await this.SendAsync(HttpMethod.Post, $"/api/summaries/{Encode(userId)}/{Encode(alertType)}", body); + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + throw new SummaryBackendUnavailableException(); + } + + response.EnsureSuccessStatusCode(); + } + + public async Task DeleteScheduleAsync(string userId, string alertType) + { + var response = await this.SendAsync(HttpMethod.Delete, $"/api/summaries/{Encode(userId)}/{Encode(alertType)}"); + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + throw new SummaryBackendUnavailableException(); + } + + response.EnsureSuccessStatusCode(); + } + + public async Task TriggerAsync(string userId, string alertType) + { + var response = await this.SendAsync(HttpMethod.Post, $"/api/summaries/{Encode(userId)}/{Encode(alertType)}/trigger"); + if (response.StatusCode == HttpStatusCode.ServiceUnavailable) + { + throw new SummaryBackendUnavailableException(); + } + + response.EnsureSuccessStatusCode(); + } + + private async Task SendAsync(HttpMethod method, string path, string? body = null) + { + var request = new HttpRequestMessage(method, $"{this._apiAddress}{path}"); + if (!string.IsNullOrEmpty(this._apiSecret)) + { + request.Headers.Add("X-Poracle-Secret", this._apiSecret); + } + + if (body != null) + { + request.Content = new StringContent(body, Encoding.UTF8, "application/json"); + } + + return await this._httpClient.SendAsync(request); + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleTrackingProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleTrackingProxy.cs index 0bad53e9..989e9c66 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/PoracleTrackingProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleTrackingProxy.cs @@ -1,150 +1,212 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Core.Services; - -public partial class PoracleTrackingProxy( - HttpClient httpClient, - IConfiguration configuration, - ILogger logger) : IPoracleTrackingProxy -{ - private static string Encode(string id) => Uri.EscapeDataString(id); - private readonly HttpClient _httpClient = httpClient; - private readonly string _apiAddress = configuration["Poracle:ApiAddress"] ?? string.Empty; - private readonly string _apiSecret = configuration["Poracle:ApiSecret"] ?? string.Empty; - private readonly ILogger _logger = logger; - - public async Task GetByUserAsync(string type, string userId) - { - var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}"); - var response = await this._httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - - // PoracleNG returns { "pokemon": [...], ... } — extract the array by type key - if (doc.RootElement.TryGetProperty(type, out var array)) - { - return array.Clone(); - } - - return PoracleJsonHelper.EmptyArray; - } - - public async Task CreateAsync(string type, string userId, JsonElement body) - { - var bodyText = body.GetRawText(); - LogCreateRequest(this._logger, type, userId, bodyText); - var request = this.CreateRequest(HttpMethod.Post, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}?silent=true"); - request.Content = new StringContent(bodyText, Encoding.UTF8, "application/json"); - - var response = await this._httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - LogCreateResponse(this._logger, type, userId, json); - using var doc = JsonDocument.Parse(json); - var root = doc.RootElement; - - var newUids = new List(); - if (root.TryGetProperty("newUids", out var uidsEl) && uidsEl.ValueKind == JsonValueKind.Array) - { - foreach (var uid in uidsEl.EnumerateArray()) - { - newUids.Add(uid.GetInt64()); - } - } - - return new TrackingCreateResult( - newUids, - root.TryGetProperty("alreadyPresent", out var ap) ? ap.GetInt32() : 0, - root.TryGetProperty("updates", out var upd) ? upd.GetInt32() : 0, - root.TryGetProperty("insert", out var ins) ? ins.GetInt32() : 0); - } - - public async Task DeleteByUidAsync(string type, string userId, int uid) - { - var request = this.CreateRequest(HttpMethod.Delete, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}/byUid/{uid}"); - var response = await this._httpClient.SendAsync(request); - - if (response.StatusCode == HttpStatusCode.NotFound) - { - LogDeleteNotFound(this._logger, type, uid); - return; - } - - response.EnsureSuccessStatusCode(); - } - - public async Task BulkDeleteByUidsAsync(string type, string userId, IEnumerable uids) - { - var uidList = uids.ToList(); - if (uidList.Count == 0) - { - return; - } - - var request = this.CreateRequest(HttpMethod.Post, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}/delete"); - request.Content = new StringContent( - JsonSerializer.Serialize(uidList.Select(u => (long)u)), - Encoding.UTF8, - "application/json"); - - var response = await this._httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); - } - - public async Task GetAllTrackingAsync(string userId) - { - var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/tracking/all/{Encode(userId)}"); - var response = await this._httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } - - public async Task GetAllTrackingAllProfilesAsync(string userId) - { - var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/tracking/allProfiles/{Encode(userId)}?includeDescriptions=true"); - var response = await this._httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); - - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } - - public async Task ReloadStateAsync() - { - var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/reload"); - var response = await this._httpClient.SendAsync(request); - response.EnsureSuccessStatusCode(); - } - - private HttpRequestMessage CreateRequest(HttpMethod method, string url) - { - var request = new HttpRequestMessage(method, url); - if (!string.IsNullOrEmpty(this._apiSecret)) - { - request.Headers.Add("X-Poracle-Secret", this._apiSecret); - } - - return request; - } - - [LoggerMessage(Level = LogLevel.Debug, Message = "Delete {Type} uid={Uid} returned 404 (already deleted)")] - private static partial void LogDeleteNotFound(ILogger logger, string type, int uid); - - [LoggerMessage(Level = LogLevel.Information, Message = "Create {Type} for {UserId} request: {Body}")] - private static partial void LogCreateRequest(ILogger logger, string type, string userId, string body); - - [LoggerMessage(Level = LogLevel.Information, Message = "Create {Type} for {UserId} response: {Response}")] - private static partial void LogCreateResponse(ILogger logger, string type, string userId, string response); -} +using Pgan.PoracleWebNet.Core.Models; +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +public partial class PoracleTrackingProxy( + HttpClient httpClient, + IConfiguration configuration, + ILogger logger) : IPoracleTrackingProxy +{ + /// + /// PoracleNG answers 404 for a user that no longer exists; that is a dead session, not a server fault. + /// + /// + /// #584 fixed this on the human proxy only, so the alarm lists, dashboard, cleaning and profile + /// overview kept returning 500 for a deleted account and the SPA -- which signs out on 401 -- left the + /// user in a broken app. See #595. + /// + private static void EnsureAccountStillExists(HttpResponseMessage response) + { + if (response.StatusCode == HttpStatusCode.NotFound) + { + throw new AccountGoneException(); + } + } + + private static string Encode(string id) => Uri.EscapeDataString(id); + private readonly HttpClient _httpClient = httpClient; + private readonly string _apiAddress = configuration["Poracle:ApiAddress"] ?? string.Empty; + private readonly string _apiSecret = configuration["Poracle:ApiSecret"] ?? string.Empty; + private readonly ILogger _logger = logger; + + public async Task GetByUserAsync(string type, string userId) + { + var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}"); + var response = await this._httpClient.SendAsync(request); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + // PoracleNG returns { "pokemon": [...], ... } — extract the array by type key + if (doc.RootElement.TryGetProperty(type, out var array)) + { + return array.Clone(); + } + + return PoracleJsonHelper.EmptyArray; + } + + public async Task CreateAsync(string type, string userId, JsonElement body) + { + var bodyText = body.GetRawText(); + LogCreateRequest(this._logger, type, userId, bodyText); + var request = this.CreateRequest(HttpMethod.Post, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}?silent=true"); + request.Content = new StringContent(bodyText, Encoding.UTF8, "application/json"); + + var response = await this._httpClient.SendAsync(request); + + // A 400 from PoracleNG is the caller's problem, not the server's. EnsureSuccessStatusCode threw + // an HttpRequestException that the global handler flattened into 500 "An unexpected error + // occurred", so the user was told the server broke instead of what was wrong with their input, + // and it was logged as a fault. Pass the explanation through as a 400. See #539. + if (response.StatusCode == HttpStatusCode.BadRequest) + { + throw new AlarmValidationException(await ExtractMessageAsync(response)); + } + + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + LogCreateResponse(this._logger, type, userId, json); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var newUids = new List(); + if (root.TryGetProperty("newUids", out var uidsEl) && uidsEl.ValueKind == JsonValueKind.Array) + { + foreach (var uid in uidsEl.EnumerateArray()) + { + newUids.Add(uid.GetInt64()); + } + } + + return new TrackingCreateResult( + newUids, + root.TryGetProperty("alreadyPresent", out var ap) ? ap.GetInt32() : 0, + root.TryGetProperty("updates", out var upd) ? upd.GetInt32() : 0, + root.TryGetProperty("insert", out var ins) ? ins.GetInt32() : 0); + } + + public async Task DeleteByUidAsync(string type, string userId, int uid) + { + var request = this.CreateRequest(HttpMethod.Delete, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}/byUid/{uid}"); + var response = await this._httpClient.SendAsync(request); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + LogDeleteNotFound(this._logger, type, uid); + return; + } + + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task BulkDeleteByUidsAsync(string type, string userId, IEnumerable uids) + { + var uidList = uids.ToList(); + if (uidList.Count == 0) + { + return; + } + + var request = this.CreateRequest(HttpMethod.Post, $"{this._apiAddress}/api/tracking/{type}/{Encode(userId)}/delete"); + request.Content = new StringContent( + JsonSerializer.Serialize(uidList.Select(u => (long)u)), + Encoding.UTF8, + "application/json"); + + var response = await this._httpClient.SendAsync(request); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + public async Task GetAllTrackingAsync(string userId) + { + var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/tracking/all/{Encode(userId)}"); + var response = await this._httpClient.SendAsync(request); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + public async Task GetAllTrackingAllProfilesAsync(string userId) + { + var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/tracking/allProfiles/{Encode(userId)}?includeDescriptions=true"); + var response = await this._httpClient.SendAsync(request); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + public async Task ReloadStateAsync() + { + var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/reload"); + var response = await this._httpClient.SendAsync(request); + EnsureAccountStillExists(response); + response.EnsureSuccessStatusCode(); + } + + /// Reads whatever explanation PoracleNG returned, falling back to something honest. + private static async Task ExtractMessageAsync(HttpResponseMessage response) + { + var body = await response.Content.ReadAsStringAsync(); + + try + { + var root = JsonDocument.Parse(body).RootElement; + foreach (var name in new[] { "message", "error", "status" }) + { + if (root.TryGetProperty(name, out var value) + && value.ValueKind == JsonValueKind.String + && !string.IsNullOrWhiteSpace(value.GetString())) + { + return value.GetString()!; + } + } + } + catch (JsonException) + { + // Not JSON; the raw body is still better than nothing, as long as it is short. + } + + return string.IsNullOrWhiteSpace(body) || body.Length > 300 + ? "Poracle rejected the alarm." + : body; + } + + private HttpRequestMessage CreateRequest(HttpMethod method, string url) + { + var request = new HttpRequestMessage(method, url); + if (!string.IsNullOrEmpty(this._apiSecret)) + { + request.Headers.Add("X-Poracle-Secret", this._apiSecret); + } + + return request; + } + + [LoggerMessage(Level = LogLevel.Debug, Message = "Delete {Type} uid={Uid} returned 404 (already deleted)")] + private static partial void LogDeleteNotFound(ILogger logger, string type, int uid); + + [LoggerMessage(Level = LogLevel.Information, Message = "Create {Type} for {UserId} request: {Body}")] + private static partial void LogCreateRequest(ILogger logger, string type, string userId, string body); + + [LoggerMessage(Level = LogLevel.Information, Message = "Create {Type} for {UserId} response: {Response}")] + private static partial void LogCreateResponse(ILogger logger, string type, string userId, string response); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/ProfileOverviewService.cs b/Core/Pgan.PoracleWebNet.Core.Services/ProfileOverviewService.cs index 1feb7e86..e170565c 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/ProfileOverviewService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/ProfileOverviewService.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -53,8 +55,20 @@ public async Task DuplicateProfileAsync(string userId, int sourceProfileNo, continue; } - // Strip uid so PoracleNG creates a new alarm instead of updating - var cleaned = PoracleJsonHelper.StripProperty(alarm, "uid"); + // Strip uid so PoracleNG creates a new alarm instead of updating -- and profile_no + // with it. PoracleNG takes a submitted profile_no at face value (#411), and the + // copy carries the SOURCE profile's, so every pokemon alarm was written back onto + // the profile being copied FROM: the new profile came up with no Pokemon tracking + // and the source quietly gained a duplicate on every run. Import has stripped these + // since #465; duplicate never did. See #576. + var cleaned = alarm; + foreach (var owned in ImportIgnoredFields) + { + if (cleaned.TryGetProperty(owned, out _)) + { + cleaned = PoracleJsonHelper.StripProperty(cleaned, owned); + } + } await this._trackingProxy.CreateAsync(type, userId, cleaned); totalCreated++; } @@ -69,6 +83,12 @@ public async Task DuplicateProfileAsync(string userId, int sourceProfileNo, return totalCreated; } + /// + /// Fields an imported alarm may not dictate: they identify the owner and the profile, which are + /// determined by who is importing and where. See #465. + /// + private static readonly string[] ImportIgnoredFields = ["uid", "profile_no", "id"]; + public async Task ImportAlarmsAsync(string userId, int targetProfileNo, JsonElement alarms) { // Pre-validate the import payload before any state mutation. See DuplicateProfileAsync above @@ -78,6 +98,12 @@ public async Task ImportAlarmsAsync(string userId, int targetProfileNo, Jso var humanJson = await this._humanProxy.GetHumanAsync(userId); var originalProfileNo = humanJson?.GetIntProp("current_profile_no") ?? 1; + // Checked before anything is written, and before the profile switch: this is the one write path + // that never ran the alarm models' own rules, so a hand-edited backup could persist a distance of + // -77, an IV window of -999 to 500 or a clean value of 99 -- every one of which the matching POST + // refuses with a 400. A partial import is worse than a refused one. See #548. + EnsureAlarmsAreValid(alarms); + await this._humanProxy.SwitchProfileAsync(userId, targetProfileNo); var totalCreated = 0; @@ -93,10 +119,19 @@ public async Task ImportAlarmsAsync(string userId, int targetProfileNo, Jso foreach (var alarm in alarmsArray.EnumerateArray()) { - // Strip uid defensively — export removes it client-side but manually edited backups may include it - var cleaned = alarm.TryGetProperty("uid", out _) - ? PoracleJsonHelper.StripProperty(alarm, "uid") - : alarm; + // The file decides none of these. PoracleNG takes a submitted profile_no at face + // value (#411), so an alarm carrying profile_no: 7 landed on profile 7 instead of + // the profile just created -- an orphan that a future profile 7 would inherit. id + // is stripped for the same reason: it names the owner, and the URL already does + // that. uid is stripped because a hand-edited backup may still carry one. See #465. + var cleaned = alarm; + foreach (var owned in ImportIgnoredFields) + { + if (cleaned.TryGetProperty(owned, out _)) + { + cleaned = PoracleJsonHelper.StripProperty(cleaned, owned); + } + } await this._trackingProxy.CreateAsync(type, userId, cleaned); totalCreated++; } @@ -110,6 +145,100 @@ public async Task ImportAlarmsAsync(string userId, int targetProfileNo, Jso return totalCreated; } + + /// + /// Runs each imported alarm through the same model rules a direct POST would. + /// + /// + /// Deserializing into the *Create model applies its [Range] and [StringLength] attributes; the monster + /// pair check catches windows nothing can satisfy. A file that fails is refused whole, so the caller + /// never ends up with half a profile. See #548. + /// + private static void EnsureAlarmsAreValid(JsonElement alarms) + { + if (alarms.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (var type in AlarmTypes) + { + if (!alarms.TryGetProperty(type, out var alarmsArray) + || alarmsArray.ValueKind != JsonValueKind.Array) + { + continue; + } + + var index = 0; + foreach (var alarm in alarmsArray.EnumerateArray()) + { + index++; + ValidateAlarm(type, alarm, index); + } + } + } + + private static void ValidateAlarm(string type, JsonElement alarm, int index) + { + object? model; + try + { + model = type switch + { + "pokemon" => Deserialize(alarm), + "raid" => Deserialize(alarm), + "egg" => Deserialize(alarm), + "quest" => Deserialize(alarm), + "invasion" => Deserialize(alarm), + "lure" => Deserialize(alarm), + "nest" => Deserialize(alarm), + "gym" => Deserialize(alarm), + "fort" => Deserialize(alarm), + "maxbattle" => Deserialize(alarm), + _ => null, + }; + } + catch (JsonException ex) + { + throw new AlarmValidationException( + $"{type} alarm {index} in this file could not be read: {ex.Message}"); + } + + if (model is null) + { + return; + } + + var results = new List(); + if (!Validator.TryValidateObject(model, new ValidationContext(model), results, validateAllProperties: true)) + { + throw new AlarmValidationException( + $"{type} alarm {index} in this file is not valid: {results[0].ErrorMessage}"); + } + + // The [Range] and [StringLength] attributes live on the *Create DTOs -- the domain models carry + // none, so validating those found nothing and the import sailed through. The cross-field check + // wants the domain model, so the same JSON is read a second time; Core.Services does not + // reference Core.Mappings and one import is not worth a new project dependency. See #548. + if (model is MonsterCreate) + { + var monster = Deserialize(alarm) ?? new Monster(); + var inverted = MonsterRangeValidator.Validate(monster); + if (inverted is not null) + { + throw new AlarmValidationException($"{type} alarm {index} in this file is impossible: {inverted}"); + } + } + } + + private static T? Deserialize(JsonElement alarm) => + JsonSerializer.Deserialize(alarm.GetRawText(), SnakeCaseOptions); + + private static readonly JsonSerializerOptions SnakeCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; public async Task GetAllProfilesOverviewAsync(string userId) => await this._trackingProxy.GetAllTrackingAllProfilesAsync(userId); /// diff --git a/Core/Pgan.PoracleWebNet.Core.Services/ProfileService.cs b/Core/Pgan.PoracleWebNet.Core.Services/ProfileService.cs index a1096e2b..730f577a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/ProfileService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/ProfileService.cs @@ -1,82 +1,130 @@ -using System.Text.Json; - -using Pgan.PoracleWebNet.Core.Abstractions.Repositories; -using Pgan.PoracleWebNet.Core.Abstractions.Services; -using Pgan.PoracleWebNet.Core.Models; - -namespace Pgan.PoracleWebNet.Core.Services; - -/// -/// Proxy-first service for profile reads. Create/Update/Delete are already proxied by -/// ProfileController via IPoracleHumanProxy; this service provides reads for -/// LocationController, UserGeofenceService, and ProfileController.GetAll. -/// IProfileRepository is kept for non-active profile operations in UserGeofenceService. -/// -public class ProfileService( - IProfileRepository repository, - IPoracleHumanProxy humanProxy) : IProfileService -{ - private readonly IProfileRepository _repository = repository; - private readonly IPoracleHumanProxy _humanProxy = humanProxy; - - public async Task> GetByUserAsync(string userId) - { - var json = await this._humanProxy.GetProfilesAsync(userId); - return DeserializeProfiles(json); - } - - public async Task GetByUserAndProfileNoAsync(string userId, int profileNo) - { - var json = await this._humanProxy.GetProfilesAsync(userId); - var profiles = DeserializeProfiles(json); - return profiles.Find(p => p.ProfileNo == profileNo); - } - - public async Task CreateAsync(Profile profile) => await this._repository.CreateAsync(profile); - - public async Task UpdateAsync(Profile profile) => await this._repository.UpdateAsync(profile); - - public async Task DeleteAsync(string userId, int profileNo) => await this._repository.DeleteAsync(userId, profileNo); - - public async Task CopyAsync(string userId, int fromProfileNo, int toProfileNo) => - await this._humanProxy.CopyProfileAsync(userId, fromProfileNo, toProfileNo); - - /// - /// Deserializes the PoracleNG profiles response. - /// PoracleNG wraps the array: { "profile": [...], "status": "ok" } - /// - private static List DeserializeProfiles(JsonElement json) - { - JsonElement profileArray; - - if (json.TryGetProperty("profile", out var arr) && arr.ValueKind == JsonValueKind.Array) - { - profileArray = arr; - } - else if (json.ValueKind == JsonValueKind.Array) - { - profileArray = json; - } - else - { - return []; - } - - var profiles = new List(); - foreach (var item in profileArray.EnumerateArray()) - { - profiles.Add(new Profile - { - Id = item.GetStringProp("id"), - ProfileNo = item.GetIntProp("profile_no"), - Name = item.GetStringPropOrNull("name"), - Area = item.GetStringPropOrNull("area") ?? "[]", - Latitude = item.GetDoubleProp("latitude"), - Longitude = item.GetDoubleProp("longitude"), - ActiveHours = item.GetStringPropOrNull("active_hours"), - }); - } - - return profiles; - } -} +using System.Text.Json; + +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Proxy-first service for profile reads. Create/Update/Delete are already proxied by +/// ProfileController via IPoracleHumanProxy; this service provides reads for +/// LocationController, UserGeofenceService, and ProfileController.GetAll. +/// IProfileRepository is kept for non-active profile operations in UserGeofenceService. +/// +public class ProfileService( + IProfileRepository repository, + IPoracleHumanProxy humanProxy) : IProfileService +{ + private readonly IProfileRepository _repository = repository; + private readonly IPoracleHumanProxy _humanProxy = humanProxy; + + public async Task> GetByUserAsync(string userId) + { + var json = await this._humanProxy.GetProfilesAsync(userId); + var profiles = DeserializeProfiles(json); + + return await this.WithActiveProfileAsync(userId, profiles); + } + + /// + /// Guarantees the profile the user is actually on appears in the list. + /// + /// + /// PoracleNG only materialises a profiles row when something writes one, so an account that has + /// never renamed or added a profile has none -- while humans.current_profile_no still points at + /// one and alarms hang off it. The Profiles and Profile Overview pages then rendered "no alarms across + /// any profiles" over a full set of alarms. Synthesised rather than written, so this stays a read. + /// See #582. + /// + private async Task> WithActiveProfileAsync(string userId, List profiles) + { + JsonElement? human; + try + { + human = await this._humanProxy.GetHumanAsync(userId); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return profiles; + } + + if (human is not { } record) + { + return profiles; + } + + var activeProfileNo = record.GetIntProp("current_profile_no"); + if (profiles.Exists(p => p.ProfileNo == activeProfileNo)) + { + return profiles; + } + + profiles.Add(new Profile + { + Id = userId, + ProfileNo = activeProfileNo, + Name = "Default", + Area = record.GetStringPropOrNull("area") ?? "[]", + Latitude = record.GetDoubleProp("latitude"), + Longitude = record.GetDoubleProp("longitude"), + }); + + return [.. profiles.OrderBy(p => p.ProfileNo)]; + } + + public async Task GetByUserAndProfileNoAsync(string userId, int profileNo) + { + var json = await this._humanProxy.GetProfilesAsync(userId); + var profiles = DeserializeProfiles(json); + return profiles.Find(p => p.ProfileNo == profileNo); + } + + public async Task CreateAsync(Profile profile) => await this._repository.CreateAsync(profile); + + public async Task UpdateAsync(Profile profile) => await this._repository.UpdateAsync(profile); + + public async Task DeleteAsync(string userId, int profileNo) => await this._repository.DeleteAsync(userId, profileNo); + + public async Task CopyAsync(string userId, int fromProfileNo, int toProfileNo) => + await this._humanProxy.CopyProfileAsync(userId, fromProfileNo, toProfileNo); + + /// + /// Deserializes the PoracleNG profiles response. + /// PoracleNG wraps the array: { "profile": [...], "status": "ok" } + /// + private static List DeserializeProfiles(JsonElement json) + { + JsonElement profileArray; + + if (json.TryGetProperty("profile", out var arr) && arr.ValueKind == JsonValueKind.Array) + { + profileArray = arr; + } + else if (json.ValueKind == JsonValueKind.Array) + { + profileArray = json; + } + else + { + return []; + } + + var profiles = new List(); + foreach (var item in profileArray.EnumerateArray()) + { + profiles.Add(new Profile + { + Id = item.GetStringProp("id"), + ProfileNo = item.GetIntProp("profile_no"), + Name = item.GetStringPropOrNull("name"), + Area = item.GetStringPropOrNull("area") ?? "[]", + Latitude = item.GetDoubleProp("latitude"), + Longitude = item.GetDoubleProp("longitude"), + ActiveHours = item.GetStringPropOrNull("active_hours"), + }); + } + + return profiles; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs b/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs index ab58e8a4..572e8ef2 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/QuestService.cs @@ -1,14 +1,17 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; -public class QuestService(IPoracleTrackingProxy proxy, IFeatureGate featureGate) : IQuestService +public class QuestService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IQuestService { private const string TrackingType = "quest"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -27,6 +30,11 @@ public async Task CreateAsync(string userId, Quest model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Quests); model.Id = userId; + + // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: + // 201 Created, and the user quietly loses the one they had. See #561. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, 0, SerializeToElement(model)); var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); @@ -41,8 +49,25 @@ public async Task CreateAsync(string userId, Quest model) public async Task UpdateAsync(string userId, Quest model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Quests); + var oldUid = model.Uid; var body = SerializeToElement(model); - await this._proxy.CreateAsync(TrackingType, userId, body); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Refuse before writing: PoracleNG would satisfy this by merging into the other alarm and + // the reconciler would then delete this one, losing a row the user never touched. See #531. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, oldUid, body); + + var result = await this._proxy.CreateAsync(TrackingType, userId, body); + + // PoracleNG inserts instead of upserting when the edit changes a dedup-key field, + // leaving the pre-edit row behind as a duplicate. Drop it and report the surviving uid. + model.Uid = await TrackingUpdateReconciler.ReconcileAsync( + this._proxy, TrackingType, userId, oldUid, result, this._logger, body, this._uidRemapper); + return model; } @@ -70,43 +95,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) diff --git a/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs b/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs index 5a4f6b90..c1afd7c3 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using System.Text.Json; using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Repositories; @@ -19,9 +20,11 @@ public partial class QuickPickService( IGymService gymService, IMaxBattleService maxBattleService, IMasterDataService masterDataService, + IFeatureGate featureGate, ILogger logger) : IQuickPickService { private readonly IQuickPickDefinitionRepository _definitionRepository = definitionRepository; + private readonly IFeatureGate _featureGate = featureGate; private readonly IQuickPickAppliedStateRepository _appliedStateRepository = appliedStateRepository; private readonly IMonsterService _monsterService = monsterService; private readonly IRaidService _raidService = raidService; @@ -48,7 +51,7 @@ public partial class QuickPickService( { "minIv", "maxIv", "minCp", "maxCp", "minLevel", "maxLevel", "minWeight", "maxWeight", "atk", "def", "sta", "maxAtk", "maxDef", "maxSta", - "pvpRankingWorst", "pvpRankingBest", "pvpRankingMinCp", "pvpRankingLeague", + "pvpRankingWorst", "pvpRankingBest", "pvpRankingMinCp", "pvpRankingLeague", "pvpRankingCap", "size", "maxSize", "form", "gender", "clean", "template", "distance", "ping", }; @@ -66,17 +69,33 @@ public async Task> GetAllAsync(string userId, int foreach (var definition in allDefinitions) { - if (!definition.Enabled) + var appliedState = await this._appliedStateRepository.GetAsync(userId, profileNo, definition.Id); + + // A disabled pick used to be skipped before the applied state was even looked up, so a pick + // the caller had already applied vanished from the list the moment it was disabled -- while + // its alarms stayed. This page is the only place Remove exists, so the user was left with + // alarms they could not un-apply and nothing to say where they came from; an admin disabling + // a global pick did that to everyone who had applied it. Disabled means "cannot be applied", + // not "cannot be undone", so it stays listed while it still owns something. See #508. + if (!definition.Enabled && appliedState is null) { continue; } - var appliedState = await this._appliedStateRepository.GetAsync(userId, profileNo, definition.Id); - // Verify tracked alarms still exist — if all deleted manually, clear applied state if (appliedState?.TrackedUids is { Count: > 0 }) { - var remaining = await this.CountRemainingUidsAsync(userId, definition.AlarmType, appliedState.TrackedUids); + // Against the type the alarms were CREATED as, not the definition's current one. Editing + // an applied pick's alarm type -- a plain dropdown in the edit dialog -- made this look + // the monster uids up among the raids, find none, conclude the user had deleted them all + // and drop the applied state. The alarms stayed behind with nothing owning them and no + // Remove button, because the card then read as never applied. RemoveAsync already keys + // off the stored type for exactly this reason. See #541. + var trackedType = string.IsNullOrEmpty(appliedState.AlarmType) + ? definition.AlarmType + : appliedState.AlarmType; + + var remaining = await this.CountRemainingUidsAsync(userId, trackedType, appliedState.TrackedUids); if (remaining == 0) { // All alarms were deleted manually — clean up stale applied state @@ -86,7 +105,7 @@ public async Task> GetAllAsync(string userId, int else if (remaining < appliedState.TrackedUids.Count) { // Some alarms were deleted — update the tracked UIDs to only valid ones - appliedState.TrackedUids = await this.GetValidUidsAsync(userId, definition.AlarmType, appliedState.TrackedUids); + appliedState.TrackedUids = await this.GetValidUidsAsync(userId, trackedType, appliedState.TrackedUids); await this._appliedStateRepository.CreateOrUpdateAsync(appliedState); } } @@ -103,8 +122,69 @@ public async Task> GetAllAsync(string userId, int public async Task GetByIdAsync(string id) => await this._definitionRepository.GetByIdAsync(id); - public async Task SaveAdminPickAsync(QuickPickDefinition definition) + /// + /// Refuses a definition whose filters the alarm endpoints would reject. + /// + /// + /// Apply validates the alarm it builds (#565), but the definition itself was never checked, so an + /// admin could save a global pick holding a value no alarm accepts -- a PVP league of 1000, say -- and + /// every user who applied it got the refusal instead. Failing at save time puts the error in front of + /// the person who can fix it. See #604. + /// + private static void EnsureFiltersAreUsable(QuickPickDefinition definition) { + // Nothing to check, and checking anyway broke seeding. Two built-ins -- all-invasions and + // invasion-leader -- carry no filters on purpose because ApplyInvasionAsync fans them out across + // grunt types at apply time, so the sample alarm built here has no grunt_type and the Create DTO + // requires one. SeedDefaultsAsync goes through this method, so it threw partway and left a + // partial preset list behind both entry points that call it. Apply-time validation (#565) still + // covers the alarm that actually gets built. See #637. + if (definition.Filters is null || definition.Filters.Count == 0) + { + return; + } + + try + { + BuildSampleAlarm(definition, 0, new QuickPickApplyRequest()); + } + catch (AlarmValidationException ex) + { + throw new AlarmValidationException( + $"This quick pick cannot be saved: {ex.Message}"); + } + } + /// + /// Returns the pick only if is allowed to see it: global picks are public, + /// user picks are visible to their owner alone. + /// + public async Task GetVisibleByIdAsync(string userId, string id) => + await this.LoadDefinitionAsync(userId, id); + + public async Task SaveAdminPickAsync(QuickPickDefinition definition, bool isSeeding = false) + { + EnsureFiltersAreUsable(definition); + + definition.Id = await this.EnsureIdAsync(definition); + + // Given an id, this used to convert whatever it found into a global pick -- including a private + // one belonging to somebody else, which then appeared for every user and vanished from its + // owner's list. SaveUserPickAsync has always had this guard. See #631. + // + // Skipped when seeding. SeedDefaultsAsync creates the built-ins through this method, so a + // user-scoped pick that happens to hold a built-in id aborted the seed partway -- the same + // partial-preset-list failure #637 fixed, reintroduced by the guard in the same commit. See #659. + if (!isSeeding) + { + var existing = await this._definitionRepository.GetByIdAsync(definition.Id); + if (existing is not null + && !string.Equals(existing.Scope, "global", StringComparison.OrdinalIgnoreCase)) + { + throw new AlarmValidationException( + "That quick pick belongs to a user. Publish a copy instead of converting theirs."); + } + } + definition.Scope = "global"; definition.OwnerUserId = null; @@ -113,8 +193,95 @@ public async Task SaveAdminPickAsync(QuickPickDefinition de return definition; } + /// + /// Returns the definition's id, generating a slug from its name when the caller supplied none. + /// + /// The create dialog has no id field and sends "", which the repository stored verbatim. Every + /// id-bearing route then collapsed to /api/quick-picks/ and could not match: delete returned 405, + /// apply returned 404, and the pick could not be removed through any API path. + /// + /// + private async Task EnsureIdAsync(QuickPickDefinition definition) + { + if (!string.IsNullOrWhiteSpace(definition.Id)) + { + return definition.Id; + } + + var slug = Slugify(definition.Name); + if (slug.Length == 0) + { + slug = "quick-pick"; + } + + // The id column is 50 characters and the name column is 200, so a perfectly legal name produced + // an id that could not be stored and the create came back as an opaque 500. Room is left for the + // longest suffix the collision loop can append. See #555. + slug = Truncate(slug, MaxIdLength - SuffixAllowance); + + // Names are not unique, so settle collisions with a counter before falling back to a guid. + var candidate = slug; + for (var attempt = 2; attempt <= 50; attempt++) + { + if (await this._definitionRepository.GetByIdAsync(candidate) is null) + { + return candidate; + } + + candidate = $"{slug}-{attempt}"; + } + + return Truncate($"{slug}-{Guid.NewGuid():N}", MaxIdLength); + } + + /// The quick_pick_definitions.id column width. + private const int MaxIdLength = 50; + + /// Room for the "-2".."-50" the collision loop appends, and for a guid tail if it gets there. + private const int SuffixAllowance = 4; + + private static string Truncate(string value, int maxLength) => + value.Length <= maxLength ? value : value[..maxLength].TrimEnd('-'); + + private static string Slugify(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return string.Empty; + } + + var chars = name.Trim().ToLowerInvariant() + .Select(c => char.IsLetterOrDigit(c) ? c : '-'); + + // Collapse runs of separators so "Hundo IV!" becomes "hundo-iv". + var slug = string.Concat(chars); + while (slug.Contains("--", StringComparison.Ordinal)) + { + slug = slug.Replace("--", "-", StringComparison.Ordinal); + } + + return slug.Trim('-'); + } + public async Task SaveUserPickAsync(string userId, QuickPickDefinition definition) { + EnsureFiltersAreUsable(definition); + + // CreateOrUpdateAsync upserts on Id alone, and the Id arrives from the request body. Without this + // check a user could post a global pick's well-known Id (hundo, nundo, raid-5star, ...) and the + // upsert would rewrite that row -- flipping it to scope=user under their ownership and removing it + // from every other user's list. The same applies to another user's private pick. + if (!string.IsNullOrEmpty(definition.Id)) + { + var existing = await this._definitionRepository.GetByIdAsync(definition.Id); + if (existing != null && !IsOwnedBy(existing, userId)) + { + throw new UnauthorizedAccessException( + $"Quick pick '{definition.Id}' already exists and is not yours."); + } + } + + definition.Id = await this.EnsureIdAsync(definition); definition.Scope = "user"; definition.OwnerUserId = userId; @@ -123,6 +290,11 @@ public async Task SaveUserPickAsync(string userId, QuickPic return definition; } + /// A pick is the caller's only when it is user-scoped and owned by them. + private static bool IsOwnedBy(QuickPickDefinition definition, string userId) => + !string.Equals(definition.Scope, "global", StringComparison.OrdinalIgnoreCase) + && string.Equals(definition.OwnerUserId, userId, StringComparison.Ordinal); + public async Task DeleteAdminPickAsync(string id) { var existing = await this._definitionRepository.GetByIdAsync(id); @@ -132,6 +304,9 @@ public async Task DeleteAdminPickAsync(string id) } await this._definitionRepository.DeleteAsync(id); + + // A global pick can be applied by anyone, so every user's state for it goes with it. See #470. + await this._appliedStateRepository.DeleteByQuickPickIdAsync(id); return true; } @@ -144,6 +319,9 @@ public async Task DeleteUserPickAsync(string userId, string id) } await this._definitionRepository.DeleteByIdAndOwnerAsync(id, userId); + + // Only the owner can apply a user-scoped pick, so only their state exists to clear. See #470. + await this._appliedStateRepository.DeleteByQuickPickIdAsync(id, userId); return true; } @@ -152,7 +330,35 @@ public async Task ApplyAsync( { var definition = await this.LoadDefinitionAsync(userId, quickPickId) ?? throw new InvalidOperationException($"Quick pick '{quickPickId}' not found."); - var trackedUids = definition.AlarmType switch + // A disabled pick now stays listed while it still owns alarms, so that Remove remains reachable + // (#508). It must not be appliable from there. + if (!definition.Enabled) + { + throw new AlarmValidationException("That quick pick is disabled and cannot be applied."); + } + + // Applying a pick whose alarm type changed since it was applied would strand the alarms it made + // under the old type: the new applied state records the new type, and Remove -- which keys off + // the stored type -- can never reach them again. Refuse rather than strand, and say what to do. + // Re-apply is the supported way through, because it removes the old alarms first. See #557. + var existingState = await this._appliedStateRepository.GetAsync(userId, profileNo, quickPickId); + if (existingState is not null + && existingState.TrackedUids.Count > 0 + && !string.Equals(existingState.AlarmType, definition.AlarmType, StringComparison.OrdinalIgnoreCase)) + { + throw new AlarmValidationException( + "This quick pick still owns alarms of a different type. Remove it first, then apply it again."); + } + + // Snapshot first: the tracked set is what this apply ADDED, not what the create calls + // reported. PoracleNG hands back the existing row's uid when a pick matches an alarm the + // user built by hand, so trusting the reported uid made the pick adopt that alarm - and + // Remove then deleted it. It also reports no uid at all for an exact duplicate, which + // recorded a tracked uid of 0 that no lookup could resolve, so the applied state was wiped + // on the next page load. Diffing sidesteps both. See #468, #469. + var existingUids = await this.ExistingUidsAsync(userId, profileNo, definition.AlarmType); + + var reportedUids = definition.AlarmType switch { "monster" => await this.ApplyMonsterAsync(userId, profileNo, definition, request), "raid" => await this.ApplyRaidAsync(userId, profileNo, definition, request), @@ -165,6 +371,33 @@ public async Task ApplyAsync( "maxbattle" => await this.ApplyMaxBattleAsync(userId, profileNo, definition, request), _ => throw new InvalidOperationException($"Unknown alarm type '{definition.AlarmType}'."), }; + + var afterUids = await this.ExistingUidsAsync(userId, profileNo, definition.AlarmType); + var addedUids = afterUids.Except(existingUids).ToList(); + var displacedUids = existingUids.Except(afterUids).ToList(); + + // A uid that appeared is not automatically ours. When a pick matches an alarm the user built by + // hand, PoracleNG does not add a row - it RE-KEYS theirs, so the old uid disappears and a new one + // appears and the diff looks identical to a creation. Removing the pick then deleted the user's + // alarm. If anything was displaced we claim nothing: an untracked leftover is recoverable by + // hand, a deleted alarm is not. See #469. + var trackedUids = displacedUids.Count == 0 ? addedUids : []; + + if (displacedUids.Count > 0) + { + LogQuickPickDisplaced(this._logger, quickPickId, displacedUids.Count, addedUids.Count); + } + + LogQuickPickTracking(this._logger, quickPickId, reportedUids.Count, trackedUids.Count); + + // Applying an already-applied pick adds nothing, because PoracleNG dedups -- so the diff is + // empty and writing it verbatim handed the pick an empty tracked list while its alarms were + // still there. Remove then answered 204 and deleted nothing, and the alarms were left with no + // way to attribute them. Keep what the pick already owned and add whatever this run created. + // See #542. + var previouslyTracked = await this._appliedStateRepository.GetAsync(userId, profileNo, quickPickId); + var stillOwned = previouslyTracked?.TrackedUids?.Where(afterUids.Contains) ?? []; + var appliedState = new QuickPickAppliedState { UserId = userId, @@ -173,7 +406,7 @@ public async Task ApplyAsync( AlarmType = definition.AlarmType, AppliedAt = DateTime.UtcNow, ExcludePokemonIds = request.ExcludePokemonIds, - TrackedUids = trackedUids + TrackedUids = [.. stillOwned.Union(trackedUids)], }; await this._appliedStateRepository.CreateOrUpdateAsync(appliedState); @@ -183,13 +416,136 @@ public async Task ApplyAsync( return appliedState; } + /// + /// The uids the user currently holds for an alarm type. Used either side of an apply so the + /// pick claims only the rows it actually created. + /// + private async Task> ExistingUidsAsync(string userId, int profileNo, string alarmType) => alarmType switch + { + "monster" => [.. (await this._monsterService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "raid" => [.. (await this._raidService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "egg" => [.. (await this._eggService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "quest" => [.. (await this._questService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "invasion" => [.. (await this._invasionService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "lure" => [.. (await this._lureService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "nest" => [.. (await this._nestService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "gym" => [.. (await this._gymService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + "maxbattle" => [.. (await this._maxBattleService.GetByUserAsync(userId, profileNo)).Select(x => x.Uid)], + _ => [], + }; + public async Task ReapplyAsync( string userId, int profileNo, string quickPickId, QuickPickApplyRequest request) { + // Remove used to run first unconditionally. Once apply grew guards -- a disabled pick, a filter + // the alarm model refuses, an alarm type an admin has switched off -- a refused re-apply + // destroyed the alarms and created nothing in their place, while the error read as "nothing + // happened". The pick also dropped off the list, taking the Remove button with it. Everything + // that can refuse this is checked BEFORE anything is deleted. See #531. + await this.EnsureApplicableAsync(userId, profileNo, quickPickId, request); + await this.RemoveAsync(userId, profileNo, quickPickId); return await this.ApplyAsync(userId, profileNo, quickPickId, request); } + /// + /// Runs everything that can refuse an apply, without writing anything. + /// + /// + /// Builds the alarms the apply would build and validates them, which is where an impossible filter + /// surfaces. Building is side-effect free, so this is a genuine dry run rather than a partial apply. + /// + private async Task EnsureApplicableAsync( + string userId, int profileNo, string quickPickId, QuickPickApplyRequest request) + { + var definition = await this.LoadDefinitionAsync(userId, quickPickId) + ?? throw new InvalidOperationException($"Quick pick '{quickPickId}' not found."); + + if (!definition.Enabled) + { + throw new AlarmValidationException("That quick pick is disabled and cannot be applied."); + } + + // The same gate the alarm services apply, so a type an admin has switched off refuses here + // rather than half-way through, after the deletes. + var disableKey = DisableFeatureKeys.ByTrackingType.TryGetValue(definition.AlarmType, out var key) + ? key + : null; + if (disableKey is not null) + { + await this._featureGate.EnsureEnabledAsync(disableKey); + } + + // The alarm-type guard runs here too, not just in ApplyAsync. Re-apply deletes before it applies, + // so a pick whose type changed since it was applied lost its alarms AND its applied state before + // the refusal ever fired -- the deletes are committed and the error reads as "nothing happened". + // See #579. + var applied = await this._appliedStateRepository.GetAsync(userId, profileNo, quickPickId); + if (applied is not null + && applied.TrackedUids.Count > 0 + && !string.Equals(applied.AlarmType, definition.AlarmType, StringComparison.OrdinalIgnoreCase)) + { + throw new AlarmValidationException( + "This quick pick still owns alarms of a different type. Remove it first, then apply it again."); + } + + // Building throws for a filter the alarm model refuses. Nothing is sent anywhere. + BuildSampleAlarm(definition, profileNo, request); + } + + /// Builds one alarm of the definition's type purely to run its validation. + /// Deserializes a definition's filters onto its model and validates them, writing nothing. + private static void BuildFromFilters(Dictionary filters) + where T : new() + { + var json = JsonSerializer.Serialize(filters, JsonOptions); + var alarm = JsonSerializer.Deserialize(json, JsonOptions) ?? new T(); + EnsureValidAlarm(alarm!); + } + + private static void BuildSampleAlarm( + QuickPickDefinition definition, int profileNo, QuickPickApplyRequest request) + { + switch (definition.AlarmType) + { + case "monster": + BuildMonster(definition.Filters, 1, profileNo, request); + break; + case "raid": + BuildRaid(definition.Filters, profileNo, request); + break; + case "maxbattle": + BuildMaxBattle(definition.Filters, profileNo, request); + break; + // The rest deserialize their filters straight onto the model. That IS validated on the way + // through -- but only after RemoveAsync has already deleted the alarms, so a re-apply of a pick + // with a bad filter destroyed them and created nothing. And a definition holding such a filter + // could still be SAVED, because the save check runs the same dry run. Six of the nine types + // fell through here. See #607, #608. + case "egg": + BuildFromFilters(definition.Filters); + break; + case "quest": + BuildFromFilters(definition.Filters); + break; + case "invasion": + BuildFromFilters(definition.Filters); + break; + case "lure": + BuildFromFilters(definition.Filters); + break; + case "nest": + BuildFromFilters(definition.Filters); + break; + case "gym": + BuildFromFilters(definition.Filters); + break; + default: + // An alarm type this build does not know. Apply will fail on it anyway. + break; + } + } + public async Task RemoveAsync(string userId, int profileNo, string quickPickId) { var appliedState = await this._appliedStateRepository.GetAsync(userId, profileNo, quickPickId); @@ -255,13 +611,23 @@ public async Task SeedDefaultsAsync() var existingGlobal = await this._definitionRepository.GetAllGlobalAsync(); var existingCount = existingGlobal.Count; + // Applied state goes with the definitions it belongs to, exactly as DeleteAdminPickAsync does + // (#470). Without this, every user kept a row pointing at a definition that no longer exists: + // GetAllAsync iterates definitions, so the state was never listed and never cleaned, and the + // alarms it owned lost their Remove button for good. Worse, a later pick generating a colliding + // slug re-attached that state, and its trackedUids then named unrelated alarms. See #630. + foreach (var stale in existingGlobal) + { + await this._appliedStateRepository.DeleteByQuickPickIdAsync(stale.Id); + } + await this._definitionRepository.DeleteAllGlobalAsync(); LogSeedingDefaults(this._logger, Defaults.Count, existingCount); foreach (var definition in Defaults) { - await this.SaveAdminPickAsync(definition); + await this.SaveAdminPickAsync(definition, isSeeding: true); } } @@ -269,14 +635,16 @@ public async Task SeedDefaultsAsync() private async Task LoadDefinitionAsync(string userId, string quickPickId) { - // Check global picks first + // Global picks are readable by everyone. The unscoped lookup returns rows of ANY scope, so it must + // be narrowed to global here -- otherwise applying another user's private pick succeeds and creates + // real alarms from filters the caller was never allowed to see. var definition = await this._definitionRepository.GetByIdAsync(quickPickId); - if (definition != null) + if (definition != null && string.Equals(definition.Scope, "global", StringComparison.OrdinalIgnoreCase)) { return definition; } - // Check user picks + // Otherwise it must be the caller's own pick. return await this._definitionRepository.GetByIdAndOwnerAsync(quickPickId, userId); } @@ -329,6 +697,66 @@ private async Task> ApplyMonsterAsync( } } + /// + /// Runs the checks a model-bound POST would have run on an alarm built from quick-pick filters. + /// + /// + /// Applying a pick builds the alarm in code and hands it straight to the alarm service, so none of the + /// DataAnnotations that ASP.NET applies to a bound request ever ran: values POST /api/monsters refuses + /// with a 400 -- minIv 500, say -- were persisted verbatim, producing an alarm that can never match + /// anything and notifies nothing, with no error anywhere. A quick pick is not a side door around the + /// model's own rules. See #507. + /// + private static void EnsureValidAlarm(object alarm) + { + // Against the *Create DTO, because that is where the [Range] and [StringLength] attributes live -- + // the domain models carry none, so validating those found nothing and a pick holding minIv 500 was + // applied verbatim. Same trap as #548. See #565. + var validationTarget = AsCreateDto(alarm) ?? alarm; + + var results = new List(); + if (!Validator.TryValidateObject( + validationTarget, new ValidationContext(validationTarget), results, validateAllProperties: true)) + { + throw new AlarmValidationException( + "This quick pick holds a filter value the alarm does not accept: " + + (results[0].ErrorMessage ?? "value out of range")); + } + + if (alarm is Monster monster) + { + var inverted = MonsterRangeValidator.Validate(monster); + if (inverted is not null) + { + throw new AlarmValidationException($"This quick pick holds an impossible filter: {inverted}"); + } + } + } + /// Re-reads a built alarm as the DTO the POST endpoints bind, which carries the rules. + private static object? AsCreateDto(object alarm) + { + var json = JsonSerializer.Serialize(alarm, alarm.GetType(), SnakeCaseOptions); + + return alarm switch + { + Monster => JsonSerializer.Deserialize(json, SnakeCaseOptions), + Raid => JsonSerializer.Deserialize(json, SnakeCaseOptions), + Egg => JsonSerializer.Deserialize(json, SnakeCaseOptions), + Quest => JsonSerializer.Deserialize(json, SnakeCaseOptions), + Invasion => JsonSerializer.Deserialize(json, SnakeCaseOptions), + Lure => JsonSerializer.Deserialize(json, SnakeCaseOptions), + Nest => JsonSerializer.Deserialize(json, SnakeCaseOptions), + Gym => JsonSerializer.Deserialize(json, SnakeCaseOptions), + MaxBattle => JsonSerializer.Deserialize(json, SnakeCaseOptions), + _ => null, + }; + } + + private static readonly JsonSerializerOptions SnakeCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + }; + private static Monster BuildMonster(Dictionary filters, int pokemonId, int profileNo, QuickPickApplyRequest request) { // Start with sensible defaults (matching the add dialog defaults) @@ -381,6 +809,18 @@ private static Monster BuildMonster(Dictionary filters, int pok monster.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + monster.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + monster.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { monster.Clean = request.Clean.Value; @@ -391,6 +831,8 @@ private static Monster BuildMonster(Dictionary filters, int pok monster.Template = request.Template; } + EnsureValidAlarm(monster); + return monster; } @@ -424,6 +866,18 @@ private static Raid BuildRaid(Dictionary filters, int profileNo raid.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + raid.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + raid.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { raid.Clean = request.Clean.Value; @@ -434,6 +888,8 @@ private static Raid BuildRaid(Dictionary filters, int profileNo raid.Template = request.Template; } + EnsureValidAlarm(raid); + return raid; } @@ -444,6 +900,7 @@ private async Task> ApplyEggAsync( { var json = JsonSerializer.Serialize(definition.Filters, JsonOptions); var egg = JsonSerializer.Deserialize(json, JsonOptions) ?? new Egg(); + EnsureValidAlarm(egg); egg.ProfileNo = profileNo; @@ -452,6 +909,18 @@ private async Task> ApplyEggAsync( egg.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + egg.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + egg.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { egg.Clean = request.Clean.Value; @@ -473,6 +942,7 @@ private async Task> ApplyQuestAsync( { var json = JsonSerializer.Serialize(definition.Filters, JsonOptions); var quest = JsonSerializer.Deserialize(json, JsonOptions) ?? new Quest(); + EnsureValidAlarm(quest); quest.ProfileNo = profileNo; @@ -481,6 +951,18 @@ private async Task> ApplyQuestAsync( quest.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + quest.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + quest.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { quest.Clean = request.Clean.Value; @@ -502,14 +984,23 @@ private async Task> ApplyQuestAsync( // one alarm per leader rather than complicating the QuickPick schema. Giovanni is // deliberately excluded (separate `invasion-giovanni` pick, since he spawns from the // Super Rocket Radar only). - private static readonly string[] LeaderFanOutGruntTypes = ["cliff", "arlo", "sierra"]; + private static readonly IReadOnlyList LeaderFanOutGruntTypes = InvasionGruntTypes.Leaders; private async Task> ApplyInvasionAsync( string userId, int profileNo, QuickPickDefinition definition, QuickPickApplyRequest request) { - if (definition.Id == "invasion-leader") + // "All Invasions" shipped with empty filters, so BuildInvasion produced grunt_type "" and every + // apply failed with a 500. PoracleNG has no catch-all, so "all" has to be a fan-out too. See #416. + var fanOut = definition.Id switch + { + "all-invasions" => InvasionGruntTypes.All, + "invasion-leader" => LeaderFanOutGruntTypes, + _ => null + }; + + if (fanOut != null) { - var invasions = LeaderFanOutGruntTypes.Select(gt => BuildInvasion(definition.Filters, profileNo, request, gt)).ToList(); + var invasions = fanOut.Select(gt => BuildInvasion(definition.Filters, profileNo, request, gt)).ToList(); var created = await this._invasionService.BulkCreateAsync(userId, invasions); return [.. created.Select(i => i.Uid)]; } @@ -532,13 +1023,23 @@ private static Invasion BuildInvasion( invasion.GruntType = gruntTypeOverride; } - invasion.GruntType ??= ""; - if (request.Distance.HasValue) { invasion.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + invasion.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + invasion.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { invasion.Clean = request.Clean.Value; @@ -549,6 +1050,8 @@ private static Invasion BuildInvasion( invasion.Template = request.Template; } + EnsureValidAlarm(invasion); + return invasion; } @@ -559,6 +1062,7 @@ private async Task> ApplyLureAsync( { var json = JsonSerializer.Serialize(definition.Filters, JsonOptions); var lure = JsonSerializer.Deserialize(json, JsonOptions) ?? new Lure(); + EnsureValidAlarm(lure); lure.ProfileNo = profileNo; @@ -567,6 +1071,18 @@ private async Task> ApplyLureAsync( lure.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + lure.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + lure.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { lure.Clean = request.Clean.Value; @@ -588,6 +1104,7 @@ private async Task> ApplyNestAsync( { var json = JsonSerializer.Serialize(definition.Filters, JsonOptions); var nest = JsonSerializer.Deserialize(json, JsonOptions) ?? new Nest(); + EnsureValidAlarm(nest); nest.ProfileNo = profileNo; @@ -596,6 +1113,18 @@ private async Task> ApplyNestAsync( nest.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + nest.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + nest.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { nest.Clean = request.Clean.Value; @@ -617,6 +1146,7 @@ private async Task> ApplyGymAsync( { var json = JsonSerializer.Serialize(definition.Filters, JsonOptions); var gym = JsonSerializer.Deserialize(json, JsonOptions) ?? new Gym(); + EnsureValidAlarm(gym); gym.ProfileNo = profileNo; @@ -625,6 +1155,18 @@ private async Task> ApplyGymAsync( gym.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + gym.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + gym.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { gym.Clean = request.Clean.Value; @@ -684,6 +1226,18 @@ private static MaxBattle BuildMaxBattle(Dictionary filters, int maxBattle.Distance = request.Distance.Value; } + // The scope travels with the distance: a quick pick makes ordinary alarms, so it can aim + // them anywhere an alarm can be aimed. See #730. + if (request.OverrideLocationLabel != null) + { + maxBattle.OverrideLocationLabel = request.OverrideLocationLabel; + } + + if (request.OverrideAreas != null) + { + maxBattle.OverrideAreas = request.OverrideAreas; + } + if (request.Clean.HasValue) { maxBattle.Clean = request.Clean.Value; @@ -694,6 +1248,8 @@ private static MaxBattle BuildMaxBattle(Dictionary filters, int maxBattle.Template = request.Template; } + EnsureValidAlarm(maxBattle); + return maxBattle; } @@ -837,6 +1393,11 @@ private async Task> GetValidUidsAsync(string userId, string alarmType, new() { Id = "lure-golden", Name = "Golden Lures", Description = "Track Golden Lure Modules at PokeStops", Icon = "stars", Category = "Lures", AlarmType = "lure", SortOrder = 64, Filters = new() { ["lureId"] = 505 } }, ]; + [LoggerMessage( + Level = LogLevel.Information, + Message = "Quick pick {QuickPickId} matched {DisplacedCount} alarm(s) the user already had; claiming none of the {AddedCount} resulting row(s) so removing the pick cannot delete them.")] + private static partial void LogQuickPickDisplaced(ILogger logger, string quickPickId, int displacedCount, int addedCount); + [LoggerMessage(Level = LogLevel.Information, Message = "Applied quick pick '{QuickPickId}' for user {UserId} profile {ProfileNo}, created {Count} alarm(s).")] private static partial void LogQuickPickApplied(ILogger logger, string quickPickId, string userId, int profileNo, int count); @@ -845,4 +1406,9 @@ private async Task> GetValidUidsAsync(string userId, string alarmType, [LoggerMessage(Level = LogLevel.Information, Message = "Seeding {Count} default quick picks (replaced {Existing} existing).")] private static partial void LogSeedingDefaults(ILogger logger, int count, int existing); + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Quick pick {QuickPickId}: PoracleNG named {ReportedCount} uid(s), {TrackedCount} of which are new rows this apply created.")] + private static partial void LogQuickPickTracking(ILogger logger, string quickPickId, int reportedCount, int trackedCount); } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs b/Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs new file mode 100644 index 00000000..db6999d7 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs @@ -0,0 +1,64 @@ +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Returns the canonical raid-level list. Currently sources from a baked-in +/// snapshot of the WatWowMap masterfile; a future enhancement can refresh +/// this list from the live masterfile URL (see comments in `GetAllAsync`) +/// and persist to disk under DATA_DIR. +/// +/// The baked-in list IS the fallback when an upstream fetch fails. Frontend +/// callers must never assume this list is complete — they always allow +/// arbitrary integers via the custom-level input. +/// +public class RaidLevelService : IRaidLevelService +{ + // Mirror of the masterfile's raid_{N} keys as of writing, with the "Raid" + // noun stripped so callers can compose phrases like "All Mega Legendary + // Raids" without doubling the word. The masterfile keeps the long form + // (e.g. "Mega Legendary Raid") — see Name vs NamePlural fields for the + // intended use: + // - Name → modifier form, used inline ("Mega Legendary") + // - NamePlural → full phrase, used standalone ("Mega Legendary Raids") + // Source: https://github.com/WatWowMap/Masterfile-Generator (master-latest-poracle-v2.json) + // When upstream adds raid_20+, append entries here and bump the i18n keys in + // RAIDS.LEVEL.* — or wire up the live fetch documented below. + private static readonly IReadOnlyList BakedIn = new RaidLevelInfo[] + { + new() { Value = 1, Category = "star", Name = "1 Star", NamePlural = "1 Star Raids" }, + new() { Value = 2, Category = "star", Name = "2 Star", NamePlural = "2 Star Raids" }, + new() { Value = 3, Category = "star", Name = "3 Star", NamePlural = "3 Star Raids" }, + new() { Value = 4, Category = "star", Name = "4 Star", NamePlural = "4 Star Raids" }, + new() { Value = 5, Category = "star", Name = "Legendary", NamePlural = "Legendary Raids" }, + new() { Value = 6, Category = "mega", Name = "Mega", NamePlural = "Mega Raids" }, + new() { Value = 7, Category = "mega", Name = "Mega Legendary", NamePlural = "Mega Legendary Raids" }, + new() { Value = 8, Category = "special", Name = "Ultra Beast", NamePlural = "Ultra Beast Raids" }, + new() { Value = 9, Category = "special", Name = "Elite", NamePlural = "Elite Raids" }, + new() { Value = 10, Category = "special", Name = "Primal", NamePlural = "Primal Raids" }, + new() { Value = 11, Category = "shadow", Name = "1 Shadow", NamePlural = "1 Shadow Raids" }, + new() { Value = 12, Category = "shadow", Name = "2 Shadow", NamePlural = "2 Shadow Raids" }, + new() { Value = 13, Category = "shadow", Name = "3 Shadow", NamePlural = "3 Shadow Raids" }, + new() { Value = 14, Category = "shadow", Name = "4 Shadow", NamePlural = "4 Shadow Raids" }, + new() { Value = 15, Category = "shadow", Name = "5 Shadow", NamePlural = "5 Shadow Raids" }, + new() { Value = 16, Category = "superMega", Name = "4 Super Mega", NamePlural = "4 Super Mega Raids" }, + new() { Value = 17, Category = "superMega", Name = "5 Super Mega", NamePlural = "5 Super Mega Raids" }, + new() { Value = 18, Category = "coordinated", Name = "Coordinated 1", NamePlural = "Coordinated 1 Raids" }, + new() { Value = 19, Category = "coordinated", Name = "Coordinated 2", NamePlural = "Coordinated 2 Raids" }, + }; + + /// + public Task> GetAllAsync() + { + // TODO: fetch + cache from the WatWowMap masterfile URL so new raid types + // appear without a code change. Recommended approach: + // 1. HttpClient GET https://raw.githubusercontent.com/WatWowMap/Masterfile-Generator/main/master-latest-poracle-v2.json + // 2. Parse top-level keys matching ^raid_\d+$ + matching `_plural` siblings + // 3. Persist parsed structure to `${DATA_DIR}/raid-levels.json` + // 4. Refresh every 24h via a hosted service + // 5. Fall back to BakedIn on any failure + // The frontend already tolerates the list being incomplete (custom-level input). + return Task.FromResult(BakedIn); + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/RaidService.cs b/Core/Pgan.PoracleWebNet.Core.Services/RaidService.cs index 81ea7148..3eea45b9 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/RaidService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/RaidService.cs @@ -1,14 +1,17 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Core.Services; -public class RaidService(IPoracleTrackingProxy proxy, IFeatureGate featureGate) : IRaidService +public class RaidService(IPoracleTrackingProxy proxy, IFeatureGate featureGate, ILogger logger, ITrackedUidRemapper uidRemapper) : IRaidService { private const string TrackingType = "raid"; private readonly IPoracleTrackingProxy _proxy = proxy; private readonly IFeatureGate _featureGate = featureGate; + private readonly ILogger _logger = logger; + private readonly ITrackedUidRemapper _uidRemapper = uidRemapper; public async Task> GetByUserAsync(string userId, int profileNo) { @@ -27,22 +30,50 @@ public async Task CreateAsync(string userId, Raid model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Raids); model.Id = userId; + + // An Add that PoracleNG resolves into an update of an existing alarm takes that alarm over: + // 201 Created, and the user quietly loses the one they had. See #561. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, 0, SerializeToElement(model)); var body = SerializeToElement(model); var result = await this._proxy.CreateAsync(TrackingType, userId, body); - if (result.NewUids.Count > 0) + if (result.NewUids.Count == 0) { - model.Uid = (int)result.NewUids[0]; + return model; } - return model; + model.Uid = (int)result.NewUids[0]; + + // Read back rather than echo. PoracleNG rewrites level to 9000 when the alarm names a specific + // boss, so the response advertised a level the stored row does not have -- and the card the SPA + // renders from it disagreed with the same alarm after a refresh. Same rule PUT /api/areas was + // given in #476. See #523. + return await this.GetByUidAsync(userId, model.Uid) ?? model; } public async Task UpdateAsync(string userId, Raid model) { await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Raids); + var oldUid = model.Uid; var body = SerializeToElement(model); - await this._proxy.CreateAsync(TrackingType, userId, body); + + // Carry forward anything the stored row holds that the model does not declare. See #730. + body = await TrackingFieldPreserver.PreserveStoredFieldsAsync( + this._proxy, TrackingType, userId, model.Uid, body); + + // Refuse before writing: PoracleNG would satisfy this by merging into the other alarm and + // the reconciler would then delete this one, losing a row the user never touched. See #531. + await TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync( + this._proxy, TrackingType, userId, oldUid, body); + + var result = await this._proxy.CreateAsync(TrackingType, userId, body); + + // PoracleNG inserts instead of upserting when the edit changes a dedup-key field, + // leaving the pre-edit row behind as a duplicate. Drop it and report the surviving uid. + model.Uid = await TrackingUpdateReconciler.ReconcileAsync( + this._proxy, TrackingType, userId, oldUid, result, this._logger, body, this._uidRemapper); + return model; } @@ -70,43 +101,71 @@ public async Task DeleteAllByUserAsync(string userId, int profileNo) public async Task UpdateDistanceByUserAsync(string userId, int profileNo, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var itemList = items.ToList(); + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var body = PoracleJsonHelper.RewriteRows(json, _ => true, ("distance", distance)); + var count = body.GetArrayLength(); - if (itemList.Count == 0) + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in itemList) - { - item.Distance = distance; - } - - var body = SerializeToElement(itemList); await this._proxy.CreateAsync(TrackingType, userId, body); - return itemList.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task UpdateDistanceByUidsAsync(List uids, string userId, int distance) { var json = await this._proxy.GetByUserAsync(TrackingType, userId); - var items = DeserializeItems(json); - var matching = items.Where(x => uids.Contains(x.Uid)).ToList(); - - if (matching.Count == 0) + // The stored rows are rewritten in place rather than round-tripped through the typed model, + // so fields PoracleWeb does not model survive the write-back. See #730. + var selected = new HashSet(uids); + var body = PoracleJsonHelper.RewriteRows( + json, + row => PoracleJsonHelper.UidOf(row) is int rowUid && selected.Contains(rowUid), + ("distance", distance)); + var count = body.GetArrayLength(); + + if (count == 0) { return 0; } + // Two selected rows that differed only by radius become the same alarm once both are set to + // the same one, and PoracleNG resolves that inside the batch -- fewer alarms than selected, + // one left at its old radius, and a response claiming all were updated. See #580. + TrackingUpdateReconciler.EnsureBatchDoesNotCollapse(body, TrackingType); + + // And against the rows NOT selected: at the new radius a selected row can differ from an + // unselected sibling by exactly one updatable field, and PoracleNG then rewrites the SIBLING + // -- an alarm the user never touched -- while the selected one keeps its old radius and the + // response claims it was updated. See #598. + await TrackingUpdateReconciler.EnsureBatchDoesNotTakeOverOthersAsync( + this._proxy, TrackingType, userId, body); - foreach (var item in matching) - { - item.Distance = distance; - } - - var body = SerializeToElement(matching); await this._proxy.CreateAsync(TrackingType, userId, body); - return matching.Count; + // PoracleNG rewrites every row, so the uids change. Follow any quick-pick that + // tracks them, pairing on content because the batch response is reordered. See #443. + await BulkUidRemap.ApplyAsync( + this._proxy, TrackingType, userId, body, this._uidRemapper, this._logger); + + return count; } public async Task CountByUserAsync(string userId, int profileNo) diff --git a/Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs b/Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs index d009bf1d..4b085788 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs @@ -6,9 +6,9 @@ namespace Pgan.PoracleWebNet.Core.Services; -public partial class ScannerService(ScannerDbContext context, ILogger logger) : IScannerService +public partial class ScannerService(ScannerContext context, ILogger logger) : IScannerService { - private readonly ScannerDbContext _context = context; + private readonly ScannerContext _context = context; private readonly ILogger _logger = logger; private const int MaxResultRows = 5000; @@ -141,7 +141,7 @@ public async Task> SearchGymsAsync(string search, i return await this._context.Gyms .AsNoTracking() - .Where(g => g.Name != null && EF.Functions.Like(g.Name, pattern, "\\")) + .Where(g => g.Name != null && EF.Functions.Like(g.Name, pattern, LikeEscape.EscapeChar)) .OrderBy(g => g.Name) .Take(safeLimit) .Select(g => new GymSearchResult diff --git a/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs b/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs index ce5ba213..bc16bacd 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs @@ -64,8 +64,8 @@ public partial class SettingsMigrationService( ["disable_profiles"] = "features", ["disable_location"] = "features", ["disable_nominatim"] = "features", - ["disable_geomap"] = "features", - ["disable_geomap_select"] = "features", + ["disable_update_check"] = "features", + ["disable_user_geofences"] = "features", ["enable_templates"] = "features", // admin @@ -85,6 +85,9 @@ public partial class SettingsMigrationService( ["telegram_bot"] = "telegram", ["telegram_bot_token"] = "telegram", + // oidc (generic external SSO provider) + ["enable_oidc"] = "oidc", + // maps ["provider_url"] = "maps", @@ -116,9 +119,10 @@ public partial class SettingsMigrationService( "disable_mons", "disable_raids", "disable_quests", "disable_invasions", "disable_lures", "disable_nests", "disable_gyms", "disable_maxbattles", "disable_fort_changes", "disable_areas", - "disable_profiles", "disable_location", "disable_nominatim", - "disable_geomap", "disable_geomap_select", + "disable_profiles", "disable_location", "disable_nominatim", "disable_update_check", + "disable_user_geofences", "enable_templates", "enable_roles", "enable_telegram", "enable_discord", + "enable_oidc", "hide_header_logo", "site_is_https", "debug", }; @@ -403,6 +407,9 @@ private static readonly (string Key, string Value, string Category, string Value ("custom_title", "PoracleWeb.NET", "branding", "string"), ]; + /// Marks that the built-in quick picks have been created once. See #662, #666. + private const string QuickPicksSeededKey = "quick_picks_seeded"; + public async Task SeedDefaultsAsync() { foreach (var (key, value, category, valueType) in DefaultSettings) @@ -423,6 +430,48 @@ await this._siteSettingService.CreateOrUpdateAsync(new SiteSetting LogDefaultSeeded(this._logger, key, value); } + + await this.BackfillQuickPickSeedMarkerAsync(); + } + + /// + /// Records that the built-in quick picks exist, for installations seeded before the marker did. + /// + /// + /// The marker used to be a per-browser localStorage flag and became a site setting in #662, so an + /// installation seeded before that has no row and would seed again on the next admin visit. + /// + /// This covers installations that still hold at least one global pick, which is the common case. It + /// deliberately does NOT cover an admin who had already deleted every preset before upgrading: they + /// have no global picks, so there is nothing here to infer from, and the old marker lived in their + /// browser where the server cannot see it. That admin gets one reseed. Said plainly because the + /// first version of this comment claimed to cover it and did not. See #666, #672. + /// + /// + private async Task BackfillQuickPickSeedMarkerAsync() + { + if (await this._siteSettingService.GetByKeyAsync(QuickPicksSeededKey) is not null) + { + return; + } + + var globals = await this._quickPickDefinitionRepository.GetAllGlobalAsync(); + if (globals is not { Count: > 0 }) + { + // Genuinely fresh, or deliberately emptied before the marker existed. Either way the first + // admin visit seeds, which is the behaviour that shipped. + return; + } + + await this._siteSettingService.CreateOrUpdateAsync(new SiteSetting + { + Key = QuickPicksSeededKey, + Value = "true", + Category = "admin", + ValueType = "boolean", + }); + + LogDefaultSeeded(this._logger, QuickPicksSeededKey, "true"); } [LoggerMessage(Level = LogLevel.Information, Message = "Settings migration completed: {SiteSettings} site settings, {WebhookDelegates} webhook delegates, {QuickPickDefinitions} quick pick definitions, {QuickPickAppliedStates} quick pick applied states, {Failed} failed")] diff --git a/Core/Pgan.PoracleWebNet.Core.Services/SiteSettingService.cs b/Core/Pgan.PoracleWebNet.Core.Services/SiteSettingService.cs index 20832005..61ad787a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/SiteSettingService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/SiteSettingService.cs @@ -39,6 +39,10 @@ public partial class SiteSettingService( /// private static readonly HashSet PublicKeys = new(StringComparer.OrdinalIgnoreCase) { + // The login page picks a display language before anyone is authenticated, so it needs the same + // admin restriction the rest of the app honours. Without it a deployment limited to en,fr still + // offered all eleven in the signed-out language menu. + "allowed_languages", "custom_title", "enable_discord", "enable_telegram", diff --git a/Core/Pgan.PoracleWebNet.Core.Services/SummaryCapabilityService.cs b/Core/Pgan.PoracleWebNet.Core.Services/SummaryCapabilityService.cs new file mode 100644 index 00000000..dd983d32 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/SummaryCapabilityService.cs @@ -0,0 +1,47 @@ +using Microsoft.Extensions.Caching.Memory; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Resolves whether the upstream PoracleNG deployment has quest summary delivery enabled. +/// The capability is a deployment property derived from the config proxy +/// (tracking.quest_summary_enabled) and surfaced as a 200-body boolean — never inferred +/// from a 503. The value (including false) is cached server-wide for 5 minutes. +/// Any fault while reading the config degrades to false (graceful degradation). +/// +public class SummaryCapabilityService(IPoracleApiProxy poracleApiProxy, IMemoryCache cache) : ISummaryCapabilityService +{ + private const string CacheKey = "summary_capability:quest"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); + + private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly IMemoryCache _cache = cache; + + public async Task IsQuestSummaryEnabledAsync() + { + if (this._cache.TryGetValue(CacheKey, out bool cached)) + { + return cached; + } + + var enabled = await this.ProbeConfigAsync(); + this._cache.Set(CacheKey, enabled, CacheTtl); + return enabled; + } + + private async Task ProbeConfigAsync() + { + try + { + // Read the effective tracking.quest_summary_enabled from PoracleNG's config-values + // endpoint. null (can't determine) and any fault degrade to false so the UI stays hidden + // rather than showing a dead-end where nothing is ever delivered. + return await this._poracleApiProxy.GetQuestSummaryEnabledAsync() ?? false; + } + catch + { + return false; + } + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/TrackedUidRemapper.cs b/Core/Pgan.PoracleWebNet.Core.Services/TrackedUidRemapper.cs new file mode 100644 index 00000000..57d4458e --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/TrackedUidRemapper.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +public partial class TrackedUidRemapper( + IQuickPickAppliedStateRepository appliedStateRepository, + ILogger logger) : ITrackedUidRemapper +{ + private readonly IQuickPickAppliedStateRepository _appliedStateRepository = appliedStateRepository; + private readonly ILogger _logger = logger; + + public async Task RemapAsync(string userId, string alarmType, int oldUid, int newUid) + { + if (oldUid == newUid || oldUid <= 0 || newUid <= 0 || string.IsNullOrEmpty(userId)) + { + return; + } + + try + { + // Scanned across every profile: an alarm edit does not tell us which profile the row belongs + // to, and a uid is unique per user anyway, so a match in any profile is the right one. + var states = await this._appliedStateRepository.GetByUserAsync(userId); + + foreach (var state in states) + { + if (!string.Equals(state.AlarmType, alarmType, StringComparison.Ordinal)) + { + continue; + } + + var index = state.TrackedUids.IndexOf(oldUid); + if (index < 0) + { + continue; + } + + state.TrackedUids[index] = newUid; + await this._appliedStateRepository.CreateOrUpdateAsync(state); + LogRemapped(this._logger, state.QuickPickId, alarmType, oldUid, newUid); + } + } + catch (Exception ex) + { + // The edit itself already succeeded. Losing the remap costs the user a working "remove" + // button on one quick pick; failing the request would cost them the edit. Log and move on. + LogRemapFailed(this._logger, ex, alarmType, oldUid, newUid); + } + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Quick pick {QuickPickId} now tracks {AlarmType} uid {NewUid} in place of rotated uid {OldUid}.")] + private static partial void LogRemapped(ILogger logger, string quickPickId, string alarmType, int oldUid, int newUid); + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Could not remap quick-pick tracked {AlarmType} uid {OldUid} to {NewUid}; removing that quick pick may leave the alarm behind.")] + private static partial void LogRemapFailed(ILogger logger, Exception exception, string alarmType, int oldUid, int newUid); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/TrackingFieldPreserver.cs b/Core/Pgan.PoracleWebNet.Core.Services/TrackingFieldPreserver.cs new file mode 100644 index 00000000..49888593 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/TrackingFieldPreserver.cs @@ -0,0 +1,71 @@ +using System.Text.Json; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Carries forward the properties of a stored alarm that PoracleWeb has no model for. +/// +/// +/// +/// An edit is sent as a create carrying the existing uid, which PoracleNG upserts. The body is +/// built by serializing the typed model, so every property the model does not declare was absent from the +/// write and PoracleNG stored the column's default over the user's value. PoracleNG 5.1.0 added +/// override_location_label, override_areas and pvp_ranking_evolution; 5.2.0 adds +/// costume. Set any of them with the bot, edit the alarm on the web, and they were gone. See #730. +/// +/// +/// This runs BEFORE the collision guards on purpose. CountUpdatableDifferences only compares the +/// properties present in the submission, so an unmodelled property could not tell two alarms apart and +/// the guard refused edits PoracleNG would have accepted, which is the #553 shape. Merging first means +/// the guard compares the row that is actually going to be written. +/// +/// +internal static class TrackingFieldPreserver +{ + /// + /// Returns with any property the stored row carries and it does not. + /// + /// + /// A read failure returns the body untouched. Losing an override on an edit is bad; failing every + /// edit whenever a read hiccups is worse, and the guards downstream still run either way. + /// + public static async Task PreserveStoredFieldsAsync( + IPoracleTrackingProxy proxy, + string trackingType, + string userId, + int uid, + JsonElement body) + { + // uid <= 0 is a create: there is no stored row to carry anything forward from. + if (uid <= 0 || body.ValueKind != JsonValueKind.Object) + { + return body; + } + + JsonElement rows; + try + { + rows = await proxy.GetByUserAsync(trackingType, userId); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return body; + } + + if (rows.ValueKind != JsonValueKind.Array) + { + return body; + } + + foreach (var row in rows.EnumerateArray()) + { + if (PoracleJsonHelper.UidOf(row) == uid) + { + return PoracleJsonHelper.PreserveUnmodelled(row, body); + } + } + + return body; + } +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/TrackingUpdateReconciler.cs b/Core/Pgan.PoracleWebNet.Core.Services/TrackingUpdateReconciler.cs new file mode 100644 index 00000000..e3ac7350 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/TrackingUpdateReconciler.cs @@ -0,0 +1,649 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Reconciles a tracking update against what PoracleNG actually did. +/// +/// Updates are sent as a create carrying the existing uid, which PoracleNG normally treats as an +/// upsert. But it dedups each tracking type by a natural key (egg level, raid team/exclusive, quest reward, +/// lure id, and so on), so when an edit changes a field in that key it inserts a new row instead of +/// updating the one the uid points at. The original row survives and the user ends up with two alarms — +/// one still matching their pre-edit filter — while the API reports success. +/// +/// +/// This detects that case from the create response and removes the superseded row. The upsert path is left +/// alone, so the uid only changes when PoracleNG genuinely made a new row. +/// +/// +internal static partial class TrackingUpdateReconciler +{ + /// + /// Deletes the superseded row when PoracleNG inserted rather than updated. + /// Returns the uid the caller should report back — the new one when a row was inserted, otherwise the original. + /// + public static async Task ReconcileAsync( + IPoracleTrackingProxy proxy, + string trackingType, + string userId, + int oldUid, + TrackingCreateResult result, + ILogger logger, + JsonElement submitted, + ITrackedUidRemapper? uidRemapper = null) + { + // oldUid <= 0 means this was not an edit, so there is nothing to reconcile. + if (oldUid <= 0) + { + return oldUid; + } + + // PoracleNG declined to write because the edited values collide with an alarm the user + // already has. Nothing changed, so reporting success while echoing the requested values back + // told the user their edit applied when it had not. See #463. + // + // But it reports the same {alreadyPresent:1, insert:0, updates:0} when the collision is with the + // row being edited -- pressing Save with nothing changed, which every edit dialog does by + // resubmitting the whole form. Reading that as a conflict told the user another alarm was in the + // way when the only candidate was itself. The two are told apart by asking whether the row at + // oldUid already holds what was submitted: if it does, the edit is a no-op and there is nothing + // to report. See #495. + if (result.AlreadyPresent > 0 && result.Inserts == 0 && result.Updates == 0) + { + if (await IsNoOpEditAsync(proxy, trackingType, userId, oldUid, submitted)) + { + return oldUid; + } + + throw new TrackingConflictException( + trackingType, + "Another alarm of this type already uses those settings. Edit or remove that one instead."); + } + + if (result.NewUids.Count == 0) + { + return oldUid; + } + + // Trust newUids, not the insert counter. PoracleNG re-keys a row on edit while reporting + // {"insert":0,"updates":1,"newUids":[]} -- verified directly against it. Gating on + // Inserts > 0 therefore skipped both the uid correction and the remap for raids, eggs, + // quests, gyms, fort changes and nests: the PUT answered 200 with a uid that 404s on the very + // next GET, and any quick pick tracking the row kept pointing at the dead uid. Monsters were + // unaffected because PoracleNG genuinely updates that type in place. See #460, #464. + var newUid = (int)result.NewUids[0]; + if (newUid == oldUid) + { + return oldUid; + } + + try + { + // Idempotent: when PoracleNG replaced the row in place rather than inserting a duplicate, + // the old uid is already gone and the proxy swallows the resulting 404. + await proxy.DeleteByUidAsync(trackingType, userId, oldUid); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + // The new row already carries the user's intended settings, so the edit succeeded. Surface the + // leftover duplicate for triage rather than failing an update that actually applied. + LogStaleDeleteFailed(logger, ex, trackingType, oldUid, newUid); + } + + // Quick-pick applied state stores uids captured at apply time; follow the row. See #403. + if (uidRemapper != null) + { + await uidRemapper.RemapAsync(userId, trackingType, oldUid, newUid); + } + + return newUid; + } + + /// + /// Refuses a bulk write in which a submitted row would take over a row that was NOT submitted. + /// + /// + /// The batch check only looks at the rows being written. At the new radius a selected row can differ + /// from an unselected sibling by exactly one updatable field, and PoracleNG then rewrites the sibling -- + /// an alarm the user never selected -- while the selected one keeps its old radius and the response + /// reports it updated. See #598. + /// + public static async Task EnsureBatchDoesNotTakeOverOthersAsync( + IPoracleTrackingProxy proxy, + string trackingType, + string userId, + JsonElement body) + { + if (body.ValueKind != JsonValueKind.Array) + { + return; + } + + var submittedUids = new HashSet(); + foreach (var row in body.EnumerateArray()) + { + if (row.ValueKind == JsonValueKind.Object + && row.TryGetProperty("uid", out var uid) + && uid.ValueKind == JsonValueKind.Number) + { + submittedUids.Add(uid.GetInt32()); + } + } + + JsonElement existing; + try + { + existing = await proxy.GetByUserAsync(trackingType, userId); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return; + } + + if (existing.ValueKind != JsonValueKind.Array) + { + return; + } + + foreach (var candidate in body.EnumerateArray()) + { + // Ordering matters here too: PoracleNG resolves each candidate against the FIRST row that + // classifies, so a later sibling is never touched. Checking every row refused bulk changes that + // would have been harmless -- and told the user to make the single change that was also + // refused. See #606. + var decisive = FirstClassifyingRow(existing, candidate, trackingType); + if (decisive is { Classification: RowMatch.Update } match + && !submittedUids.Contains(match.Uid)) + { + throw new TrackingConflictException( + trackingType, + "That radius would overwrite another alarm you did not select. " + + "Remove or edit that alarm first."); + } + } + } + + /// + /// Refuses a bulk write in which two of the submitted rows would become the same alarm. + /// + /// + /// The distance endpoints rewrite every selected row and POST them as one batch. Two rows that differed + /// only by radius become identical once both are set to the same radius, and PoracleNG resolves that + /// within the batch -- so the user ended up with FEWER alarms than they selected, at least one still at + /// its old radius, and a response claiming every one was updated. Refuse instead of silently losing a + /// row. See #580. + /// + public static void EnsureBatchDoesNotCollapse(JsonElement body, string trackingType) + { + if (body.ValueKind != JsonValueKind.Array) + { + return; + } + + var seen = new HashSet(StringComparer.Ordinal); + foreach (var row in body.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object) + { + continue; + } + + if (!seen.Add(IdentityOf(row))) + { + throw new TrackingConflictException( + trackingType, + "Two of the selected alarms would end up identical at that radius. " + + "Change them separately, or remove one first."); + } + } + } + + /// Everything about a row that distinguishes it from another, in a stable order. + private static string IdentityOf(JsonElement row) => + string.Join( + '|', + row.EnumerateObject() + .Where(p => !AssignedByPoracle.Contains(p.Name)) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => $"{p.Name}={p.Value}")); + /// + /// Refuses an edit that PoracleNG would satisfy by merging into a DIFFERENT alarm. + /// + /// + /// PoracleNG decides what to do with a submitted row by diffing it against the existing ones + /// (diffTracking in processor/internal/api/tracking.go). When the only differences are in fields it + /// tags diff:"update", it updates that existing row in place and re-keys it -- answering + /// {insert:0, updates:1, newUids:[new]}. If the row it picked is not the one being edited, the edit + /// has just overwritten somebody else's alarm, and the reconciler below then deleted the original as + /// "superseded": two alarms became one, the victim's radius replaced by the editor's, reported as a + /// clean 200. Reachable from the ordinary edit dialogs -- changing a raid's team, a gym's slot or + /// battle toggles, an egg's level, a fort-change's change types. Lures and invasions were never + /// exposed because they carry their own pre-flight checks from #462. + /// + /// The updatable set is uniform upstream -- template, distance and clean, plus slot_changes and + /// battle_changes on gyms -- so the collision test is the same for every type: equal on everything + /// else means PoracleNG will merge them. + /// + /// + public static async Task EnsureNoMergeIntoAnotherAlarmAsync( + IPoracleTrackingProxy proxy, + string trackingType, + string userId, + int oldUid, + JsonElement submitted) + { + if (submitted.ValueKind != JsonValueKind.Object) + { + return; + } + + // Pokemon edits cannot merge at all: trackingMonster.go splits rows on req.UID.isSet() and sends + // uid-bearing ones straight to UpdateMonsterByUID, never reaching the diff. Every refusal this + // guard produced for a monster edit was therefore false. It is the only type that does this. + // See #606. + if (oldUid > 0 && string.Equals(trackingType, "pokemon", StringComparison.Ordinal)) + { + return; + } + + JsonElement rows; + try + { + rows = await proxy.GetByUserAsync(trackingType, userId); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + return; + } + + if (rows.ValueKind != JsonValueKind.Array) + { + return; + } + + // In list order, and stopping at the first row that classifies. DiffAndClassify walks the + // existing rows and BREAKS on the first duplicate-or-update match, so only that row is ever + // taken over -- a later sibling is irrelevant. Refusing on any match anywhere blocked ordinary + // radius and template edits whenever a similar alarm existed further down the list, and the + // error told the user to do the very thing that was blocked. See #606. + var decisive = FirstClassifyingRow(rows, submitted, trackingType); + if (decisive is not { Classification: RowMatch.Update } match) + { + return; + } + + if (match.Uid == oldUid) + { + // PoracleNG would update the row being edited, which is exactly what was asked for. + return; + } + + throw new TrackingConflictException( + trackingType, + "Another alarm of this type already uses those settings. Edit or remove that one instead."); + } + + private enum RowMatch + { + Duplicate, + Update, + } + + private readonly record struct ClassifiedRow(int Uid, RowMatch Classification); + + /// + /// The first existing row PoracleNG would resolve the submission against, mirroring the break in + /// DiffAndClassify. Rows it would treat as unrelated, or as a separate insert, are skipped. + /// + private static ClassifiedRow? FirstClassifyingRow( + JsonElement rows, JsonElement submitted, string trackingType) + { + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object + || !row.TryGetProperty("uid", out var uid) + || uid.ValueKind != JsonValueKind.Number) + { + continue; + } + + var differences = CountUpdatableDifferences(submitted, row, trackingType); + if (differences is null) + { + // An identity field differs: unrelated, or a separate insert. Keep looking. + continue; + } + + if (differences == 0) + { + return new ClassifiedRow(uid.GetInt32(), RowMatch.Duplicate); + } + + if (differences == 1) + { + return new ClassifiedRow(uid.GetInt32(), RowMatch.Update); + } + } + + return null; + } + /// Fields the comparison ignores entirely: PoracleNG owns them. + private static readonly HashSet AssignedByPoracle = new(StringComparer.Ordinal) + { + // Assigned by PoracleNG, or rendered by it from the rest. + "uid", "id", "profile_no", "description", + // Never persisted (see #494). + "ping", + }; + + /// + /// The diff:"update" fields, per tracking type, transcribed from the structs in + /// processor/internal/db/tracking_queries.go at the commit prod runs. + /// + /// + /// Per type, not a shared set with exceptions. Assuming a common {distance, template, clean} missed + /// that monsters also tag min_iv, so adding the same Pokemon at a tighter IV floor was let + /// through and PoracleNG took the existing alarm over -- 201 Created over the row it had just + /// destroyed. Forts have no clean column at all. When PoracleNG is upgraded, re-read those tags + /// before assuming this still holds. See #574. + /// + /// Re-read at PoracleNG 5.1.0 (c5e08cb4), the version prod runs since 2026-08-18: every entry below + /// still matches. 5.1.0 added override_location_label and override_areas to all ten + /// types tagged diff:"", which is the untagged identity behaviour, so they need no entry here + /// -- but they DO have to reach the comparison, which is why the write paths merge the stored row in + /// before these guards run. See #730. + /// + /// + private static readonly Dictionary> UpdatableFieldsByType = + new(StringComparer.Ordinal) + { + ["pokemon"] = new(StringComparer.Ordinal) { "clean", "distance", "min_iv", "template" }, + ["gym"] = new(StringComparer.Ordinal) + { + "battle_changes", "clean", "distance", "slot_changes", "template", + }, + ["fort"] = new(StringComparer.Ordinal) { "distance", "template" }, + ["raid"] = new(StringComparer.Ordinal) { "clean", "distance", "template" }, + ["egg"] = new(StringComparer.Ordinal) { "clean", "distance", "template" }, + ["quest"] = new(StringComparer.Ordinal) { "clean", "distance", "template" }, + ["invasion"] = new(StringComparer.Ordinal) { "clean", "distance", "template" }, + ["lure"] = new(StringComparer.Ordinal) { "clean", "distance", "template" }, + ["nest"] = new(StringComparer.Ordinal) { "clean", "distance", "template" }, + }; + + /// Types PoracleNG does not diff at all fall back to the common three. + /// + /// Unreached today. Maxbattle is the only type missing from the dictionary above, and it tags nothing + /// diff:"update" upstream, so this fallback would over-predict merges for it -- except + /// MaxBattleService never calls the reconciler: it deletes and re-creates, and pre-checks siblings + /// with its own IsSameAlarm. Verified at 5.1.0 rather than assumed. Left as the conservative default + /// for whatever type is added next. + /// + private static readonly HashSet DefaultUpdatableFields = new(StringComparer.Ordinal) + { + "clean", "distance", "template", + }; + + + + /// + /// Counts differing updatable fields, or null when an identity field differs (no relation). + /// + private static int? CountUpdatableDifferences( + JsonElement submitted, JsonElement existing, string trackingType) + { + var updatableDifferences = 0; + + foreach (var field in submitted.EnumerateObject()) + { + if (AssignedByPoracle.Contains(field.Name)) + { + continue; + } + + // A field the stored row does not carry cannot tell the two apart. + if (!existing.TryGetProperty(field.Name, out var storedValue)) + { + continue; + } + + // template needs the value PoracleNG will STORE, not the one we sent. Both previous attempts + // at this were wrong in opposite directions: counting a blank as a difference made the check + // miss real collisions (#561), and skipping it entirely made an Add that differs from an + // existing alarm only by that alarm's custom template read as zero differences -- so it was + // let through, and PoracleNG overwrote the custom template (#593). + // + // Substituting the default resolves both: a blank submission is compared as the default + // PoracleNG would fill in, so it matches a stored default and differs from a stored custom + // template, which is exactly what PoracleNG's own diff does. + var submittedValue = field.Value; + if (string.Equals(field.Name, "template", StringComparison.Ordinal) && IsBlank(submittedValue)) + { + submittedValue = DefaultTemplateElement; + } + + // Compare what PoracleNG will STORE, not what was sent: it rewrites some values on the way + // in, and it diffs the rewritten row. Comparing the raw submission made every collision + // look like a difference, which is exactly how the destructive merge got through. + var same = SameValue(NormalizeForStorage(field, submittedValue, submitted, trackingType), storedValue); + + if (IsUpdatable(field.Name, trackingType)) + { + if (!same) + { + updatableDifferences++; + } + + continue; + } + + if (!same) + { + return null; + } + } + + // Mirrors DiffTracking in processor/internal/db/diff.go at the commit prod runs: + // + // if totalDiffs == 0 -> duplicate (nothing written) + // if totalDiffs == 1 && nonUpdatableDiffs == 0 -> UPDATE of that existing row + // otherwise -> new insert + // + // Counted, not ignored. Ignoring the updatable fields wholesale called every pair that differs in + // two of them a collision, and refused every ordinary edit on both -- radius, template, + // auto-delete, clearing the gym -- leaving them uneditable (#553). + // + return updatableDifferences; + } + + private static bool IsUpdatable(string fieldName, string trackingType) => + (UpdatableFieldsByType.TryGetValue(trackingType, out var fields) ? fields : DefaultUpdatableFields) + .Contains(fieldName); + /// + /// True when the row being edited already holds every value the update submitted, so PoracleNG's + /// "already present" was the row colliding with itself rather than with a different alarm. + /// + /// + /// A missing row is not a no-op: something else removed it, and the caller should hear about the + /// conflict rather than be told the edit applied. Fields PoracleNG does not persist are excluded -- + /// otherwise a ping-only edit, which PoracleNG drops on every tracking type, looks like a change and + /// reads as a conflict. + /// + private static async Task IsNoOpEditAsync( + IPoracleTrackingProxy proxy, + string trackingType, + string userId, + int oldUid, + JsonElement submitted) + { + if (submitted.ValueKind != JsonValueKind.Object) + { + return false; + } + + JsonElement rows; + try + { + rows = await proxy.GetByUserAsync(trackingType, userId); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + // Cannot tell the two cases apart, so keep the conservative answer: report the conflict. + return false; + } + + if (rows.ValueKind != JsonValueKind.Array) + { + return false; + } + + foreach (var row in rows.EnumerateArray()) + { + if (row.ValueKind != JsonValueKind.Object + || !row.TryGetProperty("uid", out var uid) + || uid.ValueKind != JsonValueKind.Number + || uid.GetInt32() != oldUid) + { + continue; + } + + return MatchesStoredRow(submitted, row); + } + + return false; + } + + /// Fields the comparison ignores, because the stored row never reflects them. + private static readonly HashSet IgnoredForNoOp = new(StringComparer.Ordinal) + { + // Assigned by PoracleNG, or rendered by it from the other fields. + "uid", "profile_no", "description", + // Never persisted, on any tracking type -- verified directly against PoracleNG. An edit that + // changes only this therefore leaves the row untouched, which is a no-op, not a conflict. + "ping", + }; + + private static bool MatchesStoredRow(JsonElement submitted, JsonElement stored) + { + foreach (var field in submitted.EnumerateObject()) + { + if (IgnoredForNoOp.Contains(field.Name)) + { + continue; + } + + // A field the stored row does not carry cannot have changed it. + if (!stored.TryGetProperty(field.Name, out var storedValue)) + { + continue; + } + + if (!SameValue(field.Value, storedValue)) + { + return false; + } + } + + return true; + } + + /// + /// Equality that tolerates the shapes PoracleNG stores a value in. + /// + /// + /// A list is written as an array and stored as its JSON text -- fort-change change_types comes + /// back as the string ["name"] -- which is why the models carry StringOrArrayConverter. A byte + /// comparison would call that unchanged list a change. + /// + /// + /// Applies the rewrites PoracleNG performs before it stores a submitted field. + /// + /// + /// Raids and max battles force level to 9000 unless the alarm tracks any boss + /// (trackingRaid.go:217-219, trackingMaxbattle.go:137-139). Nothing else in the identity set is + /// rewritten -- the updatable fields are excluded from the comparison already. + /// + private static JsonElement NormalizeForStorage( + JsonProperty field, JsonElement submittedValue, JsonElement submitted, string trackingType) + { + var levelIsForced = string.Equals(field.Name, "level", StringComparison.Ordinal) + && (string.Equals(trackingType, "raid", StringComparison.Ordinal) + || string.Equals(trackingType, "maxbattle", StringComparison.Ordinal)) + && submitted.TryGetProperty("pokemon_id", out var pokemonId) + && pokemonId.ValueKind == JsonValueKind.Number + && pokemonId.GetInt32() != AnyPokemonId; + + return levelIsForced ? AnyLevelElement : submittedValue; + } + + /// + /// The template PoracleNG stores when a submission leaves it blank. + /// + /// + /// PoracleNG uses general.defaultTemplateName and falls back to "1" when it is unset + /// (trackingMonster.go and its siblings). PoracleWeb does not read that setting here, so a deployment + /// that configures a custom default would see an exact-duplicate Add answered 409 rather than 200. + /// The durable fix is for the alarm services to send the resolved default instead of a blank; this + /// constant matches the upstream fallback in the meantime. See #593. + /// + private static readonly JsonElement DefaultTemplateElement = + JsonDocument.Parse("\"1\"").RootElement.Clone(); + + private const int AnyPokemonId = 9000; + + private static readonly JsonElement AnyLevelElement = + JsonDocument.Parse("9000").RootElement.Clone(); + + private static bool SameValue(JsonElement submitted, JsonElement stored) + { + if (JsonElement.DeepEquals(submitted, stored)) + { + return true; + } + + // null and "" are the same absence: the models use null for "any gym", PoracleNG stores "". + if (IsBlank(submitted) && IsBlank(stored)) + { + return true; + } + + if (stored.ValueKind == JsonValueKind.String && TryParse(stored.GetString(), out var reparsed)) + { + return JsonElement.DeepEquals(submitted, reparsed); + } + + return false; + } + + private static bool IsBlank(JsonElement value) => + value.ValueKind == JsonValueKind.Null + || (value.ValueKind == JsonValueKind.String && string.IsNullOrEmpty(value.GetString())); + + private static bool TryParse(string? text, out JsonElement parsed) + { + parsed = default; + + if (string.IsNullOrWhiteSpace(text)) + { + return false; + } + + try + { + parsed = JsonDocument.Parse(text).RootElement.Clone(); + return true; + } + catch (JsonException) + { + return false; + } + } + + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Failed to delete superseded {TrackingType} uid {OldUid} after the edit created uid {NewUid}; a duplicate row may remain.")] + private static partial void LogStaleDeleteFailed(ILogger logger, Exception exception, string trackingType, int oldUid, int newUid); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UpdateCheckService.cs b/Core/Pgan.PoracleWebNet.Core.Services/UpdateCheckService.cs new file mode 100644 index 00000000..133be5d8 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/UpdateCheckService.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Asks GitHub what the newest PoracleNG and PoracleWeb are, so an admin finds out they are behind here +/// rather than from a bug report. +/// +/// +/// +/// The two are read differently because the two projects publish differently. PoracleWeb cuts GitHub +/// releases, so its latest is a tag name. PoracleNG has no releases and no tags at all — its version is +/// a constant in processor/version.go, bumped by hand each cycle, so the released number is read +/// from that file on main. +/// +/// +/// That file is also what makes a development build identifiable: main holds 5.1.0 while +/// develop already holds 5.2.0, so a binary reporting more than main cannot have come from +/// a release. It is a better signal than the branch name, which the binary knows and never publishes. +/// +/// +/// This is the only part of PoracleWeb that talks to anything outside the deployment, so it is +/// switchable off with disable_update_check and fails silently. Nothing is sent: two anonymous +/// GETs, no identifiers, no payload. +/// +/// +public partial class UpdateCheckService( + HttpClient httpClient, + ISiteSettingService siteSettings, + IMemoryCache cache, + ILogger logger) : IUpdateCheckService +{ + /// Site setting that switches the outbound check off entirely. + public const string DisableKey = "disable_update_check"; + + private const string CacheKey = "poracle:update-check"; + + /// + /// Releases happen weekly at most, and the unauthenticated GitHub allowance is 60 calls an hour for + /// the whole host. Six hours keeps this far away from both. + /// + private static readonly TimeSpan CacheFor = TimeSpan.FromHours(6); + + private const string PoracleWebReleaseUrl = "https://api.github.com/repos/PGAN-Dev/PoracleWeb.NET/releases/latest"; + private const string PoracleNgVersionUrl = "https://raw.githubusercontent.com/jfberry/PoracleNG/main/processor/version.go"; + + private readonly HttpClient _httpClient = httpClient; + private readonly ISiteSettingService _siteSettings = siteSettings; + private readonly IMemoryCache _cache = cache; + private readonly ILogger _logger = logger; + + /// + public async Task<(UpdateStatus PoracleWeb, UpdateStatus PoracleNg)> CheckAsync( + string? runningPoracleWeb, + string? runningPoracleNg, + CancellationToken cancellationToken = default) + { + if (await this._siteSettings.GetBoolAsync(DisableKey)) + { + return (UpdateStatus.Unknown(runningPoracleWeb), UpdateStatus.Unknown(runningPoracleNg)); + } + + var (latestWeb, latestNg) = await this.GetLatestAsync(cancellationToken); + + return ( + UpdateStatus.Compare(runningPoracleWeb, latestWeb), + UpdateStatus.Compare(runningPoracleNg, latestNg)); + } + + /// + public void Invalidate() => this._cache.Remove(CacheKey); + + private async Task<(string? Web, string? Ng)> GetLatestAsync(CancellationToken cancellationToken) + { + if (this._cache.TryGetValue(CacheKey, out (string? Web, string? Ng) cached)) + { + return cached; + } + + // Independently: one project being unreachable should not hide the other's answer. + var web = await this.ReadLatestPoracleWebAsync(cancellationToken); + var ng = await this.ReadLatestPoracleNgAsync(cancellationToken); + + this._cache.Set(CacheKey, (web, ng), CacheFor); + + return (web, ng); + } + + private async Task ReadLatestPoracleWebAsync(CancellationToken cancellationToken) + { + try + { + var json = await this._httpClient.GetStringAsync(PoracleWebReleaseUrl, cancellationToken); + using var document = JsonDocument.Parse(json); + + return document.RootElement.TryGetProperty("tag_name", out var tag) && tag.ValueKind == JsonValueKind.String + ? tag.GetString() + : null; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException) + { + LogCheckFailed(this._logger, "PoracleWeb", ex); + return null; + } + } + + private async Task ReadLatestPoracleNgAsync(CancellationToken cancellationToken) + { + try + { + // No releases and no tags on that repository, so the released number is the constant on main. + var source = await this._httpClient.GetStringAsync(PoracleNgVersionUrl, cancellationToken); + var match = VersionConstant().Match(source); + + return match.Success ? match.Groups[1].Value : null; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + LogCheckFailed(this._logger, "PoracleNG", ex); + return null; + } + } + + [GeneratedRegex(@"const\s+Version\s*=\s*""([^""]+)""", RegexOptions.None, matchTimeoutMilliseconds: 1000)] + private static partial Regex VersionConstant(); + + [LoggerMessage( + EventId = 6120, + Level = LogLevel.Debug, + Message = "Could not read the latest published {Component} version. The update line is left blank.")] + private static partial void LogCheckFailed(ILogger logger, string component, Exception exception); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs b/Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs new file mode 100644 index 00000000..31344efa --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/UpstreamFeatureFlagService.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Reads Poracle's own per-type disable flags and reports them as disable_* keys. +/// +/// +/// +/// Two upstream reads are needed because the flags are split across two shapes. The +/// disabledHooks array on GET /api/config/poracleWeb covers the nine webhook types in +/// PoracleNG's hookTypes list; general.disable_fort_update on +/// GET /api/config/values covers fort changes, which PoracleNG enforces in the processor and +/// the bot but leaves out of the array. +/// +/// +/// The result is cached server-wide for five minutes, matching SiteSettingService. Upstream +/// this is a restart-scoped value read from config.toml, so even five minutes is generous — +/// but the gate is on the hot path (the dashboard fans out across ~10 alarm endpoints) and must not +/// add two HTTP round-trips per request. +/// +/// +/// It fails open, deliberately. Any fault, timeout, or absent field yields an empty +/// set, leaving the site settings in sole charge. Failing closed would let a Poracle outage disable +/// every alarm type for everyone, which is a far worse failure than the one this feature prevents. +/// +/// +public sealed partial class UpstreamFeatureFlagService( + IPoracleApiProxy poracleApiProxy, + IMemoryCache cache, + ILogger logger) : IUpstreamFeatureFlagService +{ + private const string CacheKey = "upstream_disabled_keys"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(5); + private static readonly IReadOnlySet None = new HashSet(StringComparer.Ordinal); + + private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly IMemoryCache _cache = cache; + private readonly ILogger _logger = logger; + + public async Task> GetDisabledKeysAsync() + { + if (this._cache.TryGetValue>(CacheKey, out var cached) && cached is not null) + { + return cached; + } + + var keys = await this.ProbeAsync(); + this._cache.Set(CacheKey, keys, CacheTtl); + return keys; + } + + private async Task> ProbeAsync() + { + var keys = new HashSet(StringComparer.Ordinal); + + try + { + var config = await this._poracleApiProxy.GetConfigAsync(); + foreach (var key in PoracleDisabledHookMap.ToDisableKeys(config?.DisabledHooks)) + { + keys.Add(key); + } + } + catch (Exception ex) + { + LogProbeFailed(this._logger, "disabledHooks", ex); + return None; + } + + try + { + if (await this._poracleApiProxy.GetFortUpdateDisabledAsync() == true) + { + keys.Add(DisableFeatureKeys.FortChanges); + } + } + catch (Exception ex) + { + // Independent degradation: a missing /api/config/values must not discard the hook list + // we already have. PoracleJS does not serve that route at all. + LogProbeFailed(this._logger, "general.disable_fort_update", ex); + } + + return keys; + } + + [LoggerMessage( + Level = LogLevel.Debug, + Message = "Could not read '{Source}' from Poracle; leaving the site settings in sole charge")] + private static partial void LogProbeFailed(ILogger logger, string source, Exception exception); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs b/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs index a2b14979..6cfc663e 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs @@ -1,5 +1,6 @@ using System.Text.Json; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Pgan.PoracleWebNet.Core.Abstractions.Repositories; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -16,6 +17,8 @@ public partial class UserGeofenceService( IHumanRepository humanRepository, IUserAreaDualWriter areaWriter, IDiscordNotificationService discordNotificationService, + IFeatureGate featureGate, + IConfiguration configuration, ILogger logger) : IUserGeofenceService { private const int MaxGeofencesPerUser = 10; @@ -27,6 +30,8 @@ public partial class UserGeofenceService( private readonly IHumanRepository _humanRepository = humanRepository; private readonly IUserAreaDualWriter _areaWriter = areaWriter; private readonly IDiscordNotificationService _discordNotificationService = discordNotificationService; + private readonly IFeatureGate _featureGate = featureGate; + private readonly IConfiguration _configuration = configuration; private readonly ILogger _logger = logger; public async Task> GetByUserAsync(string humanId) @@ -40,6 +45,9 @@ public async Task> GetByUserAsync(string humanId) try { g.Polygon = JsonSerializer.Deserialize(g.PolygonJson); + // The admin listing set this and the user listing did not, so every geofence on + // the user page reported 0 points. See #477. + g.PointCount = g.Polygon?.Length ?? 0; } catch (JsonException ex) { @@ -53,6 +61,10 @@ public async Task> GetByUserAsync(string humanId) public async Task CreateAsync(string humanId, int profileNo, UserGeofenceCreate model) { + // Gate the "provide a geofence" path (#214). Also covers GeoJSON import, which funnels + // through here. Throws FeatureDisabledException → 403 via the global exception filter. + await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.UserGeofences); + // Check count limit via local DB var count = await this._repository.GetCountByHumanIdAsync(humanId); if (count >= MaxGeofencesPerUser) @@ -72,15 +84,12 @@ public async Task CreateAsync(string humanId, int profileNo, UserG throw new InvalidOperationException("Display name contains invalid characters."); } - // Validate polygon point count - if (model.Polygon.Length > 500) + // Point count was the only thing checked here, so a polygon of [[1],[2],[3]] or one with + // coordinates outside the globe was stored verbatim and then served by the anonymous geofence + // feed. Import already validated arity and range; this is the same rule, applied once. See #410. + if (!PolygonValidation.TryValidate(model.Polygon, out var polygonError)) { - throw new InvalidOperationException("Polygon cannot exceed 500 points."); - } - - if (model.Polygon.Length < 3) - { - throw new InvalidOperationException("Polygon must have at least 3 points."); + throw new InvalidOperationException(polygonError); } // Use lowercase display name as the Koji geofence name @@ -88,17 +97,23 @@ public async Task CreateAsync(string humanId, int profileNo, UserG // and humans.area stores names in lowercase var kojiName = model.DisplayName.Trim().ToLowerInvariant(); - // Check for collision with existing geofences (our DB + Koji) - var existing = await this._repository.GetByKojiNameAsync(kojiName); - if (existing != null) + // Collisions are checked against BOTH sources, which is what the original comment claimed + // and what the code did not do: only user_geofences was consulted, so a user could take a + // name an admin area already held. Both then reach PoracleJS through the same feed under one + // name, and approving the private one pushes to Koji keyed on __name - an upsert that + // overwrites the real area's polygon and flags. Matching is case-insensitive because Poracle + // area matching is, in practice, and lowercasing alone does not separate "Nyack" from + // "nyack". See #475. + var takenNames = await this.ReservedGeofenceNamesAsync(); + + if (takenNames.Contains(kojiName)) { var baseName = kojiName; var found = false; for (var i = 2; i <= 10; i++) { kojiName = $"{baseName} {i}"; - existing = await this._repository.GetByKojiNameAsync(kojiName); - if (existing == null) + if (!takenNames.Contains(kojiName)) { found = true; break; @@ -126,10 +141,18 @@ public async Task CreateAsync(string humanId, int profileNo, UserG Status = "active", }); - // HACK: trusted-set-areas (see docs/poracleng-enhancement-requests.md) - // Atomic direct-DB dual-write of humans.area + current profiles.area. Revert to - // IPoracleHumanProxy.SetAreasAsync once PoracleNG ships a trusted setAreas variant. - await this._areaWriter.AddAreaToActiveProfileAsync(humanId, kojiName); + // Creating a geofence subscribes the current profile to it -- an area write, and one that ran + // straight past disable_areas while every documented area path was refused. Drawing and + // deleting fences was a way to edit area subscriptions with the switch on, and a GeoJSON import + // could write up to 50 of them in one request. With areas frozen the fence is still created; + // it just arrives inactive, and the user can turn it on when areas are re-enabled. See #505. + if (await this._featureGate.IsEnabledAsync(DisableFeatureKeys.Areas)) + { + // HACK: trusted-set-areas (see docs/poracleng-enhancement-requests.md) + // Atomic direct-DB dual-write of humans.area + current profiles.area. Revert to + // IPoracleHumanProxy.SetAreasAsync once PoracleNG ships a trusted setAreas variant. + await this._areaWriter.AddAreaToActiveProfileAsync(humanId, kojiName); + } // Reload Poracle geofences (Poracle reads from our feed + Koji) await this.ReloadGeofencesSafeAsync(); @@ -142,19 +165,174 @@ public async Task CreateAsync(string humanId, int profileNo, UserG return geofence; } + /// + /// Every geofence name already in use, from both sources that feed the geofence feed. + /// + /// + /// A Koji outage must not block geofence creation, so an unreachable Koji degrades to checking + /// PoracleWeb only - the same graceful-degradation rule the feed endpoint follows. + /// + private async Task> ReservedGeofenceNamesAsync() + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var g in await this._repository.GetAllAsync() ?? []) + { + names.Add(g.KojiName); + + if (!string.IsNullOrEmpty(g.PromotedName)) + { + names.Add(g.PromotedName); + } + } + + try + { + foreach (var admin in await this._kojiService.GetAdminGeofencesAsync() ?? []) + { + if (!string.IsNullOrEmpty(admin.Name)) + { + names.Add(admin.Name); + } + } + } + catch (Exception ex) + { + LogReservedNameLookupFailed(this._logger, ex); + } + + return names; + } + + public async Task RenameAsync( + string humanId, int id, string displayName, string? groupName, int? parentId) + { + await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.UserGeofences); + + var geofence = await this._repository.GetByIdAsync(id) + ?? throw new GeofenceNotFoundException(id); + + if (!string.Equals(geofence.HumanId, humanId, StringComparison.OrdinalIgnoreCase)) + { + throw new UnauthorizedAccessException("Geofence does not belong to this user."); + } + + // The same rules create enforces. Rename checked only for emptiness, so a name creation refuses -- + // over 50 characters, or carrying characters outside the allowlist -- could be applied by editing + // an existing geofence instead. The polygon-side validation on this feature is thorough, which + // made the gap look like an oversight rather than a decision. See #585. + // Once a fence is approved, Koji owns it under PromotedName and the area lists hold that name, + // not KojiName. Renaming then rewrote the subscription to a name neither Koji nor the feed + // serves, silently unsubscribing the owner from a live public area -- and PublicAreaName still + // returned the promoted name, so a later admin delete cleaned the wrong entry. A submission + // under review is frozen for the same reason: the admin is looking at the name as submitted. + // See #646. + if (!RenameableStatuses.Contains(geofence.Status)) + { + throw new InvalidOperationException( + $"A geofence cannot be renamed while its status is '{geofence.Status}'."); + } + + var trimmedName = displayName?.Trim() ?? string.Empty; + if (string.IsNullOrEmpty(trimmedName) || trimmedName.Length > 50) + { + throw new ArgumentException("Display name must be between 1 and 50 characters.", nameof(displayName)); + } + + if (!MyRegex().IsMatch(trimmedName)) + { + throw new ArgumentException("Display name contains invalid characters.", nameof(displayName)); + } + + var oldKojiName = geofence.KojiName; + var newKojiName = trimmedName.ToLowerInvariant(); + + if (!string.Equals(oldKojiName, newKojiName, StringComparison.OrdinalIgnoreCase)) + { + var takenNames = await this.ReservedGeofenceNamesAsync(); + if (takenNames.Contains(newKojiName)) + { + throw new TrackingConflictException( + "geofence", + "Another area already uses that name. Choose a different one."); + } + } + + geofence.DisplayName = trimmedName; + geofence.KojiName = newKojiName; + // group_name is NOT NULL, and the rename dialog does not always send one -- keep what is there rather than clearing it. + geofence.GroupName = string.IsNullOrWhiteSpace(groupName) ? geofence.GroupName : groupName; + // Same treatment as group_name above: 0 is the dialog's "nothing selected", not a request to + // clear the parent. Taken literally it wiped the region of every renamed geofence, and a later + // approval then sent __parent: null to Koji and landed the area ungrouped. See #648. + geofence.ParentId = parentId is > 0 ? parentId.Value : geofence.ParentId; + var updated = await this._repository.UpdateAsync(geofence); + + // Every profile that was subscribed stays subscribed, under the new name. This is the whole + // point of renaming in place rather than delete-then-recreate. See #543. + // HACK: trusted-set-areas (see docs/poracleng-enhancement-requests.md) + await this._areaWriter.RenameAreaInAllProfilesAsync(humanId, oldKojiName, newKojiName); + + await this.ReloadGeofencesSafeAsync(); + + // The repository round-trip returns the row without its parsed polygon, and the page renders the + // map straight from this response -- so a renamed geofence vanished from the map and reported 0 + // points until a reload. Carry both across, exactly as the listing does. See #559, #566. + updated.Polygon = geofence.Polygon ?? ParsePolygonSafe(updated.PolygonJson, updated.KojiName); + updated.PointCount = updated.Polygon?.Length ?? 0; + return updated; + } + /// Parses a stored polygon, returning null rather than throwing on a malformed one. + private double[][]? ParsePolygonSafe(string? polygonJson, string kojiName) + { + if (string.IsNullOrEmpty(polygonJson)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(polygonJson); + } + catch (JsonException ex) + { + LogPolygonDeserializationFailed(this._logger, ex, kojiName); + return null; + } + } + public async Task DeleteAsync(string humanId, int profileNo, int id) { var geofence = await this._repository.GetByIdAsync(id) - ?? throw new InvalidOperationException($"Geofence with ID {id} not found."); + ?? throw new GeofenceNotFoundException(id); if (!string.Equals(geofence.HumanId, humanId, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException("Geofence does not belong to this user."); } + // Same orphan risk as the admin path: if this fence was ever promoted it exists in Koji under the + // public name, and deleting only the local row leaves it there forever. See #409. + if (WasPromotedToKoji(geofence)) + { + try + { + await this._kojiService.RemoveGeofenceFromProjectAsync(PublicAreaName(geofence)); + } + catch (Exception ex) + { + LogKojiRemovalFailed(this._logger, ex, geofence.KojiName); + } + } + + // Deliberately not gated on disable_areas, unlike the subscribe on create: this is cleanup, not + // editing. The polygon is about to stop existing, and leaving its name in every profile's area + // list would subscribe those profiles to something Poracle can no longer resolve. See #505. + // // HACK: trusted-set-areas (see docs/poracleng-enhancement-requests.md) - // Atomic direct-DB removal from humans.area + every profiles.area row. - await this._areaWriter.RemoveAreaFromAllProfilesAsync(humanId, geofence.KojiName); + // Atomic direct-DB removal from humans.area + every profiles.area row. Uses the promoted name + // when there is one — that is what the owner is actually subscribed to after an approval. + await this._areaWriter.RemoveAreaFromAllProfilesAsync(humanId, PublicAreaName(geofence).ToLowerInvariant()); // Delete from local DB await this._repository.DeleteAsync(id); @@ -170,6 +348,25 @@ public async Task DeleteAsync(string humanId, int profileNo, int id) public async Task> GetAllWithDetailsAsync() { var geofences = await this.GetAllAsync(); + await this.EnrichWithDetailsAsync(geofences); + return geofences; + } + + /// + /// Fills in the fields the admin list renders but the table does not store: owner and reviewer + /// names, and the parsed polygon with its point count. + /// + /// + /// Shared with approve and reject so their responses carry the same projection. Returning the bare + /// row from those left the SPA -- which swaps the row for the response -- showing raw Discord + /// snowflakes and no map thumbnail until the page was reloaded. See #618. + /// + private async Task EnrichWithDetailsAsync(List geofences) + { + if (geofences.Count == 0) + { + return; + } // Batch-fetch owner humans by distinct HumanId var humanIds = geofences.Select(g => g.HumanId).Distinct().ToList(); @@ -215,22 +412,22 @@ public async Task> GetAllWithDetailsAsync() } } } - - return geofences; } public async Task AdminDeleteAsync(string adminId, int id) { var geofence = await this._repository.GetByIdAsync(id) - ?? throw new InvalidOperationException($"Geofence with ID {id} not found."); + ?? throw new GeofenceNotFoundException(id); - // If approved (promoted to Koji), remove from Koji too - if (geofence.Status == "approved") + // Keyed on "was this ever pushed to Koji?", not on the current status. Gating on + // status == "approved" meant a reject-then-delete left a public, userSelectable fence in the + // shared Koji project that no PoracleWeb record could manage — recoverable only by hand-editing + // Koji. PromotedName is set by approval and never cleared, so it is the durable marker. See #409. + if (WasPromotedToKoji(geofence)) { try { - var name = geofence.PromotedName ?? geofence.KojiName; - await this._kojiService.RemoveGeofenceFromProjectAsync(name); + await this._kojiService.RemoveGeofenceFromProjectAsync(PublicAreaName(geofence)); } catch (Exception ex) { @@ -241,9 +438,7 @@ public async Task AdminDeleteAsync(string adminId, int id) // Remove from user's area across all profiles try { - var areaName = geofence.Status == "approved" && geofence.PromotedName != null - ? geofence.PromotedName.ToLowerInvariant() - : geofence.KojiName; + var areaName = PublicAreaName(geofence).ToLowerInvariant(); // HACK: trusted-set-areas (see docs/poracleng-enhancement-requests.md) await this._areaWriter.RemoveAreaFromAllProfilesAsync(geofence.HumanId, areaName); } @@ -260,8 +455,10 @@ public async Task AdminDeleteAsync(string adminId, int id) public async Task SubmitForReviewAsync(string humanId, string kojiName) { + await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.UserGeofences); + var geofence = await this._repository.GetByKojiNameAsync(kojiName) - ?? throw new InvalidOperationException($"Geofence '{kojiName}' not found."); + ?? throw new GeofenceNotFoundException(kojiName); if (!string.Equals(geofence.HumanId, humanId, StringComparison.OrdinalIgnoreCase)) { @@ -281,56 +478,140 @@ public async Task SubmitForReviewAsync(string humanId, string koji // Create Discord forum post for the submission try { - var human = await this._humanRepository.GetByIdAndProfileAsync(humanId, 1); - var userName = human?.Name ?? humanId; + var post = await this.BuildSubmissionPostAsync(updated, GeofenceReviewState.Pending); + var threadId = await this._discordNotificationService.CreateGeofenceSubmissionPostAsync(post); - // Get polygon point count and static map from Poracle - var polygonPoints = 0; - string? mapImageUrl = null; - if (!string.IsNullOrEmpty(geofence.PolygonJson)) + if (threadId != null) { - try - { - var polygon = JsonSerializer.Deserialize(geofence.PolygonJson); - polygonPoints = polygon?.Length ?? 0; - } - catch (JsonException ex) - { - LogPolygonDeserializationFailed(this._logger, ex, geofence.KojiName); - } + updated.DiscordThreadId = threadId; + updated = await this._repository.UpdateAsync(updated); } + } + catch (Exception ex) + { + LogDiscordForumPostCreationFailed(this._logger, ex, kojiName); + } + + LogGeofenceSubmittedForReview(this._logger, humanId, kojiName); + return updated; + } + + /// + /// Assembles everything the Discord review card shows. Rebuilt on approval/rejection rather than + /// cached, so the edited post reflects whatever the admin actually published. + /// + private async Task BuildSubmissionPostAsync(UserGeofence geofence, GeofenceReviewState state) + { + // Profile-agnostic on purpose. GetByIdAndProfileAsync(id, 1) also filters on current_profile_no, so + // it finds nobody whose active profile isn't #1 -- which is most people, since the default is 0. + var human = await this._humanRepository.GetByIdAsync(geofence.HumanId); + + double[][]? polygon = null; + if (!string.IsNullOrEmpty(geofence.PolygonJson)) + { try { - mapImageUrl = await this._poracleApiProxy.GetAreaMapUrlAsync(geofence.KojiName); + polygon = JsonSerializer.Deserialize(geofence.PolygonJson); } - catch (Exception ex) + catch (JsonException ex) { - LogStaticMapFetchFailed(this._logger, ex, geofence.KojiName); + LogPolygonDeserializationFailed(this._logger, ex, geofence.KojiName); } + } - var threadId = await this._discordNotificationService.CreateGeofenceSubmissionPostAsync( - humanId, userName, geofence.DisplayName, geofence.GroupName, polygonPoints, mapImageUrl); + var area = polygon != null ? GeoMath.AreaSqKm(polygon) : 0; + var (lat, lon) = polygon != null ? GeoMath.Centroid(polygon) : (0, 0); - if (threadId != null) + string? mapImageUrl = null; + try + { + // Approved geofences live in Koji under their published name; everything else under the original. + var mapName = state == GeofenceReviewState.Approved && geofence.PromotedName != null + ? geofence.PromotedName.ToLowerInvariant() + : geofence.KojiName; + + mapImageUrl = await this._poracleApiProxy.GetAreaMapUrlAsync(mapName); + if (mapImageUrl == null) { - updated.DiscordThreadId = threadId; - updated = await this._repository.UpdateAsync(updated); + LogStaticMapUnavailable(this._logger, geofence.KojiName); } } catch (Exception ex) { - LogDiscordForumPostCreationFailed(this._logger, ex, kojiName); + LogStaticMapFetchFailed(this._logger, ex, geofence.KojiName); } - LogGeofenceSubmittedForReview(this._logger, humanId, kojiName); + var publicName = state == GeofenceReviewState.Approved && geofence.PromotedName != null + ? geofence.PromotedName.ToLowerInvariant() + : geofence.KojiName; - return updated; + return new GeofenceSubmissionPost + { + UserId = geofence.HumanId, + // Never a raw snowflake: with no name the author block is dropped and the mention in the + // message body carries the identity instead. + UserName = string.IsNullOrWhiteSpace(human?.Name) ? null : human.Name, + DisplayName = geofence.DisplayName, + PublicName = publicName, + GroupName = geofence.GroupName, + AreaSqKm = area, + CentroidLat = lat, + CentroidLon = lon, + OverlapsArea = polygon != null && state == GeofenceReviewState.Pending + ? await this.FindContainingPublicAreaAsync(lat, lon) + : null, + MapImageUrl = mapImageUrl, + State = state, + ReviewNotes = geofence.ReviewNotes, + ReviewUrl = this.BuildReviewUrl(), + }; + } + + /// + /// Names an existing public area whose polygon contains this submission's centre, so a reviewer can spot + /// a duplicate without opening a map. A centroid test, not a true overlap measurement. + /// + private async Task FindContainingPublicAreaAsync(double lat, double lon) + { + try + { + var adminGeofences = await this._kojiService.GetAdminGeofencesAsync(); + + foreach (var fence in adminGeofences) + { + // Bounding box first -- the Koji cache precomputes it precisely to avoid ray-casting 800 fences. + if (lat < fence.MinLat || lat > fence.MaxLat || lon < fence.MinLon || lon > fence.MaxLon) + { + continue; + } + + if (GeoMath.Contains(fence.Path, lat, lon)) + { + return fence.Name; + } + } + } + catch (Exception ex) + { + LogOverlapCheckFailed(this._logger, ex); + } + + return null; + } + + /// Deep link to the admin review queue, or null when no public site URL is configured. + private string? BuildReviewUrl() + { + var siteUrl = this._configuration["Site:PublicUrl"]; + return string.IsNullOrWhiteSpace(siteUrl) + ? null + : $"{siteUrl.TrimEnd('/')}/admin/geofence-submissions"; } public async Task> GetPendingSubmissionsAsync() => await this._repository.GetByStatusAsync("pending_review"); - public async Task ApproveSubmissionAsync(string adminId, int id, string? promotedName) + public async Task ApproveSubmissionAsync(string adminId, int id, string? promotedName, int? parentId = null, string? groupName = null) { // Validate promotedName with the same rules as display names if (promotedName != null) @@ -350,7 +631,23 @@ public async Task ApproveSubmissionAsync(string adminId, int id, s } var geofence = await this._repository.GetByIdAsync(id) - ?? throw new InvalidOperationException($"Geofence with ID {id} not found."); + ?? throw new GeofenceNotFoundException(id); + + // Approve accepted any status, the same gap #409 fixed on reject. + // + // pending_review the normal path + // rejected allowed: an admin reconsidering a call they already made. Nothing is in Koji + // yet, so this is a plain promotion. + // active refused: never submitted. Approving it skips the submission flow entirely, so + // there is no review thread and the owner never asked for it to be public. + // approved refused: already public in Koji. Re-approving under a different promoted name + // would push a second entry and strand the first — the exact leak #409 closed. + // Renaming a live public area is a different operation from approving one. + if (!ApprovableStatuses.Contains(geofence.Status)) + { + throw new InvalidOperationException( + $"Geofence must be awaiting review to be approved. Current status: '{geofence.Status}'."); + } // Parse polygon from local DB if (string.IsNullOrEmpty(geofence.PolygonJson)) @@ -361,49 +658,69 @@ public async Task ApproveSubmissionAsync(string adminId, int id, s var polygon = JsonSerializer.Deserialize(geofence.PolygonJson) ?? throw new InvalidOperationException($"Failed to deserialize polygon for geofence '{geofence.KojiName}'."); + // The region (parent/group) is what makes a promoted geofence appear under a region in Koji and + // PoracleNG's area picker. End users may create a geofence without one (issue #314), so let the + // approving admin set/override it here. Null args mean "keep whatever the submission already had". + if (parentId.HasValue) + { + // Koji resolves __parent as a geofence id and rejects one it does not know. That rejection used + // to surface as an opaque 500, so check it here where the id can be named. Values <= 0 are the + // documented "no region" case (#314) and are left alone. + if (parentId.Value > 0) + { + // Best-effort: if the region list itself cannot be fetched we let Koji decide rather than + // blocking an approval that would have worked. Koji's own rejection is now typed and + // surfaces as a 502 either way, so nothing becomes opaque again. + List? regions = null; + try + { + regions = await this._kojiService.GetRegionsAsync(); + } + catch (Exception ex) + { + LogRegionLookupFailed(this._logger, ex, parentId.Value); + } + + if (regions is { Count: > 0 } && !regions.Any(r => r.Id == parentId.Value)) + { + throw new InvalidOperationException( + $"Region {parentId.Value} does not exist in Koji. Pick a region from the list."); + } + } + + geofence.ParentId = parentId.Value; + } + + if (groupName != null) + { + geofence.GroupName = groupName.Trim(); + } + // Save to Koji as a public geofence (userSelectable + displayInMatches = true) var targetName = promotedName ?? geofence.KojiName; await this._kojiService.SaveGeofenceAsync( targetName, geofence.DisplayName, geofence.GroupName, geofence.ParentId, polygon, isPublic: true); - // If the name changed, update the user's area list via proxy + // Move the owner's subscription to the promoted name. + // + // HACK: trusted-set-areas — this must not go through SetAreasAsync. PoracleNG intersects the + // submitted list against userSelectable=true fences for non-admins, and at this point BOTH names + // fail that test: the old one because user geofences are served userSelectable=false, and the + // promoted one because PoracleNG has not reloaded its fence list yet (that happens below). The + // result was a silent wipe of the owner's entire custom-geofence subscription set — not just this + // fence — while approve still returned 200. See #408. if (promotedName != null && !string.Equals(promotedName, geofence.KojiName, StringComparison.Ordinal)) { try { - var currentAreas = await this.GetCurrentAreasAsync(geofence.HumanId); - var oldLower = geofence.KojiName.ToLowerInvariant(); - var newLower = promotedName.ToLowerInvariant(); - if (currentAreas.Remove(oldLower)) - { - currentAreas.Add(newLower); - await this._humanProxy.SetAreasAsync(geofence.HumanId, [.. currentAreas]); - } + await this._areaWriter.RenameAreaInAllProfilesAsync( + geofence.HumanId, geofence.KojiName, promotedName); } catch (Exception ex) { - LogProxyAreaSwapFailed(this._logger, ex, geofence.KojiName, promotedName); - // Fallback to direct DB for the area swap - try - { - var human = await this._humanRepository.GetByIdAndProfileAsync(geofence.HumanId, 1); - if (human != null) - { - var areas = AreaListJson.Parse(human.Area); - var oldLower = geofence.KojiName.ToLowerInvariant(); - var newLower = promotedName.ToLowerInvariant(); - if (areas.Remove(oldLower)) - { - areas.Add(newLower); - human.Area = AreaListJson.Serialize(areas); - await this._humanRepository.UpdateAsync(human); - } - } - } - catch (Exception innerEx) - { - LogAreaSwapFallbackFailed(this._logger, innerEx, geofence.KojiName); - } + // The geofence is public in Koji by now, so the approval itself stands. Surface the + // subscription loss instead of failing an approval that already took effect upstream. + LogAreaSwapFallbackFailed(this._logger, ex, geofence.KojiName); } } @@ -423,8 +740,8 @@ await this._kojiService.SaveGeofenceAsync( { try { - await this._discordNotificationService.PostApprovalMessageAsync( - geofence.DiscordThreadId, geofence.DisplayName, promotedName ?? geofence.DisplayName); + var post = await this.BuildSubmissionPostAsync(updated, GeofenceReviewState.Approved); + await this._discordNotificationService.PostReviewOutcomeAsync(geofence.DiscordThreadId, post); } catch (Exception ex) { @@ -434,13 +751,26 @@ await this._discordNotificationService.PostApprovalMessageAsync( LogGeofenceApproved(this._logger, adminId, geofence.KojiName, id, promotedName); + await this.EnrichWithDetailsAsync([updated]); return updated; } public async Task RejectSubmissionAsync(string adminId, int id, string reviewNotes) { var geofence = await this._repository.GetByIdAsync(id) - ?? throw new InvalidOperationException($"Geofence with ID {id} not found."); + ?? throw new GeofenceNotFoundException(id); + + // Without this, reject accepted any status. Rejecting an already-approved fence flipped the row to + // "rejected" while leaving it public in Koji, and admin delete then skipped the Koji cleanup + // because that was gated on status == "approved" — orphaning a public, userSelectable fence in the + // shared project with no local record able to manage it. It would also silently "reject" a + // geofence the owner had never submitted. SubmitForReviewAsync has always enforced the state + // machine; the review endpoints did not. See #409. + if (!string.Equals(geofence.Status, "pending_review", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Geofence must be awaiting review to be rejected. Current status: '{geofence.Status}'."); + } geofence.Status = "rejected"; geofence.ReviewedBy = adminId; @@ -454,8 +784,8 @@ public async Task RejectSubmissionAsync(string adminId, int id, st { try { - await this._discordNotificationService.PostRejectionMessageAsync( - geofence.DiscordThreadId, geofence.DisplayName, reviewNotes); + var post = await this.BuildSubmissionPostAsync(updated, GeofenceReviewState.Rejected); + await this._discordNotificationService.PostReviewOutcomeAsync(geofence.DiscordThreadId, post); } catch (Exception ex) { @@ -465,13 +795,21 @@ await this._discordNotificationService.PostRejectionMessageAsync( LogGeofenceRejected(this._logger, adminId, geofence.KojiName, id); + await this.EnrichWithDetailsAsync([updated]); return updated; } public async Task AddToProfileAsync(string humanId, int profileNo, int geofenceId) { + // Activating or deactivating writes an area subscription, so it must answer to + // disable_areas as well as disable_user_geofences. Enforced here rather than as a second + // attribute (the filter disallows two), which also covers service-to-service callers. + // See #478. + await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Areas); + await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.UserGeofences); + var geofence = await this._repository.GetByIdAsync(geofenceId) - ?? throw new InvalidOperationException($"Geofence with ID {geofenceId} not found."); + ?? throw new GeofenceNotFoundException(geofenceId); if (!string.Equals(geofence.HumanId, humanId, StringComparison.OrdinalIgnoreCase)) { @@ -493,8 +831,15 @@ public async Task AddToProfileAsync(string humanId, int profileNo, int geofenceI public async Task RemoveFromProfileAsync(string humanId, int profileNo, int geofenceId) { + // Activating or deactivating writes an area subscription, so it must answer to + // disable_areas as well as disable_user_geofences. Enforced here rather than as a second + // attribute (the filter disallows two), which also covers service-to-service callers. + // See #478. + await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.Areas); + await this._featureGate.EnsureEnabledAsync(DisableFeatureKeys.UserGeofences); + var geofence = await this._repository.GetByIdAsync(geofenceId) - ?? throw new InvalidOperationException($"Geofence with ID {geofenceId} not found."); + ?? throw new GeofenceNotFoundException(geofenceId); if (!string.Equals(geofence.HumanId, humanId, StringComparison.OrdinalIgnoreCase)) { @@ -554,20 +899,29 @@ public async Task> PreserveOwnedAreasInHumanAsync(string h } /// - /// Gets the current area list from humans.area via the PoracleNG proxy. Used by - /// for name-swap bookkeeping. + /// Statuses an admin may approve from. See the comment in for why + /// active and approved are not among them. /// - private async Task> GetCurrentAreasAsync(string humanId) - { - var humanJson = await this._humanProxy.GetHumanAsync(humanId); - if (humanJson is not null) - { - var areaStr = humanJson.Value.GetStringPropOrNull("area"); - return AreaListJson.Parse(areaStr); - } + /// Statuses a geofence may still be renamed in. See #646. + private static readonly HashSet RenameableStatuses = + new(StringComparer.OrdinalIgnoreCase) { "active", "rejected" }; - return []; - } + private static readonly HashSet ApprovableStatuses = + new(StringComparer.Ordinal) { "pending_review", "rejected" }; + + /// + /// Whether this geofence was ever pushed into the shared Koji project. PromotedName is written + /// by approval and never cleared, so it stays true through a later rejection — which is the point. + /// + private static bool WasPromotedToKoji(UserGeofence geofence) => + !string.IsNullOrEmpty(geofence.PromotedName) || string.Equals(geofence.Status, "approved", StringComparison.Ordinal); + + /// + /// The name this geofence is known by outside PoracleWeb: the promoted name once approved under one, + /// otherwise the original. This is the name in Koji and in the owner's area list. + /// + private static string PublicAreaName(UserGeofence geofence) => + string.IsNullOrEmpty(geofence.PromotedName) ? geofence.KojiName : geofence.PromotedName; private async Task ReloadGeofencesSafeAsync() { @@ -606,6 +960,12 @@ private async Task ReloadGeofencesSafeAsync() [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to fetch static map for geofence '{KojiName}'")] private static partial void LogStaticMapFetchFailed(ILogger logger, Exception ex, string kojiName); + [LoggerMessage(Level = LogLevel.Warning, Message = "Poracle returned no static map URL for geofence '{KojiName}'; the submission embed will have no map")] + private static partial void LogStaticMapUnavailable(ILogger logger, string kojiName); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not check the submission against existing public areas; the overlap hint will be omitted")] + private static partial void LogOverlapCheckFailed(ILogger logger, Exception ex); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to create Discord forum post for geofence submission '{KojiName}'")] private static partial void LogDiscordForumPostCreationFailed(ILogger logger, Exception ex, string kojiName); @@ -624,12 +984,15 @@ private async Task ReloadGeofencesSafeAsync() [LoggerMessage(Level = LogLevel.Information, Message = "Admin {AdminId} rejected geofence '{KojiName}' (ID {Id})")] private static partial void LogGeofenceRejected(ILogger logger, string adminId, string kojiName, int id); + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not fetch Koji regions to validate parent {ParentId}; letting Koji decide")] + private static partial void LogRegionLookupFailed(ILogger logger, Exception ex, int parentId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not read Koji areas while checking a geofence name for collisions; checked PoracleWeb only")] + private static partial void LogReservedNameLookupFailed(ILogger logger, Exception ex); + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to reload Poracle geofences after custom geofence change")] private static partial void LogGeofenceReloadFailed(ILogger logger, Exception ex); - [LoggerMessage(Level = LogLevel.Warning, Message = "Proxy area swap failed for geofence '{KojiName}' → '{PromotedName}', trying direct DB fallback")] - private static partial void LogProxyAreaSwapFailed(ILogger logger, Exception ex, string kojiName, string promotedName); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Direct DB fallback also failed for area swap on geofence '{KojiName}'")] + [LoggerMessage(Level = LogLevel.Warning, Message = "Could not move the owner's subscription to the promoted name for geofence '{KojiName}'; they are no longer subscribed to it")] private static partial void LogAreaSwapFallbackFailed(ILogger logger, Exception ex, string kojiName); } diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UserOwnedOverrideAreaProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/UserOwnedOverrideAreaProxy.cs new file mode 100644 index 00000000..3129aca6 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/UserOwnedOverrideAreaProxy.cs @@ -0,0 +1,320 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Lets an alarm confine itself to a geofence the user drew themselves. +/// +/// +/// +/// PoracleNG validates every entry of override_areas against GetAvailableAreas, which +/// filters on userSelectable for non-admins. PoracleWeb serves user-drawn geofences with +/// userSelectable: false on purpose, to keep them out of the bot's !area picker, so +/// submitting one is refused with 400 "area not permitted" and the whole write fails. Unlike +/// setAreas, which strips silently, this one rejects. +/// +/// +/// Matching never consults userSelectableresolveOverride hands the rule's areas +/// straight to areaOverlap, a name comparison against the fences the spawn fell in. So the fix +/// is to send PoracleNG only the names it will accept, then write the full list into the row +/// afterwards. Verified against PoracleNG 5.1.0. +/// +/// +/// This sits as a decorator over the tracking proxy rather than inside the ten alarm services because +/// the outbound body already carries everything the decision needs, and a service-layer version would +/// mean ten new constructor parameters and the same logic repeated ten times. See #730 and the +/// per-alarm scope proposal. +/// +/// +/// HACK: trusted-set-areas — remove this whole class if PoracleNG grows a trusted override write. +/// +/// +public partial class UserOwnedOverrideAreaProxy( + IPoracleTrackingProxy inner, + IUserGeofenceRepository geofences, + IUserAreaDualWriter areaWriter, + ILogger logger) : IPoracleTrackingProxy +{ + private readonly IPoracleTrackingProxy _inner = inner; + private readonly IUserGeofenceRepository _geofences = geofences; + private readonly IUserAreaDualWriter _areaWriter = areaWriter; + private readonly ILogger _logger = logger; + + public Task GetByUserAsync(string type, string userId) => + this._inner.GetByUserAsync(type, userId); + + public Task DeleteByUidAsync(string type, string userId, int uid) => + this._inner.DeleteByUidAsync(type, userId, uid); + + public Task BulkDeleteByUidsAsync(string type, string userId, IEnumerable uids) => + this._inner.BulkDeleteByUidsAsync(type, userId, uids); + + public Task GetAllTrackingAsync(string userId) => + this._inner.GetAllTrackingAsync(userId); + + public Task GetAllTrackingAllProfilesAsync(string userId) => + this._inner.GetAllTrackingAllProfilesAsync(userId); + + public Task ReloadStateAsync() => this._inner.ReloadStateAsync(); + + public async Task CreateAsync(string type, string userId, JsonElement body) + { + // Refuse an incoherent scope before anything is written. PoracleNG enforces the same three rules + // in validateOverrideFields and answers 400, but it only sees the sanitised body — a row whose + // only areas were the user's own would arrive with no override_areas at all and the + // areas-versus-distance rule would not fire there. Checking here also covers the callers that + // never touch a controller, which is where profile import and quick-pick apply slipped past their + // guards before (#548, #565). + EnsureScopeIsCoherent(body); + + // Fast path. Almost every write carries no override at all, and this must not add a geofence + // query to each of them. + if (!MentionsAnyOverrideArea(body)) + { + return await this._inner.CreateAsync(type, userId, body); + } + + var owned = await this.OwnedGeofenceNamesAsync(userId); + if (owned.Count == 0) + { + return await this._inner.CreateAsync(type, userId, body); + } + + var rows = RowsOf(body).ToList(); + var fullLists = rows + .Select(r => OverrideAreasOf(r)) + .ToList(); + + // Only the rows that actually name one of this user's own geofences need the workaround. + var needsWriteBack = fullLists + .Select(list => list is not null && list.Any(a => owned.Contains(a))) + .ToList(); + + if (!needsWriteBack.Contains(true)) + { + return await this._inner.CreateAsync(type, userId, body); + } + + var sanitised = StripOwned(body, owned); + var result = await this._inner.CreateAsync(type, userId, sanitised); + + var uids = await this.ResolveUidsAsync(type, userId, sanitised, result); + + for (var i = 0; i < rows.Count; i++) + { + if (!needsWriteBack[i] || uids[i] is not int uid) + { + continue; + } + + var written = await this._areaWriter.SetAlarmOverrideAreasAsync(userId, type, uid, fullLists[i]!); + if (!written) + { + // The row PoracleNG just reported is not there to write to. Refusing loudly beats an + // alarm that silently alerts on the whole profile instead of one small geofence. + LogWriteBackMissedRow(this._logger, type, uid, userId); + throw new InvalidOperationException( + $"Could not apply the area restriction to the {type} alarm that was just saved."); + } + } + + // PoracleNG reloads its state on its own mutations, and a direct column write is not one. + await this._inner.ReloadStateAsync(); + + return result; + } + + /// + /// The three mutual-exclusion rules PoracleNG applies to a per-alarm scope, mirrored so the refusal + /// arrives before any write and with wording a person can act on. + /// + /// + /// A place and a set of areas are two different answers to the same question, so a row cannot carry + /// both. A place is an anchor for a radius, so it needs one. Areas replace the radius entirely, so + /// they cannot coexist with one. + /// + private static void EnsureScopeIsCoherent(JsonElement body) + { + foreach (var row in RowsOf(body)) + { + var label = row.TryGetProperty("override_location_label", out var l) + && l.ValueKind == JsonValueKind.String + ? l.GetString() + : null; + var hasLabel = !string.IsNullOrWhiteSpace(label); + var hasAreas = OverrideAreasOf(row) is { Count: > 0 }; + var distance = row.TryGetProperty("distance", out var d) && d.ValueKind == JsonValueKind.Number + ? d.GetInt32() + : 0; + + if (hasLabel && hasAreas) + { + throw new AlarmValidationException( + "An alarm can be limited to a place or to areas, not both."); + } + + if (hasAreas && distance > 0) + { + throw new AlarmValidationException( + "An alarm limited to areas cannot also have a radius. Clear one of them."); + } + + if (hasLabel && distance == 0) + { + throw new AlarmValidationException( + "An alarm measured from a place needs a radius."); + } + } + } + + /// The lowercase names of every geofence this user drew. + private async Task> OwnedGeofenceNamesAsync(string userId) + { + var owned = await this._geofences.GetByHumanIdAsync(userId); + return owned + .Select(g => g.KojiName) + .Where(n => !string.IsNullOrWhiteSpace(n)) + .Select(n => n.ToLowerInvariant()) + .ToHashSet(StringComparer.Ordinal); + } + + /// + /// The uid each submitted row ended up under. Single-row writes read it from the response; + /// batches re-read and pair on content, because PoracleNG returns newUids in its own order + /// and index-pairing a batch response has bitten this codebase before (see BulkUidRemap, #443). + /// + private async Task> ResolveUidsAsync( + string type, string userId, JsonElement submitted, TrackingCreateResult result) + { + var rows = RowsOf(submitted).ToList(); + + if (rows.Count == 1) + { + return [result.PrimaryUid ?? UidOf(rows[0])]; + } + + var stored = await this._inner.GetByUserAsync(type, userId); + var byIdentity = new Dictionary(StringComparer.Ordinal); + foreach (var row in RowsOf(stored)) + { + if (UidOf(row) is int uid) + { + byIdentity[IdentityOf(row)] = uid; + } + } + + return rows + .Select(r => byIdentity.TryGetValue(IdentityOf(r), out var uid) ? uid : UidOf(r)) + .ToList(); + } + + /// + /// Everything about a row that distinguishes it from another, ignoring what PoracleNG assigns and + /// what this class rewrote. override_areas is excluded because the submitted row and the + /// stored row deliberately disagree on it at this point. + /// + private static string IdentityOf(JsonElement row) => + string.Join( + '|', + row.EnumerateObject() + .Where(p => p.Name is not ("uid" or "id" or "profile_no" or "description" + or "ping" or "override_areas")) + .OrderBy(p => p.Name, StringComparer.Ordinal) + .Select(p => $"{p.Name}={p.Value}")); + + private static IEnumerable RowsOf(JsonElement body) => + body.ValueKind switch + { + JsonValueKind.Array => body.EnumerateArray().Where(r => r.ValueKind == JsonValueKind.Object), + JsonValueKind.Object => [body], + _ => [], + }; + + private static int? UidOf(JsonElement row) => + row.TryGetProperty("uid", out var uid) && uid.ValueKind == JsonValueKind.Number + ? uid.GetInt32() + : null; + + private static List? OverrideAreasOf(JsonElement row) => + row.TryGetProperty("override_areas", out var areas) && areas.ValueKind == JsonValueKind.Array + ? areas.EnumerateArray() + .Where(a => a.ValueKind == JsonValueKind.String) + .Select(a => a.GetString()!.ToLowerInvariant()) + .ToList() + : null; + + private static bool MentionsAnyOverrideArea(JsonElement body) => + RowsOf(body).Any(r => OverrideAreasOf(r) is { Count: > 0 }); + + /// + /// The same body with the user's own geofence names removed from every override_areas. + /// A list left empty drops the property, so PoracleNG sees no override rather than an empty one. + /// + private static JsonElement StripOwned(JsonElement body, HashSet owned) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + var isArray = body.ValueKind == JsonValueKind.Array; + if (isArray) + { + writer.WriteStartArray(); + } + + foreach (var row in RowsOf(body)) + { + WriteStripped(writer, row, owned); + } + + if (isArray) + { + writer.WriteEndArray(); + } + } + + return JsonDocument.Parse(stream.ToArray()).RootElement.Clone(); + } + + private static void WriteStripped(Utf8JsonWriter writer, JsonElement row, HashSet owned) + { + writer.WriteStartObject(); + foreach (var prop in row.EnumerateObject()) + { + if (!prop.NameEquals("override_areas") || prop.Value.ValueKind != JsonValueKind.Array) + { + prop.WriteTo(writer); + continue; + } + + var permitted = prop.Value.EnumerateArray() + .Where(a => a.ValueKind == JsonValueKind.String + && !owned.Contains(a.GetString()!.ToLowerInvariant())) + .Select(a => a.GetString()!) + .ToList(); + + if (permitted.Count == 0) + { + continue; + } + + writer.WriteStartArray(prop.Name); + foreach (var area in permitted) + { + writer.WriteStringValue(area); + } + + writer.WriteEndArray(); + } + + writer.WriteEndObject(); + } + + [LoggerMessage( + Level = LogLevel.Error, + Message = "PoracleNG reported a {TrackingType} alarm at uid {Uid} for {UserId}, but no such row was there to apply the area restriction to.")] + private static partial void LogWriteBackMissedRow(ILogger logger, string trackingType, int uid, string userId); +} diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UserPurgeService.cs b/Core/Pgan.PoracleWebNet.Core.Services/UserPurgeService.cs new file mode 100644 index 00000000..0cc0fc96 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/UserPurgeService.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.Logging; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +public partial class UserPurgeService( + IHumanRepository humanRepository, + IUserGeofenceRepository geofenceRepository, + IUserGeofenceService geofenceService, + IWebhookDelegateRepository webhookDelegateRepository, + IQuickPickDefinitionRepository quickPickRepository, + IQuickPickAppliedStateRepository appliedStateRepository, + IHumanService humanService, + ILogger logger) : IUserPurgeService +{ + private readonly IHumanRepository _humanRepository = humanRepository; + private readonly IUserGeofenceRepository _geofenceRepository = geofenceRepository; + private readonly IUserGeofenceService _geofenceService = geofenceService; + private readonly IWebhookDelegateRepository _webhookDelegateRepository = webhookDelegateRepository; + private readonly IQuickPickDefinitionRepository _quickPickRepository = quickPickRepository; + private readonly IQuickPickAppliedStateRepository _appliedStateRepository = appliedStateRepository; + private readonly IHumanService _humanService = humanService; + private readonly ILogger _logger = logger; + + public async Task PurgeAsync(string userId) + { + if (!await this._humanRepository.ExistsAsync(userId)) + { + return false; + } + + // Order matters only at the end: the humans row goes last, so a failure part-way through leaves an + // account that is still visible and still deletable rather than a half-erased ghost. Each step is + // logged and swallowed for the same reason -- one unreachable dependency must not strand the rest. + await this.TryAsync("alarms", () => this._humanService.DeleteAllAlarmsByUserAsync(userId)); + await this.TryAsync("geofences", () => this.PurgeGeofencesAsync(userId)); + await this.TryAsync("webhook delegates", () => this._webhookDelegateRepository.RemoveAllForIdAsync(userId)); + await this.TryAsync("quick picks", () => this.PurgeQuickPicksAsync(userId)); + + return await this._humanRepository.DeleteUserAsync(userId); + } + + /// + /// Goes through the geofence service rather than the repository so a promoted fence is also removed from + /// the shared Koji project, and so Poracle re-reads the feed. Otherwise a deleted user's polygons keep + /// being served to PoracleJS. See #511. + /// + private async Task PurgeGeofencesAsync(string userId) + { + foreach (var geofence in await this._geofenceRepository.GetByHumanIdAsync(userId)) + { + await this._geofenceService.AdminDeleteAsync(userId, geofence.Id); + } + } + + private async Task PurgeQuickPicksAsync(string userId) + { + // Applied state first: it is keyed on the definition, and a global pick the user applied leaves a row + // that no definition delete would reach. + await this._appliedStateRepository.DeleteByUserAsync(userId); + + foreach (var definition in await this._quickPickRepository.GetByOwnerAsync(userId)) + { + await this._quickPickRepository.DeleteByIdAndOwnerAsync(definition.Id, userId); + } + } + + private async Task TryAsync(string step, Func work) + { + try + { + await work(); + } + catch (Exception ex) + { + LogPurgeStepFailed(this._logger, ex, step); + } + } + + // Its own try/catch rather than delegating to the overload above: an async lambda wrapping a Func> + // binds back to this method, which recurses until the stack goes. + private async Task TryAsync(string step, Func> work) + { + try + { + await work(); + } + catch (Exception ex) + { + LogPurgeStepFailed(this._logger, ex, step); + } + } + + [LoggerMessage( + Level = LogLevel.Error, + Message = "Could not remove {Step} while deleting a user; the account was still deleted and the leftovers need clearing by hand.")] + private static partial void LogPurgeStepFailed(ILogger logger, Exception exception, string step); +} diff --git a/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj b/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj index aeb789c7..8fc1a264 100644 --- a/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj +++ b/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj @@ -7,9 +7,10 @@ - - - + + + + diff --git a/Data/Pgan.PoracleWebNet.Data.Scanner/ScannerDbContext.cs b/Data/Pgan.PoracleWebNet.Data.Scanner/ScannerContext.cs similarity index 83% rename from Data/Pgan.PoracleWebNet.Data.Scanner/ScannerDbContext.cs rename to Data/Pgan.PoracleWebNet.Data.Scanner/ScannerContext.cs index fa1f4da5..20085814 100644 --- a/Data/Pgan.PoracleWebNet.Data.Scanner/ScannerDbContext.cs +++ b/Data/Pgan.PoracleWebNet.Data.Scanner/ScannerContext.cs @@ -3,7 +3,7 @@ namespace Pgan.PoracleWebNet.Data.Scanner; -public class ScannerDbContext(DbContextOptions options) : DbContext(options) +public class ScannerContext(DbContextOptions options) : DbContext(options) { public DbSet Pokestops => this.Set(); public DbSet Gyms => this.Set(); diff --git a/Data/Pgan.PoracleWebNet.Data/Configurations/OidcSessionConfiguration.cs b/Data/Pgan.PoracleWebNet.Data/Configurations/OidcSessionConfiguration.cs new file mode 100644 index 00000000..0c45d8d1 --- /dev/null +++ b/Data/Pgan.PoracleWebNet.Data/Configurations/OidcSessionConfiguration.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Pgan.PoracleWebNet.Data.Entities; + +namespace Pgan.PoracleWebNet.Data.Configurations; + +public class OidcSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.SessionTokenHash) + .HasMaxLength(64) + .IsRequired(); + + builder.Property(e => e.FamilyId) + .HasMaxLength(36) + .IsRequired(); + + builder.Property(e => e.FamilyIssuedAt) + .IsRequired(); + + builder.Property(e => e.UserId) + .HasMaxLength(100) + .IsRequired(); + + // longtext — DataProtection ciphertext is variable length. + builder.Property(e => e.EncryptedRefreshToken) + .IsRequired(); + + builder.Property(e => e.ExpiresAt).IsRequired(); + builder.Property(e => e.CreatedUtc).IsRequired(); + builder.Property(e => e.RevokedReason).HasMaxLength(32); + builder.Property(e => e.ReplacedByHash).HasMaxLength(64); + builder.Property(e => e.IpAddress).HasMaxLength(45); + builder.Property(e => e.UserAgent).HasMaxLength(256); + + // Hot lookup + DB-level reuse guard. + builder.HasIndex(e => e.SessionTokenHash).IsUnique(); + // Family revoke. + builder.HasIndex(e => e.FamilyId); + // Revoke-all-for-user (composite, left-to-right covering). + builder.HasIndex(e => new { e.UserId, e.RevokedAt }); + // Cleanup compound predicate. + builder.HasIndex(e => new { e.RevokedAt, e.ExpiresAt }); + } +} diff --git a/Data/Pgan.PoracleWebNet.Data/Entities/MonsterEntity.cs b/Data/Pgan.PoracleWebNet.Data/Entities/MonsterEntity.cs index c565077f..3ab84c37 100644 --- a/Data/Pgan.PoracleWebNet.Data/Entities/MonsterEntity.cs +++ b/Data/Pgan.PoracleWebNet.Data/Entities/MonsterEntity.cs @@ -116,6 +116,12 @@ public int PvpRankingLeague get; set; } + [Column("pvp_ranking_cap")] + public int PvpRankingCap + { + get; set; + } + [Column("form")] public int Form { diff --git a/Data/Pgan.PoracleWebNet.Data/Entities/OidcSessionEntity.cs b/Data/Pgan.PoracleWebNet.Data/Entities/OidcSessionEntity.cs new file mode 100644 index 00000000..89278d08 --- /dev/null +++ b/Data/Pgan.PoracleWebNet.Data/Entities/OidcSessionEntity.cs @@ -0,0 +1,84 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Pgan.PoracleWebNet.Data.Entities; + +/// +/// A server-side OIDC refresh session for an SSO-authenticated user. One row = one link in a +/// rotation chain (a "family"). The browser holds only an opaque PoracleWeb token whose SHA-256 +/// hash is ; the real provider refresh token lives encrypted in +/// and never leaves the server. Used only when OIDC refresh +/// consumption is enabled (Oidc:UseRefreshTokens); otherwise this table stays empty. +/// +[Table("oidc_sessions")] +public class OidcSessionEntity +{ + [Key] + [Column("id")] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id + { + get; set; + } + + /// SHA-256 (hex) of the opaque PoracleWeb refresh token handed to the browser. Unique. + [Column("session_token_hash")] + [Required] + public string SessionTokenHash { get; set; } = string.Empty; + + /// Rotation-chain id: every refresh revokes the presented row and inserts a successor in the same family. + [Column("family_id")] + [Required] + public string FamilyId { get; set; } = string.Empty; + + /// When the family (login session) began — denormalized absolute-cap anchor. + [Column("family_issued_at")] + public DateTime FamilyIssuedAt { get; set; } + + /// The Poracle human id (a Discord/Telegram id) this session authenticates. + [Column("user_id")] + [Required] + public string UserId { get; set; } = string.Empty; + + /// The provider's refresh token, protected via DataProtection. Never returned to clients. + [Column("encrypted_refresh_token")] + [Required] + public string EncryptedRefreshToken { get; set; } = string.Empty; + + [Column("expires_at")] + public DateTime ExpiresAt { get; set; } + + [Column("created_utc")] + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + [Column("revoked_at")] + public DateTime? RevokedAt + { + get; set; + } + + /// rotation | logout | replay_detected | absolute_cap | provider_revoked | account_inactive | admin_disable + [Column("revoked_reason")] + public string? RevokedReason + { + get; set; + } + + [Column("replaced_by_hash")] + public string? ReplacedByHash + { + get; set; + } + + [Column("ip_address")] + public string? IpAddress + { + get; set; + } + + [Column("user_agent")] + public string? UserAgent + { + get; set; + } +} diff --git a/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.Designer.cs b/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.Designer.cs new file mode 100644 index 00000000..cb230742 --- /dev/null +++ b/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.Designer.cs @@ -0,0 +1,394 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Pgan.PoracleWebNet.Data; + +#nullable disable + +namespace Pgan.PoracleWebNet.Data.Migrations.PoracleWeb +{ + [DbContext(typeof(PoracleWebContext))] + [Migration("20260608015721_AddOidcSessions")] + partial class AddOidcSessions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.OidcSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CreatedUtc") + .HasColumnType("datetime(6)") + .HasColumnName("created_utc"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("encrypted_refresh_token"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)") + .HasColumnName("expires_at"); + + b.Property("FamilyId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("varchar(36)") + .HasColumnName("family_id"); + + b.Property("FamilyIssuedAt") + .HasColumnType("datetime(6)") + .HasColumnName("family_issued_at"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("varchar(45)") + .HasColumnName("ip_address"); + + b.Property("ReplacedByHash") + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("replaced_by_hash"); + + b.Property("RevokedAt") + .HasColumnType("datetime(6)") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("revoked_reason"); + + b.Property("SessionTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("session_token_hash"); + + b.Property("UserAgent") + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("SessionTokenHash") + .IsUnique(); + + b.HasIndex("RevokedAt", "ExpiresAt"); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("oidc_sessions"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.QuickPickAppliedStateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AlarmType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("monster") + .HasColumnName("alarm_type"); + + b.Property("AppliedAt") + .HasColumnType("datetime(6)") + .HasColumnName("applied_at"); + + b.Property("ExcludePokemonIdsJson") + .HasColumnType("json") + .HasColumnName("exclude_pokemon_ids_json"); + + b.Property("ProfileNo") + .HasColumnType("int") + .HasColumnName("profile_no"); + + b.Property("QuickPickId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("quick_pick_id"); + + b.Property("TrackedUidsJson") + .IsRequired() + .HasColumnType("json") + .HasColumnName("tracked_uids_json"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ProfileNo", "QuickPickId") + .IsUnique(); + + b.ToTable("quick_pick_applied_states"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.QuickPickDefinitionEntity", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("id"); + + b.Property("AlarmType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("monster") + .HasColumnName("alarm_type"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasDefaultValue("Common") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("FiltersJson") + .IsRequired() + .HasColumnType("json") + .HasColumnName("filters_json"); + + b.Property("Icon") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasDefaultValue("bolt") + .HasColumnName("icon"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("owner_user_id"); + + b.Property("Scope") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("varchar(10)") + .HasDefaultValue("global") + .HasColumnName("scope"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Scope", "OwnerUserId"); + + b.ToTable("quick_pick_definitions"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.SiteSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("category"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("key"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("string") + .HasColumnName("value_type"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("site_settings"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.UserGeofenceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + b.Property("DiscordThreadId") + .HasColumnType("longtext") + .HasColumnName("discord_thread_id"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("display_name"); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("group_name"); + + b.Property("HumanId") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("human_id"); + + b.Property("KojiName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("koji_name"); + + b.Property("ParentId") + .HasColumnType("int") + .HasColumnName("parent_id"); + + b.Property("PolygonJson") + .HasColumnType("longtext") + .HasColumnName("polygon_json"); + + b.Property("PromotedName") + .HasColumnType("longtext") + .HasColumnName("promoted_name"); + + b.Property("ReviewNotes") + .HasColumnType("longtext") + .HasColumnName("review_notes"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("longtext") + .HasColumnName("reviewed_by"); + + b.Property("Status") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("status"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)") + .HasColumnName("submitted_at"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.ToTable("user_geofences"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.WebhookDelegateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("user_id"); + + b.Property("WebhookId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)") + .HasColumnName("webhook_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WebhookId", "UserId") + .IsUnique(); + + b.ToTable("webhook_delegates"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.cs b/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.cs new file mode 100644 index 00000000..573afded --- /dev/null +++ b/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.cs @@ -0,0 +1,69 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using MySql.EntityFrameworkCore.Metadata; + +#nullable disable + +namespace Pgan.PoracleWebNet.Data.Migrations.PoracleWeb +{ + /// + public partial class AddOidcSessions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "oidc_sessions", + columns: table => new + { + id = table.Column(type: "int", nullable: false) + .Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn), + session_token_hash = table.Column(type: "varchar(64)", maxLength: 64, nullable: false), + family_id = table.Column(type: "varchar(36)", maxLength: 36, nullable: false), + family_issued_at = table.Column(type: "datetime(6)", nullable: false), + user_id = table.Column(type: "varchar(100)", maxLength: 100, nullable: false), + encrypted_refresh_token = table.Column(type: "longtext", nullable: false), + expires_at = table.Column(type: "datetime(6)", nullable: false), + created_utc = table.Column(type: "datetime(6)", nullable: false), + revoked_at = table.Column(type: "datetime(6)", nullable: true), + revoked_reason = table.Column(type: "varchar(32)", maxLength: 32, nullable: true), + replaced_by_hash = table.Column(type: "varchar(64)", maxLength: 64, nullable: true), + ip_address = table.Column(type: "varchar(45)", maxLength: 45, nullable: true), + user_agent = table.Column(type: "varchar(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_oidc_sessions", x => x.id); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_oidc_sessions_family_id", + table: "oidc_sessions", + column: "family_id"); + + migrationBuilder.CreateIndex( + name: "IX_oidc_sessions_revoked_at_expires_at", + table: "oidc_sessions", + columns: new[] { "revoked_at", "expires_at" }); + + migrationBuilder.CreateIndex( + name: "IX_oidc_sessions_session_token_hash", + table: "oidc_sessions", + column: "session_token_hash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_oidc_sessions_user_id_revoked_at", + table: "oidc_sessions", + columns: new[] { "user_id", "revoked_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "oidc_sessions"); + } + } +} diff --git a/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/PoracleWebContextModelSnapshot.cs b/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/PoracleWebContextModelSnapshot.cs index acd53ae0..c364109f 100644 --- a/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/PoracleWebContextModelSnapshot.cs +++ b/Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/PoracleWebContextModelSnapshot.cs @@ -1,311 +1,391 @@ -// -using System; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Pgan.PoracleWebNet.Data; - -#nullable disable - -namespace Pgan.PoracleWebNet.Data.Migrations.PoracleWeb -{ - [DbContext(typeof(PoracleWebContext))] - partial class PoracleWebContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "10.0.1") - .HasAnnotation("Relational:MaxIdentifierLength", 64); - - modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.QuickPickAppliedStateEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasColumnName("id"); - - b.Property("AlarmType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("varchar(20)") - .HasDefaultValue("monster") - .HasColumnName("alarm_type"); - - b.Property("AppliedAt") - .HasColumnType("datetime(6)") - .HasColumnName("applied_at"); - - b.Property("ExcludePokemonIdsJson") - .HasColumnType("json") - .HasColumnName("exclude_pokemon_ids_json"); - - b.Property("ProfileNo") - .HasColumnType("int") - .HasColumnName("profile_no"); - - b.Property("QuickPickId") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("varchar(50)") - .HasColumnName("quick_pick_id"); - - b.Property("TrackedUidsJson") - .IsRequired() - .HasColumnType("json") - .HasColumnName("tracked_uids_json"); - - b.Property("UserId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("varchar(100)") - .HasColumnName("user_id"); - - b.HasKey("Id"); - - b.HasIndex("UserId", "ProfileNo", "QuickPickId") - .IsUnique(); - - b.ToTable("quick_pick_applied_states"); - }); - - modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.QuickPickDefinitionEntity", b => - { - b.Property("Id") - .HasMaxLength(50) - .HasColumnType("varchar(50)") - .HasColumnName("id"); - - b.Property("AlarmType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("varchar(20)") - .HasDefaultValue("monster") - .HasColumnName("alarm_type"); - - b.Property("Category") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(50) - .HasColumnType("varchar(50)") - .HasDefaultValue("Common") - .HasColumnName("category"); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)") - .HasColumnName("created_at"); - - b.Property("Description") - .HasColumnType("text") - .HasColumnName("description"); - - b.Property("Enabled") - .ValueGeneratedOnAdd() - .HasColumnType("tinyint(1)") - .HasDefaultValue(true) - .HasColumnName("enabled"); - - b.Property("FiltersJson") - .IsRequired() - .HasColumnType("json") - .HasColumnName("filters_json"); - - b.Property("Icon") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(50) - .HasColumnType("varchar(50)") - .HasDefaultValue("bolt") - .HasColumnName("icon"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(200) - .HasColumnType("varchar(200)") - .HasColumnName("name"); - - b.Property("OwnerUserId") - .HasMaxLength(100) - .HasColumnType("varchar(100)") - .HasColumnName("owner_user_id"); - - b.Property("Scope") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(10) - .HasColumnType("varchar(10)") - .HasDefaultValue("global") - .HasColumnName("scope"); - - b.Property("SortOrder") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasDefaultValue(0) - .HasColumnName("sort_order"); - - b.Property("UpdatedAt") - .HasColumnType("datetime(6)") - .HasColumnName("updated_at"); - - b.HasKey("Id"); - - b.HasIndex("Scope", "OwnerUserId"); - - b.ToTable("quick_pick_definitions"); - }); - - modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.SiteSettingEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasColumnName("id"); - - b.Property("Category") - .IsRequired() - .HasMaxLength(50) - .HasColumnType("varchar(50)") - .HasColumnName("category"); - - b.Property("Key") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("varchar(100)") - .HasColumnName("key"); - - b.Property("Value") - .HasColumnType("text") - .HasColumnName("value"); - - b.Property("ValueType") - .IsRequired() - .ValueGeneratedOnAdd() - .HasMaxLength(20) - .HasColumnType("varchar(20)") - .HasDefaultValue("string") - .HasColumnName("value_type"); - - b.HasKey("Id"); - - b.HasIndex("Key") - .IsUnique(); - - b.ToTable("site_settings"); - }); - - modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.UserGeofenceEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)") - .HasColumnName("created_at"); - - b.Property("DiscordThreadId") - .HasColumnType("longtext") - .HasColumnName("discord_thread_id"); - - b.Property("DisplayName") - .IsRequired() - .HasColumnType("longtext") - .HasColumnName("display_name"); - - b.Property("GroupName") - .IsRequired() - .HasColumnType("longtext") - .HasColumnName("group_name"); - - b.Property("HumanId") - .IsRequired() - .HasColumnType("longtext") - .HasColumnName("human_id"); - - b.Property("KojiName") - .IsRequired() - .HasColumnType("longtext") - .HasColumnName("koji_name"); - - b.Property("ParentId") - .HasColumnType("int") - .HasColumnName("parent_id"); - - b.Property("PolygonJson") - .HasColumnType("longtext") - .HasColumnName("polygon_json"); - - b.Property("PromotedName") - .HasColumnType("longtext") - .HasColumnName("promoted_name"); - - b.Property("ReviewNotes") - .HasColumnType("longtext") - .HasColumnName("review_notes"); - - b.Property("ReviewedAt") - .HasColumnType("datetime(6)") - .HasColumnName("reviewed_at"); - - b.Property("ReviewedBy") - .HasColumnType("longtext") - .HasColumnName("reviewed_by"); - - b.Property("Status") - .IsRequired() - .HasColumnType("longtext") - .HasColumnName("status"); - - b.Property("SubmittedAt") - .HasColumnType("datetime(6)") - .HasColumnName("submitted_at"); - - b.Property("UpdatedAt") - .HasColumnType("datetime(6)") - .HasColumnName("updated_at"); - - b.HasKey("Id"); - - b.ToTable("user_geofences"); - }); - - modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.WebhookDelegateEntity", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int") - .HasColumnName("id"); - - b.Property("CreatedAt") - .HasColumnType("datetime(6)") - .HasColumnName("created_at"); - - b.Property("UserId") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("varchar(100)") - .HasColumnName("user_id"); - - b.Property("WebhookId") - .IsRequired() - .HasMaxLength(500) - .HasColumnType("varchar(500)") - .HasColumnName("webhook_id"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.HasIndex("WebhookId", "UserId") - .IsUnique(); - - b.ToTable("webhook_delegates"); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Pgan.PoracleWebNet.Data; + +#nullable disable + +namespace Pgan.PoracleWebNet.Data.Migrations.PoracleWeb +{ + [DbContext(typeof(PoracleWebContext))] + partial class PoracleWebContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.OidcSessionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CreatedUtc") + .HasColumnType("datetime(6)") + .HasColumnName("created_utc"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("encrypted_refresh_token"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)") + .HasColumnName("expires_at"); + + b.Property("FamilyId") + .IsRequired() + .HasMaxLength(36) + .HasColumnType("varchar(36)") + .HasColumnName("family_id"); + + b.Property("FamilyIssuedAt") + .HasColumnType("datetime(6)") + .HasColumnName("family_issued_at"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("varchar(45)") + .HasColumnName("ip_address"); + + b.Property("ReplacedByHash") + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("replaced_by_hash"); + + b.Property("RevokedAt") + .HasColumnType("datetime(6)") + .HasColumnName("revoked_at"); + + b.Property("RevokedReason") + .HasMaxLength(32) + .HasColumnType("varchar(32)") + .HasColumnName("revoked_reason"); + + b.Property("SessionTokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)") + .HasColumnName("session_token_hash"); + + b.Property("UserAgent") + .HasMaxLength(256) + .HasColumnType("varchar(256)") + .HasColumnName("user_agent"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("SessionTokenHash") + .IsUnique(); + + b.HasIndex("RevokedAt", "ExpiresAt"); + + b.HasIndex("UserId", "RevokedAt"); + + b.ToTable("oidc_sessions"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.QuickPickAppliedStateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("AlarmType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("monster") + .HasColumnName("alarm_type"); + + b.Property("AppliedAt") + .HasColumnType("datetime(6)") + .HasColumnName("applied_at"); + + b.Property("ExcludePokemonIdsJson") + .HasColumnType("json") + .HasColumnName("exclude_pokemon_ids_json"); + + b.Property("ProfileNo") + .HasColumnType("int") + .HasColumnName("profile_no"); + + b.Property("QuickPickId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("quick_pick_id"); + + b.Property("TrackedUidsJson") + .IsRequired() + .HasColumnType("json") + .HasColumnName("tracked_uids_json"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("user_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ProfileNo", "QuickPickId") + .IsUnique(); + + b.ToTable("quick_pick_applied_states"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.QuickPickDefinitionEntity", b => + { + b.Property("Id") + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("id"); + + b.Property("AlarmType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("monster") + .HasColumnName("alarm_type"); + + b.Property("Category") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasDefaultValue("Common") + .HasColumnName("category"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("FiltersJson") + .IsRequired() + .HasColumnType("json") + .HasColumnName("filters_json"); + + b.Property("Icon") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasDefaultValue("bolt") + .HasColumnName("icon"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)") + .HasColumnName("name"); + + b.Property("OwnerUserId") + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("owner_user_id"); + + b.Property("Scope") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("varchar(10)") + .HasDefaultValue("global") + .HasColumnName("scope"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("Scope", "OwnerUserId"); + + b.ToTable("quick_pick_definitions"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.SiteSettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("category"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("key"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.Property("ValueType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("varchar(20)") + .HasDefaultValue("string") + .HasColumnName("value_type"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("site_settings"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.UserGeofenceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + b.Property("DiscordThreadId") + .HasColumnType("longtext") + .HasColumnName("discord_thread_id"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("display_name"); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("group_name"); + + b.Property("HumanId") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("human_id"); + + b.Property("KojiName") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("koji_name"); + + b.Property("ParentId") + .HasColumnType("int") + .HasColumnName("parent_id"); + + b.Property("PolygonJson") + .HasColumnType("longtext") + .HasColumnName("polygon_json"); + + b.Property("PromotedName") + .HasColumnType("longtext") + .HasColumnName("promoted_name"); + + b.Property("ReviewNotes") + .HasColumnType("longtext") + .HasColumnName("review_notes"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)") + .HasColumnName("reviewed_at"); + + b.Property("ReviewedBy") + .HasColumnType("longtext") + .HasColumnName("reviewed_by"); + + b.Property("Status") + .IsRequired() + .HasColumnType("longtext") + .HasColumnName("status"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)") + .HasColumnName("submitted_at"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.ToTable("user_geofences"); + }); + + modelBuilder.Entity("Pgan.PoracleWebNet.Data.Entities.WebhookDelegateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)") + .HasColumnName("user_id"); + + b.Property("WebhookId") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)") + .HasColumnName("webhook_id"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WebhookId", "UserId") + .IsUnique(); + + b.ToTable("webhook_delegates"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj b/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj index 62f11b09..954885bc 100644 --- a/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj +++ b/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj @@ -7,12 +7,14 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + + + diff --git a/Data/Pgan.PoracleWebNet.Data/PoracleWebContext.cs b/Data/Pgan.PoracleWebNet.Data/PoracleWebContext.cs index 0d2e6027..d5afd51a 100644 --- a/Data/Pgan.PoracleWebNet.Data/PoracleWebContext.cs +++ b/Data/Pgan.PoracleWebNet.Data/PoracleWebContext.cs @@ -30,6 +30,11 @@ public DbSet QuickPickAppliedStates get; set; } + public DbSet OidcSessions + { + get; set; + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/Dockerfile b/Dockerfile index 82b82d6f..c3e3127d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ # Stage 1: Build Angular SPA FROM node:22-alpine AS angular-build WORKDIR /app/angular +# node:22-alpine bundles npm 10.9.x, which rejects the npm-11-generated +# package-lock.json with EUSAGE (pruned optional chokidar/readdirp peers). +# CI pins npm 11 for the same reason; do the same here so `npm ci` succeeds. +RUN npm install -g npm@11 COPY Applications/Pgan.PoracleWebNet.App/ClientApp/package*.json ./ RUN npm ci COPY Applications/Pgan.PoracleWebNet.App/ClientApp/ ./ @@ -27,6 +31,15 @@ RUN dotnet publish Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.cs # Stage 3: Runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app +# curl is for the compose healthcheck -- the aspnet:10.0 base (Ubuntu 24.04) +# ships neither curl nor wget, so the probe fails with "curl: not found" and +# the container reports unhealthy while serving traffic fine. See #239. +# Unpinned deliberately: the base is the rolling `aspnet:10.0` tag, so a pinned +# curl version would fail to resolve as soon as Ubuntu supersedes the package. +# hadolint ignore=DL3008 +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* RUN useradd --system --no-create-home appuser COPY --from=dotnet-build /app/publish . COPY --from=angular-build /app/angular/dist/ClientApp/browser wwwroot/ @@ -40,5 +53,16 @@ ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_ENVIRONMENT=Production ENV DATA_DIR=/app/data +# Build provenance, surfaced at runtime by GET /api/version. The image's OCI labels already +# carry this, but labels are only readable via `docker inspect` on the host -- useless for +# checking a deployed instance from outside. CI passes these from the same metadata that +# produces the labels; local builds leave them "unknown". +ARG BUILD_VERSION=unknown +ARG BUILD_REVISION=unknown +ARG BUILD_DATE=unknown +ENV BUILD_VERSION=$BUILD_VERSION +ENV BUILD_REVISION=$BUILD_REVISION +ENV BUILD_DATE=$BUILD_DATE + USER appuser ENTRYPOINT ["dotnet", "Pgan.PoracleWebNet.Api.dll"] diff --git a/README.md b/README.md index ec0b3c85..1961fe7b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # PoracleWeb.NET -A web application for managing Pokemon GO notification alarms through the Poracle bot system. Compatible with both [PoracleJS](https://github.com/KartulUdus/PoracleJS) and [PoracleNG](https://github.com/jfberry/PoracleNG). Users authenticate via Discord OAuth2 or Telegram and configure personalized alert filters (Pokemon, Raids, Quests, Invasions, Lures, Nests, Gyms) through a browser-based UI. +A web application for managing Pokemon GO notification alarms through the [PoracleNG](https://github.com/jfberry/PoracleNG) bot. Users authenticate via Discord OAuth2 or Telegram and configure personalized alert filters (Pokemon, Raids and Eggs, Max Battles, Quests, Invasions, Lures, Nests, Gyms, Fort Changes) through a browser-based UI. + +> **PoracleNG 5.1.0 or newer is required.** All alarm management, profile handling, and user operations are proxied through PoracleNG's REST API. On an older server, per-alarm delivery scope, the PVP mega evolution filter and the minimum time-left filter write columns that don't exist: the controls accept input, save, and change nothing. PoracleWeb logs an error at startup and shows the mismatch on the Versions card under Admin > Settings. +> +> [PoracleJS](https://github.com/KartulUdus/PoracleJS) is not a tested or supported configuration — some operations that rely on PoracleNG-specific endpoints will not work. **[Documentation](https://pgan-dev.github.io/PoracleWeb.NET/)** | **[Changelog](CHANGELOG.md)** @@ -40,18 +44,23 @@ See the [Quick Start guide](https://pgan-dev.github.io/PoracleWeb.NET/getting-st ## Features -- **Alarm Management** — Pokemon, Raids, Quests, Invasions, Lures, Nests, Gyms +- **Alarm Management** — Pokemon, Raids and Eggs, Max Battles, Quests, Invasions, Lures, Nests, Gyms, Fort Changes - **Gym Picker** — Search and target specific gyms for team change, raid, and egg alarms - **Bulk Operations** — Multi-select with bulk delete and distance update -- **Custom Geofences** — Draw polygons, auto-served to PoracleJS via unified feed +- **Per-Alarm Delivery Scope** — Aim each alert anywhere in your areas, at only specific areas, or within a radius of your pin or a saved place +- **Saved Places** — Name the points your alerts measure from, so an alarm doesn't have to follow your profile pin +- **Test Alerts** — Send yourself a sample notification for any alarm to check its filters and template +- **Alert Defaults** — Choose where new alerts default to reaching you: your areas, or a radius from your pin or a saved place +- **Custom Geofences** — Draw polygons, auto-served to the Poracle bot via unified feed - **Geofence Admin Review** — Approve/reject with Discord forum integration - **Quick Picks** — One-click alarm templates - **Profile Switching** — Multiple alarm profiles per user - **Profile Active Hours** — Schedule automatic profile switching by day and time - **DTS Preview** — Live Discord notification template preview - **Dark/Light Mode** — Theme toggle with accent color customization -- **18 Languages** — Pokemon name localization -- **Admin Panel** — User management, webhooks, settings, geofence review +- **11 UI Languages** — the interface, plus Pokemon names, types and forms, which follow the display language. What Poracle writes in your DMs is a separate choice, **Alert language**, sitting beside **Display language** in the user menu +- **Single Sign-On** — Discord and Telegram login, plus any OIDC provider, with optional silent refresh and single logout +- **Admin Panel** — User management, webhooks, settings, geofence review, and a Versions card showing the running PoracleWeb and PoracleNG builds. Opening that card runs an anonymous GitHub check, cached six hours, switched off with the **Do not check for updates** (`disable_update_check`) site setting ## Documentation @@ -71,19 +80,26 @@ Full documentation is available at **[pgan-dev.github.io/PoracleWeb.NET](https:/ git clone https://github.com/PGAN-Dev/PoracleWeb.NET.git cd PoracleWeb.NET -# Backend (http://localhost:5048) -cd Applications/Pgan.PoracleWebNet.Api -dotnet run +# First-time setup (interactive; writes .env) and frontend dependencies +./scripts/setup.sh +./scripts/dev.sh install + +# Run both servers — API on http://localhost:5048, Angular on http://localhost:4200 +./scripts/dev.sh start -# Frontend (http://localhost:4200) -cd Applications/Pgan.PoracleWebNet.App/ClientApp -npm install && npm start +# Or one at a time +./scripts/dev.sh api +./scripts/dev.sh app # Tests -dotnet test # Backend -cd Applications/Pgan.PoracleWebNet.App/ClientApp && npm test # Frontend +./scripts/dev.sh test ``` +Run everything from the repo root. `Program.cs` reads `.env` from the working directory, so +`cd`-ing into the API project before `dotnet run` starts the app with no connection strings, no JWT +secret and no Poracle API address. `scripts/dev.sh` exports `.env` for you; without it, use +`dotnet run --project Applications/Pgan.PoracleWebNet.Api` from the root. + See the [Development Setup guide](https://pgan-dev.github.io/PoracleWeb.NET/getting-started/development-setup/) for full instructions. ## Branch Naming @@ -110,24 +126,48 @@ Three Docker channels are published to GHCR — see [TESTING.md](TESTING.md) for | Channel | Tag | Trigger | |---|---|---| -| Stable | `:latest`, `:vX.Y.Z` | Release tag | -| Beta | `:beta`, `:main-` | Every push to `main` | +| Stable | `:latest`, `:X.Y.Z`, `:X.Y` | Release published | +| Beta | `:beta`, `:develop-` | Every push to `develop` | | PR preview | `:pr-` | PRs with the `preview` label | +The same split applies when you **build from source**: + +| You check out | You get | +|---|---| +| `main`, or a release tag | Stable — the same code as `:latest` | +| `develop` | Beta — every merged PR, including work that has never been in a release | + +`main` only moves when a release is published, so a plain `git clone` gives you released code. `develop` is where changes soak first; running it means running code that has not shipped yet. + +## Branches + +| Branch | Purpose | +|---|---| +| `main` | Released code. Only moves on a release. Publishes `:latest`. | +| `develop` | Integration. **Pull requests target this.** Publishes `:beta` on every merge. | + +Cutting a release means merging `develop` into `main` and publishing a GitHub release; the changelog is promoted from `[Unreleased]` automatically. + ## CI/CD - **ci.yml** — Builds backend, runs tests, builds frontend, runs lint/prettier/jest -- **docker-publish.yml** — Builds and publishes Docker image to [`ghcr.io/pgan-dev/poracleweb.net`](https://github.com/PGAN-Dev/PoracleWeb.NET/pkgs/container/poracleweb.net) (`:latest` on release, `:beta` on main) +- **docker-publish.yml** — Builds and publishes Docker image to [`ghcr.io/pgan-dev/poracleweb.net`](https://github.com/PGAN-Dev/PoracleWeb.NET/pkgs/container/poracleweb.net) (`:latest` on release, `:beta` on `develop`) - **docker-preview.yml** — Builds `:pr-` images on PRs labeled `preview` -- **docker-prune.yml** — Nightly cleanup of stale `pr-*` and `main-` tags +- **docker-prune.yml** — Nightly cleanup of stale `pr-*` and `develop-` tags - **pr-labeler.yml** — Auto-labels PRs from branch prefix / PR title for release-note grouping -- **release.yml** (config) — Groups PRs by label when generating GitHub release notes +- **changelog.yml** — Checks that a PR adds an entry under `## [Unreleased]` in CHANGELOG.md +- **release-changelog.yml** — On a published release, opens a PR promoting `[Unreleased]` to the new version section +- **docs.yml** — Builds the MkDocs site and deploys it to GitHub Pages +- **auto-merge-deps.yml** — Enables auto-merge on low-risk Dependabot bumps (patches, curated groups, Actions minors); majors wait for review + +`.github/release.yml` is a config file, not a workflow: it groups PRs by label when GitHub generates +release notes. ## Credits PoracleWeb.NET stands on the shoulders of these projects and their authors: -- **[PoracleJS](https://github.com/KartulUdus/PoracleJS)** by KartulUdus — the original Poracle bot that this UI manages +- **[PoracleJS](https://github.com/KartulUdus/PoracleJS)** by KartulUdus — the original Poracle bot (alarm management in this app uses PoracleNG's REST API) - **[PoracleNG](https://github.com/jfberry/PoracleNG)** by jfberry — next-generation fork whose REST API powers all alarm tracking - **[PoracleWeb (PHP)](https://github.com/bbdoc/PoracleWeb)** by bbdoc — the original PHP web interface that inspired this .NET rewrite - **[Kōji](https://github.com/TurtIeSocks/Koji)** by TurtIeSocks — geofence management platform used for admin areas, region detection, and public geofence promotion diff --git a/TESTING.md b/TESTING.md index cbd62243..76d39d81 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4,8 +4,8 @@ PoracleWeb.NET publishes three Docker image channels on GHCR. Pick one based on | Channel | Tag | What it is | Updates | |---|---|---|---| -| **Stable** | `:latest`, `:v2.5.0` | Tagged releases. Battle-tested. | On release | -| **Beta** | `:beta` | Latest `main`. Next release candidate. | Every merge to main | +| **Stable** | `:latest`, `:X.Y.Z`, `:X.Y` | Tagged releases, built from `main`. Battle-tested. | On release | +| **Beta** | `:beta`, `:develop-` | Latest `develop`. Next release candidate. | Every merge to `develop` | | **PR preview** | `:pr-123` | A specific pull request. Unreviewed code. | Every push to that PR | Image registry: [`ghcr.io/pgan-dev/poracleweb.net`](https://github.com/PGAN-Dev/PoracleWeb.NET/pkgs/container/poracleweb.net). @@ -77,7 +77,7 @@ docker compose up -d --force-recreate ## Reporting issues - **On a PR preview**: comment directly on the PR. -- **On `:beta`**: open a GitHub issue and mention the `main-` tag you're running (`docker inspect` the container to find it). +- **On `:beta`**: open a GitHub issue and mention the `develop-` tag you're running (`docker inspect` the container to find it). - **On `:latest`**: open a GitHub issue with the version tag. Include: your channel/tag, `docker compose logs --tail 200`, steps to reproduce. diff --git a/Tests/Pgan.PoracleWebNet.Tests/Configuration/JwtServiceReissueTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Configuration/JwtServiceReissueTests.cs new file mode 100644 index 00000000..09ab3752 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Configuration/JwtServiceReissueTests.cs @@ -0,0 +1,107 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.Extensions.Options; +using Pgan.PoracleWebNet.Api.Configuration; + +namespace Pgan.PoracleWebNet.Tests.Configuration; + +/// +/// A token re-issue must not extend the session or carry a stale isAdmin claim. See #624. +/// +public class JwtServiceReissueTests +{ + private static readonly JwtSettings Settings = new() + { + Secret = "this-is-a-test-signing-key-long-enough-for-hmac-sha256", + Issuer = "PoracleWeb.Api", + Audience = "PoracleWeb.App", + ExpirationMinutes = 1440, + }; + + private static ClaimsPrincipal PrincipalExpiringIn(TimeSpan remaining, bool isAdmin = true) + { + var expiresAt = DateTimeOffset.UtcNow.Add(remaining).ToUnixTimeSeconds(); + var identity = new ClaimsIdentity( + [ + new Claim("userId", "u1"), + new Claim("username", "Tester"), + new Claim("isAdmin", isAdmin.ToString().ToLowerInvariant()), + new Claim("profileNo", "0"), + new Claim("exp", expiresAt.ToString(System.Globalization.CultureInfo.InvariantCulture)), + ], "TestAuth"); + + return new ClaimsPrincipal(identity); + } + + private static JwtSecurityToken Read(string token) => new JwtSecurityTokenHandler().ReadJwtToken(token); + + [Fact] + public void ReissueKeepsTheOriginalExpiryRatherThanStartingAFreshLifetime() + { + // An OIDC access token is deliberately short so revocation propagates. Re-issuing at the + // configured default turned a 30-minute session into a 24-hour one on the first profile switch. + var sut = new JwtService(Options.Create(Settings)); + var principal = PrincipalExpiringIn(TimeSpan.FromMinutes(30)); + + var token = Read(sut.GenerateTokenWithReplacedProfile(principal, 2)); + + var remaining = token.ValidTo - DateTime.UtcNow; + Assert.InRange(remaining.TotalMinutes, 25, 35); + } + + [Fact] + public void ReissueDoesNotRenewASessionThatIsAlmostOver() + { + var sut = new JwtService(Options.Create(Settings)); + var principal = PrincipalExpiringIn(TimeSpan.FromMinutes(2)); + + var token = Read(sut.GenerateTokenWithReplacedProfile(principal, 2)); + + Assert.True((token.ValidTo - DateTime.UtcNow).TotalMinutes < 10); + } + + [Fact] + public void ReissueReplacesIsAdminWhenAFreshValueIsSupplied() + { + // Copied verbatim, the claim outlived the rights it described: a de-admined user who switched + // profile once a day never lost access. + var sut = new JwtService(Options.Create(Settings)); + var principal = PrincipalExpiringIn(TimeSpan.FromHours(1), isAdmin: true); + + var token = Read(sut.GenerateTokenWithReplacedProfile(principal, 2, isAdmin: false)); + + Assert.Equal("false", token.Claims.Single(c => c.Type == "isAdmin").Value); + } + + [Fact] + public void ReissueKeepsTheExistingIsAdminWhenNoFreshValueIsSupplied() + { + var sut = new JwtService(Options.Create(Settings)); + var principal = PrincipalExpiringIn(TimeSpan.FromHours(1), isAdmin: true); + + var token = Read(sut.GenerateTokenWithReplacedProfile(principal, 2)); + + Assert.Equal("true", token.Claims.Single(c => c.Type == "isAdmin").Value); + } + + [Fact] + public void ReissueFallsBackToTheConfiguredLifetimeWhenThePrincipalCarriesNoExpiry() + { + var sut = new JwtService(Options.Create(Settings)); + var identity = new ClaimsIdentity([new Claim("userId", "u1"), new Claim("profileNo", "0")], "TestAuth"); + + var token = Read(sut.GenerateTokenWithReplacedProfile(new ClaimsPrincipal(identity), 1)); + + Assert.InRange((token.ValidTo - DateTime.UtcNow).TotalMinutes, 1400, 1441); + } + + [Fact] + public void ReissueStillReplacesTheProfileNumber() + { + var sut = new JwtService(Options.Create(Settings)); + + var token = Read(sut.GenerateTokenWithReplacedProfile(PrincipalExpiringIn(TimeSpan.FromHours(1)), 7)); + + Assert.Equal("7", token.Claims.Single(c => c.Type == "profileNo").Value); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Configuration/PublicOriginTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Configuration/PublicOriginTests.cs new file mode 100644 index 00000000..a78d9945 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Configuration/PublicOriginTests.cs @@ -0,0 +1,56 @@ +using Pgan.PoracleWebNet.Api.Configuration; + +namespace Pgan.PoracleWebNet.Tests.Configuration; + +/// +/// PUBLIC_URL is validated at startup so a typo surfaces there rather than as an OAuth provider +/// rejecting a malformed redirect_uri, which names neither the setting nor the mistake. +/// +public class PublicOriginTests +{ + [Theory] + [InlineData("https://poracle.example.com", "https://poracle.example.com")] + [InlineData("http://192.168.1.50:8082", "http://192.168.1.50:8082")] + [InlineData("https://poracle.example.com/", "https://poracle.example.com")] + [InlineData(" https://poracle.example.com ", "https://poracle.example.com")] + [InlineData("https://poracle.example.com:8443", "https://poracle.example.com:8443")] + public void AcceptsAnOriginAndStripsTheTrailingSlash(string configured, string expected) + { + Assert.True(PublicOrigin.TryNormalize(configured, out var normalized, out var error)); + Assert.Equal(expected, normalized); + Assert.Null(error); + } + + /// Unset is the documented default, not an error -- callers fall back to the request. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TreatsUnsetAsAbsentRatherThanInvalid(string? configured) + { + Assert.False(PublicOrigin.TryNormalize(configured, out var normalized, out var error)); + Assert.Equal(string.Empty, normalized); + Assert.Null(error); + } + + [Theory] + [InlineData("poracle.example.com")] // no scheme + [InlineData("ftp://poracle.example.com")] // wrong scheme + [InlineData("https://poracle.example.com/poracle")] // path + [InlineData("https://poracle.example.com/?a=b")] // query + [InlineData("https://poracle.example.com/#frag")] // fragment + [InlineData("not a url at all")] + public void RejectsValuesThatWouldProduceABrokenCallbackUri(string configured) + { + Assert.False(PublicOrigin.TryNormalize(configured, out _, out var error)); + Assert.NotNull(error); + } + + [Fact] + public void NormalizeOrNullReturnsNullForBothUnsetAndInvalid() + { + Assert.Null(PublicOrigin.NormalizeOrNull(null)); + Assert.Null(PublicOrigin.NormalizeOrNull("https://example.com/with/path")); + Assert.Equal("https://example.com", PublicOrigin.NormalizeOrNull("https://example.com")); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Configuration/SecurityHeadersTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Configuration/SecurityHeadersTests.cs new file mode 100644 index 00000000..ed2900e5 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Configuration/SecurityHeadersTests.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Http; +using Pgan.PoracleWebNet.Api.Configuration; + +namespace Pgan.PoracleWebNet.Tests.Configuration; + +/// +/// Tests for the response security headers (issue #383). +/// The Referrer-Policy assertions are the point of this file: the value has to stay +/// cross-origin-suppressing (so remote image hosts don't learn the instance origin) while +/// still sending a same-origin referrer, which AuthController's login/logout redirects +/// depend on. +/// +public class SecurityHeadersTests +{ + /// + /// The CSP exactly as it was written inline in Program.cs before being extracted into + /// . Guards the extraction against a typo in the + /// concatenated string. + /// + private const string OriginalCsp = + "default-src 'self'; script-src 'self' 'unsafe-hashes' 'sha256-MhtPZXr7+LpJUY5qtMutB+qWfQtMaPccfe7QXtCcEYc=' https://telegram.org; style-src 'self' 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://raw.githubusercontent.com; frame-src https://oauth.telegram.org"; + + [Fact] + public void Apply_SetsReferrerPolicy_ToSameOrigin() + { + IHeaderDictionary headers = new HeaderDictionary(); + + SecurityHeaders.Apply(headers); + + Assert.Equal("same-origin", headers["Referrer-Policy"]); + } + + /// + /// A referrer policy of no-referrer blanks the Referer header on same-origin requests + /// too, which breaks the origin recovery in AuthController's DiscordLogin, OIDC login, + /// and OIDC logout handlers -- they would silently fall back to this host's own origin + /// and redirect users to the wrong place after a provider callback. If this assertion + /// fails, read the remarks on SecurityHeaders.ReferrerPolicy before changing it. + /// + [Fact] + public void ReferrerPolicy_IsNotNoReferrer_SoAuthRedirectsKeepWorking() + { + Assert.NotEqual("no-referrer", SecurityHeaders.ReferrerPolicy); + } + + /// + /// The whole reason for #383: the policy must not send anything cross-origin. The two + /// values that leak the origin are the browser default and the explicit unsafe opt-outs. + /// + [Theory] + [InlineData("strict-origin-when-cross-origin")] + [InlineData("no-referrer-when-downgrade")] + [InlineData("origin")] + [InlineData("origin-when-cross-origin")] + [InlineData("unsafe-url")] + public void ReferrerPolicy_DoesNotLeakOriginCrossOrigin(string leakyPolicy) + { + Assert.NotEqual(leakyPolicy, SecurityHeaders.ReferrerPolicy); + } + + [Fact] + public void Apply_SetsContentSecurityPolicy_UnchangedFromTheInlineVersion() + { + IHeaderDictionary headers = new HeaderDictionary(); + + SecurityHeaders.Apply(headers); + + Assert.Equal(OriginalCsp, headers.ContentSecurityPolicy); + } + + [Fact] + public void Apply_SetsTheRemainingHardeningHeaders() + { + IHeaderDictionary headers = new HeaderDictionary(); + + SecurityHeaders.Apply(headers); + + Assert.Equal("nosniff", headers.XContentTypeOptions); + Assert.Equal("DENY", headers.XFrameOptions); + Assert.Equal("0", headers.XXSSProtection); + } + + [Fact] + public void Apply_OverwritesAnyValuePresetByAnUpstreamProxyOrHost() + { + IHeaderDictionary headers = new HeaderDictionary + { + ["Referrer-Policy"] = "unsafe-url" + }; + + SecurityHeaders.Apply(headers); + + Assert.Equal("same-origin", headers["Referrer-Policy"]); + } + + [Fact] + public void Apply_Throws_WhenHeadersAreNull() => + Assert.Throws(() => SecurityHeaders.Apply(null!)); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminControllerTests.cs index 7259b22e..a606e7df 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminControllerTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Memory; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -12,10 +14,14 @@ namespace Pgan.PoracleWebNet.Tests.Controllers; public class AdminControllerTests : ControllerTestBase { private readonly Mock _humanService = new(); + private readonly Mock _userPurgeService = new(); private readonly Mock _proxy = new(); private readonly Mock _humanProxy = new(); private readonly Mock _webhookDelegateService = new(); private readonly Mock _jwtService = new(); + private readonly Mock _roleResolver = new(); + private readonly Mock _serverProfile = new(); + private readonly Mock _updateCheck = new(); private readonly Mock> _logger = new(); private readonly AdminController _sut; @@ -26,14 +32,86 @@ public AdminControllerTests() .Returns("test-impersonation-jwt"); this._sut = new AdminController( this._humanService.Object, + new MemoryCache(new MemoryCacheOptions()), + this._userPurgeService.Object, this._webhookDelegateService.Object, this._proxy.Object, + this._serverProfile.Object, + this._updateCheck.Object, + new ConfigurationBuilder().Build(), this._humanProxy.Object, poracleSettings, this._jwtService.Object, + this._roleResolver.Object, this._logger.Object); } + /// Both ids must name real accounts before a grant is meaningful. See #514. + private void GivenWebhookAndUserExist(string webhookId = "wh1", string userId = "u2") + { + this._humanService.Setup(s => s.GetByIdAsync(webhookId)) + .ReturnsAsync(new Human { Id = webhookId, Type = "webhook" }); + this._humanService.Setup(s => s.ExistsAsync(userId)).ReturnsAsync(true); + } + + // --- GetUserAvatars (#395) --- + + [Fact] + public void GetUserAvatarsReturnsForbidWhenNotAdmin() + { + SetupUser(this._sut, isAdmin: false); + Assert.IsType(this._sut.GetUserAvatars(["u1"])); + } + + [Fact] + public void GetUserAvatarsReturnsAnEntryForEveryRequestedId() + { + SetupUser(this._sut, isAdmin: true); + + var result = Assert.IsType(this._sut.GetUserAvatars(["111", "222"])); + var avatars = Assert.IsType>(result.Value); + + // Unknown IDs still resolve, to Discord's default avatar rather than nothing -- the caller + // treats a missing key as "not yet loaded" and would keep asking. + Assert.Equal(2, avatars.Count); + Assert.All(avatars.Values, url => Assert.False(string.IsNullOrWhiteSpace(url))); + } + + [Fact] + public void GetUserAvatarsHandlesEmptyAndNullInput() + { + SetupUser(this._sut, isAdmin: true); + + Assert.Empty(Assert.IsType>( + Assert.IsType(this._sut.GetUserAvatars([])).Value)); + Assert.Empty(Assert.IsType>( + Assert.IsType(this._sut.GetUserAvatars(null!)).Value)); + } + + [Fact] + public void GetUserAvatarsDedupesAndSkipsBlankIds() + { + SetupUser(this._sut, isAdmin: true); + + var result = Assert.IsType(this._sut.GetUserAvatars(["111", "111", " ", ""])); + var avatars = Assert.IsType>(result.Value); + + Assert.Single(avatars); + Assert.True(avatars.ContainsKey("111")); + } + + [Fact] + public void GetUserAvatarsCapsTheBatchSize() + { + SetupUser(this._sut, isAdmin: true); + + var ids = Enumerable.Range(1, 500).Select(i => i.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray(); + var result = Assert.IsType(this._sut.GetUserAvatars(ids)); + var avatars = Assert.IsType>(result.Value); + + Assert.Equal(200, avatars.Count); + } + // --- GetAllUsers --- [Fact] @@ -123,6 +201,30 @@ public async Task DisableUserCallsProxyAdminDisabledTrue() this._humanProxy.Verify(p => p.AdminDisabledAsync("u1", true), Times.Once); } + [Fact] + public async Task DisableUserRefusesToBlockTheCallersOwnAccount() + { + // A block is enforced on every request, so this would take the admin's own API access away -- + // including the endpoint that would give it back. See #613. + SetupUser(this._sut, userId: "u1", isAdmin: true); + + var result = await this._sut.DisableUser("u1"); + + Assert.IsType(result); + this._humanProxy.Verify(p => p.AdminDisabledAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DisableUserStillBlocksOtherAccounts() + { + SetupUser(this._sut, userId: "admin", isAdmin: true); + this._humanService.Setup(s => s.GetByIdAsync("u1")).ReturnsAsync(new Human { Id = "u1" }); + + await this._sut.DisableUser("u1"); + + this._humanProxy.Verify(p => p.AdminDisabledAsync("u1", true), Times.Once); + } + // --- PauseUser / ResumeUser --- [Fact] @@ -232,7 +334,9 @@ public async Task DeleteUserReturnsForbidWhenNotAdmin() public async Task DeleteUserReturnsNotFoundWhenMissing() { SetupUser(this._sut, isAdmin: true); - this._humanService.Setup(s => s.DeleteUserAsync("u1")).ReturnsAsync(false); + // The delete goes through the purge service now, so everything the account owns goes with it. + // See #510, #511, #512. + this._userPurgeService.Setup(s => s.PurgeAsync("u1")).ReturnsAsync(false); Assert.IsType(await this._sut.DeleteUser("u1")); } @@ -240,8 +344,11 @@ public async Task DeleteUserReturnsNotFoundWhenMissing() public async Task DeleteUserReturnsNoContentWhenDeleted() { SetupUser(this._sut, isAdmin: true); - this._humanService.Setup(s => s.DeleteUserAsync("u1")).ReturnsAsync(true); + this._userPurgeService.Setup(s => s.PurgeAsync("u1")).ReturnsAsync(true); + Assert.IsType(await this._sut.DeleteUser("u1")); + + this._userPurgeService.Verify(s => s.PurgeAsync("u1"), Times.Once); } // --- ImpersonateUser --- @@ -284,12 +391,34 @@ public async Task ImpersonateByIdReturnsForbidWhenNotAdminOrDelegate() public async Task ImpersonateByIdAllowsDelegateWhenManagedWebhookMatches() { SetupUser(this._sut, isAdmin: false, managedWebhooks: ["u1"]); + // Delegation is resolved live now, not read from the JWT claim, so a revoked delegate loses access + // immediately rather than at their next sign-in. See #601. + // Resolved through IUserRoleResolver, which unions the local delegate table with the webhooks + // PoracleNG reports -- a PoracleJS-configured delegate was refused by the local-table-only + // lookup. See #626. + this._roleResolver.Setup(r => r.ResolveAsync("123456789")) + .ReturnsAsync(new Pgan.PoracleWebNet.Api.Services.UserRoles(false, ["u1"])); this._humanService.Setup(s => s.GetByIdAsync("u1")).ReturnsAsync(new Human { Id = "u1", Name = "WH", Type = "webhook", Enabled = 1, AdminDisable = 0, CurrentProfileNo = 1 }); var result = await this._sut.ImpersonateById(new AdminController.ImpersonateRequest("u1")); Assert.IsType(result); } + /// + /// The JWT claim is minted at login and lives 24 hours, so trusting it let a revoked delegate keep + /// impersonating the webhook until they next signed in. See #601. + /// + [Fact] + public async Task ImpersonateByIdRefusesADelegateWhoseGrantWasRevoked() + { + SetupUser(this._sut, isAdmin: false, managedWebhooks: ["u1"]); + this._webhookDelegateService.Setup(s => s.GetManagedWebhookIdsAsync("123456789")) + .ReturnsAsync([]); + + Assert.IsType( + await this._sut.ImpersonateById(new AdminController.ImpersonateRequest("u1"))); + } + [Fact] public async Task ImpersonateByIdReturnsNotFoundWhenHumanMissing() { @@ -322,10 +451,97 @@ public async Task GetAllWebhookDelegatesReturnsGroupedDelegates() Assert.IsType(result); } + /// + /// webhookId was length-checked and userId was not, though its column is half the width: over 100 + /// characters surfaced as an unhandled DbUpdateException, and an empty string persisted a delegate + /// granting nothing to nobody that then appeared in the admin view. See #483. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task AddWebhookDelegateRejectsAnEmptyUserId(string userId) + { + SetupUser(this._sut, isAdmin: true); + + var result = await this._sut.AddWebhookDelegate(new AdminController.WebhookDelegateRequest("wh1", userId)); + + Assert.IsType(result); + this._webhookDelegateService.Verify( + s => s.AddDelegateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddWebhookDelegateRejectsAUserIdLongerThanItsColumn() + { + SetupUser(this._sut, isAdmin: true); + + var result = await this._sut.AddWebhookDelegate( + new AdminController.WebhookDelegateRequest("wh1", new string('9', 101))); + + Assert.IsType(result); + this._webhookDelegateService.Verify( + s => s.AddDelegateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AddWebhookDelegateAcceptsAUserIdExactlyAtTheLimit() + { + SetupUser(this._sut, isAdmin: true); + var userId = new string('9', 100); + this.GivenWebhookAndUserExist(userId: userId); + this._webhookDelegateService.Setup(s => s.AddDelegateAsync("wh1", userId)) + .ReturnsAsync([userId]); + + var result = await this._sut.AddWebhookDelegate(new AdminController.WebhookDelegateRequest("wh1", userId)); + + Assert.IsType(result); + } + [Fact] + public async Task AddWebhookDelegateRejectsAWebhookThatDoesNotExist() + { + SetupUser(this._sut, isAdmin: true); + this._humanService.Setup(s => s.GetByIdAsync("wh-ghost")).ReturnsAsync((Human?)null); + + var result = await this._sut.AddWebhookDelegate(new AdminController.WebhookDelegateRequest("wh-ghost", "u2")); + + Assert.IsType(result); + this._webhookDelegateService.Verify( + s => s.AddDelegateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// A grant may only name a webhook, not an ordinary user account. + [Fact] + public async Task AddWebhookDelegateRejectsAnIdThatIsNotAWebhook() + { + SetupUser(this._sut, isAdmin: true); + this._humanService.Setup(s => s.GetByIdAsync("someone")) + .ReturnsAsync(new Human { Id = "someone", Type = "discord:user" }); + + var result = await this._sut.AddWebhookDelegate(new AdminController.WebhookDelegateRequest("someone", "u2")); + + Assert.IsType(result); + } + + [Fact] + public async Task AddWebhookDelegateRejectsAUserThatDoesNotExist() + { + SetupUser(this._sut, isAdmin: true); + this._humanService.Setup(s => s.GetByIdAsync("wh1")) + .ReturnsAsync(new Human { Id = "wh1", Type = "webhook" }); + this._humanService.Setup(s => s.ExistsAsync("ghost")).ReturnsAsync(false); + + var result = await this._sut.AddWebhookDelegate(new AdminController.WebhookDelegateRequest("wh1", "ghost")); + + Assert.IsType(result); + this._webhookDelegateService.Verify( + s => s.AddDelegateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task AddWebhookDelegateAddsNewDelegate() { SetupUser(this._sut, isAdmin: true); + this.GivenWebhookAndUserExist(); this._webhookDelegateService.Setup(s => s.AddDelegateAsync("wh1", "u2")) .ReturnsAsync(["u1", "u2"]); diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs index f80953d8..ffbdfff3 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs @@ -139,7 +139,7 @@ public async Task AdminDeleteReturnsNoContent() public async Task AdminDeleteReturnsNotFoundWhenNotExists() { this._service.Setup(s => s.AdminDeleteAsync("123456789", 99)) - .ThrowsAsync(new InvalidOperationException("Geofence with ID 99 not found.")); + .ThrowsAsync(new GeofenceNotFoundException(99)); var result = await this._sut.AdminDelete(99); @@ -198,7 +198,7 @@ public async Task ApproveSubmissionReturnsOkWithApprovedGeofence() Status = "approved", PromotedName = "Downtown Official" }; - this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 1, "Downtown Official")).ReturnsAsync(approved); + this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 1, "Downtown Official", null, null)).ReturnsAsync(approved); var result = await this._sut.ApproveSubmission(1, new AdminGeofenceController.ApproveRequest { PromotedName = "Downtown Official" }); @@ -206,11 +206,56 @@ public async Task ApproveSubmissionReturnsOkWithApprovedGeofence() Assert.Equal(approved, ok.Value); } + [Fact] + public async Task ApproveSubmissionFillsInTheAvatarsTheListProjectionAdds() + { + // The SPA swaps the list row for this response, so a bare row blanked the card. See #618. + var approved = new UserGeofence + { + Id = 1, + KojiName = "pweb_111_downtown", + DisplayName = "Downtown", + HumanId = "111", + ReviewedBy = "123456789", + Status = "approved", + }; + this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 1, null, null, null)).ReturnsAsync(approved); + + var result = await this._sut.ApproveSubmission(1, null); + + var ok = Assert.IsType(result); + var value = Assert.IsType(ok.Value); + Assert.False(string.IsNullOrEmpty(value.OwnerAvatarUrl)); + Assert.False(string.IsNullOrEmpty(value.ReviewedByAvatarUrl)); + } + + [Fact] + public async Task RejectSubmissionFillsInTheAvatarsTheListProjectionAdds() + { + var rejected = new UserGeofence + { + Id = 2, + KojiName = "pweb_111_uptown", + DisplayName = "Uptown", + HumanId = "111", + ReviewedBy = "123456789", + Status = "rejected", + }; + this._service.Setup(s => s.RejectSubmissionAsync("123456789", 2, "too big")).ReturnsAsync(rejected); + + var result = await this._sut.RejectSubmission(2, new AdminGeofenceController.RejectRequest { ReviewNotes = "too big" }); + + var ok = Assert.IsType(result); + var value = Assert.IsType(ok.Value); + Assert.False(string.IsNullOrEmpty(value.OwnerAvatarUrl)); + Assert.False(string.IsNullOrEmpty(value.ReviewedByAvatarUrl)); + } + [Fact] public async Task ApproveSubmissionReturnsNotFoundWhenNotFound() { - this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 99, null)) - .ThrowsAsync(new InvalidOperationException("Submission not found.")); + this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 99, null, null, null)) + .ThrowsAsync(new GeofenceNotFoundException(99)); var result = await this._sut.ApproveSubmission(99, null); @@ -232,12 +277,26 @@ public async Task ApproveSubmissionReturnsForbidWhenNotAdmin() public async Task ApproveSubmissionPassesNullPromotedNameWhenRequestIsNull() { var approved = new UserGeofence { Id = 1, Status = "approved" }; - this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 1, null)).ReturnsAsync(approved); + this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 1, null, null, null)).ReturnsAsync(approved); var result = await this._sut.ApproveSubmission(1, null); var ok = Assert.IsType(result); - this._service.Verify(s => s.ApproveSubmissionAsync("123456789", 1, null), Times.Once); + this._service.Verify(s => s.ApproveSubmissionAsync("123456789", 1, null, null, null), Times.Once); + } + + [Fact] + public async Task ApproveSubmissionForwardsRegionOverride() + { + var approved = new UserGeofence { Id = 1, Status = "approved" }; + this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 1, "Downtown Official", 42, "Downtown")).ReturnsAsync(approved); + + var result = await this._sut.ApproveSubmission( + 1, + new AdminGeofenceController.ApproveRequest { PromotedName = "Downtown Official", ParentId = 42, GroupName = "Downtown" }); + + Assert.IsType(result); + this._service.Verify(s => s.ApproveSubmissionAsync("123456789", 1, "Downtown Official", 42, "Downtown"), Times.Once); } // --- RejectSubmission --- @@ -267,7 +326,7 @@ public async Task RejectSubmissionReturnsOkWithRejectedGeofence() public async Task RejectSubmissionReturnsNotFoundWhenNotFound() { this._service.Setup(s => s.RejectSubmissionAsync("123456789", 99, "Not needed")) - .ThrowsAsync(new InvalidOperationException("Submission not found.")); + .ThrowsAsync(new GeofenceNotFoundException(99)); var result = await this._sut.RejectSubmission(99, new AdminGeofenceController.RejectRequest { ReviewNotes = "Not needed" }); @@ -284,4 +343,46 @@ public async Task RejectSubmissionReturnsForbidWhenNotAdmin() Assert.IsType(result); } + + // ── 400 vs 404 on the review endpoints (#421) ─────────────────────────────── + // Every InvalidOperationException became a 404, so an admin whose promoted name contained a slash + // was told the submission did not exist while it sat visible in the list. Status and body disagreed + // and no client could tell bad input from a deleted record. + + [Fact] + public async Task ApproveSubmissionReturnsBadRequestForAValidationFailure() + { + this._service + .Setup(s => s.ApproveSubmissionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Promoted name contains invalid characters.")); + + var result = await this._sut.ApproveSubmission(1, new AdminGeofenceController.ApproveRequest { PromotedName = "Downtown / Uptown" }); + + var badRequest = Assert.IsType(result); + Assert.Contains("invalid characters", badRequest.Value!.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task RejectSubmissionReturnsBadRequestForAWrongStateTransition() + { + this._service + .Setup(s => s.RejectSubmissionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Geofence must be awaiting review to be rejected. Current status: 'approved'.")); + + var result = await this._sut.RejectSubmission(1, new AdminGeofenceController.RejectRequest { ReviewNotes = "x" }); + + Assert.IsType(result); + } + + [Fact] + public async Task AdminDeleteReturnsBadRequestForAValidationFailure() + { + this._service + .Setup(s => s.AdminDeleteAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("something invalid")); + + var result = await this._sut.AdminDelete(1); + + Assert.IsType(result); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AreaControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AreaControllerTests.cs index 3d52f2b1..363a16e6 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AreaControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AreaControllerTests.cs @@ -1,3 +1,4 @@ +using Pgan.PoracleWebNet.Core.Models; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; @@ -89,6 +90,40 @@ public async Task GetAvailableAreasReturnsContentWhenProxyReturnsData() Assert.IsType(result); } + /// + /// PoracleNG returns its whole fence set regardless of userSelectable, and PoracleWeb feeds every + /// user-drawn geofence into it, so streaming the response through handed any signed-in user the names + /// of every private geofence anyone had drawn. See #544. + /// + [Fact] + public async Task GetAvailableAreasHidesOtherUsersPrivateGeofences() + { + this._proxy.Setup(p => p.GetAreasWithGroupsAsync("123456789")).ReturnsAsync( + /*lang=json,strict*/ + "[{\"name\":\"downtown\",\"userSelectable\":true},{\"name\":\"someone elses home\",\"userSelectable\":false}]"); + + var result = Assert.IsType(await this._sut.GetAvailableAreas()); + + Assert.Contains("downtown", result.Content, StringComparison.Ordinal); + Assert.DoesNotContain("someone elses home", result.Content, StringComparison.Ordinal); + } + + /// The caller's own fences are theirs to see, even though they are not selectable. + [Fact] + public async Task GetAvailableAreasKeepsTheCallersOwnGeofences() + { + this._proxy.Setup(p => p.GetAreasWithGroupsAsync("123456789")).ReturnsAsync( + /*lang=json,strict*/ + "[{\"name\":\"my garden\",\"userSelectable\":false},{\"name\":\"someone elses home\",\"userSelectable\":false}]"); + this._userGeofenceService.Setup(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([new UserGeofence { Id = 1, HumanId = "123456789", KojiName = "my garden" }]); + + var result = Assert.IsType(await this._sut.GetAvailableAreas()); + + Assert.Contains("my garden", result.Content, StringComparison.Ordinal); + Assert.DoesNotContain("someone elses home", result.Content, StringComparison.Ordinal); + } + [Fact] public async Task GetAvailableAreasReturnsOkEmptyWhenProxyReturnsNull() { @@ -218,4 +253,16 @@ public async Task GetAreaMapReturnsNotFoundWhenThrows() this._proxy.Setup(p => p.GetAreaMapUrlAsync(It.IsAny())).ThrowsAsync(new InvalidOperationException()); Assert.IsType(await this._sut.GetAreaMap("bad")); } + + // --- Client input must not 500 (#418) --- + + [Fact] + public async Task UpdateAreasRejectsNullEntriesInsteadOfThrowing() + { + var result = await this._sut.UpdateAreas(new AreaController.UpdateAreasRequest { Areas = ["west", null!] }); + + Assert.IsType(result); + this._humanProxy.Verify(p => p.SetAreasAsync(It.IsAny(), It.IsAny()), Times.Never); + } + } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerLoginOriginTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerLoginOriginTests.cs new file mode 100644 index 00000000..4354ca59 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerLoginOriginTests.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Api.Services.Oidc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +/// +/// Tests for the frontend-origin recovery in AuthController.DiscordLogin — the Referer read +/// that decides which origin the user is sent back to after the provider callback, stashed in +/// the oauth_origin cookie. +/// +/// This behavior is why the app's Referrer-Policy is "same-origin" and not "no-referrer" +/// (issue #383): a policy that blanks the same-origin Referer would make every case below +/// fall back to the API's own origin. See SecurityHeaders.ReferrerPolicy. +/// +public class AuthControllerLoginOriginTests +{ + private const string SelfOrigin = "https://alerts.example.net"; + + [Fact] + public async Task DiscordLoginUsesRefererOriginWhenItIsAnAllowedCorsOrigin() + { + var controller = CreateController( + allowedOrigins: ["https://app.example.net", SelfOrigin], + referer: "https://app.example.net/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal("https://app.example.net", ReadOriginCookie(controller)); + } + + /// + /// The shared-host production topology: the SPA is served by this same host, so the + /// referrer is same-origin. "same-origin" policy sends the full URL here, so the header + /// is present and the recovered origin matches self. + /// + [Fact] + public async Task DiscordLoginUsesRefererOriginWhenItMatchesSelfAndNoCorsOriginsConfigured() + { + var controller = CreateController( + allowedOrigins: [], + referer: $"{SelfOrigin}/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + /// + /// The no-referrer scenario, asserted explicitly: with no Referer to read, the origin + /// silently degrades to this host. Harmless when the SPA shares the host, wrong when it + /// doesn't — which is the regression a "no-referrer" policy would introduce everywhere. + /// + [Fact] + public async Task DiscordLoginFallsBackToSelfOriginWhenRefererIsAbsent() + { + var controller = CreateController(allowedOrigins: ["https://app.example.net"], referer: null); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + [Fact] + public async Task DiscordLoginIgnoresRefererOriginThatIsNotAllowed() + { + var controller = CreateController( + allowedOrigins: ["https://app.example.net"], + referer: "https://evil.example.com/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + [Fact] + public async Task DiscordLoginIgnoresRefererThatIsNotAnAbsoluteUri() + { + var controller = CreateController(allowedOrigins: [], referer: "/auth/login"); + + await controller.DiscordLogin(); + + Assert.Equal(SelfOrigin, ReadOriginCookie(controller)); + } + + private static AuthController CreateController(string[] allowedOrigins, string? referer) + { + var settings = new Dictionary(); + for (var i = 0; i < allowedOrigins.Length; i++) + { + settings[$"Cors:AllowedOrigins:{i}"] = allowedOrigins[i]; + } + + var controller = new AuthController( + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + Options.Create(new DiscordSettings { ClientId = "test-id", ClientSecret = "test-secret" }), + Options.Create(new TelegramSettings()), + Options.Create(new OidcSettings()), + Options.Create(new PoracleSettings()), + new ConfigurationBuilder().AddInMemoryCollection(settings).Build(), + new Mock>().Object); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("alerts.example.net"); + if (referer != null) + { + httpContext.Request.Headers.Referer = referer; + } + + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + return controller; + } + + private static string? ReadOriginCookie(ControllerBase controller) + { + const string Name = "oauth_origin="; + + var cookie = controller.Response.Headers.SetCookie + .FirstOrDefault(c => c != null && c.StartsWith(Name, StringComparison.Ordinal)); + if (cookie == null) + { + return null; + } + + var value = cookie[Name.Length..]; + var end = value.IndexOf(';', StringComparison.Ordinal); + if (end >= 0) + { + value = value[..end]; + } + + return Uri.UnescapeDataString(value); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs index fd9ec973..2ace96ab 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -16,31 +17,74 @@ namespace Pgan.PoracleWebNet.Tests.Controllers; public class AuthControllerMeTests : ControllerTestBase { private readonly Mock _humanService = new(); + private readonly Mock _profileService = new(); private readonly Mock _jwtService = new(); + private readonly Mock _roleResolver = new(); private readonly AuthController _sut; public AuthControllerMeTests() { this._jwtService.Setup(j => j.GenerateToken(It.IsAny())) .Returns("refreshed-jwt-token"); + this._jwtService.Setup(j => j.GenerateTokenWithReplacedProfile(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns("refreshed-jwt-token"); var config = new ConfigurationBuilder().Build(); this._sut = new AuthController( - this._humanService.Object, + this._humanService.Object, this._profileService.Object, new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, this._jwtService.Object, + this._roleResolver.Object, + new Mock().Object, + new Mock().Object, Options.Create(new DiscordSettings()), Options.Create(new TelegramSettings()), + Options.Create(new OidcSettings()), Options.Create(new PoracleSettings()), config, new Mock>().Object); } + /// + /// The SPA renders this in the user menu with a "Profile {n}" fallback and the property did not exist, + /// so the fallback fired every time. See #520. + /// [Fact] - public async Task Me_ReturnsRefreshedToken_WhenProfileNoMismatch() + public async Task MeCarriesTheActiveProfileName() + { + SetupUser(this._sut, profileNo: 2); + this._humanService.Setup(s => s.GetByIdAsync("123456789")) + .ReturnsAsync(new Human { CurrentProfileNo = 2, Enabled = 1 }); + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 2)) + .ReturnsAsync(new Profile { ProfileNo = 2, Name = "Work Profile" }); + + var result = await this._sut.Me(); + + var userInfo = Assert.IsType(Assert.IsType(result).Value); + Assert.Equal("Work Profile", userInfo.ProfileName); + } + + /// The label is cosmetic; the enabled flag and the profile resync are not. + [Fact] + public async Task MeStillAnswersWhenTheProfileNameCannotBeRead() + { + SetupUser(this._sut, profileNo: 2); + this._humanService.Setup(s => s.GetByIdAsync("123456789")) + .ReturnsAsync(new Human { CurrentProfileNo = 2, Enabled = 1 }); + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 2)) + .ThrowsAsync(new HttpRequestException("PoracleNG is down")); + + var result = await this._sut.Me(); + + var userInfo = Assert.IsType(Assert.IsType(result).Value); + Assert.Null(userInfo.ProfileName); + } + + [Fact] + public async Task MeReturnsRefreshedTokenWhenProfileNoMismatch() { SetupUser(this._sut, profileNo: 2); this._humanService.Setup(s => s.GetByIdAsync("123456789")) @@ -52,11 +96,14 @@ public async Task Me_ReturnsRefreshedToken_WhenProfileNoMismatch() var userInfo = Assert.IsType(ok.Value); Assert.Equal(1, userInfo.ProfileNo); Assert.Equal("refreshed-jwt-token", userInfo.Token); - this._jwtService.Verify(j => j.GenerateToken(It.Is(u => u.ProfileNo == 1)), Times.Once); + this._jwtService.Verify( + // isAdmin is passed explicitly now: this is the one place a long-lived session routinely + // re-mints its token, so copying the claim let revoked admin rights survive. See #624. + j => j.GenerateTokenWithReplacedProfile(It.IsAny(), 1, It.IsAny()), Times.Once); } [Fact] - public async Task Me_DoesNotIncludeToken_WhenProfileNoMatches() + public async Task MeDoesNotIncludeTokenWhenProfileNoMatches() { SetupUser(this._sut, profileNo: 1); this._humanService.Setup(s => s.GetByIdAsync("123456789")) @@ -72,7 +119,7 @@ public async Task Me_DoesNotIncludeToken_WhenProfileNoMatches() } [Fact] - public async Task Me_UsesDbProfileNo_WhenHumanExists() + public async Task MeUsesDbProfileNoWhenHumanExists() { SetupUser(this._sut, profileNo: 3); this._humanService.Setup(s => s.GetByIdAsync("123456789")) @@ -86,8 +133,13 @@ public async Task Me_UsesDbProfileNo_WhenHumanExists() Assert.NotNull(userInfo.Token); } + /// + /// A missing row used to read as healthy, so a deleted account kept answering 200 with enabled:true + /// while every other endpoint threw -- and the SPA, which only signs out on 401, left the user in a + /// fully rendered app where nothing worked. See #545. + /// [Fact] - public async Task Me_FallsBackToJwtProfileNo_WhenHumanNotFound() + public async Task MeSignsOutAnAccountThatNoLongerExists() { SetupUser(this._sut, profileNo: 2); this._humanService.Setup(s => s.GetByIdAsync("123456789")) @@ -95,10 +147,187 @@ public async Task Me_FallsBackToJwtProfileNo_WhenHumanNotFound() var result = await this._sut.Me(); - var ok = Assert.IsType(result); - var userInfo = Assert.IsType(ok.Value); - Assert.Equal(2, userInfo.ProfileNo); - Assert.Null(userInfo.Token); - this._jwtService.Verify(j => j.GenerateToken(It.IsAny()), Times.Never); + Assert.IsType(result); + this._jwtService.Verify( + j => j.GenerateTokenWithReplacedProfile(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + + /// + /// Blocking a user did nothing to an existing token, which kept full read/write access for the rest of + /// its 24-hour life. The SPA signs out on 401, so this is what ends the session. See #597. + /// + [Fact] + public async Task MeSignsOutABlockedAccount() + { + SetupUser(this._sut, profileNo: 1); + this._humanService.Setup(s => s.GetByIdAsync("123456789")) + .ReturnsAsync(new Human { CurrentProfileNo = 1, Enabled = 1, AdminDisable = 1 }); + + var result = await this._sut.Me(); + + Assert.IsType(result); + } + + /// + /// That 401 ends the CALLER's session, and while inspecting an account the caller is the admin -- so + /// inspecting a blocked user signed the admin out of their own session, with the SPA discarding the + /// stashed admin token along with it. A lapsed subscription is the main thing an admin inspects an + /// account to confirm. See #706. + /// + [Fact] + public async Task MeDoesNotSignOutAnAdminInspectingABlockedAccount() + { + SetupUser(this._sut, profileNo: 1); + ((ClaimsIdentity)this._sut.User.Identity!).AddClaim(new Claim("impersonatedBy", "999")); + this._humanService.Setup(s => s.GetByIdAsync("123456789")) + .ReturnsAsync(new Human { CurrentProfileNo = 1, Enabled = 1, AdminDisable = 1 }); + + var result = await this._sut.Me(); + + var userInfo = Assert.IsType(Assert.IsType(result).Value); + + // Returned as data, not as a refusal: the SPA renders its blocked banner from these two. + Assert.True(userInfo.AdminDisable); + Assert.False(userInfo.Enabled); + } + + /// + /// The deleted-account 401 is deliberately not relaxed for inspection: there is no account left to + /// render. The SPA drops the admin back to their own token rather than ending the session. See #706. + /// + [Fact] + public async Task MeStillSignsOutAnInspectionOfAnAccountThatNoLongerExists() + { + SetupUser(this._sut, profileNo: 1); + ((ClaimsIdentity)this._sut.User.Identity!).AddClaim(new Claim("impersonatedBy", "999")); + this._humanService.Setup(s => s.GetByIdAsync("123456789")) + .ReturnsAsync((Human?)null); + + var result = await this._sut.Me(); + + Assert.IsType(result); + } + + /// + /// The resync used to rebuild the token from UserInfo, which has no impersonatedBy field, so an admin + /// impersonation session lost the only record of what it was - on exactly the out-of-band profile + /// changes this branch exists to absorb. See #484. + /// + [Fact] + public async Task MeResyncPreservesTheImpersonationClaim() + { + SetupUser(this._sut, profileNo: 2); + ((ClaimsIdentity)this._sut.User.Identity!).AddClaim(new Claim("impersonatedBy", "999")); + this._humanService.Setup(s => s.GetByIdAsync("123456789")) + .ReturnsAsync(new Human { CurrentProfileNo = 1, Enabled = 1 }); + + ClaimsPrincipal? seen = null; + this._jwtService + .Setup(j => j.GenerateTokenWithReplacedProfile(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((p, _, _) => seen = p) + .Returns("refreshed-jwt-token"); + + await this._sut.Me(); + + Assert.Equal("999", seen?.FindFirst("impersonatedBy")?.Value); + } + // --- Managed webhooks (#786) --- + + /// + /// The nav item for /my-webhooks renders off this response, so reading it from the JWT claim meant a + /// delegate granted access today waited up to 24 hours for a token refresh before they could reach a + /// page that would already have let them in. Both real checks moved to live resolution in #601/#626; + /// this was the last consumer of the claim. + /// + [Fact] + public async Task MeReportsAWebhookGrantedSinceTheTokenWasMinted() + { + SetupUser(this._sut, profileNo: 1); + this.SetupHuman(profileNo: 1); + this.Resolves(["http://webhook.example/new"]); + + var ok = Assert.IsType(await this._sut.Me()); + + Assert.Equal(["http://webhook.example/new"], Assert.IsType(ok.Value).ManagedWebhooks); + } + + [Fact] + public async Task MeDropsAWebhookThatHasBeenRevoked() + { + SetupUser(this._sut, profileNo: 1, managedWebhooks: ["http://webhook.example/old"]); + this.SetupHuman(profileNo: 1); + this.Resolves([]); + + var ok = Assert.IsType(await this._sut.Me()); + + Assert.Null(Assert.IsType(ok.Value).ManagedWebhooks); + } + + /// + /// A resolve that could not read one of its three sources must not strip a delegate mid-session — the + /// failure mode #656 and #667 were both about. The claim is stale but never invents access, and the + /// impersonation grant re-checks live and fails closed. + /// + [Fact] + public async Task MeKeepsTheClaimWhenResolutionIsDegraded() + { + SetupUser(this._sut, profileNo: 1, managedWebhooks: ["http://webhook.example/known"]); + this.SetupHuman(profileNo: 1); + this._roleResolver.Setup(r => r.ResolveAsync(It.IsAny())) + .ReturnsAsync(new Pgan.PoracleWebNet.Api.Services.UserRoles(false, null, Resolved: false)); + + var ok = Assert.IsType(await this._sut.Me()); + + Assert.Equal(["http://webhook.example/known"], Assert.IsType(ok.Value).ManagedWebhooks); + } + + [Fact] + public async Task MeUnionsBothSetsWhenResolutionIsDegraded() + { + SetupUser(this._sut, profileNo: 1, managedWebhooks: ["http://webhook.example/known"]); + this.SetupHuman(profileNo: 1); + this._roleResolver.Setup(r => r.ResolveAsync(It.IsAny())) + .ReturnsAsync(new Pgan.PoracleWebNet.Api.Services.UserRoles( + false, ["http://webhook.example/found"], Resolved: false)); + + var ok = Assert.IsType(await this._sut.Me()); + + Assert.Equal( + ["http://webhook.example/found", "http://webhook.example/known"], + Assert.IsType(ok.Value).ManagedWebhooks); + } + + /// + /// While impersonating, this.UserId is the impersonated account, so resolving its delegations answers + /// a different question than "what may this session manage" — the trap #663 fixed for admin status. + /// + [Fact] + public async Task MeDoesNotResolveDelegationsForAnImpersonatedAccount() + { + SetupUser(this._sut, profileNo: 1); + this._sut.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("userId", "123456789"), + new Claim("profileNo", "1"), + new Claim("isAdmin", "false"), + new Claim("username", "TestUser"), + new Claim("impersonatedBy", "admin-1"), + ], "TestAuth")); + this.SetupHuman(profileNo: 1); + this.Resolves(["http://webhook.example/not-mine"]); + + var ok = Assert.IsType(await this._sut.Me()); + + Assert.Null(Assert.IsType(ok.Value).ManagedWebhooks); + this._roleResolver.Verify(r => r.ResolveAsync(It.IsAny()), Times.Never); + } + + private void Resolves(string[] webhooks) => + this._roleResolver.Setup(r => r.ResolveAsync(It.IsAny())) + .ReturnsAsync(new Pgan.PoracleWebNet.Api.Services.UserRoles( + false, webhooks.Length > 0 ? webhooks : null)); + + private void SetupHuman(int profileNo) => + this._humanService.Setup(h => h.GetByIdAsync(It.IsAny())) + .ReturnsAsync(new Human { Id = "123456789", Enabled = 1, AdminDisable = 0, CurrentProfileNo = profileNo }); } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs index 0102fc1b..4dd18663 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs @@ -6,6 +6,7 @@ using Moq; using Pgan.PoracleWebNet.Api.Configuration; using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Api.Services.Oidc; using Pgan.PoracleWebNet.Core.Abstractions.Services; namespace Pgan.PoracleWebNet.Tests.Controllers; @@ -18,19 +19,38 @@ public class AuthControllerProvidersTests : ControllerTestBase private readonly Mock _siteSettingService = new(); private readonly IConfiguration _config = new ConfigurationBuilder().Build(); - private AuthController CreateController(DiscordSettings? discord = null, TelegramSettings? telegram = null) => new AuthController( + private AuthController CreateController(DiscordSettings? discord = null, TelegramSettings? telegram = null, OidcSettings? oidc = null, IConfiguration? config = null) => new( new Mock().Object, + new Mock().Object, new Mock().Object, new Mock().Object, this._siteSettingService.Object, new Mock().Object, new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, Options.Create(discord ?? new DiscordSettings { ClientId = "test-id", ClientSecret = "test-secret" }), Options.Create(telegram ?? new TelegramSettings()), + Options.Create(oidc ?? new OidcSettings()), Options.Create(new PoracleSettings()), - this._config, + config ?? this._config, new Mock>().Object); + private static IConfiguration ConfigWith(string key, string value) => + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { [key] = value }).Build(); + + private static OidcSettings FullyConfiguredOidc() => new() + { + Enabled = true, + ProviderName = "PogoAlerts", + AuthorizationUrl = "https://idp.example.com/login", + TokenUrl = "https://idp.example.com/api/oauth/token", + UserInfoUrl = "https://idp.example.com/api/oauth/userinfo", + ClientId = "client-id", + ClientSecret = "client-secret", + }; + [Fact] public async Task ProvidersDiscordConfiguredWhenClientIdAndSecretPresent() { @@ -100,6 +120,35 @@ public async Task ProvidersTelegramConfiguredWhenEnabled() Assert.Equal("testbot", telegram.GetProperty("botUsername").GetString()); } + [Fact] + public async Task ProvidersFallsBackToTheSiteSettingBotUsername() + { + // The admin page has always offered this field and nothing ever read it. See #620. + this._siteSettingService.Setup(s => s.GetValueAsync("telegram_bot")).ReturnsAsync("@uibot"); + var controller = this.CreateController(telegram: new TelegramSettings { Enabled = true, BotUsername = "" }); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var doc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.Equal("uibot", doc.RootElement.GetProperty("telegram").GetProperty("botUsername").GetString()); + } + + [Fact] + public async Task ProvidersPrefersTheConfiguredBotUsernameOverTheSiteSetting() + { + // A working env var must not be overridden by whatever was typed into the UI while the field + // was inert. See #620. + this._siteSettingService.Setup(s => s.GetValueAsync("telegram_bot")).ReturnsAsync("staleuibot"); + var controller = this.CreateController(telegram: new TelegramSettings { Enabled = true, BotUsername = "envbot" }); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var doc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.Equal("envbot", doc.RootElement.GetProperty("telegram").GetProperty("botUsername").GetString()); + } + [Fact] public async Task ProvidersTelegramNotConfiguredWhenDisabledInEnv() { @@ -177,4 +226,350 @@ public async Task ProvidersTelegramBotUsernameEmptyWhenNotConfigured() var doc = JsonDocument.Parse(json); Assert.Equal(string.Empty, doc.RootElement.GetProperty("telegram").GetProperty("botUsername").GetString()); } + + [Fact] + public async Task ProvidersOidcConfiguredWhenEnabledWithFullConfig() + { + var controller = this.CreateController(oidc: FullyConfiguredOidc()); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + var doc = JsonDocument.Parse(json); + var oidc = doc.RootElement.GetProperty("oidc"); + Assert.True(oidc.GetProperty("configured").GetBoolean()); + Assert.Equal("PogoAlerts", oidc.GetProperty("providerName").GetString()); + } + + [Fact] + public async Task ProvidersOidcNotConfiguredWhenDisabled() + { + var oidc = FullyConfiguredOidc(); + oidc.Enabled = false; + var controller = this.CreateController(oidc: oidc); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + var doc = JsonDocument.Parse(json); + var node = doc.RootElement.GetProperty("oidc"); + Assert.False(node.GetProperty("configured").GetBoolean()); + // providerName hidden when not configured + Assert.Equal(string.Empty, node.GetProperty("providerName").GetString()); + } + + [Fact] + public async Task ProvidersOidcNotConfiguredWhenUrlsMissing() + { + var controller = this.CreateController(oidc: new OidcSettings { Enabled = true, ClientId = "id", ProviderName = "X" }); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + var doc = JsonDocument.Parse(json); + Assert.False(doc.RootElement.GetProperty("oidc").GetProperty("configured").GetBoolean()); + } + + [Fact] + public async Task ProvidersOidcDisabledByAdminWhenSettingFalse() + { + this._siteSettingService.Setup(s => s.GetValueAsync("enable_oidc")).ReturnsAsync("false"); + var controller = this.CreateController(oidc: FullyConfiguredOidc()); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + var doc = JsonDocument.Parse(json); + var node = doc.RootElement.GetProperty("oidc"); + Assert.True(node.GetProperty("configured").GetBoolean()); + Assert.False(node.GetProperty("enabledByAdmin").GetBoolean()); + } + + [Fact] + public async Task ProvidersOidcDisabledByAdminWhenSettingAbsent() + { + // OIDC is opt-in: an absent enable_oidc means local is the default sign-in mode. + this._siteSettingService.Setup(s => s.GetValueAsync("enable_oidc")).ReturnsAsync((string?)null); + var controller = this.CreateController(oidc: FullyConfiguredOidc()); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + var doc = JsonDocument.Parse(json); + Assert.False(doc.RootElement.GetProperty("oidc").GetProperty("enabledByAdmin").GetBoolean()); + } + + [Fact] + public async Task ProvidersOidcEnabledByAdminWhenSettingTrue() + { + this._siteSettingService.Setup(s => s.GetValueAsync("enable_oidc")).ReturnsAsync("true"); + var controller = this.CreateController(oidc: FullyConfiguredOidc()); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + var doc = JsonDocument.Parse(json); + Assert.True(doc.RootElement.GetProperty("oidc").GetProperty("enabledByAdmin").GetBoolean()); + } + + [Fact] + public async Task ProvidersOidcForceLocalOverridesEnabled() + { + // enable_oidc=true but AUTH_FORCE_LOCAL break-glass is set → OIDC reported disabled. + this._siteSettingService.Setup(s => s.GetValueAsync("enable_oidc")).ReturnsAsync("true"); + var controller = this.CreateController(oidc: FullyConfiguredOidc(), config: ConfigWith("Auth:ForceLocal", "true")); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var json = JsonSerializer.Serialize(ok.Value); + var doc = JsonDocument.Parse(json); + var node = doc.RootElement.GetProperty("oidc"); + Assert.True(node.GetProperty("configured").GetBoolean()); + Assert.False(node.GetProperty("enabledByAdmin").GetBoolean()); + } + + [Fact] + public void OidcLoginReturnsNotFoundWhenProviderNotConfigured() + { + var controller = this.CreateController(oidc: new OidcSettings()); + controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext() + }; + + var result = controller.OidcLogin(); + + Assert.IsType(result); + } + + [Fact] + public void OidcLoginRedirectsToProviderWithStateAndPkce() + { + var controller = this.CreateController(oidc: FullyConfiguredOidc()); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new Microsoft.AspNetCore.Http.HostString("alerts.example.com"); + controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = httpContext }; + + var result = controller.OidcLogin(); + + var redirect = Assert.IsType(result); + Assert.StartsWith("https://idp.example.com/login", redirect.Url, StringComparison.Ordinal); + Assert.Contains("client_id=client-id", redirect.Url, StringComparison.Ordinal); + Assert.Contains("response_type=code", redirect.Url, StringComparison.Ordinal); + Assert.Contains("code_challenge=", redirect.Url, StringComparison.Ordinal); + Assert.Contains("code_challenge_method=S256", redirect.Url, StringComparison.Ordinal); + Assert.Contains("state=", redirect.Url, StringComparison.Ordinal); + + // CSRF state and PKCE verifier are persisted in cookies for the callback to validate. + var setCookies = httpContext.Response.Headers["Set-Cookie"].ToString(); + Assert.Contains("oauth_state=", setCookies, StringComparison.Ordinal); + Assert.Contains("oauth_pkce_verifier=", setCookies, StringComparison.Ordinal); + } + + [Fact] + public async Task ProvidersOidcEndSessionTrueWhenEndSessionUrlConfigured() + { + var oidc = FullyConfiguredOidc(); + oidc.EndSessionUrl = "https://idp.example.com/logout"; + var controller = this.CreateController(oidc: oidc); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var doc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.True(doc.RootElement.GetProperty("oidc").GetProperty("endSession").GetBoolean()); + } + + [Fact] + public async Task ProvidersOidcEndSessionFalseWhenNotConfigured() + { + var controller = this.CreateController(oidc: FullyConfiguredOidc()); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var doc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.False(doc.RootElement.GetProperty("oidc").GetProperty("endSession").GetBoolean()); + } + + private AuthController CreateLogoutController(string? endSessionUrl) + { + var oidc = FullyConfiguredOidc(); + oidc.EndSessionUrl = endSessionUrl ?? string.Empty; + var controller = this.CreateController(oidc: oidc); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new Microsoft.AspNetCore.Http.HostString("alerts.example.com"); + controller.ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext { HttpContext = httpContext }; + return controller; + } + + [Fact] + public async Task OidcLogoutRedirectsToEndSessionWithPostLogoutWhenConfigured() + { + var controller = this.CreateLogoutController("https://idp.example.com/logout"); + + var result = await controller.OidcLogout(); + + var redirect = Assert.IsType(result); + Assert.StartsWith("https://idp.example.com/logout", redirect.Url, StringComparison.Ordinal); + Assert.Contains("post_logout_redirect_uri=", redirect.Url, StringComparison.Ordinal); + Assert.Contains(Uri.EscapeDataString("https://alerts.example.com/login?loggedout=1"), redirect.Url, StringComparison.Ordinal); + } + + [Fact] + public async Task OidcLogoutRedirectsToSignedOutLandingWhenNoEndSession() + { + var controller = this.CreateLogoutController(endSessionUrl: null); + + var result = await controller.OidcLogout(); + + var redirect = Assert.IsType(result); + Assert.Equal("https://alerts.example.com/login?loggedout=1", redirect.Url); + } + + [Fact] + public async Task OidcLogoutFallsBackToLocalWhenSloDisabledByAdmin() + { + // End-session is configured, but the admin turned single logout off (enable_oidc_slo=false). + this._siteSettingService.Setup(s => s.GetValueAsync("enable_oidc_slo")).ReturnsAsync("false"); + var controller = this.CreateLogoutController("https://idp.example.com/logout"); + + var result = await controller.OidcLogout(); + + var redirect = Assert.IsType(result); + Assert.Equal("https://alerts.example.com/login?loggedout=1", redirect.Url); + } + + [Fact] + public async Task ProvidersOidcEndSessionFalseWhenSloDisabledByAdmin() + { + this._siteSettingService.Setup(s => s.GetValueAsync("enable_oidc_slo")).ReturnsAsync("false"); + var oidc = FullyConfiguredOidc(); + oidc.EndSessionUrl = "https://idp.example.com/logout"; + var controller = this.CreateController(oidc: oidc); + + var result = await controller.Providers(); + + var ok = Assert.IsType(result); + var doc = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.False(doc.RootElement.GetProperty("oidc").GetProperty("endSession").GetBoolean()); + } + + // ── PUBLIC_URL and OAuth callback construction ──────────────────────────────────────────── + // + // Behind a proxy that has not been declared via PROXY_KNOWN_*, X-Forwarded-Proto is discarded + // and Request.Scheme reads "http" on an HTTPS site, so the callback URI goes out as http:// and + // the provider refuses it. PUBLIC_URL states the origin outright. The pairs below cover both + // directions: the override works, AND leaving it unset still follows the request. + + private static Microsoft.AspNetCore.Http.DefaultHttpContext ProxiedHttpContext() + { + // What an undeclared reverse proxy produces: TLS terminated at the edge, plain http here. + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Scheme = "http"; + httpContext.Request.Host = new Microsoft.AspNetCore.Http.HostString("alerts.example.com"); + return httpContext; + } + + [Fact] + public async Task DiscordLoginUsesPublicUrlForTheCallbackWhenConfigured() + { + var controller = this.CreateController(config: ConfigWith("PublicUrl", "https://alerts.example.com")); + controller.ControllerContext = new ControllerContext { HttpContext = ProxiedHttpContext() }; + + var result = await controller.DiscordLogin(); + + var redirect = Assert.IsType(result); + Assert.Contains( + "redirect_uri=" + Uri.EscapeDataString("https://alerts.example.com/api/auth/discord/callback"), + redirect.Url, + StringComparison.Ordinal); + } + + [Fact] + public async Task DiscordLoginFollowsTheRequestWhenPublicUrlIsUnset() + { + // The historical behaviour, and still correct for a direct-exposed instance or a declared + // proxy. A regression here would break every install that does not set PUBLIC_URL. + var controller = this.CreateController(); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new Microsoft.AspNetCore.Http.HostString("direct.example.com:8082"); + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + var result = await controller.DiscordLogin(); + + var redirect = Assert.IsType(result); + Assert.Contains( + "redirect_uri=" + Uri.EscapeDataString("https://direct.example.com:8082/api/auth/discord/callback"), + redirect.Url, + StringComparison.Ordinal); + } + + [Fact] + public void OidcLoginUsesPublicUrlForTheCallbackWhenConfigured() + { + var controller = this.CreateController( + oidc: FullyConfiguredOidc(), + config: ConfigWith("PublicUrl", "https://alerts.example.com")); + controller.ControllerContext = new ControllerContext { HttpContext = ProxiedHttpContext() }; + + var result = controller.OidcLogin(); + + var redirect = Assert.IsType(result); + Assert.Contains( + "redirect_uri=" + Uri.EscapeDataString("https://alerts.example.com/api/auth/oidc/callback"), + redirect.Url, + StringComparison.Ordinal); + } + + [Fact] + public void OidcLoginFollowsTheRequestWhenPublicUrlIsUnset() + { + var controller = this.CreateController(oidc: FullyConfiguredOidc()); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new Microsoft.AspNetCore.Http.HostString("direct.example.com:8082"); + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + var result = controller.OidcLogin(); + + var redirect = Assert.IsType(result); + Assert.Contains( + "redirect_uri=" + Uri.EscapeDataString("https://direct.example.com:8082/api/auth/oidc/callback"), + redirect.Url, + StringComparison.Ordinal); + } + + /// + /// A malformed PUBLIC_URL must not silently fall back to the request -- that would reintroduce + /// the exact http:// callback the setting exists to prevent, with no sign anything was wrong. + /// Startup rejects it (see PublicOriginTests); the controller ignores it if it ever gets through. + /// + [Fact] + public async Task DiscordLoginIgnoresAnUnusablePublicUrl() + { + var controller = this.CreateController(config: ConfigWith("PublicUrl", "not a url")); + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new Microsoft.AspNetCore.Http.HostString("direct.example.com"); + controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + + var result = await controller.DiscordLogin(); + + var redirect = Assert.IsType(result); + Assert.Contains( + "redirect_uri=" + Uri.EscapeDataString("https://direct.example.com/api/auth/discord/callback"), + redirect.Url, + StringComparison.Ordinal); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/CleaningControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/CleaningControllerTests.cs index 078362e0..91c5810f 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/CleaningControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/CleaningControllerTests.cs @@ -1,56 +1,138 @@ -using Microsoft.AspNetCore.Mvc; -using Moq; -using Pgan.PoracleWebNet.Api.Controllers; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Tests.Controllers; - -public class CleaningControllerTests : ControllerTestBase -{ - private readonly Mock _service = new(); - private readonly CleaningController _sut; - - public CleaningControllerTests() - { - this._sut = new CleaningController(this._service.Object); - SetupUser(this._sut); - } - - [Theory] - [InlineData("monsters")] - [InlineData("raids")] - [InlineData("eggs")] - [InlineData("quests")] - [InlineData("invasions")] - [InlineData("lures")] - [InlineData("nests")] - [InlineData("gyms")] - public async Task ToggleCleanReturnsOkForAllAlarmTypes(string alarmType) - { - this._service.Setup(s => s.ToggleCleanMonstersAsync("123456789", 1, 1)).ReturnsAsync(5); - this._service.Setup(s => s.ToggleCleanRaidsAsync("123456789", 1, 1)).ReturnsAsync(5); - this._service.Setup(s => s.ToggleCleanEggsAsync("123456789", 1, 1)).ReturnsAsync(5); - this._service.Setup(s => s.ToggleCleanQuestsAsync("123456789", 1, 1)).ReturnsAsync(5); - this._service.Setup(s => s.ToggleCleanInvasionsAsync("123456789", 1, 1)).ReturnsAsync(5); - this._service.Setup(s => s.ToggleCleanLuresAsync("123456789", 1, 1)).ReturnsAsync(5); - this._service.Setup(s => s.ToggleCleanNestsAsync("123456789", 1, 1)).ReturnsAsync(5); - this._service.Setup(s => s.ToggleCleanGymsAsync("123456789", 1, 1)).ReturnsAsync(5); - - var result = await this._sut.ToggleClean(alarmType, 1); - - Assert.IsType(result); - } - - [Fact] - public async Task ToggleCleanThrowsForUnknownAlarmType() => await Assert.ThrowsAsync(() => this._sut.ToggleClean("unknown", 1)); - - [Fact] - public async Task ToggleCleanIsCaseInsensitive() - { - this._service.Setup(s => s.ToggleCleanMonstersAsync("123456789", 1, 1)).ReturnsAsync(3); - - var result = await this._sut.ToggleClean("MONSTERS", 1); - - Assert.IsType(result); - } -} +using Microsoft.AspNetCore.Mvc; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +public class CleaningControllerTests : ControllerTestBase +{ + private readonly Mock _service = new(); + private readonly Mock _featureGate = new(); + private readonly CleaningController _sut; + + public CleaningControllerTests() + { + // Everything enabled unless a test says otherwise. + this._featureGate.Setup(g => g.IsEnabledAsync(It.IsAny())).ReturnsAsync(true); + this._sut = new CleaningController(this._service.Object, this._featureGate.Object); + SetupUser(this._sut); + } + + [Theory] + [InlineData("monsters")] + [InlineData("raids")] + [InlineData("eggs")] + [InlineData("quests")] + [InlineData("invasions")] + [InlineData("lures")] + [InlineData("nests")] + [InlineData("gyms")] + public async Task ToggleCleanReturnsOkForAllAlarmTypes(string alarmType) + { + this._service.Setup(s => s.ToggleCleanMonstersAsync("123456789", 1, 1)).ReturnsAsync(5); + this._service.Setup(s => s.ToggleCleanRaidsAsync("123456789", 1, 1)).ReturnsAsync(5); + this._service.Setup(s => s.ToggleCleanEggsAsync("123456789", 1, 1)).ReturnsAsync(5); + this._service.Setup(s => s.ToggleCleanQuestsAsync("123456789", 1, 1)).ReturnsAsync(5); + this._service.Setup(s => s.ToggleCleanInvasionsAsync("123456789", 1, 1)).ReturnsAsync(5); + this._service.Setup(s => s.ToggleCleanLuresAsync("123456789", 1, 1)).ReturnsAsync(5); + this._service.Setup(s => s.ToggleCleanNestsAsync("123456789", 1, 1)).ReturnsAsync(5); + this._service.Setup(s => s.ToggleCleanGymsAsync("123456789", 1, 1)).ReturnsAsync(5); + + var result = await this._sut.ToggleClean(alarmType, 1); + + Assert.IsType(result); + } + + + [Fact] + public async Task ToggleCleanIsCaseInsensitive() + { + this._service.Setup(s => s.ToggleCleanMonstersAsync("123456789", 1, 1)).ReturnsAsync(3); + + var result = await this._sut.ToggleClean("MONSTERS", 1); + + Assert.IsType(result); + } + + // --- Client input must not 500 (#418) --- + + [Theory] + [InlineData("pokemon")] // a natural guess for "monsters" + [InlineData("monster")] + [InlineData("")] + [InlineData("nonsense")] + public async Task ToggleCleanRejectsAnUnknownAlarmTypeWithBadRequest(string alarmType) + { + var result = await this._sut.ToggleClean(alarmType, 1); + + var bad = Assert.IsType(result); + Assert.Contains("Unknown alarm type", System.Text.Json.JsonSerializer.Serialize(bad.Value), StringComparison.Ordinal); + } + + + // --- Bulk toggle applied partially then 403'd (#402) --- + + [Fact] + public async Task ToggleAllSkipsDisabledTypesInsteadOfFailingMidway() + { + SetupUser(this._sut); + this._featureGate.Setup(g => g.IsEnabledAsync(It.IsAny())).ReturnsAsync(true); + this._featureGate.Setup(g => g.IsEnabledAsync(DisableFeatureKeys.Lures)).ReturnsAsync(false); + this._service.Setup(s => s.ToggleCleanMonstersAsync(It.IsAny(), It.IsAny(), 1)).ReturnsAsync(3); + + var result = await this._sut.ToggleAll(1); + + Assert.IsType(result); + // The disabled type is skipped, not thrown on, so the rest still apply. + this._service.Verify(s => s.ToggleCleanLuresAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + this._service.Verify(s => s.ToggleCleanMonstersAsync(It.IsAny(), It.IsAny(), 1), Times.Once); + this._service.Verify(s => s.ToggleCleanGymsAsync(It.IsAny(), It.IsAny(), 1), Times.Once); + } + + [Fact] + public async Task ToggleCleanRejectsFortChangesWhichCannotStoreTheFlag() + { + SetupUser(this._sut); + + var result = await this._sut.ToggleClean("fortchanges", 1); + + Assert.IsType(result); + } + + // enabled is a flag, not a bitmask. CleanFlags.Preserve masks with bit 1, so an even value such + // as 2 silently DISABLED cleaning while reporting rows updated -- and the write rotates every uid, + // deleting and reinserting for max battles. See #472. + + [Theory] + [InlineData(2)] + [InlineData(-1)] + [InlineData(99)] + public async Task ToggleCleanRejectsAnEnabledValueThatIsNotAFlag(int enabled) + { + var result = await this._sut.ToggleClean("monsters", enabled); + + Assert.IsType(result); + } + + [Theory] + [InlineData(2)] + [InlineData(-1)] + public async Task ToggleAllRejectsAnEnabledValueThatIsNotAFlag(int enabled) + { + var result = await this._sut.ToggleAll(enabled); + + Assert.IsType(result); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task ToggleCleanStillAcceptsBothFlagValues(int enabled) + { + var result = await this._sut.ToggleClean("monsters", enabled); + + Assert.IsNotType(result); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs index fb22acd6..7d9072f5 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs @@ -63,11 +63,30 @@ public async Task GetConfigReturnsOkWhenAvailable() var result = await this._sut.GetConfig(); var ok = Assert.IsType(result); - var returned = Assert.IsType(ok.Value); + var returned = Assert.IsType(ok.Value); Assert.Equal("en", returned.Locale); Assert.Equal(5000, returned.MaxDistance); } + [Fact] + public async Task GetConfigSurfacesPvpCapsAndDefaultPvpCap() + { + var config = new PoracleConfig + { + Locale = "en", + PvpCaps = [50, 51], + DefaultPvpCap = 50, + }; + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(config); + + var result = await this._sut.GetConfig(); + + var ok = Assert.IsType(result); + var returned = Assert.IsType(ok.Value); + Assert.Equal(new[] { 50, 51 }, returned.PvpCaps); + Assert.Equal(50, returned.DefaultPvpCap); + } + [Fact] public async Task GetConfigReturnsFallbackConfigWhenNull() { @@ -76,7 +95,7 @@ public async Task GetConfigReturnsFallbackConfigWhenNull() var result = await this._sut.GetConfig(); var ok = Assert.IsType(result); - var config = Assert.IsType(ok.Value); + var config = Assert.IsType(ok.Value); Assert.Equal("en", config.Locale); Assert.Equal("unknown", config.PoracleVersion); Assert.Equal(10726000, config.MaxDistance); @@ -90,10 +109,93 @@ public async Task GetConfigReturnsFallbackConfigWhenExceptionThrown() var result = await this._sut.GetConfig(); var ok = Assert.IsType(result); - var config = Assert.IsType(ok.Value); + var config = Assert.IsType(ok.Value); Assert.Equal("en", config.Locale); } + // --- GetConfig: disclosure guards (#415) --- + + [Fact] + public async Task GetConfigDoesNotExposeAdminIdsOrDelegationOrKeys() + { + // The proxy hands back everything upstream sends. The controller must project it down -- + // these four fields used to be served to anonymous callers verbatim. + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig + { + Locale = "en", + ProviderUrl = "http://internal-host:4000", + StaticKey = "secret-static-key", + Admins = new PoracleAdmins { Discord = ["111", "222"], Telegram = ["333"] }, + DelegateAdministration = [new PoracleDelegateEntry { WebhookId = "http://hook", DiscordIds = ["444"] }] + }); + + var result = await this._sut.GetConfig(); + + var ok = Assert.IsType(result); + Assert.IsType(ok.Value); + + // Assert on the serialized payload, because that is what actually reaches the browser. + var json = System.Text.Json.JsonSerializer.Serialize(ok.Value); + Assert.DoesNotContain("111", json, StringComparison.Ordinal); + Assert.DoesNotContain("222", json, StringComparison.Ordinal); + Assert.DoesNotContain("333", json, StringComparison.Ordinal); + Assert.DoesNotContain("444", json, StringComparison.Ordinal); + Assert.DoesNotContain("secret-static-key", json, StringComparison.Ordinal); + Assert.DoesNotContain("internal-host", json, StringComparison.Ordinal); + Assert.DoesNotContain("admins", json, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("delegate", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void GetConfigRequiresAuthenticationWhileTemplatesAndDtsStayAnonymous() + { + // GetConfig inherits [Authorize] from BaseApiController; the regression is someone + // re-adding [AllowAnonymous] to it. The sibling routes are genuinely pre-auth. + static bool Anonymous(string method) => typeof(ConfigController) + .GetMethod(method)! + .GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AllowAnonymousAttribute), inherit: true) + .Length > 0; + + Assert.False(Anonymous(nameof(ConfigController.GetConfig))); + Assert.True(Anonymous(nameof(ConfigController.GetTemplates))); + Assert.True(Anonymous(nameof(ConfigController.GetDts))); + } + + [Fact] + public void PublicProjectionCopiesEveryFieldTheBrowserUses() + { + var full = new PoracleConfig + { + Locale = "de", + PoracleVersion = "1.2.3", + PvpFilterMaxRank = 42, + PvpFilterLittleMinCp = 1, + PvpFilterGreatMinCp = 2, + PvpFilterUltraMinCp = 3, + PvpLittleLeagueAllowed = false, + PvpCaps = [50, 51], + DefaultPvpCap = 51, + DefaultTemplateName = "custom", + EverythingFlagPermissions = "everyone", + MaxDistance = 1234 + }; + + var projected = PublicPoracleConfig.From(full); + + Assert.Equal("de", projected.Locale); + Assert.Equal("1.2.3", projected.PoracleVersion); + Assert.Equal(42, projected.PvpFilterMaxRank); + Assert.Equal(1, projected.PvpFilterLittleMinCp); + Assert.Equal(2, projected.PvpFilterGreatMinCp); + Assert.Equal(3, projected.PvpFilterUltraMinCp); + Assert.False(projected.PvpLittleLeagueAllowed); + Assert.Equal(new[] { 50, 51 }, projected.PvpCaps); + Assert.Equal(51, projected.DefaultPvpCap); + Assert.Equal("custom", projected.DefaultTemplateName); + Assert.Equal("everyone", projected.EverythingFlagPermissions); + Assert.Equal(1234, projected.MaxDistance); + } + // --- GetDts --- [Fact] diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/GeofenceFeedControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/GeofenceFeedControllerTests.cs index 6cb2e07a..386e9b57 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/GeofenceFeedControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/GeofenceFeedControllerTests.cs @@ -268,4 +268,59 @@ public async Task GetPoracleFeedAdminGeofencesIncludeColorAndDescription() Assert.Equal("A test area", item.GetProperty("description").GetString()); Assert.Equal("#ff0000", item.GetProperty("color").GetString()); } + + // ── Malformed polygons must not reach the shared feed (#410) ──────────────── + // This endpoint is anonymous and is the single geofence source for PoracleJS, so one user's bad + // polygon is every user's problem. + + private static List FeedRows(params double[][][] polygons) + { + var rows = new List(); + for (var i = 0; i < polygons.Length; i++) + { + rows.Add(new UserGeofence + { + Id = i + 1, + KojiName = $"fence{i}", + PolygonJson = JsonSerializer.Serialize(polygons[i]) + }); + } + + return rows; + } + + private async Task FeedCountAsync() + { + var result = await this._sut.GetPoracleFeed(); + var json = JsonSerializer.Serialize(Assert.IsType(result).Value); + return JsonDocument.Parse(json).RootElement.GetProperty("data").GetArrayLength(); + } + + [Fact] + public async Task FeedSkipsPolygonsWhosePointsAreNotPairs() + { + double[][] good = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]; + double[][] bad = [[1.0], [2.0], [3.0]]; + this._repository.Setup(r => r.GetAllActiveAsync()).ReturnsAsync(FeedRows(good, bad)); + + Assert.Equal(1, await this.FeedCountAsync()); + } + + [Fact] + public async Task FeedSkipsPolygonsWithCoordinatesOffTheGlobe() + { + double[][] bad = [[999, -999], [998, -998], [997, -997]]; + this._repository.Setup(r => r.GetAllActiveAsync()).ReturnsAsync(FeedRows(bad)); + + Assert.Equal(0, await this.FeedCountAsync()); + } + + [Fact] + public async Task FeedStillServesWellFormedPolygons() + { + double[][] good = [[40.0, -75.0], [40.01, -75.0], [40.01, -74.99]]; + this._repository.Setup(r => r.GetAllActiveAsync()).ReturnsAsync(FeedRows(good, good)); + + Assert.Equal(2, await this.FeedCountAsync()); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/LocationControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/LocationControllerTests.cs index cae7cba6..49698100 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/LocationControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/LocationControllerTests.cs @@ -1,190 +1,198 @@ -using Microsoft.AspNetCore.Mvc; -using Moq; -using Pgan.PoracleWebNet.Api.Controllers; -using Pgan.PoracleWebNet.Core.Abstractions.Services; -using Pgan.PoracleWebNet.Core.Models; - -namespace Pgan.PoracleWebNet.Tests.Controllers; - -public class LocationControllerTests : ControllerTestBase -{ - private readonly Mock _humanService = new(); - private readonly Mock _profileService = new(); - private readonly Mock _humanProxy = new(); - private readonly Mock _proxy = new(); - private readonly Mock _httpClientFactory = new(); - private readonly LocationController _sut; - - public LocationControllerTests() - { - this._sut = new LocationController( - this._humanService.Object, - this._profileService.Object, - this._humanProxy.Object, - this._proxy.Object, - this._httpClientFactory.Object); - SetupUser(this._sut); - } - - // --- GetLocation --- - - [Fact] - public async Task GetLocationReturnsOkWhenProfileFound() - { - this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)) - .ReturnsAsync(new Profile { Id = "123456789", ProfileNo = 1, Latitude = 40.7128, Longitude = -74.006 }); - - var result = await this._sut.GetLocation(); - - Assert.IsType(result); - } - - [Fact] - public async Task GetLocationFallsBackToHumanWhenProfileMissing() - { - this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync((Profile?)null); - this._humanService.Setup(s => s.GetByIdAsync("123456789")) - .ReturnsAsync(new Human { Id = "123456789", Latitude = 41.235, Longitude = -96.174 }); - - var result = await this._sut.GetLocation(); - - Assert.IsType(result); - } - - [Fact] - public async Task GetLocationReturnsNotFoundWhenProfileAndHumanMissing() - { - this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync((Profile?)null); - this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync((Human?)null); - - Assert.IsType(await this._sut.GetLocation()); - } - - // --- UpdateLocation --- - - [Fact] - public async Task UpdateLocationCallsProxySetLocation() - { - var human = new Human { Id = "123456789", Latitude = 0, Longitude = 0 }; - this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync(human); - - var result = await this._sut.UpdateLocation( - new LocationController.LocationUpdateRequest { Latitude = 51.5074, Longitude = -0.1278 }); - - Assert.IsType(result); - this._humanProxy.Verify(p => p.SetLocationAsync("123456789", 51.5074, -0.1278), Times.Once); - } - - [Fact] - public async Task UpdateLocationReturnsNotFoundWhenHumanMissing() - { - this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync((Human?)null); - - Assert.IsType( - await this._sut.UpdateLocation(new LocationController.LocationUpdateRequest { Latitude = 0, Longitude = 0 })); - } - - // --- UpdateLanguage --- - - [Fact] - public async Task UpdateLanguageSetsLanguage() - { - var human = new Human { Id = "123456789", Language = "en" }; - this._humanService.Setup(s => s.GetByIdAndProfileAsync("123456789", 1)).ReturnsAsync(human); - this._humanService.Setup(s => s.UpdateAsync(human)).ReturnsAsync(human); - - var result = await this._sut.UpdateLanguage(new LocationController.LanguageUpdateRequest { Language = "de" }); - - Assert.IsType(result); - Assert.Equal("de", human.Language); - } - - [Fact] - public async Task UpdateLanguageReturnsNotFoundWhenHumanMissing() - { - this._humanService.Setup(s => s.GetByIdAndProfileAsync("123456789", 1)).ReturnsAsync((Human?)null); - Assert.IsType( - await this._sut.UpdateLanguage(new LocationController.LanguageUpdateRequest { Language = "de" })); - } - - // --- Geocode --- - - [Fact] - public async Task GeocodeReturnsBadRequestWhenQueryEmpty() - { - var result = await this._sut.Geocode(""); - Assert.IsType(result); - } - - [Fact] - public async Task GeocodeReturnsBadRequestWhenQueryWhitespace() - { - var result = await this._sut.Geocode(" "); - Assert.IsType(result); - } - - [Fact] - public async Task GeocodeReturnsBadRequestWhenNoProviderConfigured() - { - this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig { ProviderUrl = "" }); - var result = await this._sut.Geocode("London"); - Assert.IsType(result); - } - - [Fact] - public async Task GeocodeReturnsBadRequestWhenConfigNull() - { - this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync((PoracleConfig?)null); - var result = await this._sut.Geocode("London"); - Assert.IsType(result); - } - - // --- GetStaticMap --- - - [Fact] - public async Task GetStaticMapReturnsOkWhenUrlAvailable() - { - this._proxy.Setup(p => p.GetLocationMapUrlAsync(51.5, -0.1)).ReturnsAsync("https://map.example/img.png"); - var result = await this._sut.GetStaticMap(51.5, -0.1); - Assert.IsType(result); - } - - [Fact] - public async Task GetStaticMapReturnsNotFoundWhenUrlNull() - { - this._proxy.Setup(p => p.GetLocationMapUrlAsync(0, 0)).ReturnsAsync((string?)null); - Assert.IsType(await this._sut.GetStaticMap(0, 0)); - } - - [Fact] - public async Task GetStaticMapReturnsNotFoundWhenThrows() - { - this._proxy.Setup(p => p.GetLocationMapUrlAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException()); - Assert.IsType(await this._sut.GetStaticMap(0, 0)); - } - - // --- GetDistanceMap --- - - [Fact] - public async Task GetDistanceMapReturnsOkWhenUrlAvailable() - { - this._proxy.Setup(p => p.GetDistanceMapUrlAsync(51.5, -0.1, 500)).ReturnsAsync("https://map.example/dist.png"); - var result = await this._sut.GetDistanceMap(51.5, -0.1, 500); - Assert.IsType(result); - } - - [Fact] - public async Task GetDistanceMapReturnsNotFoundWhenUrlNull() - { - this._proxy.Setup(p => p.GetDistanceMapUrlAsync(0, 0, 0)).ReturnsAsync((string?)null); - Assert.IsType(await this._sut.GetDistanceMap(0, 0, 0)); - } - - [Fact] - public async Task GetDistanceMapReturnsNotFoundWhenThrows() - { - this._proxy.Setup(p => p.GetDistanceMapUrlAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException()); - Assert.IsType(await this._sut.GetDistanceMap(0, 0, 0)); - } -} +using Microsoft.AspNetCore.Mvc; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +public class LocationControllerTests : ControllerTestBase +{ + private readonly Mock _humanService = new(); + private readonly Mock _profileService = new(); + private readonly Mock _humanProxy = new(); + private readonly Mock _proxy = new(); + private readonly Mock _httpClientFactory = new(); + private readonly LocationController _sut; + + public LocationControllerTests() + { + this._sut = new LocationController( + this._humanService.Object, + this._profileService.Object, + this._humanProxy.Object, + this._proxy.Object, + this._httpClientFactory.Object); + SetupUser(this._sut); + } + + // --- GetLocation --- + + [Fact] + public async Task GetLocationReturnsOkWhenProfileFound() + { + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)) + .ReturnsAsync(new Profile { Id = "123456789", ProfileNo = 1, Latitude = 40.7128, Longitude = -74.006 }); + + var result = await this._sut.GetLocation(); + + Assert.IsType(result); + } + + [Fact] + public async Task GetLocationFallsBackToHumanWhenProfileMissing() + { + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync((Profile?)null); + this._humanService.Setup(s => s.GetByIdAsync("123456789")) + .ReturnsAsync(new Human { Id = "123456789", Latitude = 41.235, Longitude = -96.174 }); + + var result = await this._sut.GetLocation(); + + Assert.IsType(result); + } + + [Fact] + public async Task GetLocationReturnsNotFoundWhenProfileAndHumanMissing() + { + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync((Profile?)null); + this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync((Human?)null); + + Assert.IsType(await this._sut.GetLocation()); + } + + // --- UpdateLocation --- + + [Fact] + public async Task UpdateLocationCallsProxySetLocation() + { + var human = new Human { Id = "123456789", Latitude = 0, Longitude = 0 }; + this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync(human); + + var result = await this._sut.UpdateLocation( + new LocationController.LocationUpdateRequest { Latitude = 51.5074, Longitude = -0.1278 }); + + Assert.IsType(result); + this._humanProxy.Verify(p => p.SetLocationAsync("123456789", 51.5074, -0.1278), Times.Once); + } + + [Fact] + public async Task UpdateLocationReturnsNotFoundWhenHumanMissing() + { + this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync((Human?)null); + + Assert.IsType( + await this._sut.UpdateLocation(new LocationController.LocationUpdateRequest { Latitude = 0, Longitude = 0 })); + } + + // --- UpdateLanguage --- + + [Fact] + public async Task UpdateLanguageSetsLanguage() + { + var human = new Human { Id = "123456789", Language = "en" }; + this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync(human); + this._humanService.Setup(s => s.UpdateAsync(human)).ReturnsAsync(human); + + var result = await this.LanguageSut().UpdateLanguage(new NotificationLanguageController.LanguageUpdateRequest { Language = "de" }); + + Assert.IsType(result); + Assert.Equal("de", human.Language); + } + + [Fact] + public async Task UpdateLanguageReturnsNotFoundWhenHumanMissing() + { + this._humanService.Setup(s => s.GetByIdAsync("123456789")).ReturnsAsync((Human?)null); + Assert.IsType( + await this.LanguageSut().UpdateLanguage(new NotificationLanguageController.LanguageUpdateRequest { Language = "de" })); + } + + // --- Geocode --- + + [Fact] + public async Task GeocodeReturnsBadRequestWhenQueryEmpty() + { + var result = await this._sut.Geocode(""); + Assert.IsType(result); + } + + [Fact] + public async Task GeocodeReturnsBadRequestWhenQueryWhitespace() + { + var result = await this._sut.Geocode(" "); + Assert.IsType(result); + } + + [Fact] + public async Task GeocodeReturnsBadRequestWhenNoProviderConfigured() + { + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig { ProviderUrl = "" }); + var result = await this._sut.Geocode("London"); + Assert.IsType(result); + } + + [Fact] + public async Task GeocodeReturnsBadRequestWhenConfigNull() + { + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync((PoracleConfig?)null); + var result = await this._sut.Geocode("London"); + Assert.IsType(result); + } + + // --- GetStaticMap --- + + [Fact] + public async Task GetStaticMapReturnsOkWhenUrlAvailable() + { + this._proxy.Setup(p => p.GetLocationMapUrlAsync(51.5, -0.1)).ReturnsAsync("https://map.example/img.png"); + var result = await this._sut.GetStaticMap(51.5, -0.1); + Assert.IsType(result); + } + + [Fact] + public async Task GetStaticMapReturnsNotFoundWhenUrlNull() + { + this._proxy.Setup(p => p.GetLocationMapUrlAsync(0, 0)).ReturnsAsync((string?)null); + Assert.IsType(await this._sut.GetStaticMap(0, 0)); + } + + [Fact] + public async Task GetStaticMapReturnsNotFoundWhenThrows() + { + this._proxy.Setup(p => p.GetLocationMapUrlAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException()); + Assert.IsType(await this._sut.GetStaticMap(0, 0)); + } + + // --- GetDistanceMap --- + + [Fact] + public async Task GetDistanceMapReturnsOkWhenUrlAvailable() + { + this._proxy.Setup(p => p.GetDistanceMapUrlAsync(51.5, -0.1, 500)).ReturnsAsync("https://map.example/dist.png"); + var result = await this._sut.GetDistanceMap(51.5, -0.1, 500); + Assert.IsType(result); + } + + [Fact] + public async Task GetDistanceMapReturnsNotFoundWhenUrlNull() + { + this._proxy.Setup(p => p.GetDistanceMapUrlAsync(0, 0, 0)).ReturnsAsync((string?)null); + Assert.IsType(await this._sut.GetDistanceMap(0, 0, 0)); + } + + [Fact] + public async Task GetDistanceMapReturnsNotFoundWhenThrows() + { + this._proxy.Setup(p => p.GetDistanceMapUrlAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException()); + Assert.IsType(await this._sut.GetDistanceMap(0, 0, 0)); + } + + /// Language moved to its own controller so disable_location stops blocking it (#479). + private NotificationLanguageController LanguageSut() + { + var c = new NotificationLanguageController(this._humanService.Object); + SetupUser(c); + return c; + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs new file mode 100644 index 00000000..6951744b --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +/// +/// Coverage for the GET /api/masterdata/raid-levels endpoint added for #259. +/// +public class MasterDataControllerRaidLevelsTests +{ + private static readonly IReadOnlyList SampleLevels = + [ + new() { Value = 1, Category = "star", Name = "1 Star Raid", NamePlural = "1 Star Raids" }, + new() { Value = 9, Category = "special", Name = "Elite Raid", NamePlural = "Elite Raids" }, + ]; + + private static MasterDataController CreateController(IRaidLevelService raidLevelService) => new( + new Mock().Object, + new Mock().Object, + raidLevelService); + + [Fact] + public async Task GetRaidLevelsReturnsOkWithServicePayload() + { + var svc = new Mock(); + svc.Setup(s => s.GetAllAsync()).ReturnsAsync(SampleLevels); + var sut = CreateController(svc.Object); + + var result = await sut.GetRaidLevels(); + + var ok = Assert.IsType(result); + var payload = Assert.IsType>(ok.Value, exactMatch: false); + Assert.Equal(2, payload.Count); + Assert.Equal(9, payload[1].Value); + Assert.Equal("Elite Raid", payload[1].Name); + } + + [Fact] + public async Task GetRaidLevelsReturnsOkEvenWhenListIsEmpty() + { + var svc = new Mock(); + svc.Setup(s => s.GetAllAsync()).ReturnsAsync([]); + var sut = CreateController(svc.Object); + + var result = await sut.GetRaidLevels(); + + var ok = Assert.IsType(result); + var payload = Assert.IsType>(ok.Value, exactMatch: false); + Assert.Empty(payload); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs index d3069cee..60dc8de0 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs @@ -1,109 +1,215 @@ -using Microsoft.AspNetCore.Mvc; -using Moq; -using Pgan.PoracleWebNet.Api.Controllers; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Tests.Controllers; - -public class MasterDataControllerTests : ControllerTestBase -{ - private readonly Mock _masterDataService = new(); - private readonly Mock _poracleApiProxy = new(); - private readonly MasterDataController _sut; - - public MasterDataControllerTests() - { - this._sut = new MasterDataController(this._masterDataService.Object, this._poracleApiProxy.Object); - SetupUser(this._sut); - } - - // --- GetPokemon --- - - [Fact] - public async Task GetPokemonReturnsContentWhenCacheHit() - { - this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}"); - - var result = await this._sut.GetPokemon(); - - var content = Assert.IsType(result); - Assert.Equal("application/json", content.ContentType); - Assert.Contains("Bulbasaur", content.Content); - } - - [Fact] - public async Task GetPokemonRefreshesCacheWhenCacheMissThenReturnsContent() - { - // First call returns null, after refresh returns data - var callCount = 0; - this._masterDataService.Setup(s => s.GetPokemonDataAsync()) - .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}" : null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - - var result = await this._sut.GetPokemon(); - - var content = Assert.IsType(result); - Assert.Contains("Bulbasaur", content.Content); - this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); - } - - [Fact] - public async Task GetPokemonReturnsNotFoundWhenCacheMissAndRefreshFails() - { - this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync((string?)null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - - var result = await this._sut.GetPokemon(); - - Assert.IsType(result); - } - - // --- GetItems --- - - [Fact] - public async Task GetItemsReturnsContentWhenCacheHit() - { - this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Poke Ball\"}"); - var result = await this._sut.GetItems(); - Assert.IsType(result); - } - - [Fact] - public async Task GetItemsRefreshesCacheWhenCacheMissThenReturnsContent() - { - var callCount = 0; - this._masterDataService.Setup(s => s.GetItemDataAsync()) - .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Poke Ball\"}" : null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - - var result = await this._sut.GetItems(); - - Assert.IsType(result); - this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); - } - - [Fact] - public async Task GetItemsReturnsNotFoundWhenCacheMissAndRefreshFails() - { - this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync((string?)null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - Assert.IsType(await this._sut.GetItems()); - } - - // --- GetGrunts --- - - [Fact] - public async Task GetGruntsReturnsContentWhenAvailable() - { - this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"grunts\":[]}"); - var result = await this._sut.GetGrunts(); - Assert.IsType(result); - } - - [Fact] - public async Task GetGruntsReturnsNotFoundWhenNull() - { - this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync((string?)null); - Assert.IsType(await this._sut.GetGrunts()); - } -} +using Microsoft.AspNetCore.Mvc; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +public class MasterDataControllerTests : ControllerTestBase +{ + private readonly Mock _masterDataService = new(); + private readonly Mock _poracleApiProxy = new(); + private readonly Mock _raidLevelService = new(); + private readonly MasterDataController _sut; + + public MasterDataControllerTests() + { + this._sut = new MasterDataController( + this._masterDataService.Object, + this._poracleApiProxy.Object, + this._raidLevelService.Object); + SetupUser(this._sut); + } + + // --- GetPokemon --- + + [Fact] + public async Task GetPokemonReturnsContentWhenCacheHit() + { + this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}"); + + var result = await this._sut.GetPokemon(); + + var content = Assert.IsType(result); + Assert.Equal("application/json", content.ContentType); + Assert.Contains("Bulbasaur", content.Content); + } + + [Fact] + public async Task GetPokemonRefreshesCacheWhenCacheMissThenReturnsContent() + { + // First call returns null, after refresh returns data + var callCount = 0; + this._masterDataService.Setup(s => s.GetPokemonDataAsync()) + .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}" : null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + + var result = await this._sut.GetPokemon(); + + var content = Assert.IsType(result); + Assert.Contains("Bulbasaur", content.Content); + this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); + } + + [Fact] + public async Task GetPokemonReturnsNotFoundWhenCacheMissAndRefreshFails() + { + this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync((string?)null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + + var result = await this._sut.GetPokemon(); + + Assert.IsType(result); + } + + // --- GetItems --- + + [Fact] + public async Task GetItemsReturnsContentWhenCacheHit() + { + this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Poke Ball\"}"); + var result = await this._sut.GetItems(); + Assert.IsType(result); + } + + [Fact] + public async Task GetItemsRefreshesCacheWhenCacheMissThenReturnsContent() + { + var callCount = 0; + this._masterDataService.Setup(s => s.GetItemDataAsync()) + .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Poke Ball\"}" : null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + + var result = await this._sut.GetItems(); + + Assert.IsType(result); + this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); + } + + [Fact] + public async Task GetItemsReturnsNotFoundWhenCacheMissAndRefreshFails() + { + this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync((string?)null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + Assert.IsType(await this._sut.GetItems()); + } + + // --- GetGrunts --- + + [Fact] + public async Task GetGruntsReturnsContentWhenAvailable() + { + this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"grunts\":[]}"); + var result = await this._sut.GetGrunts(); + Assert.IsType(result); + } + + [Fact] + public async Task GetGruntsReturnsNotFoundWhenNull() + { + this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync((string?)null); + Assert.IsType(await this._sut.GetGrunts()); + } + + // --- GetMonsters --- + + [Fact] + public async Task GetMonstersServesPoracleNgTranslationForTheRequestedLocale() + { + this._poracleApiProxy.Setup(p => p.GetMonstersAsync("de")) + .ReturnsAsync(/*lang=json,strict*/ "{\"1_0\":{\"id\":1,\"name\":\"Bisasam\"}}"); + + var result = await this._sut.GetMonsters("de"); + + var content = Assert.IsType(result); + Assert.Contains("Bisasam", content.Content, StringComparison.Ordinal); + this._masterDataService.Verify(s => s.GetMonsterDataAsync(), Times.Never); + } + + [Fact] + public async Task GetMonstersDefaultsToEnglishWhenNoLocaleIsGiven() + { + this._poracleApiProxy.Setup(p => p.GetMonstersAsync("en")) + .ReturnsAsync(/*lang=json,strict*/ "{\"1_0\":{\"id\":1,\"name\":\"Bulbasaur\"}}"); + + Assert.IsType(await this._sut.GetMonsters(null)); + + this._poracleApiProxy.Verify(p => p.GetMonstersAsync("en"), Times.Once); + } + + /// + /// PoracleJS and older PoracleNG builds do not serve /api/masterdata/monsters. Falling back to + /// the English masterfile keeps names, types and forms in the selector instead of emptying it. + /// + [Fact] + public async Task GetMonstersFallsBackToTheEnglishMasterfileWhenUpstreamHasNoSuchRoute() + { + this._poracleApiProxy.Setup(p => p.GetMonstersAsync(It.IsAny())).ReturnsAsync((string?)null); + this._masterDataService.Setup(s => s.GetMonsterDataAsync()) + .ReturnsAsync(/*lang=json,strict*/ "{\"1_0\":{\"id\":1,\"name\":\"Bulbasaur\"}}"); + + var content = Assert.IsType(await this._sut.GetMonsters("de")); + + Assert.Contains("Bulbasaur", content.Content, StringComparison.Ordinal); + } + + [Fact] + public async Task GetMonstersFallsBackWhenUpstreamIsUnreachable() + { + this._poracleApiProxy.Setup(p => p.GetMonstersAsync(It.IsAny())).ThrowsAsync(new HttpRequestException("down")); + this._masterDataService.Setup(s => s.GetMonsterDataAsync()) + .ReturnsAsync(/*lang=json,strict*/ "{\"1_0\":{\"id\":1,\"name\":\"Bulbasaur\"}}"); + + var content = Assert.IsType(await this._sut.GetMonsters("de")); + + Assert.Contains("Bulbasaur", content.Content, StringComparison.Ordinal); + } + + [Fact] + public async Task GetMonstersReturnsNotFoundWhenNeitherSourceHasData() + { + this._poracleApiProxy.Setup(p => p.GetMonstersAsync(It.IsAny())).ReturnsAsync((string?)null); + this._masterDataService.Setup(s => s.GetMonsterDataAsync()).ReturnsAsync((string?)null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + + Assert.IsType(await this._sut.GetMonsters("en")); + + this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); + } + + [Theory] + // Every locale the UI can be set to has to survive normalization - refusing one would silently + // send that user back to English names. + [InlineData("en")] + [InlineData("de")] + [InlineData("fr")] + [InlineData("es")] + [InlineData("nl")] + [InlineData("it")] + [InlineData("pt")] + [InlineData("pt-BR")] + [InlineData("pl")] + [InlineData("da")] + [InlineData("sv")] + // PoracleNG's own locale codes, which an admin can set as the Poracle default. + [InlineData("ja")] + [InlineData("ru")] + [InlineData("zh-cn")] + [InlineData("nb-no")] + public void NormalizeLocaleKeepsEverySupportedLocale(string locale) + { + Assert.Equal(locale, MasterDataController.NormalizeLocale(locale)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + [InlineData("de&foo=bar")] + [InlineData("../../config")] + [InlineData("e")] + public void NormalizeLocaleFallsBackToEnglishForAnythingElse(string? locale) + { + Assert.Equal("en", MasterDataController.NormalizeLocale(locale)); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileControllerTests.cs index cbca5e60..419653aa 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileControllerTests.cs @@ -3,6 +3,7 @@ using Moq; using Pgan.PoracleWebNet.Api.Configuration; using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -16,15 +17,34 @@ public class ProfileControllerTests : ControllerTestBase private readonly ProfileController _sut; private readonly Mock _jwtService = new(); + private readonly Mock _profileRepository = new(); + private readonly Mock _roleResolver = new(); + private readonly Mock _userGeofenceRepository = new(); public ProfileControllerTests() { - this._jwtService.Setup(j => j.GenerateTokenWithReplacedProfile(It.IsAny(), It.IsAny())) + this._jwtService.Setup(j => j.GenerateTokenWithReplacedProfile(It.IsAny(), It.IsAny(), It.IsAny())) .Returns("test-jwt-token"); - this._sut = new ProfileController(this._profileService.Object, this._humanService.Object, this._humanProxy.Object, this._jwtService.Object); + this._sut = new ProfileController(this._profileService.Object, this._humanService.Object, this._humanProxy.Object, this._profileRepository.Object, this._jwtService.Object, this._roleResolver.Object, this._userGeofenceRepository.Object); SetupUser(this._sut); } + + /// + /// PoracleNG picks the profile number, so the controller creates first and then diffs the profile + /// list to learn which number it got. Mocks therefore have to return a DIFFERENT list after the + /// create than before it. See #407. + /// + private void SetupCreateAssigns(int newProfileNo, string name, params Profile[] before) + { + var after = before.Append(new Profile { Id = "123456789", ProfileNo = newProfileNo, Name = name }).ToArray(); + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync(before) + .ReturnsAsync(after); + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", newProfileNo)) + .ReturnsAsync(new Profile { Id = "123456789", ProfileNo = newProfileNo, Name = name }); + } + [Fact] public async Task GetAllReturnsOkWithProfiles() { @@ -37,7 +57,7 @@ public async Task GetAllReturnsOkWithProfiles() public async Task CreateReturnsCreatedAtAction() { var profile = new Profile { Name = "New" }; - this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(profile); + this.SetupCreateAssigns(1, "New"); var result = await this._sut.Create(profile); Assert.IsType(result); this._humanProxy.Verify(p => p.AddProfileAsync("123456789", It.IsAny()), Times.Once); @@ -47,7 +67,7 @@ public async Task CreateReturnsCreatedAtAction() public async Task CreateSetsUserId() { var profile = new Profile { Name = "New" }; - this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(profile); + this.SetupCreateAssigns(1, "New"); await this._sut.Create(profile); Assert.Equal("123456789", profile.Id); } @@ -57,9 +77,14 @@ public async Task UpdateReturnsOkWhenFound() { var existing = new Profile { Id = "123456789", ProfileNo = 1, Name = "Old" }; this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(existing); + this._profileRepository.Setup(r => r.RenameAsync("123456789", 1, "Updated")).ReturnsAsync(true); + var result = await this._sut.Update(1, new Profile { Name = "Updated" }); + Assert.IsType(result); this._humanProxy.Verify(p => p.UpdateProfileAsync("123456789", It.IsAny()), Times.Once); + // PoracleNG drops the name, so the rename has to be written directly. See #406. + this._profileRepository.Verify(r => r.RenameAsync("123456789", 1, "Updated"), Times.Once); } [Fact] @@ -93,8 +118,7 @@ public async Task DuplicateCreatesProfileAndCopiesAlarms() { var sourceProfile = new Profile { Id = "123456789", ProfileNo = 1, Name = "Main", Area = "[\"area1\"]" }; this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(sourceProfile); - this._profileService.Setup(s => s.GetByUserAsync("123456789")).ReturnsAsync([sourceProfile]); - this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 2)).ReturnsAsync(new Profile { ProfileNo = 2, Name = "Main (copy)" }); + this.SetupCreateAssigns(2, "Main (copy)", sourceProfile); var result = await this._sut.Duplicate(new DuplicateProfileRequest { FromProfileNo = 1, Name = "Main (copy)" }); @@ -109,8 +133,7 @@ public async Task DuplicateCopiesActiveHoursFromSource() var schedule = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"09\",\"mins\":\"00\"}]"; var sourceProfile = new Profile { Id = "123456789", ProfileNo = 1, Name = "Main", ActiveHours = schedule }; this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(sourceProfile); - this._profileService.Setup(s => s.GetByUserAsync("123456789")).ReturnsAsync([sourceProfile]); - this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 2)).ReturnsAsync(new Profile { ProfileNo = 2, Name = "Copy" }); + this.SetupCreateAssigns(2, "Copy", sourceProfile); JsonElement? capturedBody = null; this._humanProxy @@ -223,4 +246,165 @@ public async Task GetAllReturnsProfilesWithActiveHours() Assert.Equal(activeHours, returnedProfiles[0].ActiveHours); Assert.Null(returnedProfiles[1].ActiveHours); } + + // ── Profile numbering with a gap (#407) ───────────────────────────────────── + // PoracleWeb predicted max+1; PoracleNG assigns the lowest free number. Any user who had deleted a + // non-last profile therefore got a different number than PoracleWeb assumed, and create returned an + // empty body while duplicate copied alarms to a profile_no with no profile row. + + private static Profile P(int no, string name) => new() { Id = "123456789", ProfileNo = no, Name = name }; + + [Fact] + public async Task CreateReturnsTheProfileAtTheNumberPoracleNgActuallyChose() + { + // Profiles 0, 1, 3 -> PoracleNG fills the gap at 2, not max+1 = 4. + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([P(0, "Default"), P(1, "Work"), P(3, "Other")]) + .ReturnsAsync([P(0, "Default"), P(1, "Work"), P(2, "Gap Filler"), P(3, "Other")]); + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 2)) + .ReturnsAsync(P(2, "Gap Filler")); + + var result = await this._sut.Create(new Profile { Name = "Gap Filler" }); + + var created = Assert.IsType(result); + Assert.Equal(2, Assert.IsType(created.Value).ProfileNo); + } + + [Fact] + public async Task CreateDoesNotDictateTheProfileNumberToPoracleNg() + { + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([P(0, "Default")]) + .ReturnsAsync([P(0, "Default"), P(1, "New")]); + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(P(1, "New")); + + JsonElement? sent = null; + this._humanProxy.Setup(h => h.AddProfileAsync("123456789", It.IsAny())) + .Callback((_, b) => sent = b.Clone()) + .Returns(Task.CompletedTask); + + await this._sut.Create(new Profile { Name = "New" }); + + Assert.NotNull(sent); + Assert.False(sent!.Value.TryGetProperty("profileNo", out _)); + } + + [Fact] + public async Task DuplicateCopiesAlarmsToTheNumberThatActuallyExists() + { + // The orphan case: alarms used to be copied to max+1 = 4 while the profile was created at 2. + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 0)).ReturnsAsync(P(0, "Source")); + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([P(0, "Source"), P(1, "Work"), P(3, "Other")]) + .ReturnsAsync([P(0, "Source"), P(1, "Work"), P(2, "Dup"), P(3, "Other")]); + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 2)).ReturnsAsync(P(2, "Dup")); + + await this._sut.Duplicate(new DuplicateProfileRequest { FromProfileNo = 0, Name = "Dup" }); + + this._profileService.Verify(s => s.CopyAsync("123456789", 0, 2), Times.Once); + this._profileService.Verify(s => s.CopyAsync("123456789", 0, 4), Times.Never); + } + + [Fact] + public async Task CreateReportsAFailureRatherThanReturningAnEmptyBody() + { + // When nothing appears, the create did not happen. Returning 201 with a null body left the SPA + // list and counter stale while the user was told it worked. + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([P(0, "Default")]) + .ReturnsAsync([P(0, "Default")]); + + var result = await this._sut.Create(new Profile { Name = "Nope" }); + + Assert.Equal(Microsoft.AspNetCore.Http.StatusCodes.Status502BadGateway, Assert.IsType(result).StatusCode); + } + + // ── Rename (#406) ─────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateWritesTheRenameDirectlyBecauseTheProxyDropsIt() + { + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(P(1, "Old")); + this._profileRepository.Setup(r => r.RenameAsync("123456789", 1, "New")).ReturnsAsync(true); + + await this._sut.Update(1, new Profile { Name = "New" }); + + this._profileRepository.Verify(r => r.RenameAsync("123456789", 1, "New"), Times.Once); + } + + [Fact] + public async Task UpdateDoesNotRenameWhenTheNameIsUnchanged() + { + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(P(1, "Same")); + + await this._sut.Update(1, new Profile { Name = "Same" }); + + this._profileRepository.Verify( + r => r.RenameAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateDoesNotRenameWhenOnlyActiveHoursChange() + { + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(P(1, "Keep")); + + await this._sut.Update(1, new Profile { ActiveHours = "[]" }); + + this._profileRepository.Verify( + r => r.RenameAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // ── Name length and duplicate geography (#466, #467) ──────────────────── + + [Fact] + public async Task CreateRejectsANameLongerThanTheColumn() + { + // 256 chars reached the database and came back as an opaque 500. + var result = await this._sut.Create(new Profile { Name = new string('a', 256) }); + + Assert.IsType(result); + } + + [Fact] + public async Task CreateStillAcceptsANameAtTheLimit() + { + this.SetupCreateAssigns(1, new string('a', 255)); + + var result = await this._sut.Create(new Profile { Name = new string('a', 255) }); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateRejectsANameLongerThanTheColumn() + { + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)) + .ReturnsAsync(P(1, "Old")); + + var result = await this._sut.Update(1, new Profile { Name = new string('a', 256) }); + + Assert.IsType(result); + } + + [Fact] + public async Task DuplicateCarriesTheSourceProfilesAreasAndLocation() + { + // PoracleNG drops area/latitude/longitude from addProfile, so the copy silently inherited the + // ACTIVE profile's geography: the right alarms over the wrong map. See #466. + var source = new Profile + { + Id = "123456789", ProfileNo = 1, Name = "Work", + Area = "[\"downtown\",\"fan\"]", Latitude = 37.5, Longitude = -77.4, + }; + this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(source); + this.SetupCreateAssigns(2, "Copy", source); + + await this._sut.Duplicate(new DuplicateProfileRequest { FromProfileNo = 1, Name = "Copy" }); + + this._profileRepository.Verify(r => r.UpdateAsync(It.Is(x => + x.ProfileNo == 2 + && x.Area == source.Area + && x.Latitude == source.Latitude + && x.Longitude == source.Longitude)), Times.Once); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileOverviewControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileOverviewControllerTests.cs index 69c9801e..668836e7 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileOverviewControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ProfileOverviewControllerTests.cs @@ -3,6 +3,7 @@ using Moq; using Pgan.PoracleWebNet.Api.Configuration; using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -12,20 +13,25 @@ public class ProfileOverviewControllerTests : ControllerTestBase { private readonly Mock _humanProxy = new(); private readonly Mock _profileService = new(); + private readonly Mock _profileRepository = new(); private readonly Mock _service = new(); private readonly ProfileOverviewController _sut; - private readonly Mock _jwtService = new(); + private readonly Mock _jwtService = new(); + private readonly Mock _roleResolver = new(); public ProfileOverviewControllerTests() { - this._jwtService.Setup(j => j.GenerateTokenWithReplacedProfile(It.IsAny(), It.IsAny())) + this._jwtService.Setup(j => j.GenerateTokenWithReplacedProfile(It.IsAny(), It.IsAny(), It.IsAny())) .Returns("test-jwt-token"); this._sut = new ProfileOverviewController( this._service.Object, this._profileService.Object, + this._profileRepository.Object, this._humanProxy.Object, - this._jwtService.Object); + this._jwtService.Object, + this._roleResolver.Object, + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); SetupUser(this._sut); } @@ -76,9 +82,9 @@ public async Task DuplicateProfileReturnsOkWithAlarmCount() this._profileService .Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)) .ReturnsAsync(source); - this._profileService - .Setup(s => s.GetByUserAsync("123456789")) - .ReturnsAsync([source]); + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([source]) + .ReturnsAsync([source, new Profile { ProfileNo = 2, Name = "created" }]); this._humanProxy .Setup(h => h.AddProfileAsync("123456789", It.IsAny())) .Returns(Task.CompletedTask); @@ -98,9 +104,9 @@ public async Task DuplicateProfileReturnsOkWithAlarmCount() [Fact] public async Task ImportProfileReturnsOkWithAlarmCount() { - this._profileService - .Setup(s => s.GetByUserAsync("123456789")) - .ReturnsAsync([new Profile { ProfileNo = 1, Name = "Main" }]); + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([new Profile { ProfileNo = 1, Name = "Main" }]) + .ReturnsAsync([new Profile { ProfileNo = 1, Name = "Main" }, new Profile { ProfileNo = 2, Name = "created" }]); this._humanProxy .Setup(h => h.AddProfileAsync("123456789", It.IsAny())) .Returns(Task.CompletedTask); @@ -161,7 +167,9 @@ public async Task DuplicateProfilePropagatesFeatureDisabledException() // propagate (no try/catch swallowing it) so the global filter can map it to 403. (#236) var source = new Profile { ProfileNo = 1, Name = "Main", Area = "[]" }; this._profileService.Setup(s => s.GetByUserAndProfileNoAsync("123456789", 1)).ReturnsAsync(source); - this._profileService.Setup(s => s.GetByUserAsync("123456789")).ReturnsAsync([source]); + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([source]) + .ReturnsAsync([source, new Profile { ProfileNo = 2, Name = "created" }]); this._humanProxy.Setup(h => h.AddProfileAsync("123456789", It.IsAny())).Returns(Task.CompletedTask); this._humanProxy.Setup(h => h.DeleteProfileAsync("123456789", It.IsAny())).Returns(Task.CompletedTask); this._service @@ -177,9 +185,9 @@ public async Task DuplicateProfilePropagatesFeatureDisabledException() [Fact] public async Task ImportProfilePropagatesFeatureDisabledException() { - this._profileService - .Setup(s => s.GetByUserAsync("123456789")) - .ReturnsAsync([new Profile { ProfileNo = 1, Name = "Main" }]); + this._profileService.SetupSequence(s => s.GetByUserAsync("123456789")) + .ReturnsAsync([new Profile { ProfileNo = 1, Name = "Main" }]) + .ReturnsAsync([new Profile { ProfileNo = 1, Name = "Main" }, new Profile { ProfileNo = 2, Name = "created" }]); this._humanProxy.Setup(h => h.AddProfileAsync("123456789", It.IsAny())).Returns(Task.CompletedTask); var alarms = CreateJsonObject(new { diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ScannerControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ScannerControllerTests.cs index 71d0e6cb..2a8992b6 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ScannerControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ScannerControllerTests.cs @@ -295,11 +295,12 @@ public async Task GetGymByIdReturnsNotFoundWhenServiceThrows() [Theory] [InlineData("abc", "abc")] - [InlineData("100%", "100\\%")] - [InlineData("a_b", "a\\_b")] - [InlineData("back\\slash", "back\\\\slash")] - [InlineData("%_\\", "\\%\\_\\\\")] - public void EscapeLikePatternEscapesWildcardsAndBackslash(string input, string expected) + [InlineData("100%", "100|%")] + [InlineData("a_b", "a|_b")] + [InlineData("pipe|sep", "pipe||sep")] + [InlineData("%_|", "|%|_||")] + [InlineData("back\\slash", "back\\slash")] // backslash is no longer special + public void EscapeLikePatternEscapesWildcardsAndEscapeChar(string input, string expected) { var actual = Core.Services.LikeEscape.Escape(input); Assert.Equal(expected, actual); diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs index 66044214..f7f29052 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs @@ -1,4 +1,7 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Pgan.PoracleWebNet.Api.Configuration; @@ -11,13 +14,29 @@ namespace Pgan.PoracleWebNet.Tests.Controllers; public class SettingsControllerTests : ControllerTestBase { private readonly Mock _siteService = new(); + private readonly Mock _upstreamFlags = new(); + private readonly Mock _poracleApi = new(); private readonly SettingsController _sut; - public SettingsControllerTests() => this._sut = new SettingsController( + public SettingsControllerTests() + { + this._poracleApi.Setup(p => p.GetConfigAsync()).ReturnsAsync((PoracleConfig?)null); + this._sut = this.CreateController(); + } + + private SettingsController CreateController( + DiscordSettings? discord = null, + PoracleSettings? poracle = null) => new( this._siteService.Object, - Options.Create(new DiscordSettings()), - Options.Create(new PoracleSettings()), - Options.Create(new TelegramSettings())); + Options.Create(discord ?? new DiscordSettings()), + Options.Create(poracle ?? new PoracleSettings()), + Options.Create(new TelegramSettings()), + Options.Create(new OidcSettings()), + this._upstreamFlags.Object, + new ConfigurationBuilder().Build(), + this._poracleApi.Object, + new MemoryCache(new MemoryCacheOptions()), + NullLogger.Instance); [Fact] public async Task GetAllReturnsOkForAdmin() @@ -53,6 +72,191 @@ public async Task GetAllFiltersSensitiveKeysForNonAdmin() Assert.Equal("custom_title", settings[0].Key); } + /// + /// The real key names, not the placeholder ones. The old denylist contained the literal "scan_db", + /// which matches none of these rows, and omitted cf_id/cf_secret entirely -- so a scanner-database + /// password and a Cloudflare Access token were served to every authenticated non-admin session. + /// + [Theory] + [InlineData("scan_dbhost")] + [InlineData("scan_dbuser")] + [InlineData("scan_dbpass")] + [InlineData("scan_dbport")] + [InlineData("scan_dbname")] + [InlineData("cf_id")] + [InlineData("cf_secret")] + [InlineData("api_secret")] + [InlineData("telegram_bot_token")] + [InlineData("discord_client_secret")] + [InlineData("discord_bot_token")] + [InlineData("admin_channel_id")] + public async Task GetAllWithholdsCredentialBearingKeysFromNonAdmins(string key) + { + SetupUser(this._sut, isAdmin: false); + this._siteService.Setup(s => s.GetAllAsync()).ReturnsAsync( + [ + new() { Key = "custom_title", Value = "My App" }, + new() { Key = key, Value = "s3cret" } + ]); + + var settings = await this.GetAllKeysAsync(); + + Assert.DoesNotContain(key, settings); + Assert.Contains("custom_title", settings); + } + + /// Allowlist semantics: a key nobody has classified is hidden rather than exposed. + [Fact] + public async Task GetAllHidesUnrecognisedKeysFromNonAdminsByDefault() + { + SetupUser(this._sut, isAdmin: false); + this._siteService.Setup(s => s.GetAllAsync()).ReturnsAsync( + [ + new() { Key = "some_future_integration_token", Value = "s3cret" }, + new() { Key = "site_name", Value = "PGAN" } + ]); + + var settings = await this.GetAllKeysAsync(); + + Assert.DoesNotContain("some_future_integration_token", settings); + Assert.Contains("site_name", settings); + } + + [Theory] + [InlineData("disable_mons")] + [InlineData("disable_user_geofences")] + [InlineData("enable_discord")] + [InlineData("enable_templates")] + [InlineData("uicons_pkmn")] + [InlineData("allowed_languages")] + [InlineData("custom_title")] + [InlineData("favicon_url")] + [InlineData("header_logo_url")] + [InlineData("hide_header_logo")] + [InlineData("signup_url")] + [InlineData("site_name")] + public async Task GetAllStillServesTheKeysTheSpaNeedsToNonAdmins(string key) + { + SetupUser(this._sut, isAdmin: false); + this._siteService.Setup(s => s.GetAllAsync()).ReturnsAsync([new() { Key = key, Value = "v" }]); + + Assert.Contains(key, await this.GetAllKeysAsync()); + } + + [Fact] + public async Task GetAllStillReturnsEverythingToAdmins() + { + SetupUser(this._sut, isAdmin: true); + this._siteService.Setup(s => s.GetAllAsync()).ReturnsAsync( + [ + new() { Key = "scan_dbpass", Value = "p" }, + new() { Key = "cf_secret", Value = "s" }, + new() { Key = "custom_title", Value = "t" } + ]); + + var settings = await this.GetAllKeysAsync(); + + Assert.Contains("scan_dbpass", settings); + Assert.Contains("cf_secret", settings); + Assert.Contains("custom_title", settings); + } + + private async Task> GetAllKeysAsync() + { + var ok = Assert.IsType(await this._sut.GetAll()); + return [.. Assert.IsType>(ok.Value, exactMatch: false).Select(s => s.Key!)]; + } + + /// + /// The SPA picks its display language before login, where /api/config 401s (#426), so Poracle's locale + /// has to ride out on the anonymous settings endpoint instead. + /// + [Fact] + public async Task GetPublicServesPoraclesLocaleToAnonymousVisitors() + { + this._sut.ControllerContext = new ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext() + }; + this._poracleApi.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig { Locale = "de" }); + this._siteService.Setup(s => s.GetPublicAsync()).ReturnsAsync([new() { Key = "custom_title", Value = "App" }]); + + var ok = Assert.IsType(await this._sut.GetPublic()); + var settings = Assert.IsType>(ok.Value, exactMatch: false).ToList(); + + Assert.Contains(settings, s => s.Key == SettingsController.PoracleLocaleKey && s.Value == "de"); + Assert.Contains(settings, s => s.Key == "custom_title"); + } + + [Fact] + public async Task GetAllServesPoraclesLocaleToNonAdmins() + { + SetupUser(this._sut, isAdmin: false); + this._poracleApi.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig { Locale = "de" }); + this._siteService.Setup(s => s.GetAllAsync()).ReturnsAsync([new() { Key = "custom_title", Value = "t" }]); + + Assert.Contains(SettingsController.PoracleLocaleKey, await this.GetAllKeysAsync()); + } + + /// Poracle being down must cost the caller nothing but the locale. + [Fact] + public async Task GetPublicStillServesTheStoredSettingsWhenPoracleIsUnreachable() + { + this._sut.ControllerContext = new ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext() + }; + this._poracleApi.Setup(p => p.GetConfigAsync()).ThrowsAsync(new HttpRequestException("down")); + this._siteService.Setup(s => s.GetPublicAsync()).ReturnsAsync([new() { Key = "custom_title", Value = "App" }]); + + var ok = Assert.IsType(await this._sut.GetPublic()); + var settings = Assert.IsType>(ok.Value, exactMatch: false).ToList(); + + Assert.Contains(settings, s => s.Key == "custom_title"); + Assert.DoesNotContain(settings, s => s.Key == SettingsController.PoracleLocaleKey); + } + + [Fact] + public async Task GetPublicLetsAStoredRowWinOverPoraclesLocale() + { + this._sut.ControllerContext = new ControllerContext + { + HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext() + }; + this._poracleApi.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig { Locale = "de" }); + this._siteService.Setup(s => s.GetPublicAsync()) + .ReturnsAsync([new() { Key = SettingsController.PoracleLocaleKey, Value = "fr" }]); + + var ok = Assert.IsType(await this._sut.GetPublic()); + var settings = Assert.IsType>(ok.Value, exactMatch: false).ToList(); + + Assert.Single(settings); + Assert.Equal("fr", settings[0].Value); + } + + /// + /// Locales this UI ships no translation for (ja, ru, zh-cn) pass the shape check deliberately -- the SPA + /// matches them against its own language list and the allowed_languages filter, and falls back to en. + /// + [Theory] + [InlineData("de")] + [InlineData("en")] + [InlineData("pt-BR")] + [InlineData("zh-cn")] + [InlineData("ja")] + public void NormalizeLocaleKeepsAnythingShapedLikeALocaleTag(string locale) => + Assert.Equal(locale, SettingsController.NormalizeLocale(locale)); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("en; DROP TABLE humans")] + [InlineData("../../etc/passwd")] + [InlineData("englishy")] + public void NormalizeLocaleRejectsAnythingElse(string? locale) => + Assert.Null(SettingsController.NormalizeLocale(locale)); + [Fact] public async Task GetPublicReturnsOk() { @@ -131,24 +335,65 @@ public async Task UpsertReturnsBadRequestForInternalKey() Assert.IsType(result); } + /// + /// poracle_locale is synthesized from Poracle's config, and a real row would win over it, so a + /// single accidental save would pin the language default and stop tracking Poracle for good. + /// + [Fact] + public async Task UpsertRefusesToWriteThePoracleLocaleProjection() + { + SetupUser(this._sut, isAdmin: true); + var request = new SettingsController.SiteSettingRequest { Value = "de" }; + + var result = await this._sut.Upsert("poracle_locale", request); + + Assert.IsType(result); + this._siteService.Verify(s => s.CreateOrUpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpsertRefusesThePoracleLocaleProjectionWhateverItsCasing() + { + SetupUser(this._sut, isAdmin: true); + var request = new SettingsController.SiteSettingRequest { Value = "de" }; + + Assert.IsType(await this._sut.Upsert("PORACLE_LOCALE", request)); + } + + /// + /// The refusal must not spread: an ordinary key still writes. Without this the guard above passes + /// just as well with the whole endpoint broken. + /// + [Fact] + public async Task UpsertStillWritesAnOrdinarySetting() + { + SetupUser(this._sut, isAdmin: true); + this._siteService.Setup(s => s.GetByKeyAsync("custom_title")).ReturnsAsync((SiteSetting?)null); + this._siteService.Setup(s => s.CreateOrUpdateAsync(It.IsAny())) + .ReturnsAsync(new SiteSetting { Key = "custom_title", Value = "My Site" }); + + var result = await this._sut.Upsert("custom_title", new SettingsController.SiteSettingRequest { Value = "My Site" }); + + Assert.IsType(result); + this._siteService.Verify(s => s.CreateOrUpdateAsync(It.IsAny()), Times.Once); + } + [Fact] public void GetDiscordConfigReturnsOkForAdmin() { - var controller = new SettingsController( - this._siteService.Object, - Options.Create(new DiscordSettings + var controller = this.CreateController( + new DiscordSettings { ClientId = "123456789012345678", ClientSecret = "abcdefghijklmnopqrstuvwxyz123456", BotToken = "MTIzNDU2Nzg5.GhijKl.abcdefghijklmnop", GuildId = "987654321098765432", GeofenceForumChannelId = "111222333444555666", - }), - Options.Create(new PoracleSettings + }, + new PoracleSettings { AdminIds = "111111111,222222222", - }), - Options.Create(new TelegramSettings())); + }); SetupUser(controller, isAdmin: true); var result = controller.GetDiscordConfig(); @@ -217,4 +462,38 @@ public async Task UpsertAllowsDisablingWhenOtherIsAbsent() Assert.IsType(result); } + + /// + /// The keys Poracle forces off, so the SPA can hide those sections and the admin page can mark + /// the matching toggle as not-ours-to-change instead of showing a dead switch. See #769. + /// + [Fact] + public async Task GetUpstreamDisabledReturnsTheKeysPoracleForcesOff() + { + SetupUser(this._sut, isAdmin: false); + this._upstreamFlags + .Setup(f => f.GetDisabledKeysAsync()) + .ReturnsAsync(new HashSet(["disable_raids", "disable_quests"], StringComparer.Ordinal)); + + var ok = Assert.IsType(await this._sut.GetUpstreamDisabled()); + var keys = Assert.IsType>(ok.Value); + + Assert.Equal(["disable_quests", "disable_raids"], keys); + } + + /// + /// Non-admins need this to hide nav items, so it must not be admin-gated. It is also the normal + /// case: prod serves an empty disabledHooks array. + /// + [Fact] + public async Task GetUpstreamDisabledReturnsAnEmptyListForNonAdminsWhenPoracleDisablesNothing() + { + SetupUser(this._sut, isAdmin: false); + this._upstreamFlags + .Setup(f => f.GetDisabledKeysAsync()) + .ReturnsAsync(new HashSet(StringComparer.Ordinal)); + + var ok = Assert.IsType(await this._sut.GetUpstreamDisabled()); + Assert.Empty(Assert.IsType>(ok.Value)); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/SummaryScheduleControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SummaryScheduleControllerTests.cs new file mode 100644 index 00000000..57389e6a --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SummaryScheduleControllerTests.cs @@ -0,0 +1,347 @@ +using System.Reflection; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Api.Filters; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +public class SummaryScheduleControllerTests : ControllerTestBase +{ + private const string UserId = "123456789"; + + private readonly Mock _proxy = new(); + private readonly Mock _capability = new(); + private readonly SummaryScheduleController _sut; + private static readonly string[] expected = ["ActiveHours"]; + + public SummaryScheduleControllerTests() + { + this._sut = new SummaryScheduleController(this._proxy.Object, this._capability.Object); + SetupUser(this._sut, userId: UserId); + } + + // ────────────────────────────────────────────────────────────── + // GetCapability — 200 { enabled = bool }, never 5xx + // ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task GetCapabilityReturnsEnabledBoolean(bool enabled) + { + this._capability.Setup(c => c.IsQuestSummaryEnabledAsync()).ReturnsAsync(enabled); + + var result = await this._sut.GetCapability(); + + var ok = Assert.IsType(result); + Assert.NotNull(ok.Value); + var flag = (bool?)ok.Value.GetType().GetProperty("enabled")?.GetValue(ok.Value); + Assert.Equal(enabled, flag); + } + + // ────────────────────────────────────────────────────────────── + // GetSchedules — maps proxy array, NEVER projects upstream id + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetSchedulesUsesJwtUserIdAndReturnsOk() + { + var schedules = JsonDocument.Parse( + /*lang=json,strict*/ """[{"id":"123456789","alert_type":"quest","active_hours":"[{\"day\":1,\"hours\":9,\"mins\":0}]"}]""").RootElement; + this._proxy.Setup(p => p.GetSchedulesAsync(UserId)).ReturnsAsync(schedules); + + var result = await this._sut.GetSchedules(); + + Assert.IsType(result); + // IDOR guard: the JWT user id is the only id passed to the proxy. + this._proxy.Verify(p => p.GetSchedulesAsync(UserId), Times.Once); + } + + [Fact] + public async Task GetSchedulesDoesNotEchoUpstreamId() + { + var schedules = JsonDocument.Parse( + /*lang=json,strict*/ """[{"id":"123456789","alert_type":"quest","active_hours":"[]"}]""").RootElement; + this._proxy.Setup(p => p.GetSchedulesAsync(UserId)).ReturnsAsync(schedules); + + var result = await this._sut.GetSchedules(); + + var ok = Assert.IsType(result); + var payload = JsonSerializer.Serialize(ok.Value); + // The upstream "id" is the user id — it must never be echoed back to the client. + Assert.DoesNotContain("123456789", payload, StringComparison.Ordinal); + } + + // ────────────────────────────────────────────────────────────── + // GetSchedule — validates alertType, 200 empty schedule on null proxy + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetScheduleValidTypeReturnsOk() + { + var schedule = JsonDocument.Parse( + /*lang=json,strict*/ """{"id":"123456789","alert_type":"quest","active_hours":"[]"}""").RootElement; + this._proxy.Setup(p => p.GetScheduleAsync(UserId, "quest")).ReturnsAsync(schedule); + + var result = await this._sut.GetSchedule("quest"); + + Assert.IsType(result); + this._proxy.Verify(p => p.GetScheduleAsync(UserId, "quest"), Times.Once); + } + + [Fact] + public async Task GetScheduleProxyNullReturnsEmptyScheduleNot404() + { + // "No schedule yet" is a normal empty state — return 200 with an empty schedule so the + // SPA's global 404 toast does not fire when a user first opens the dialog. + this._proxy.Setup(p => p.GetScheduleAsync(UserId, "quest")).ReturnsAsync((JsonElement?)null); + + var result = await this._sut.GetSchedule("quest"); + + var ok = Assert.IsType(result); + var schedule = Assert.IsType(ok.Value); + Assert.Equal("quest", schedule.AlertType); + Assert.Equal("[]", schedule.ActiveHours); + } + + [Theory] + [InlineData("invalid")] + [InlineData("pokemon")] + [InlineData("")] + public async Task GetScheduleInvalidTypeReturnsBadRequestBeforeProxy(string alertType) + { + var result = await this._sut.GetSchedule(alertType); + + Assert.IsType(result); + this._proxy.Verify(p => p.GetScheduleAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetScheduleAlertTypeIsCaseInsensitive() + { + var schedule = JsonDocument.Parse(/*lang=json,strict*/ """{"alert_type":"quest","active_hours":"[]"}""").RootElement; + this._proxy.Setup(p => p.GetScheduleAsync(UserId, "QUEST")).ReturnsAsync(schedule); + + var result = await this._sut.GetSchedule("QUEST"); + + Assert.IsType(result); + } + + // ────────────────────────────────────────────────────────────── + // SetSchedule — validates alertType + active_hours BEFORE proxy + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task SetScheduleValidReturnsNoContent() + { + this._proxy.Setup(p => p.SetScheduleAsync(UserId, "quest", It.IsAny())).Returns(Task.CompletedTask); + var request = new SummaryScheduleRequest { ActiveHours = /*lang=json,strict*/ "[{\"day\":1,\"hours\":9,\"mins\":0}]" }; + + var result = await this._sut.SetSchedule("quest", request); + + Assert.IsType(result); + this._proxy.Verify(p => p.SetScheduleAsync(UserId, "quest", request.ActiveHours), Times.Once); + } + + [Fact] + public async Task SetScheduleNullActiveHoursClearsWithEmptyArray() + { + this._proxy.Setup(p => p.SetScheduleAsync(UserId, "quest", It.IsAny())).Returns(Task.CompletedTask); + var request = new SummaryScheduleRequest { ActiveHours = null }; + + var result = await this._sut.SetSchedule("quest", request); + + Assert.IsType(result); + // null/whitespace = clear -> "[]" + this._proxy.Verify(p => p.SetScheduleAsync(UserId, "quest", "[]"), Times.Once); + } + + [Fact] + public async Task SetScheduleInvalidActiveHoursReturnsBadRequestBeforeProxy() + { + // day 8 is out of range — the shared ActiveHoursValidator must reject before any proxy call. + var request = new SummaryScheduleRequest { ActiveHours = /*lang=json,strict*/ "[{\"day\":8,\"hours\":9,\"mins\":0}]" }; + + var result = await this._sut.SetSchedule("quest", request); + + Assert.IsType(result); + this._proxy.Verify(p => p.SetScheduleAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SetScheduleTooManyEntriesReturnsBadRequestBeforeProxy() + { + // 29 entries exceeds the load-bearing ≤28 cap (keeps payload inside the varchar(4096) column). + var entries = string.Join(",", Enumerable.Range(0, 29).Select(i => + $"{{\"day\":{(i % 7) + 1},\"hours\":{i % 24},\"mins\":0}}")); + var request = new SummaryScheduleRequest { ActiveHours = $"[{entries}]" }; + + var result = await this._sut.SetSchedule("quest", request); + + Assert.IsType(result); + this._proxy.Verify(p => p.SetScheduleAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SetScheduleInvalidTypeReturnsBadRequestBeforeProxy() + { + var request = new SummaryScheduleRequest { ActiveHours = "[]" }; + + var result = await this._sut.SetSchedule("pokemon", request); + + Assert.IsType(result); + this._proxy.Verify(p => p.SetScheduleAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // ────────────────────────────────────────────────────────────── + // DeleteSchedule — idempotent, validates alertType + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteScheduleValidReturnsNoContent() + { + this._proxy.Setup(p => p.DeleteScheduleAsync(UserId, "quest")).Returns(Task.CompletedTask); + + var result = await this._sut.DeleteSchedule("quest"); + + Assert.IsType(result); + this._proxy.Verify(p => p.DeleteScheduleAsync(UserId, "quest"), Times.Once); + } + + [Fact] + public async Task DeleteScheduleInvalidTypeReturnsBadRequestBeforeProxy() + { + var result = await this._sut.DeleteSchedule("invalid"); + + Assert.IsType(result); + this._proxy.Verify(p => p.DeleteScheduleAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ────────────────────────────────────────────────────────────── + // Trigger — validates alertType, uses JWT id + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task TriggerValidReturnsNoContent() + { + this._proxy.Setup(p => p.TriggerAsync(UserId, "quest")).Returns(Task.CompletedTask); + + var result = await this._sut.Trigger("quest"); + + Assert.IsType(result); + // Trigger delivers a real DM — the JWT user id is the only target (no path/body id). + this._proxy.Verify(p => p.TriggerAsync(UserId, "quest"), Times.Once); + } + + [Fact] + public async Task TriggerInvalidTypeReturnsBadRequestBeforeProxy() + { + var result = await this._sut.Trigger("pokemon"); + + Assert.IsType(result); + this._proxy.Verify(p => p.TriggerAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + // ────────────────────────────────────────────────────────────── + // IDOR: no id route segment / no id body field + // ────────────────────────────────────────────────────────────── + + [Fact] + public void ControllerDerivesFromBaseApiControllerForJwtUserIdSource() => + // BaseApiController.UserId reads the JWT — guarantees there is no controller-level id parameter to spoof. + Assert.True(typeof(BaseApiController).IsAssignableFrom(typeof(SummaryScheduleController))); + + [Fact] + public void NoActionMethodHasAUserIdOrIdRouteParameter() + { + // Every {alertType} action must derive the human id from the JWT, never from a route segment. + var actions = typeof(SummaryScheduleController) + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName); + + foreach (var action in actions) + { + foreach (var p in action.GetParameters()) + { + Assert.False( + p.Name is "id" or "userId", + $"Action {action.Name} must not accept an '{p.Name}' parameter (IDOR risk)."); + } + } + } + + [Fact] + public void SetScheduleRequestDtoExposesOnlyActiveHours() + { + // The PUT body must carry ONLY ActiveHours — any id/userId/alertType body field is an IDOR vector. + var props = typeof(SummaryScheduleRequest) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(p => p.Name) + .ToArray(); + + Assert.Equal(expected, props); + } + + // ────────────────────────────────────────────────────────────── + // Attribute wiring: feature gate, rate limits, base policies + // ────────────────────────────────────────────────────────────── + + [Fact] + public void ControllerIsRouted() + { + // inherit: false — BaseApiController also declares [Route], which would make the + // inherited single-attribute lookup ambiguous; we want the controller's own template. + var route = typeof(SummaryScheduleController).GetCustomAttribute(inherit: false); + Assert.NotNull(route); + Assert.Equal("api/summary-schedules", route!.Template); + } + + [Fact] + public void ControllerIsGatedByDisableQuestsFeature() + { + // #236 lesson: the controller filter is the real boundary, not the Angular guard. The gate is + // class-level on purpose: a disabled type is gone, not read-only, so the schedule goes with it. + var attr = typeof(SummaryScheduleController).GetCustomAttribute(); + + Assert.NotNull(attr); + Assert.Equal("disable_quests", (string)attr!.Arguments![0]); + } + + [Fact] + public void ControllerRequiresAuthorizationViaBase() + { + // [Authorize] is inherited from BaseApiController; ensure it is present on the type chain. + var attr = typeof(SummaryScheduleController).GetCustomAttribute(inherit: true); + Assert.NotNull(attr); + } + + [Fact] + public void TriggerActionHasTestAlertRateLimitPolicy() + { + // Trigger delivers a real DM — a double-click must not double-deliver. 5/60s "test-alert" partitioned policy. + var method = typeof(SummaryScheduleController).GetMethod(nameof(SummaryScheduleController.Trigger)); + Assert.NotNull(method); + var attr = method!.GetCustomAttribute(); + Assert.NotNull(attr); + Assert.Equal("test-alert", attr!.PolicyName); + } + + [Theory] + [InlineData(nameof(SummaryScheduleController.SetSchedule))] + [InlineData(nameof(SummaryScheduleController.DeleteSchedule))] + public void WriteActionsHaveAuthReadRateLimitPolicy(string methodName) + { + // PUT/DELETE arm a debounced upstream reload — they carry the 120/60s "auth-read" partitioned policy. + var method = typeof(SummaryScheduleController).GetMethod(methodName); + Assert.NotNull(method); + var attr = method!.GetCustomAttribute(); + Assert.NotNull(attr); + Assert.Equal("auth-read", attr!.PolicyName); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/VersionControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/VersionControllerTests.cs new file mode 100644 index 00000000..afd9bdcf --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/VersionControllerTests.cs @@ -0,0 +1,83 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +public class VersionControllerTests +{ + private const string Sha = "3f8d38aa4724209ec7ebaf4f0a1053d063008734"; + + [Fact] + public void ReturnsBuildMetadataFromConfiguration() + { + var sut = Build(new() + { + ["BUILD_VERSION"] = "beta", + ["BUILD_REVISION"] = Sha, + ["BUILD_DATE"] = "2026-08-05T14:22:16Z", + }); + + var payload = Payload(sut.Get()); + + Assert.Equal("beta", Read(payload, "version")); + Assert.Equal(Sha, Read(payload, "revision")); + Assert.Equal("2026-08-05T14:22:16Z", Read(payload, "buildDate")); + Assert.Equal("Production", Read(payload, "environment")); + } + + [Fact] + public void ShortensRevisionToSevenCharacters() + { + var sut = Build(new() { ["BUILD_REVISION"] = Sha }); + + Assert.Equal("3f8d38a", Read(Payload(sut.Get()), "revisionShort")); + } + + [Fact] + public void FallsBackToUnknownWhenBuildArgsWereNotSupplied() + { + // A local `docker build` or `dotnet run` passes no build args at all. + var payload = Payload(Build([]).Get()); + + Assert.Equal(VersionController.Unknown, Read(payload, "version")); + Assert.Equal(VersionController.Unknown, Read(payload, "revision")); + Assert.Equal(VersionController.Unknown, Read(payload, "revisionShort")); + Assert.Equal(VersionController.Unknown, Read(payload, "buildDate")); + } + + [Fact] + public void TreatsBlankValuesAsUnknown() + { + // An unset build arg reaches the container as an empty string, not a missing key. + var payload = Payload(Build(new() { ["BUILD_VERSION"] = "", ["BUILD_REVISION"] = " " }).Get()); + + Assert.Equal(VersionController.Unknown, Read(payload, "version")); + Assert.Equal(VersionController.Unknown, Read(payload, "revision")); + } + + [Fact] + public void DoesNotTruncateARevisionShorterThanSevenCharacters() + { + var payload = Payload(Build(new() { ["BUILD_REVISION"] = "abc" }).Get()); + + Assert.Equal("abc", Read(payload, "revisionShort")); + } + + private static VersionController Build(Dictionary values) + { + var environment = new Mock(); + environment.SetupGet(e => e.EnvironmentName).Returns("Production"); + + return new VersionController( + new ConfigurationBuilder().AddInMemoryCollection(values).Build(), + environment.Object); + } + + private static object Payload(IActionResult result) => Assert.IsType(result).Value!; + + private static string Read(object payload, string property) => + payload.GetType().GetProperty(property)!.GetValue(payload)!.ToString()!; +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Filters/DisabledAlarmTypeGatingTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Filters/DisabledAlarmTypeGatingTests.cs new file mode 100644 index 00000000..220c6bfd --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Filters/DisabledAlarmTypeGatingTests.cs @@ -0,0 +1,68 @@ +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Api.Filters; + +namespace Pgan.PoracleWebNet.Tests.Filters; + +/// +/// A disabled alarm type is gone, not read-only: the gate sits on the controller, so every action — +/// reads and deletes included — answers 403 until it is switched back on. +/// +/// +/// +/// This was briefly the other way round. #784 moved the attribute onto the write actions so a user +/// could still see and remove rules of a type that had been switched off. That left a page reachable +/// for a feature an operator had turned off, so it was reverted (#792): an operator disabling a type +/// means it should disappear, and dormant rules are harmless — they cannot fire, and they come back +/// intact if the type is re-enabled. +/// +/// +/// Asserted on the attribute rather than through the pipeline because the failure being guarded +/// against is someone moving the gate back onto individual actions, which no behavioural test of the +/// actions that exist today would notice. +/// +/// +public class DisabledAlarmTypeGatingTests +{ + public static TheoryData GatedControllers() => new() + { + { typeof(MonsterController), "disable_mons" }, + { typeof(RaidController), "disable_raids" }, + { typeof(EggController), "disable_raids" }, + { typeof(QuestController), "disable_quests" }, + { typeof(InvasionController), "disable_invasions" }, + { typeof(LureController), "disable_lures" }, + { typeof(NestController), "disable_nests" }, + { typeof(GymController), "disable_gyms" }, + { typeof(FortChangeController), "disable_fort_changes" }, + { typeof(MaxBattleController), "disable_maxbattles" }, + { typeof(SummaryScheduleController), "disable_quests" }, + }; + + [Theory] + [MemberData(nameof(GatedControllers))] + public void ControllerIsGatedAtClassLevel(Type controller, string expectedKey) + { + var attrs = controller.GetCustomAttributes(typeof(RequireFeatureEnabledAttribute), inherit: true) + .Cast() + .ToList(); + + Assert.True( + attrs.Count == 1, + $"{controller.Name} should carry exactly one class-level [RequireFeatureEnabled]. A per-action gate leaves " + + "the page reachable and its reads answering for a type the operator switched off."); + Assert.Equal(expectedKey, (string)attrs[0].Arguments![0]); + } + + /// Eggs share the raid key, so disabling raids takes eggs with it. + [Fact] + public void EggsAreGatedOnTheRaidKey() + { + var attr = typeof(EggController) + .GetCustomAttributes(typeof(RequireFeatureEnabledAttribute), inherit: true) + .Cast() + .Single(); + + Assert.Equal("disable_raids", (string)attr.Arguments![0]); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Filters/FeatureGateCoverageTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Filters/FeatureGateCoverageTests.cs new file mode 100644 index 00000000..1d9144e3 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Filters/FeatureGateCoverageTests.cs @@ -0,0 +1,175 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Api.Filters; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Filters; + +/// +/// Guards the bug class where a disable_* toggle existed only in the SPA: the nav item was hidden +/// but the API stayed open, so anyone calling it directly — or tampering with client state — kept full use +/// of a feature the operator had switched off. disable_areas, disable_profiles and +/// disable_location all shipped that way. +/// +/// A toggle whose only enforcement is client-side is decoration, not a gate. These tests fail if a key is +/// added to without a controller actually enforcing it. +/// +/// +public class FeatureGateCoverageTests +{ + private static readonly Assembly ApiAssembly = typeof(AreaController).Assembly; + + /// Every disable_* constant declared on . + public static TheoryData AllDisableKeys + { + get + { + var data = new TheoryData(); + foreach (var key in DeclaredKeys()) + { + data.Add(key); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(AllDisableKeys))] + public void EveryDisableKeyIsEnforcedByAtLeastOneController(string disableKey) + { + var enforced = GatedControllers() + .Any(c => string.Equals(c.key, disableKey, StringComparison.Ordinal)); + + Assert.True( + enforced, + $"'{disableKey}' is declared in DisableFeatureKeys but no controller carries " + + $"[RequireFeatureEnabled(\"{disableKey}\")]. A toggle enforced only in the SPA is not a gate — " + + "the endpoints stay reachable by direct API call."); + } + + [Theory] + [InlineData(typeof(AreaController), "disable_areas")] + [InlineData(typeof(ProfileController), "disable_profiles")] + [InlineData(typeof(ProfileOverviewController), "disable_profiles")] + [InlineData(typeof(LocationController), "disable_location")] + [InlineData(typeof(MonsterController), "disable_mons")] + // Gated per-action rather than per-controller on purpose: reads stay open so existing + // geofences keep being served while creating new ones is blocked. + [InlineData(typeof(UserGeofenceController), "disable_user_geofences")] + public void ControllerCarriesTheExpectedGate(Type controller, string expectedKey) => + Assert.Contains(expectedKey, KeysEnforcedBy(controller)); + + /// + /// The gates read their key from , so a controller referencing a + /// literal that drifted from the constants would silently never fire. + /// + [Fact] + public void NoControllerGatesOnAnUndeclaredKey() + { + var declared = DeclaredKeys().ToHashSet(StringComparer.Ordinal); + + var unknown = GatedControllers() + .Where(c => !declared.Contains(c.key)) + .Select(c => $"{c.controller.Name} -> '{c.key}'") + .ToList(); + + Assert.True(unknown.Count == 0, "Controllers gate on keys absent from DisableFeatureKeys: " + string.Join(", ", unknown)); + } + + private static IEnumerable DeclaredKeys() => + typeof(DisableFeatureKeys) + .GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy) + .Where(f => f is { IsLiteral: true, IsInitOnly: false } && f.FieldType == typeof(string)) + .Select(f => (string)f.GetRawConstantValue()!) + .Distinct(StringComparer.Ordinal); + + /// + /// Keys a controller enforces, whether the attribute sits on the class or on individual actions — + /// UserGeofenceController gates per-action so its read endpoints stay open. + /// + private static List KeysEnforcedBy(Type controller) + { + var attributes = controller.GetCustomAttributes(inherit: true) + .Concat(controller + .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .SelectMany(m => m.GetCustomAttributes(inherit: true))); + + return [.. attributes + .Where(a => a.Arguments is { Length: > 0 }) + .Select(a => (string)a.Arguments![0]) + .Distinct(StringComparer.Ordinal)]; + } + + private static List<(Type controller, string key)> GatedControllers() => + [.. ApiAssembly.GetTypes() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract) + .SelectMany(t => KeysEnforcedBy(t).Select(k => (controller: t, key: k)))]; + + /// + /// The per-key test above only asks whether SOME action on SOME controller carries the attribute, so + /// a controller gated on one action and open on another passes it. That is exactly how + /// UserGeofenceController's activate/deactivate shipped ungated while its create was gated: + /// the write endpoints bypassed both disable_areas and disable_user_geofences. See #478. + /// + [Fact] + public void EveryMutatingActionOnAPartiallyGatedControllerIsGated() + { + var offenders = new List(); + + foreach (var controller in ApiAssembly.GetTypes() + .Where(t => typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract)) + { + // Only controllers that gate per-action are in scope; a class-level attribute covers + // everything, and a controller with no gate at all is a separate question. + if (controller.GetCustomAttributes(typeof(RequireFeatureEnabledAttribute), inherit: true).Length > 0) + { + continue; + } + + var actions = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName) + .ToList(); + + var gated = actions.Where(m => m.GetCustomAttributes(typeof(RequireFeatureEnabledAttribute), inherit: true).Length > 0).ToList(); + if (gated.Count == 0) + { + continue; + } + + foreach (var action in actions.Except(gated).Where(IsMutating)) + { + var name = $"{controller.Name}.{action.Name}"; + if (!IntentionallyUngatedWrites.Contains(name)) + { + offenders.Add(name); + } + } + } + + Assert.True( + offenders.Count == 0, + "These write endpoints sit on a controller that gates some of its actions but not these: " + + string.Join(", ", offenders) + + ". A partially gated controller is the shape that hides bypasses - gate them or move them."); + } + + /// + /// Writes that are deliberately reachable while their feature is off, with the reason. Anything not + /// listed here has to be gated, so an exemption is a decision someone made on purpose rather than an + /// oversight nobody noticed. + /// + private static readonly HashSet IntentionallyUngatedWrites = new(StringComparer.Ordinal) + { + // Turning off custom geofences stops people making new ones. It must not trap users with + // existing geofences they can no longer delete - blocking cleanup prevents no harm. + "UserGeofenceController.DeleteGeofence", + + }; + + /// A write, by HTTP verb. Reads are deliberately left open on some gated controllers. + private static bool IsMutating(MethodInfo action) => + action.GetCustomAttributes(inherit: true).Any(a => + a is HttpPostAttribute or HttpPutAttribute or HttpDeleteAttribute or HttpPatchAttribute); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Filters/UserGeofenceDeleteStaysOpenTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Filters/UserGeofenceDeleteStaysOpenTests.cs new file mode 100644 index 00000000..fb8828aa --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Filters/UserGeofenceDeleteStaysOpenTests.cs @@ -0,0 +1,73 @@ +using System.Reflection; +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Api.Filters; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Filters; + +/// +/// Deleting your own geofence keeps working when custom geofences are switched off. +/// +/// +/// +/// Every other mutation on carries +/// , so the delete looks like one somebody forgot — it has +/// been reported as an oversight once already, which is why it is pinned here rather than left to read +/// as an accident. +/// +/// +/// The reason it stays open is an asymmetry with the alarm types, whose controllers gate the whole +/// class and so refuse deletes too. Those users still have the bot — !untrack removes an alarm +/// whatever the web says. Geofences are PoracleWeb-only and none of the bot's 48 commands manages a +/// drawn polygon, so this endpoint is a user's only route to their own data. Fences that already exist +/// keep being served in the feed and keep matching, so gating the delete would leave someone receiving +/// alerts from an area they can neither edit nor remove. Production carries 42 of them. +/// +/// +public class UserGeofenceDeleteStaysOpenTests +{ + private static MethodInfo Action(string name) => + typeof(UserGeofenceController).GetMethod(name, BindingFlags.Public | BindingFlags.Instance) + ?? throw new InvalidOperationException($"{name} is gone from UserGeofenceController."); + + private static bool IsGated(MethodInfo action) => + action.GetCustomAttribute() is not null; + + [Fact] + public void DeletingYourOwnGeofenceIsNotGated() + { + Assert.False(IsGated(Action(nameof(UserGeofenceController.DeleteGeofence)))); + } + + [Theory] + [InlineData(nameof(UserGeofenceController.CreateGeofence))] + [InlineData(nameof(UserGeofenceController.RenameGeofence))] + [InlineData(nameof(UserGeofenceController.SubmitForReview))] + [InlineData(nameof(UserGeofenceController.ActivateGeofence))] + [InlineData(nameof(UserGeofenceController.DeactivateGeofence))] + [InlineData(nameof(UserGeofenceController.ImportGeoJson))] + public void EveryOtherMutationIsGated(string action) + { + // The other half. Without this, "delete is not gated" could be satisfied by nothing being + // gated at all, which is the state this pin exists to tell apart from a deliberate exception. + Assert.True(IsGated(Action(action)), $"{action} lost its {nameof(RequireFeatureEnabledAttribute)}."); + } + + [Fact] + public void TheControllerItselfIsNotGated() + { + // Gating the class would silently re-gate the delete, and the reads with it. + Assert.Null(typeof(UserGeofenceController).GetCustomAttribute()); + } + + [Fact] + public void TheGateTheOthersUseIsTheUserGeofenceOne() + { + // The key travels as the filter's constructor argument, so that is where it has to be read. + var attribute = Action(nameof(UserGeofenceController.CreateGeofence)) + .GetCustomAttribute(); + + Assert.Equal(DisableFeatureKeys.UserGeofences, attribute!.Arguments!.Single()); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Mappings/MappingExtensionTests.cs similarity index 96% rename from Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs rename to Tests/Pgan.PoracleWebNet.Tests/Mappings/MappingExtensionTests.cs index 1e86f84d..85c90060 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Mappings/MappingExtensionTests.cs @@ -35,6 +35,7 @@ public void MonsterCreate_ToMonster_CopiesAllProperties() PvpRankingBest = 1, PvpRankingMinCp = 2500, PvpRankingLeague = 2500, + PvpRankingCap = 50, Form = 42, Size = 3, MaxSize = 5, @@ -66,6 +67,7 @@ public void MonsterCreate_ToMonster_CopiesAllProperties() Assert.Equal(1, model.PvpRankingBest); Assert.Equal(2500, model.PvpRankingMinCp); Assert.Equal(2500, model.PvpRankingLeague); + Assert.Equal(50, model.PvpRankingCap); Assert.Equal(42, model.Form); Assert.Equal(3, model.Size); Assert.Equal(5, model.MaxSize); @@ -93,6 +95,7 @@ public void HumanEntity_ToModel_MapsAllFields() AdminDisable = 0, CurrentProfileNo = 2, CommunityMembership = "groupA", + Notes = "My Server / Alerts", }; var model = entity.ToModel(); @@ -109,6 +112,7 @@ public void HumanEntity_ToModel_MapsAllFields() Assert.Equal(0, model.AdminDisable); Assert.Equal(2, model.CurrentProfileNo); Assert.Equal("groupA", model.CommunityMembership); + Assert.Equal("My Server / Alerts", model.Notes); } // ── ProfileEntity.ToModel ─────────────────────────────── @@ -508,7 +512,6 @@ public void FortChangeCreate_ToFortChange_CopiesAllProperties() FortType = "pokestop", IncludeEmpty = 1, ChangeTypes = ["name", "location"], - Clean = 1, Template = "fortTemplate", }; @@ -519,7 +522,6 @@ public void FortChangeCreate_ToFortChange_CopiesAllProperties() Assert.Equal("pokestop", model.FortType); Assert.Equal(1, model.IncludeEmpty); Assert.Equal(["name", "location"], model.ChangeTypes); - Assert.Equal(1, model.Clean); Assert.Equal("fortTemplate", model.Template); } @@ -552,6 +554,7 @@ public void MonsterUpdate_ApplyUpdate_NullPreservesExisting() PvpRankingBest = 1, PvpRankingMinCp = 2500, PvpRankingLeague = 2500, + PvpRankingCap = 50, Form = 42, Size = 3, MaxSize = 5, @@ -586,6 +589,7 @@ public void MonsterUpdate_ApplyUpdate_NullPreservesExisting() Assert.Equal(1, existing.PvpRankingBest); Assert.Equal(2500, existing.PvpRankingMinCp); Assert.Equal(2500, existing.PvpRankingLeague); + Assert.Equal(50, existing.PvpRankingCap); Assert.Equal(42, existing.Form); Assert.Equal(3, existing.Size); Assert.Equal(5, existing.MaxSize); @@ -639,6 +643,28 @@ public void MonsterUpdate_ApplyUpdate_PartialOverwrite() Assert.Equal(1, existing.Clean); } + [Fact] + public void MonsterUpdate_ApplyUpdate_OverwritesPvpRankingCap() + { + var existing = new Monster { PvpRankingCap = 0 }; + var update = new MonsterUpdate { PvpRankingCap = 51 }; + + update.ApplyUpdate(existing); + + Assert.Equal(51, existing.PvpRankingCap); + } + + [Fact] + public void MonsterUpdate_ApplyUpdate_NullPvpRankingCapPreservesExisting() + { + var existing = new Monster { PvpRankingCap = 50 }; + var update = new MonsterUpdate(); + + update.ApplyUpdate(existing); + + Assert.Equal(50, existing.PvpRankingCap); + } + // ── RaidUpdate.ApplyUpdate — null-skip behavior ───────── [Fact] @@ -873,6 +899,44 @@ public void QuestUpdate_ApplyUpdate_PartialOverwrite() Assert.Equal(7, existing.Form); } + // ── clean bitmask round-trip (#292) ───────────────────── + + [Fact] + public void QuestCreate_ToQuest_PreservesMultiBitClean() + { + // clean is a PoracleNG bitmask (bit 1 = auto-delete, bit 2 = edit, bit 4 = summary). + // A bot-set clean=5 (auto-delete + summary) must survive the DTO->model mapping. (#292) + var create = new QuestCreate { Clean = 5 }; + + var model = create.ToQuest(); + + Assert.Equal(5, model.Clean); + } + + [Fact] + public void QuestUpdate_ApplyUpdate_PreservesMultiBitClean() + { + // A non-null multi-bit clean must overwrite verbatim — no bit gets dropped. (#292) + var existing = new Quest { Clean = 0 }; + var update = new QuestUpdate { Clean = 5 }; + + update.ApplyUpdate(existing); + + Assert.Equal(5, existing.Clean); + } + + [Fact] + public void QuestUpdate_ApplyUpdate_NullCleanPreservesMultiBitExisting() + { + // Null clean keeps the existing multi-bit value untouched (null-skip merge). (#292) + var existing = new Quest { Clean = 5 }; + var update = new QuestUpdate(); + + update.ApplyUpdate(existing); + + Assert.Equal(5, existing.Clean); + } + // ── InvasionUpdate.ApplyUpdate — null-skip behavior ───── [Fact] @@ -1139,7 +1203,6 @@ public void FortChangeUpdate_ApplyUpdate_NullPreservesExisting() FortType = "pokestop", IncludeEmpty = 1, ChangeTypes = ["name", "location"], - Clean = 1, Template = "origTemplate", }; @@ -1153,7 +1216,6 @@ public void FortChangeUpdate_ApplyUpdate_NullPreservesExisting() Assert.Equal("pokestop", existing.FortType); Assert.Equal(1, existing.IncludeEmpty); Assert.Equal(["name", "location"], existing.ChangeTypes); - Assert.Equal(1, existing.Clean); Assert.Equal("origTemplate", existing.Template); } @@ -1168,7 +1230,6 @@ public void FortChangeUpdate_ApplyUpdate_PartialOverwrite() FortType = "pokestop", IncludeEmpty = 1, ChangeTypes = ["name", "location"], - Clean = 1, Template = "origTemplate", }; @@ -1188,7 +1249,6 @@ public void FortChangeUpdate_ApplyUpdate_PartialOverwrite() Assert.Equal(60, existing.Uid); Assert.Equal("<@orig>", existing.Ping); Assert.Equal(150, existing.Distance); - Assert.Equal(1, existing.Clean); Assert.Equal("origTemplate", existing.Template); } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Models/CleanFlagsTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Models/CleanFlagsTests.cs new file mode 100644 index 00000000..f60ed94e --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Models/CleanFlagsTests.cs @@ -0,0 +1,70 @@ +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Models; + +public class CleanFlagsTests +{ + [Fact] + public void ConstantsMatchPoracleNgBitmask() + { + Assert.Equal(1, CleanFlags.AutoDelete); + Assert.Equal(2, CleanFlags.Edit); + Assert.Equal(4, CleanFlags.Summary); + Assert.Equal(7, CleanFlags.All); + } + + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(2, false)] + [InlineData(3, true)] + [InlineData(4, false)] + [InlineData(5, true)] + [InlineData(7, true)] + public void IsAutoDeleteReadsBit1(int clean, bool expected) => Assert.Equal(expected, CleanFlags.IsAutoDelete(clean)); + + [Theory] + [InlineData(0, false)] + [InlineData(1, false)] + [InlineData(2, true)] + [InlineData(3, true)] + [InlineData(4, false)] + [InlineData(6, true)] + [InlineData(7, true)] + public void IsEditReadsBit2(int clean, bool expected) => Assert.Equal(expected, CleanFlags.IsEdit(clean)); + + [Theory] + [InlineData(0, false)] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(4, true)] + [InlineData(5, true)] + [InlineData(6, true)] + [InlineData(7, true)] + public void IsSummaryReadsBit4(int clean, bool expected) => Assert.Equal(expected, CleanFlags.IsSummary(clean)); + + [Theory] + [InlineData(false, false, false, 0)] + [InlineData(true, false, false, 1)] + [InlineData(false, true, false, 2)] + [InlineData(true, true, false, 3)] + [InlineData(false, false, true, 4)] + [InlineData(true, false, true, 5)] + [InlineData(true, true, true, 7)] + public void ComposeBuildsBitmask(bool autoDelete, bool edit, bool summary, int expected) => + Assert.Equal(expected, CleanFlags.Compose(autoDelete, edit, summary)); + + [Theory] + // Clearing the auto-delete bit on clean=5 (auto-delete + summary) leaves summary (4) intact. + [InlineData(5, 1, 0, 4)] + // Setting the auto-delete bit on clean=4 (summary only) yields 5. + [InlineData(4, 1, 1, 5)] + // Replacing only the edit bit leaves the auto-delete bit alone. + [InlineData(1, 2, 2, 3)] + // Changes outside the mask are ignored: mask is bit1 only, so bit4 in changes is dropped. + [InlineData(0, 1, 4, 0)] + // No-op when mask is empty. + [InlineData(5, 0, 7, 5)] + public void PreserveReplacesOnlyMaskedBits(int existing, int mask, int changes, int expected) => + Assert.Equal(expected, CleanFlags.Preserve(existing, mask, changes)); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Models/InvasionGruntTypesTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Models/InvasionGruntTypesTests.cs new file mode 100644 index 00000000..c1a07206 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Models/InvasionGruntTypesTests.cs @@ -0,0 +1,91 @@ +using System.Text.RegularExpressions; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Models; + +/// +/// is the backend twin of the GRUNT_TYPES table in the Angular +/// invasion dialog. Both drive a "track everything" fan-out, and PoracleNG has no catch-all to fall +/// back on, so a one-sided edit silently leaves one path tracking fewer grunts than the other. These +/// tests read the TypeScript and compare. See #416. +/// +public class InvasionGruntTypesTests +{ + private const string DialogPath = + "Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-add-dialog.component.ts"; + + [Fact] + public void AllIsTheUnionOfItsParts() + { + Assert.Equal( + [.. InvasionGruntTypes.Elemental, .. InvasionGruntTypes.Special, .. InvasionGruntTypes.Leaders, InvasionGruntTypes.Giovanni], + InvasionGruntTypes.All); + } + + [Fact] + public void AllHasNoDuplicates() + { + Assert.Equal(InvasionGruntTypes.All.Count, InvasionGruntTypes.All.Distinct(StringComparer.Ordinal).Count()); + } + + [Fact] + public void AllIsLowercase() + { + // grunt_type is matched verbatim upstream; a capitalised entry would just never fire. + Assert.All(InvasionGruntTypes.All, t => Assert.Equal(t.ToLowerInvariant(), t)); + } + + [Fact] + public void PokestopEventTypesAreExcluded() + { + // Kecleon, gold stops and showcases are not Team Rocket invasions. "Track all invasions" + // must not silently subscribe a user to them. + Assert.DoesNotContain("kecleon", InvasionGruntTypes.All); + Assert.DoesNotContain("gold-stop", InvasionGruntTypes.All); + Assert.DoesNotContain("showcase", InvasionGruntTypes.All); + } + + [Fact] + public void MatchesTheAngularDialogList() + { + var dialog = ReadDialogSource(); + + // Each GRUNT_TYPES row looks like: { gruntType: 'fire', invasionId: 7, ... } + var frontend = Regex.Matches(dialog, @"gruntType:\s*'([a-z-]+)'") + .Select(m => m.Groups[1].Value) + .Distinct(StringComparer.Ordinal) + .ToHashSet(StringComparer.Ordinal); + + Assert.NotEmpty(frontend); + + var backend = InvasionGruntTypes.All.ToHashSet(StringComparer.Ordinal); + + var missingFromBackend = frontend.Except(backend).OrderBy(x => x, StringComparer.Ordinal).ToList(); + var missingFromFrontend = backend.Except(frontend).OrderBy(x => x, StringComparer.Ordinal).ToList(); + + Assert.True( + missingFromBackend.Count == 0, + $"The dialog offers grunt types the backend list lacks: {string.Join(", ", missingFromBackend)}. " + + "Add them to InvasionGruntTypes or the 'All Invasions' quick pick will skip them."); + + Assert.True( + missingFromFrontend.Count == 0, + $"InvasionGruntTypes has entries the dialog does not offer: {string.Join(", ", missingFromFrontend)}. " + + "Add them to GRUNT_TYPES or the dialog's 'Track all' will skip them."); + } + + private static string ReadDialogSource() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Pgan.PoracleWebNet.slnx"))) + { + dir = dir.Parent; + } + + Assert.NotNull(dir); + var path = Path.Combine(dir.FullName, DialogPath.Replace('/', Path.DirectorySeparatorChar)); + Assert.True(File.Exists(path), $"Could not find {path}"); + return File.ReadAllText(path); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Models/PolygonValidationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Models/PolygonValidationTests.cs new file mode 100644 index 00000000..2271243f --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Models/PolygonValidationTests.cs @@ -0,0 +1,120 @@ +using Pgan.PoracleWebNet.Core.Models.Helpers; + +namespace Pgan.PoracleWebNet.Tests.Models; + +/// +/// Create validated only the point count, so [[1],[2],[3]] and coordinates off the globe were +/// stored verbatim, served by the anonymous geofence feed that PoracleJS reads, and crashed the owner's +/// GeoJSON export. Import validated both. One rule now, shared by every path. See #410. +/// +public class PolygonValidationTests +{ + private static double[][] Square(double lat = 40, double lon = -75) => + [[lat, lon], [lat + 0.01, lon], [lat + 0.01, lon + 0.01], [lat, lon + 0.01]]; + + [Fact] + public void AcceptsAnOrdinarySquare() + { + Assert.True(PolygonValidation.TryValidate(Square(), out var error)); + Assert.Empty(error); + } + + [Fact] + public void RejectsPointsThatAreNotPairs() + { + // The reported payload. + Assert.False(PolygonValidation.TryValidate([[1.0], [2.0], [3.0]], out var error)); + Assert.Contains("[latitude, longitude] pair", error, StringComparison.Ordinal); + } + + [Fact] + public void RejectsPointsWithTooManyValues() + { + Assert.False(PolygonValidation.TryValidate([[1, 2, 3], [4, 5, 6], [7, 8, 9]], out _)); + } + + [Fact] + public void NamesTheOffendingPoint() + { + // A 300-point polygon with one bad vertex is unusable feedback without the index. + var polygon = Square().ToList(); + polygon.Insert(2, [1.0]); + + PolygonValidation.TryValidate([.. polygon], out var error); + + Assert.Contains("point 2", error, StringComparison.Ordinal); + } + + [Fact] + public void RejectsOutOfRangeCoordinates() + { + Assert.False(PolygonValidation.TryValidate([[999, -999], [998, -998], [997, -997]], out var error)); + Assert.Contains("out of valid range", error, StringComparison.Ordinal); + } + + [Theory] + [InlineData(90.1, 0)] + [InlineData(-90.1, 0)] + [InlineData(0, 180.1)] + [InlineData(0, -180.1)] + public void RejectsCoordinatesJustOutsideTheBounds(double lat, double lon) + { + Assert.False(PolygonValidation.TryValidate([[lat, lon], [1, 1], [2, 2]], out _)); + } + + [Theory] + [InlineData(90, 180)] + [InlineData(-90, -180)] + [InlineData(0, 0)] + public void AcceptsCoordinatesExactlyOnTheBounds(double lat, double lon) + { + Assert.True(PolygonValidation.TryValidate([[lat, lon], [1, 1], [2, 2]], out _)); + } + + [Fact] + public void RejectsNonFiniteCoordinates() + { + // JSON cannot carry these, but a computed polygon can, and they serialize as null and poison the feed. + Assert.False(PolygonValidation.TryValidate([[double.NaN, 0], [1, 1], [2, 2]], out _)); + Assert.False(PolygonValidation.TryValidate([[double.PositiveInfinity, 0], [1, 1], [2, 2]], out _)); + } + + [Fact] + public void RejectsFewerThanThreePoints() + { + Assert.False(PolygonValidation.TryValidate([[1, 1], [2, 2]], out var error)); + Assert.Contains("at least 3", error, StringComparison.Ordinal); + } + + [Fact] + public void RejectsMoreThanFiveHundredPoints() + { + var polygon = Enumerable.Range(0, 501).Select(i => new double[] { 40 + (i * 0.0001), -75 }).ToArray(); + + Assert.False(PolygonValidation.TryValidate(polygon, out var error)); + Assert.Contains("exceed 500", error, StringComparison.Ordinal); + } + + [Fact] + public void AcceptsExactlyFiveHundredPoints() + { + var polygon = Enumerable.Range(0, 500).Select(i => new double[] { 40 + (i * 0.0001), -75 }).ToArray(); + + Assert.True(PolygonValidation.TryValidate(polygon, out _)); + } + + [Fact] + public void RejectsNullPolygonAndNullPoints() + { + Assert.False(PolygonValidation.TryValidate(null, out _)); + Assert.False(PolygonValidation.TryValidate([null!, [1, 1], [2, 2]], out _)); + } + + [Fact] + public void IsWellFormedAgreesWithTryValidate() + { + Assert.True(PolygonValidation.IsWellFormed(Square())); + Assert.False(PolygonValidation.IsWellFormed([[1.0], [2.0], [3.0]])); + Assert.False(PolygonValidation.IsWellFormed(null)); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj index 3ae3ea8e..0babc392 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj +++ b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj @@ -8,13 +8,17 @@ - - - + + + + + + - + - + diff --git a/Tests/Pgan.PoracleWebNet.Tests/Repositories/HumanRepositoryDeleteTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Repositories/HumanRepositoryDeleteTests.cs new file mode 100644 index 00000000..4f17c946 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Repositories/HumanRepositoryDeleteTests.cs @@ -0,0 +1,90 @@ +using Microsoft.EntityFrameworkCore; +using Pgan.PoracleWebNet.Core.Repositories; +using Pgan.PoracleWebNet.Data; +using Pgan.PoracleWebNet.Data.Entities; + +namespace Pgan.PoracleWebNet.Tests.Repositories; + +/// +/// Deleting a user removed the humans row alone, so the profiles rows survived — invisible to every API +/// surface, but re-creating the same id adopted them verbatim, and PoracleNG's human-create then collided +/// on the surviving (id, profile_no) and errored after committing the human. See #481, #482. +/// +public class HumanRepositoryDeleteTests : IDisposable +{ + private readonly PoracleContext _context; + private readonly HumanRepository _sut; + + public HumanRepositoryDeleteTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"HumanRepositoryDelete_{Guid.NewGuid()}") + .Options; + this._context = new PoracleContext(options); + this._sut = new HumanRepository(this._context); + } + + public void Dispose() + { + this._context.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task SeedAsync(string id, params int[] profileNos) + { + this._context.Humans.Add(new HumanEntity + { + Id = id, + Name = "Test", + Type = "webhook", + Area = "[]", + CommunityMembership = "[]", + }); + + foreach (var no in profileNos) + { + this._context.Profiles.Add(new ProfileEntity + { + Id = id, + ProfileNo = no, + Name = $"Profile {no}", + Area = "[]", + }); + } + + await this._context.SaveChangesAsync(); + } + + [Fact] + public async Task DeleteUserRemovesEveryProfileItOwned() + { + await this.SeedAsync("gone", 1, 2, 3); + + var deleted = await this._sut.DeleteUserAsync("gone"); + + Assert.True(deleted); + Assert.Empty(await this._context.Profiles.Where(p => p.Id == "gone").ToListAsync()); + Assert.Null(await this._context.Humans.FirstOrDefaultAsync(h => h.Id == "gone")); + } + + [Fact] + public async Task DeleteUserLeavesEveryoneElseAlone() + { + await this.SeedAsync("gone", 1, 2); + await this.SeedAsync("stays", 1, 2); + + await this._sut.DeleteUserAsync("gone"); + + Assert.Equal(2, await this._context.Profiles.CountAsync(p => p.Id == "stays")); + Assert.NotNull(await this._context.Humans.FirstOrDefaultAsync(h => h.Id == "stays")); + } + + [Fact] + public async Task DeletingAnUnknownUserReportsNothingDeleted() + { + await this.SeedAsync("stays", 1); + + Assert.False(await this._sut.DeleteUserAsync("never-existed")); + Assert.Single(await this._context.Profiles.Where(p => p.Id == "stays").ToListAsync()); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Repositories/NoAliasedDeleteTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Repositories/NoAliasedDeleteTests.cs new file mode 100644 index 00000000..ba5d6ffe --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Repositories/NoAliasedDeleteTests.cs @@ -0,0 +1,75 @@ +using System.Text.RegularExpressions; + +namespace Pgan.PoracleWebNet.Tests.Repositories; + +/// +/// ExecuteDeleteAsync is unusable against this deployment. MySql.EntityFrameworkCore emits the +/// aliased single-table form — DELETE FROM `t` AS `x` WHERE … — and MariaDB answers 1064; the +/// multi-table DELETE x FROM t AS x is required once an alias is present. Verified against +/// MariaDB 10.8.2. +/// +/// Nothing catches this at build time and nothing catches it in the test suite either, because the +/// repository tests run on SQLite, whose provider does not emit the alias. It reaches production green +/// and fails on every call. That is how the OIDC session cleanup shipped never having run once (#707), +/// and how QuickPickAppliedStateRepository acquired its load-and-remove workaround before it. +/// +/// Use raw SQL with unquoted identifiers, or load and RemoveRange when the row count is small. +/// ExecuteUpdateAsync is fine — MariaDB accepts UPDATE t AS x SET x.c = …. +/// +public sealed class NoAliasedDeleteTests +{ + private static readonly string[] ScannedProjects = + [ + "Core", + "Data", + "Applications/Pgan.PoracleWebNet.Api", + ]; + + // Matches the call, not prose: the surrounding comments and doc-comments name the method freely. + private static readonly Regex CallSite = new(@"\.ExecuteDeleteAsync\s*\(", RegexOptions.Compiled); + + [Fact] + public void NoProductionCodeCallsExecuteDeleteAsync() + { + var root = FindSolutionRoot(); + + var offenders = ScannedProjects + .Select(p => Path.Combine(root, p.Replace('/', Path.DirectorySeparatorChar))) + .Where(Directory.Exists) + .SelectMany(dir => Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories)) + .Where(f => !IsBuildOutput(f, root)) + .SelectMany(f => File.ReadLines(f) + .Select((line, i) => (line, number: i + 1)) + .Where(x => CallSite.IsMatch(x.line)) + .Select(x => $"{Path.GetRelativePath(root, f)}:{x.number}")) + .OrderBy(x => x, StringComparer.Ordinal) + .ToList(); + + Assert.True( + offenders.Count == 0, + "ExecuteDeleteAsync is back: " + string.Join(", ", offenders) + + ". The provider emits DELETE FROM `t` AS `x`, which MariaDB rejects with a 1064 at runtime " + + "while the SQLite-backed tests stay green. Use raw SQL with unquoted identifiers, or load " + + "the rows and RemoveRange them. See #707."); + } + + private static bool IsBuildOutput(string file, string root) + { + var relative = Path.GetRelativePath(root, file); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return segments.Any(s => s is "bin" or "obj"); + } + + private static string FindSolutionRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "Pgan.PoracleWebNet.slnx"))) + { + dir = dir.Parent; + } + + Assert.NotNull(dir); + return dir.FullName; + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Repositories/OidcSessionRepositoryTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Repositories/OidcSessionRepositoryTests.cs new file mode 100644 index 00000000..7c8f4a3e --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Repositories/OidcSessionRepositoryTests.cs @@ -0,0 +1,194 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Repositories; +using Pgan.PoracleWebNet.Data; + +namespace Pgan.PoracleWebNet.Tests.Repositories; + +/// +/// Repository tests over a real relational provider (SQLite in-memory) because the rotation guard +/// uses ExecuteUpdateAsync, which the EF InMemory provider cannot translate. Covers the +/// retention semantics (decoupled revoked-row retention), the atomic rotation guard, and the shape +/// of the emitted cleanup statement. +/// +public sealed class OidcSessionRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly PoracleWebContext _context; + private readonly OidcSessionRepository _repo; + private readonly CommandCapture _commands = new(); + + public OidcSessionRepositoryTests() + { + this._connection = new SqliteConnection("DataSource=:memory:"); + this._connection.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(this._connection) + .AddInterceptors(this._commands) + .Options; + this._context = new PoracleWebContext(options); + this._context.Database.EnsureCreated(); + this._repo = new OidcSessionRepository(this._context); + } + + public void Dispose() + { + this._context.Dispose(); + this._connection.Dispose(); + } + + private async Task SeedAsync(string hash, string family, DateTime expiresAt, DateTime? revokedAt = null, string userId = "user-1") + { + var session = new OidcSession + { + SessionTokenHash = hash, + FamilyId = family, + FamilyIssuedAt = DateTime.UtcNow.AddMinutes(-5), + UserId = userId, + EncryptedRefreshToken = "cipher", + ExpiresAt = expiresAt, + CreatedUtc = DateTime.UtcNow.AddMinutes(-5), + RevokedAt = revokedAt, + RevokedReason = revokedAt is null ? null : "rotation", + }; + await this._repo.AddAsync(session); + return session; + } + + [Fact] + public async Task DeleteExpiredAndStale_DeletesExpired_AndOldRevoked_ButKeepsActiveAndRecentRevoked() + { + var now = DateTime.UtcNow; + await this.SeedAsync("active", "f1", expiresAt: now.AddDays(20)); // active → keep + await this.SeedAsync("expired", "f2", expiresAt: now.AddMinutes(-1)); // expired → delete + await this.SeedAsync("revoked-recent", "f3", expiresAt: now.AddDays(20), revokedAt: now.AddDays(-1)); // revoked 1d ago → keep (retention 2d) + await this.SeedAsync("revoked-old", "f4", expiresAt: now.AddDays(20), revokedAt: now.AddDays(-5)); // revoked 5d ago → delete + + var deleted = await this._repo.DeleteExpiredAndStaleAsync(TimeSpan.FromDays(2)); + + Assert.Equal(2, deleted); + var remaining = await this._context.OidcSessions.Select(s => s.SessionTokenHash).ToListAsync(); + Assert.Contains("active", remaining); + Assert.Contains("revoked-recent", remaining); + Assert.DoesNotContain("expired", remaining); + Assert.DoesNotContain("revoked-old", remaining); + } + + [Fact] + public async Task TryRevokeForRotation_RevokesActiveRow_ReturnsOne_AndChainsSuccessor() + { + await this.SeedAsync("present", "f1", expiresAt: DateTime.UtcNow.AddDays(20)); + + var affected = await this._repo.TryRevokeForRotationAsync("present", "successor"); + + Assert.Equal(1, affected); + var row = await this._context.OidcSessions.AsNoTracking().FirstAsync(s => s.SessionTokenHash == "present"); + Assert.NotNull(row.RevokedAt); + Assert.Equal("rotation", row.RevokedReason); + Assert.Equal("successor", row.ReplacedByHash); + } + + [Fact] + public async Task TryRevokeForRotation_AlreadyRevoked_ReturnsZero() + { + await this.SeedAsync("present", "f1", expiresAt: DateTime.UtcNow.AddDays(20), revokedAt: DateTime.UtcNow.AddMinutes(-1)); + + var affected = await this._repo.TryRevokeForRotationAsync("present", "successor"); + + Assert.Equal(0, affected); + } + + [Fact] + public async Task RevokeFamily_RevokesAllActiveInFamily_Only() + { + await this.SeedAsync("a", "fam", expiresAt: DateTime.UtcNow.AddDays(20)); + await this.SeedAsync("b", "fam", expiresAt: DateTime.UtcNow.AddDays(20)); + await this.SeedAsync("other", "other-fam", expiresAt: DateTime.UtcNow.AddDays(20)); + + var revoked = await this._repo.RevokeFamilyAsync("fam", "replay_detected"); + + Assert.Equal(2, revoked); + var other = await this._context.OidcSessions.AsNoTracking().FirstAsync(s => s.SessionTokenHash == "other"); + Assert.Null(other.RevokedAt); + } + + [Fact] + public async Task RevokeAllForUser_RevokesActiveSessionsForThatUser_Only() + { + await this.SeedAsync("u1a", "f1", expiresAt: DateTime.UtcNow.AddDays(20), userId: "user-1"); + await this.SeedAsync("u1b", "f2", expiresAt: DateTime.UtcNow.AddDays(20), userId: "user-1"); + await this.SeedAsync("u2", "f3", expiresAt: DateTime.UtcNow.AddDays(20), userId: "user-2"); + + var revoked = await this._repo.RevokeAllForUserAsync("user-1", "admin_disable"); + + Assert.Equal(2, revoked); + var u2 = await this._context.OidcSessions.AsNoTracking().FirstAsync(s => s.SessionTokenHash == "u2"); + Assert.Null(u2.RevokedAt); + } + + [Fact] + public async Task GetByHash_ReturnsMatchingSession_OrNull() + { + await this.SeedAsync("known", "f1", expiresAt: DateTime.UtcNow.AddDays(20)); + + Assert.NotNull(await this._repo.GetByHashAsync("known")); + Assert.Null(await this._repo.GetByHashAsync("missing")); + } + + /// + /// The retention test above passed for the whole time cleanup was broken in production: SQLite + /// accepts what EF generated, MariaDB answered 1064 to DELETE FROM `oidc_sessions` AS `o`, + /// and no test looked at the statement itself. This one does. See #707. + /// + [Fact] + public async Task DeleteExpiredAndStale_EmitsUnaliasedDelete_MariaDbCannotParseTheAliasedForm() + { + await this.SeedAsync("expired", "f1", expiresAt: DateTime.UtcNow.AddMinutes(-1)); + this._commands.Executed.Clear(); + + await this._repo.DeleteExpiredAndStaleAsync(TimeSpan.FromDays(2)); + + var delete = Assert.Single( + this._commands.Executed, + c => c.TrimStart().StartsWith("DELETE", StringComparison.OrdinalIgnoreCase)); + + Assert.DoesNotContain(" AS ", delete, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("`", delete, StringComparison.Ordinal); + } + + private sealed class CommandCapture : DbCommandInterceptor + { + public List Executed { get; } = []; + + public override InterceptionResult NonQueryExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + this.Executed.Add(command.CommandText); + return base.NonQueryExecuting(command, eventData, result); + } + + public override ValueTask> NonQueryExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + this.Executed.Add(command.CommandText); + return base.NonQueryExecutingAsync(command, eventData, result, cancellationToken); + } + + public override InterceptionResult ReaderExecuting( + DbCommand command, CommandEventData eventData, InterceptionResult result) + { + this.Executed.Add(command.CommandText); + return base.ReaderExecuting(command, eventData, result); + } + + public override ValueTask> ReaderExecutingAsync( + DbCommand command, CommandEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + this.Executed.Add(command.CommandText); + return base.ReaderExecutingAsync(command, eventData, result, cancellationToken); + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Repositories/UserAreaDualWriterTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Repositories/UserAreaDualWriterTests.cs index 6103e298..3d8e2a3f 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Repositories/UserAreaDualWriterTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Repositories/UserAreaDualWriterTests.cs @@ -442,4 +442,114 @@ await Assert.ThrowsAnyAsync( [System.Text.RegularExpressions.GeneratedRegex("my park")] private static partial System.Text.RegularExpressions.Regex MyRegex(); + + // ── RenameAreaInAllProfilesAsync ──────────────────────────────────────────── + // Approving a custom geofence under a new public name used to run through SetAreasAsync, which + // PoracleNG filters to userSelectable=true fences — stripping the owner's whole custom-geofence + // subscription set, not just the one being renamed. See #408. + + [Fact] + public async Task RenameAreaInAllProfilesRenamesInHumanAndEveryProfileThatHadIt() + { + await this.SeedHumanAsync(area: """["academia","zq iso one"]"""); + await this.SeedProfileAsync("u1", 1, """["academia","zq iso one"]"""); + await this.SeedProfileAsync("u1", 2, """["zq iso one"]"""); + + var changed = await this._sut.RenameAreaInAllProfilesAsync("u1", "zq iso one", "ZQ Iso One Promoted"); + + Assert.True(changed); + Assert.Equal(["academia", "zq iso one promoted"], await this.HumanAreasAsync()); + Assert.Equal(["academia", "zq iso one promoted"], await this.ProfileAreasAsync(1)); + Assert.Equal(["zq iso one promoted"], await this.ProfileAreasAsync(2)); + } + + [Fact] + public async Task RenameAreaInAllProfilesLeavesUnrelatedAreasAlone() + { + // The original bug's worst symptom: unrelated subscriptions disappeared too. + await this.SeedHumanAsync(area: """["academia","other custom","zq iso one"]"""); + await this.SeedProfileAsync("u1", 1, """["academia","other custom","zq iso one"]"""); + + await this._sut.RenameAreaInAllProfilesAsync("u1", "zq iso one", "promoted"); + + Assert.Equal(["academia", "other custom", "promoted"], await this.HumanAreasAsync()); + Assert.Equal(["academia", "other custom", "promoted"], await this.ProfileAreasAsync(1)); + } + + [Fact] + public async Task RenameAreaInAllProfilesDoesNotSubscribeProfilesThatDidNotHaveIt() + { + // Per-profile activation has to survive the rename: profile 2 had it switched off. + await this.SeedHumanAsync(area: """["zq iso one"]"""); + await this.SeedProfileAsync("u1", 1, """["zq iso one"]"""); + await this.SeedProfileAsync("u1", 2, """["academia"]"""); + + await this._sut.RenameAreaInAllProfilesAsync("u1", "zq iso one", "promoted"); + + Assert.Equal(["promoted"], await this.ProfileAreasAsync(1)); + Assert.Equal(["academia"], await this.ProfileAreasAsync(2)); + } + + [Fact] + public async Task RenameAreaInAllProfilesLowercasesThePromotedName() + { + // Poracle matches areas case-sensitively and stores them lowercase. + await this.SeedHumanAsync(area: """["zq iso one"]"""); + + await this._sut.RenameAreaInAllProfilesAsync("u1", "ZQ Iso One", "ZQ Iso One Promoted"); + + Assert.Equal(["zq iso one promoted"], await this.HumanAreasAsync()); + } + + [Fact] + public async Task RenameAreaInAllProfilesDoesNotDuplicateWhenTheNewNameIsAlreadyPresent() + { + await this.SeedHumanAsync(area: """["promoted","zq iso one"]"""); + + await this._sut.RenameAreaInAllProfilesAsync("u1", "zq iso one", "promoted"); + + Assert.Equal(["promoted"], await this.HumanAreasAsync()); + } + + [Fact] + public async Task RenameAreaInAllProfilesIsANoOpWhenTheNameIsNotSubscribed() + { + await this.SeedHumanAsync(area: """["academia"]"""); + + var changed = await this._sut.RenameAreaInAllProfilesAsync("u1", "not subscribed", "promoted"); + + Assert.False(changed); + Assert.Equal(["academia"], await this.HumanAreasAsync()); + } + + [Fact] + public async Task RenameAreaInAllProfilesIsANoOpWhenTheNameIsUnchanged() + { + await this.SeedHumanAsync(area: """["zq iso one"]"""); + + var changed = await this._sut.RenameAreaInAllProfilesAsync("u1", "ZQ Iso One", "zq iso one"); + + Assert.False(changed); + Assert.Equal(["zq iso one"], await this.HumanAreasAsync()); + } + + [Fact] + public async Task RenameAreaInAllProfilesToleratesAMissingHuman() + { + var changed = await this._sut.RenameAreaInAllProfilesAsync("ghost", "a", "b"); + + Assert.False(changed); + } + + private async Task> HumanAreasAsync() + { + var human = await this._context.Humans.AsNoTracking().FirstAsync(h => h.Id == "u1"); + return Pgan.PoracleWebNet.Core.Models.Helpers.AreaListJson.Parse(human.Area); + } + + private async Task> ProfileAreasAsync(int profileNo) + { + var profile = await this._context.Profiles.AsNoTracking().FirstAsync(p => p.Id == "u1" && p.ProfileNo == profileNo); + return Pgan.PoracleWebNet.Core.Models.Helpers.AreaListJson.Parse(profile.Area); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/AlarmWritePayloadTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/AlarmWritePayloadTests.cs new file mode 100644 index 00000000..47840246 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/AlarmWritePayloadTests.cs @@ -0,0 +1,107 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// What actually goes on the wire for an alarm write. +/// +/// Alarm writes carried profile_no stamped from the caller's JWT claim. That claim goes stale +/// whenever current_profile_no moves out of band — the active-hours scheduler, the bot's +/// !profile command, a second tab — and JWTs live four hours. PoracleNG takes a submitted +/// profile_no at face value for the pokemon type (verified: profile_no: 9 creates a row on +/// a profile that does not exist) while scoping every read to the live one. So a stale claim wrote an +/// alarm that returned 201 and was then invisible to reads and undeletable. Omitting it makes PoracleNG +/// use current_profile_no, which is what the other nine types already did. See #411. +/// +/// +public class AlarmWritePayloadTests +{ + private readonly Mock _proxy = new(); + private readonly Mock _featureGate = new(); + private readonly List _sent = []; + + public AlarmWritePayloadTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._proxy + .Setup(p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, body) => this._sent.Add(body.Clone())) + .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); + } + + private MonsterService Monsters() => new(this._proxy.Object, this._featureGate.Object); + + private static IEnumerable Objects(JsonElement sent) => + sent.ValueKind == JsonValueKind.Array ? sent.EnumerateArray() : [sent]; + + [Fact] + public async Task CreateDoesNotSendProfileNo() + { + await Monsters().CreateAsync("u1", new Monster { PokemonId = 201, ProfileNo = 3 }); + + Assert.All(Objects(this._sent[0]), o => Assert.False(o.TryGetProperty("profile_no", out _))); + } + + [Fact] + public async Task CreateDoesNotSendProfileNoWhenItIsZero() + { + // Zero is a real profile number, so "strip only non-zero" would still target the wrong profile. + await Monsters().CreateAsync("u1", new Monster { PokemonId = 201, ProfileNo = 0 }); + + Assert.All(Objects(this._sent[0]), o => Assert.False(o.TryGetProperty("profile_no", out _))); + } + + [Fact] + public async Task UpdateDoesNotSendProfileNo() + { + await Monsters().UpdateAsync("u1", new Monster { Uid = 9, PokemonId = 201, ProfileNo = 3 }); + + Assert.All(Objects(this._sent[0]), o => Assert.False(o.TryGetProperty("profile_no", out _))); + } + + [Fact] + public async Task BulkCreateStripsProfileNoFromEveryElement() + { + await Monsters().BulkCreateAsync("u1", + [ + new Monster { PokemonId = 1, ProfileNo = 2 }, + new Monster { PokemonId = 2, ProfileNo = 3 } + ]); + + var items = this._sent[0].EnumerateArray().ToList(); + Assert.Equal(2, items.Count); + Assert.All(items, o => Assert.False(o.TryGetProperty("profile_no", out _))); + } + + [Fact] + public async Task CreateStillOmitsTheDefaultUidSoItIsAnInsert() + { + await Monsters().CreateAsync("u1", new Monster { PokemonId = 201 }); + + Assert.All(Objects(this._sent[0]), o => Assert.False(o.TryGetProperty("uid", out _))); + } + + [Fact] + public async Task UpdateStillSendsItsUidSoItTargetsTheRightRow() + { + await Monsters().UpdateAsync("u1", new Monster { Uid = 42, PokemonId = 201 }); + + Assert.All(Objects(this._sent[0]), o => Assert.Equal(42, o.GetProperty("uid").GetInt32())); + } + + [Fact] + public async Task TheRestOfThePayloadIsUntouched() + { + await Monsters().CreateAsync("u1", new Monster { PokemonId = 149, Distance = 500, ProfileNo = 7 }); + + var o = Objects(this._sent[0]).First(); + Assert.Equal(149, o.GetProperty("pokemon_id").GetInt32()); + Assert.Equal(500, o.GetProperty("distance").GetInt32()); + Assert.Equal("u1", o.GetProperty("id").GetString()); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/BulkUidRemapTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/BulkUidRemapTests.cs new file mode 100644 index 00000000..232c2460 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/BulkUidRemapTests.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Bulk distance updates rewrite every row, so all their uids change. #403 fixed the single-edit case +/// using the create response's 1:1 mapping; that cannot work here because the batch response comes back +/// reordered — submitting lures 161, 162, 163 returned 164, 165, 166 mapping to 163, 162, 161. Pairing by +/// position would repoint a quick pick at somebody else's alarm, so pairing is done on content. See #443. +/// +public class BulkUidRemapTests +{ + private readonly Mock _proxy = new(); + private readonly Mock _remapper = new(); + + private static JsonElement Rows(params (int Uid, int LureId, int Distance)[] rows) => + JsonSerializer.SerializeToElement( + rows.Select(r => new { uid = r.Uid, lure_id = r.LureId, distance = r.Distance, id = "u1" })); + + private Task RunAsync(JsonElement submitted) => BulkUidRemap.ApplyAsync( + this._proxy.Object, "lure", "u1", submitted, this._remapper.Object, NullLogger.Instance); + + [Fact] + public async Task PairsByContentEvenWhenTheResponseComesBackReversed() + { + // The exact ordering observed against PoracleNG. + var submitted = Rows((161, 501, 700), (162, 502, 700), (163, 503, 700)); + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")) + .ReturnsAsync(Rows((164, 503, 700), (165, 502, 700), (166, 501, 700))); + + await this.RunAsync(submitted); + + this._remapper.Verify(r => r.RemapAsync("u1", "lure", 161, 166), Times.Once); + this._remapper.Verify(r => r.RemapAsync("u1", "lure", 162, 165), Times.Once); + this._remapper.Verify(r => r.RemapAsync("u1", "lure", 163, 164), Times.Once); + } + + [Fact] + public async Task DoesNotPairByPosition() + { + // The wrong answer, spelled out: 161 must not become 164. + var submitted = Rows((161, 501, 700), (162, 502, 700), (163, 503, 700)); + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")) + .ReturnsAsync(Rows((164, 503, 700), (165, 502, 700), (166, 501, 700))); + + await this.RunAsync(submitted); + + this._remapper.Verify(r => r.RemapAsync("u1", "lure", 161, 164), Times.Never); + this._remapper.Verify(r => r.RemapAsync("u1", "lure", 163, 166), Times.Never); + } + + [Fact] + public async Task MatchesEvenThoughOutgoingRowsCarryNoProfileNo() + { + // Alarm writes stopped sending profile_no (#411) but rows read back from PoracleNG still have it. + // Without excluding it the signatures never match and nothing is ever remapped -- which is exactly + // what happened on the first live run of this fix. + var submitted = JsonSerializer.SerializeToElement( + new[] { new { uid = 172, lure_id = 501, distance = 900, id = "u1" } }); + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync( + JsonSerializer.SerializeToElement( + new[] { new { uid = 173, lure_id = 501, distance = 900, id = "u1", profile_no = 0 } })); + + await this.RunAsync(submitted); + + this._remapper.Verify(r => r.RemapAsync("u1", "lure", 172, 173), Times.Once); + } + + [Fact] + public async Task IgnoresTheDistanceChangeWhenMatching() + { + // Distance is the field that changed, so it cannot take part in identity. + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(Rows((200, 501, 900))); + + await this.RunAsync(Rows((199, 501, 500))); + + this._remapper.Verify(r => r.RemapAsync("u1", "lure", 199, 200), Times.Once); + } + + [Fact] + public async Task DoesNothingWhenTheUidSurvived() + { + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(Rows((10, 501, 900))); + + await this.RunAsync(Rows((10, 501, 500))); + + this._remapper.Verify( + r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RefusesToGuessWhenTwoRowsAreIndistinguishable() + { + // Identical apart from distance. Guessing here is exactly the failure this design avoids, and the + // two are interchangeable anyway once both carry the same distance. + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(Rows((30, 501, 900), (31, 501, 900))); + + await this.RunAsync(Rows((10, 501, 500), (11, 501, 500))); + + this._remapper.Verify( + r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task LeavesRowsAloneWhenNoReplacementIsFound() + { + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(Rows((99, 999, 900))); + + await this.RunAsync(Rows((10, 501, 500))); + + this._remapper.Verify( + r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SwallowsAReadFailure() + { + // The distance change already applied upstream; failing here would fail a request that worked. + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ThrowsAsync(new HttpRequestException("down")); + + await this.RunAsync(Rows((10, 501, 500))); + } + + [Fact] + public async Task IgnoresAnEmptySubmission() + { + await this.RunAsync(JsonSerializer.SerializeToElement(Array.Empty())); + + this._proxy.Verify(p => p.GetByUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/CleaningServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/CleaningServiceTests.cs index 39e98290..5496474d 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/CleaningServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/CleaningServiceTests.cs @@ -10,12 +10,13 @@ public class CleaningServiceTests { private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly CleaningService _sut; public CleaningServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new CleaningService(this._proxy.Object, this._featureGate.Object); + this._sut = new CleaningService(this._proxy.Object, this._featureGate.Object, this._uidRemapper.Object, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); } [Fact] @@ -234,6 +235,120 @@ public async Task ToggleCleanMaxBattlesAsyncUpdatesAllAlarms() Assert.Equal(3, await this._sut.ToggleCleanMaxBattlesAsync("u1", 1, 1)); } + [Fact] + public async Task ToggleCleanOnPreservesEditAndSummaryBits() + { + // clean=5 = auto-delete (bit 1) + summary (bit 4); clean=2 = edit-in-place only (bit 2). + // Toggling the clean flag ON must set bit 1 while leaving bot-set bits 2/4 intact. (#292) + var json = CreateJsonArray( + new + { + uid = 1, + clean = 5 + }, + new + { + uid = 2, + clean = 2 + }); + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "u1")).ReturnsAsync(json); + + JsonElement captured = default; + this._proxy.Setup(p => p.CreateAsync("pokemon", "u1", It.IsAny())) + .Callback((_, _, body) => captured = body.Clone()) + .ReturnsAsync(new TrackingCreateResult([], 0, 2, 0)); + + Assert.Equal(2, await this._sut.ToggleCleanMonstersAsync("u1", 1, 1)); + + var cleans = ExtractCleans(captured); + Assert.Equal(5, cleans[0]); // bit 1 already set, summary (4) preserved -> 5 + Assert.Equal(3, cleans[1]); // edit (2) preserved, bit 1 added -> 3 + } + + [Fact] + public async Task ToggleCleanOffPreservesEditAndSummaryBits() + { + // Toggling OFF clears only bit 1; edit (2) / summary (4) bits must survive. (#292) + var json = CreateJsonArray( + new + { + uid = 1, + clean = 5 + }, + new + { + uid = 2, + clean = 2 + }); + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "u1")).ReturnsAsync(json); + + JsonElement captured = default; + this._proxy.Setup(p => p.CreateAsync("pokemon", "u1", It.IsAny())) + .Callback((_, _, body) => captured = body.Clone()) + .ReturnsAsync(new TrackingCreateResult([], 0, 2, 0)); + + Assert.Equal(2, await this._sut.ToggleCleanMonstersAsync("u1", 1, 0)); + + var cleans = ExtractCleans(captured); + Assert.Equal(4, cleans[0]); // bit 1 cleared, summary (4) preserved -> 4 + Assert.Equal(2, cleans[1]); // bit 1 already clear, edit (2) preserved -> 2 + } + + [Fact] + public async Task GetCleanStatusAsyncTreatsMultiBitValuesAsClean() + { + // clean=3 (auto-delete+edit) and clean=5 (auto-delete+summary) both have bit 1 set, + // so AllClean must report them as clean even though they are not exactly == 1. (#292) + var obj = new Dictionary + { + ["pokemon"] = [new { uid = 1, clean = 3 }, new { uid = 2, clean = 5 }], + ["raid"] = [new { uid = 1, clean = 5 }], + ["egg"] = [], + ["quest"] = [], + ["invasion"] = [], + ["lure"] = [], + ["nest"] = [], + ["gym"] = [], + ["maxbattle"] = [new { uid = 1, clean = 3 }], + }; + var jsonStr = JsonSerializer.Serialize(obj); + using var doc = JsonDocument.Parse(jsonStr); + var json = doc.RootElement.Clone(); + this._proxy.Setup(p => p.GetAllTrackingAsync("u1")).ReturnsAsync(json); + + var result = await this._sut.GetCleanStatusAsync("u1", 1); + + Assert.True(result["monsters"]); // clean=3 and clean=5 both have bit 1 + Assert.True(result["raids"]); // clean=5 has bit 1 + Assert.True(result["maxbattles"]); // clean=3 has bit 1 + } + + [Fact] + public async Task GetCleanStatusAsyncTreatsBitlessValueAsNotClean() + { + // clean=4 = summary only (bit 1 not set) -> not auto-delete -> not clean. (#292) + var obj = new Dictionary + { + ["pokemon"] = [new { uid = 1, clean = 4 }], + ["raid"] = [], + ["egg"] = [], + ["quest"] = [], + ["invasion"] = [], + ["lure"] = [], + ["nest"] = [], + ["gym"] = [], + ["maxbattle"] = [], + }; + var jsonStr = JsonSerializer.Serialize(obj); + using var doc = JsonDocument.Parse(jsonStr); + var json = doc.RootElement.Clone(); + this._proxy.Setup(p => p.GetAllTrackingAsync("u1")).ReturnsAsync(json); + + var result = await this._sut.GetCleanStatusAsync("u1", 1); + + Assert.False(result["monsters"]); // clean=4 lacks the auto-delete bit + } + [Fact] public async Task ToggleCleanReturnsZeroWhenNoAlarms() { @@ -286,6 +401,18 @@ public async Task GetCleanStatusAsyncReturnsFalseWhenNotAllClean() Assert.True(result["maxbattles"]); // both are clean } + private static List ExtractCleans(JsonElement postedBody) + { + Assert.Equal(JsonValueKind.Array, postedBody.ValueKind); + var cleans = new List(); + foreach (var alarm in postedBody.EnumerateArray()) + { + cleans.Add(alarm.GetProperty("clean").GetInt32()); + } + + return cleans; + } + private static JsonElement CreateJsonArray(params object[] items) { var jsonStr = JsonSerializer.Serialize(items); @@ -317,7 +444,6 @@ private static JsonElement CreateAllTrackingJson(int cleanValue, int countPerTyp [InlineData("nest", DisableFeatureKeys.Nests)] [InlineData("gym", DisableFeatureKeys.Gyms)] [InlineData("maxbattle", DisableFeatureKeys.MaxBattles)] - [InlineData("fort", DisableFeatureKeys.FortChanges)] public async Task ToggleCleanThrowsFeatureDisabledExceptionPerType(string trackingType, string disableKey) { // Iteration 2 review surfaced that CleaningService bypassed the alarm-service gates by writing @@ -337,7 +463,6 @@ public async Task ToggleCleanThrowsFeatureDisabledExceptionPerType(string tracki "nest" => this._sut.ToggleCleanNestsAsync("u1", 1, 1), "gym" => this._sut.ToggleCleanGymsAsync("u1", 1, 1), "maxbattle" => this._sut.ToggleCleanMaxBattlesAsync("u1", 1, 1), - "fort" => this._sut.ToggleCleanFortChangesAsync("u1", 1, 1), _ => throw new InvalidOperationException(trackingType), }; @@ -348,4 +473,72 @@ public async Task ToggleCleanThrowsFeatureDisabledExceptionPerType(string tracki this._proxy.Verify(p => p.GetByUserAsync(It.IsAny(), It.IsAny()), Times.Never); this._proxy.Verify(p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + + // --- Max Battles duplicated on every toggle (#402) --- + // PoracleNG's maxbattle create is insert-only, so the fetch-modify-POST inserted a full duplicate + // set per click and left the originals at clean=0. Unbounded growth. + + [Fact] + public async Task ToggleCleanMaxBattlesFreesTheRowsBeforeRecreatingThem() + { + var json = JsonSerializer.SerializeToElement(new[] + { + new { uid = 82, id = "u1", clean = 0 }, + new { uid = 83, id = "u1", clean = 0 }, + }); + this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "u1")).ReturnsAsync(json); + this._proxy.Setup(p => p.BulkDeleteByUidsAsync("maxbattle", "u1", It.IsAny>())).Returns(Task.CompletedTask); + this._proxy.Setup(p => p.CreateAsync("maxbattle", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([84, 85], 0, 0, 2)); + + var count = await this._sut.ToggleCleanMaxBattlesAsync("u1", 1, 1); + + Assert.Equal(2, count); + this._proxy.Verify(p => p.BulkDeleteByUidsAsync("maxbattle", "u1", + It.Is>(u => u.OrderBy(x => x).SequenceEqual(new[] { 82, 83 }))), Times.Once); + this._proxy.Verify(p => p.CreateAsync("maxbattle", "u1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task ToggleCleanMaxBattlesRestoresTheOriginalsWhenRecreatingFails() + { + var json = JsonSerializer.SerializeToElement(new[] { new { uid = 82, id = "u1", clean = 0 } }); + this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "u1")).ReturnsAsync(json); + this._proxy.Setup(p => p.BulkDeleteByUidsAsync("maxbattle", "u1", It.IsAny>())).Returns(Task.CompletedTask); + this._proxy.SetupSequence(p => p.CreateAsync("maxbattle", "u1", It.IsAny())) + .ThrowsAsync(new HttpRequestException("upstream")) + .ReturnsAsync(new TrackingCreateResult([86], 0, 0, 1)); + + await Assert.ThrowsAsync(() => this._sut.ToggleCleanMaxBattlesAsync("u1", 1, 1)); + + // Second create is the restore: a failed toggle must not leave the user with nothing. + this._proxy.Verify(p => p.CreateAsync("maxbattle", "u1", It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task ToggleCleanMonstersStillUpsertsWithoutDeleting() + { + var json = JsonSerializer.SerializeToElement(new[] { new { uid = 5, id = "u1", clean = 0 } }); + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "u1")).ReturnsAsync(json); + this._proxy.Setup(p => p.CreateAsync("pokemon", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + await this._sut.ToggleCleanMonstersAsync("u1", 1, 1); + + this._proxy.Verify(p => p.BulkDeleteByUidsAsync(It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never); + } + + [Fact] + public async Task StatusNoLongerReportsFortChanges() + { + this._proxy.Setup(p => p.GetAllTrackingAsync("u1")) + .ReturnsAsync(JsonSerializer.SerializeToElement(new { })); + + var status = await this._sut.GetCleanStatusAsync("u1", 1); + + // forts carry no clean column in PoracleNG, so the entry was always a meaningless false. + Assert.DoesNotContain("fortChanges", status.Keys); + Assert.DoesNotContain("fortchanges", status.Keys); + } + } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/CreateResultSemanticsTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/CreateResultSemanticsTests.cs new file mode 100644 index 00000000..836d7880 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/CreateResultSemanticsTests.cs @@ -0,0 +1,377 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// PoracleNG reports exactly what it did with a create — alreadyPresent, updates, +/// insert and newUids. Reading almost none of it was the shared root of #459, #462, #463, +/// #468 and #469, two of which destroyed user data. +/// +public class CreateResultSemanticsTests +{ + private readonly Mock _proxy = new(); + private readonly Mock _gate = new(); + private readonly Mock _remapper = new(); + + public CreateResultSemanticsTests() => + this._gate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + + private static JsonElement Rows(params object[] rows) => JsonSerializer.SerializeToElement(rows); + + // ── The record itself ─────────────────────────────────────────────────── + + [Fact] + public void PrimaryUidIsNullWhenPoracleNgNamedNoRow() + { + Assert.Null(new TrackingCreateResult([], 1, 0, 0).PrimaryUid); + Assert.Equal(42, new TrackingCreateResult([42], 0, 0, 1).PrimaryUid); + } + + [Fact] + public void InsertedNothingDistinguishesAMatchFromACreate() + { + Assert.True(new TrackingCreateResult([7], 0, 1, 0).InsertedNothing); + Assert.False(new TrackingCreateResult([7], 0, 0, 1).InsertedNothing); + } + + [Fact] + public void AnExactDuplicateIsRecognisable() + { + Assert.True(new TrackingCreateResult([], 1, 0, 0).WasRejectedAsDuplicate); + Assert.False(new TrackingCreateResult([7], 0, 0, 1).WasRejectedAsDuplicate); + } + + // ── #463: a refused edit must not report success ───────────────────────── + + [Fact] + public async Task AnEditRefusedAsADuplicateRaisesAConflictRatherThanEchoingTheRequest() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + // PoracleNG declined: the edited values collide with another alarm the user already has. + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 1, 0, 0)); + + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", new Gym { Uid = 134, Team = 2 })); + } + + [Fact] + public async Task AnOrdinaryEditIsUnaffected() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([140], 0, 1, 0)); + + var result = await sut.UpdateAsync("u1", new Gym { Uid = 139, Team = 2 }); + + Assert.Equal(140, result.Uid); + } + + // ── #498, #499, #501: a row colliding with ITSELF is a no-op, not a conflict ──── + + /// + /// PoracleNG answers {alreadyPresent:1, insert:0, updates:0} both when the edit collides with a + /// different alarm and when it collides with the row being edited. Every edit dialog resubmits the + /// whole form, so pressing Save with nothing changed hit the second case and was told a non-existent + /// alarm was in the way. + /// + [Fact] + public async Task ResubmittingAnUnchangedRowIsNotAConflict() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 1, 0, 0)); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 134, id = "u1", team = 2, distance = 500, template = "1" })); + + var result = await sut.UpdateAsync("u1", new Gym + { + Id = "u1", + Uid = 134, + Team = 2, + Distance = 500, + Template = "1", + }); + + Assert.Equal(134, result.Uid); + } + + /// + /// ping is never persisted on any tracking type, so an edit changing only that leaves the row + /// untouched. It must not read as a collision with another alarm. + /// + [Fact] + public async Task APingOnlyEditIsNotAConflict() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 1, 0, 0)); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 134, id = "u1", team = 2, distance = 500, ping = "" })); + + var result = await sut.UpdateAsync("u1", new Gym + { + Id = "u1", + Uid = 134, + Team = 2, + Distance = 500, + Ping = "<@&999>", + }); + + Assert.Equal(134, result.Uid); + } + + /// A real collision with a different alarm still has to be reported. + [Fact] + public async Task AnEditOntoAnotherAlarmsSettingsIsStillAConflict() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 1, 0, 0)); + // The row being edited still holds team 2; the submission moves it onto uid 135's team 4. + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 134, id = "u1", team = 2, distance = 500 }, + new { uid = 135, id = "u1", team = 4, distance = 500 })); + + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", new Gym { Id = "u1", Uid = 134, Team = 4, Distance = 500 })); + } + + /// A vanished row is not a no-op: report the conflict rather than claim success. + [Fact] + public async Task AConflictIsStillReportedWhenTheEditedRowIsGone() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 1, 0, 0)); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 135, id = "u1", team = 4, distance = 500 })); + + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", new Gym { Id = "u1", Uid = 134, Team = 4, Distance = 500 })); + } + // ── #531: an edit must never be satisfied by merging into a DIFFERENT alarm ──── + + /// + /// PoracleNG updates an existing row in place when the submission differs from it only in fields it + /// tags updatable -- distance, template, clean, and slot/battle changes on gyms. If that row is not the + /// one being edited, the edit overwrites somebody else's alarm and the reconciler then deletes the + /// original as superseded: two alarms become one, reported as a clean 200. + /// + [Fact] + public async Task AnEditThatWouldMergeIntoAnotherAlarmIsRefusedBeforeAnythingIsWritten() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 189, id = "u1", team = 4, gym_id = "", distance = 1500 }, + new { uid = 190, id = "u1", team = 2, gym_id = "", distance = 8001 })); + + // Moving 189 onto team 2 leaves only distance differing from 190, so PoracleNG would merge them. + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", new Gym { Id = "u1", Uid = 189, Team = 2, Distance = 1500 })); + + this._proxy.Verify( + p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + this._proxy.Verify( + p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// slot_changes and battle_changes are updatable for gyms, so two alarms differing only there would + /// merge as well -- the case the sweep reproduced from the gym edit dialog. + /// + [Fact] + public async Task AGymEditDifferingOnlyInItsToggleFieldsIsAlsoRefused() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 189, id = "u1", team = 4, slot_changes = 1, battle_changes = 0, distance = 1500 }, + new { uid = 190, id = "u1", team = 4, slot_changes = 0, battle_changes = 1, distance = 8001 })); + + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", new Gym + { + Id = "u1", + Uid = 189, + Team = 4, + SlotChanges = 0, + BattleChanges = 1, + Distance = 1500, + })); + } + + /// + /// Two alarms that differ in more than one updatable field genuinely coexist -- PoracleNG inserts + /// rather than merges -- so every ordinary edit on them must keep working. Ignoring those fields + /// wholesale called such a pair a collision and refused radius, template and auto-delete edits on both, + /// leaving them uneditable. See #553. + /// + [Fact] + public async Task AnEditIsAllowedWhenTwoUpdatableFieldsSeparateTheAlarms() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 1, id = "u1", team = 4, distance = 1000, template = "1" }, + new { uid = 2, id = "u1", team = 4, distance = 6000, template = "ZZalt" })); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([3], 0, 1, 0)); + + // Changing uid 2's radius still leaves it separated from uid 1 by BOTH radius and template. + var result = await sut.UpdateAsync("u1", new Gym + { + Id = "u1", + Uid = 2, + Team = 4, + Distance = 6500, + Template = "ZZalt", + }); + + Assert.Equal(3, result.Uid); + } + + /// + /// Gyms that differ only in their slot/battle toggles are kept apart by PoracleNG, so those fields + /// identify an alarm and both must stay editable. See #553. + /// + [Fact] + public async Task AGymSeparatedOnlyByItsTogglesIsStillEditable() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 1, id = "u1", team = 4, slot_changes = 1, battle_changes = 0, distance = 1000 }, + new { uid = 2, id = "u1", team = 4, slot_changes = 0, battle_changes = 1, distance = 1000 })); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([3], 0, 1, 0)); + + var result = await sut.UpdateAsync("u1", new Gym + { + Id = "u1", + Uid = 2, + Team = 4, + SlotChanges = 0, + BattleChanges = 1, + Distance = 1500, + }); + + Assert.Equal(3, result.Uid); + } + + /// An edit that collides with nothing must still go through untouched. + [Fact] + public async Task AnEditThatCollidesWithNothingIsUnaffected() + { + var sut = new GymService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(Rows( + new { uid = 189, id = "u1", team = 4, distance = 1500 }, + new { uid = 190, id = "u1", team = 2, distance = 8001 })); + this._proxy.Setup(p => p.CreateAsync("gym", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([191], 0, 1, 0)); + + var result = await sut.UpdateAsync("u1", new Gym { Id = "u1", Uid = 189, Team = 3, Distance = 1500 }); + + Assert.Equal(191, result.Uid); + } + + /// + /// min_iv is diff:"update" on the monster struct only, so adding the same species at a tighter IV + /// floor is exactly one updatable difference -- PoracleNG updates the existing alarm rather than + /// inserting, and the user loses it. See #574. + /// + [Fact] + public async Task AddingTheSameSpeciesAtATighterIvIsRefused() + { + var sut = new MonsterService(this._proxy.Object, this._gate.Object); + this._proxy.Setup(p => p.GetByUserAsync("pokemon", "u1")).ReturnsAsync(Rows( + new { uid = 1, id = "u1", pokemon_id = 140, min_iv = 90, distance = 4000 })); + + await Assert.ThrowsAsync( + () => sut.CreateAsync("u1", new Monster { PokemonId = 140, MinIv = 95, Distance = 4000 })); + } + + /// + /// gym_id is blank precisely when the user means "any gym", so it must count as a difference. Skipping + /// every blank field made an any-gym alarm read as identical to a gym-specific one. See #575. + /// + [Fact] + public async Task AnAnyGymAlarmDoesNotCollideWithAGymSpecificOne() + { + var sut = new RaidService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("raid", "u1")).ReturnsAsync(Rows( + new { uid = 1, id = "u1", level = 5, gym_id = "zzgym", distance = 1111 })); + this._proxy.Setup(p => p.CreateAsync("raid", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([2], 0, 0, 1)); + this._proxy.Setup(p => p.GetByUserAsync("raid", "u1")).ReturnsAsync(Rows( + new { uid = 1, id = "u1", level = 5, gym_id = "zzgym", distance = 1111 })); + + var result = await sut.CreateAsync("u1", new Raid { Level = 5, GymId = "", Distance = 2222 }); + + Assert.Equal(2, result.Uid); + } + + // ── #462: a colliding natural-key edit must not destroy either alarm ───── + + [Fact] + public async Task EditingALureOntoAnotherAlarmsLureIdIsRefusedBeforeAnythingIsDeleted() + { + var sut = new LureService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + // The user holds two lures; the edit would move uid 10 onto uid 11's lure_id. + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(Rows( + new { uid = 10, id = "u1", lure_id = 501, distance = 500 }, + new { uid = 11, id = "u1", lure_id = 502, distance = 500 })); + + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", new Lure { Uid = 10, LureId = 502 })); + + // Nothing may be deleted: the destructive step is what lost the alarm. + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EditingALureWithoutChangingItsLureIdStillWorks() + { + var sut = new LureService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(Rows( + new { uid = 10, id = "u1", lure_id = 501, distance = 500 })); + this._proxy.Setup(p => p.CreateAsync("lure", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([12], 0, 0, 1)); + + var result = await sut.UpdateAsync("u1", new Lure { Uid = 10, LureId = 501, Distance = 900 }); + + Assert.Equal(12, result.Uid); + } + + [Fact] + public async Task EditingAnInvasionOntoAnotherAlarmsGenderAndGruntIsRefused() + { + var sut = new InvasionService(this._proxy.Object, this._gate.Object, + NullLogger.Instance, this._remapper.Object); + this._proxy.Setup(p => p.GetByUserAsync("invasion", "u1")).ReturnsAsync(Rows( + new { uid = 20, id = "u1", gender = 1, grunt_type = "fire", distance = 500 }, + new { uid = 21, id = "u1", gender = 2, grunt_type = "fire", distance = 500 })); + + // Flipping uid 20's gender to 2 collides with uid 21 - reachable from the gender dropdown. + await Assert.ThrowsAsync( + () => sut.UpdateAsync("u1", new Invasion { Uid = 20, Gender = 2, GruntType = "fire" })); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/DiscordNotificationServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/DiscordNotificationServiceTests.cs new file mode 100644 index 00000000..2d28ad08 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/DiscordNotificationServiceTests.cs @@ -0,0 +1,497 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Covers the geofence review post. Two things are load-bearing: the map is uploaded as an attachment +/// (PoracleNG's tileserver URL is evicted after a while, so linking it leaves a dead image), and the +/// opening embed is rewritten on approval/rejection so the thread reflects its own outcome. +/// +public class DiscordNotificationServiceTests +{ + private const string ForumChannelId = "1234567890"; + private const string ThreadId = "999"; + private const string MapUrl = "https://tiles.example.test/staticmap/pregenerated/abc123.png"; + + private static readonly byte[] PngBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02]; + + private static GeofenceSubmissionPost Post( + GeofenceReviewState state = GeofenceReviewState.Pending, + string groupName = "US - MD", + string? mapUrl = MapUrl, + string? overlaps = null, + string? reviewNotes = null, + string? reviewUrl = null, + double areaSqKm = 4.2) => new() + { + UserId = "user1", + UserName = "Tester", + DisplayName = "My Park", + PublicName = "my park", + GroupName = groupName, + AreaSqKm = areaSqKm, + CentroidLat = 38.9412, + CentroidLon = -76.7305, + OverlapsArea = overlaps, + MapImageUrl = mapUrl, + State = state, + ReviewNotes = reviewNotes, + ReviewUrl = reviewUrl, + }; + + private static IConfiguration CreateConfig() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Discord:GeofenceForumChannelId"] = ForumChannelId + }) + .Build(); + + private static DiscordNotificationService CreateSut(DiscordHandler discord, HttpMessageHandler mapHandler) + { + var discordClient = new HttpClient(discord) { BaseAddress = new Uri("https://discordapp.com/api/v9/") }; + + return new DiscordNotificationService( + discordClient, + new StubHttpClientFactory(new HttpClient(mapHandler)), + CreateConfig(), + NullLogger.Instance); + } + + // ── Submission post ──────────────────────────────────────────── + + [Fact] + public async Task CreateUploadsMapAsAttachment() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + var threadId = await sut.CreateGeofenceSubmissionPostAsync(Post()); + + Assert.Equal(ThreadId, threadId); + Assert.True(discord.WasMultipart); + Assert.Equal("geofence-map.png", discord.UploadedFileName); + Assert.Equal(PngBytes, discord.UploadedBytes); + Assert.Equal("attachment://geofence-map.png", Embed(discord).GetProperty("image").GetProperty("url").GetString()); + } + + [Fact] + public async Task CreateFallsBackToLinkingUrlWhenDownloadFails() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.NotFound, [])); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + Assert.False(discord.WasMultipart); + Assert.Equal(MapUrl, Embed(discord).GetProperty("image").GetProperty("url").GetString()); + } + + [Fact] + public async Task CreateOmitsImageWhenNoMapUrl() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post(mapUrl: null)); + + Assert.False(Embed(discord).TryGetProperty("image", out _)); + } + + [Fact] + public async Task CreateDoesNotSendTheBotTokenToTheTileserver() + { + var discord = new DiscordHandler(); + var mapHandler = new MapHandler(HttpStatusCode.OK, PngBytes); + var discordClient = new HttpClient(discord) { BaseAddress = new Uri("https://discordapp.com/api/v9/") }; + discordClient.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bot", "super-secret"); + + var sut = new DiscordNotificationService( + discordClient, + new StubHttpClientFactory(new HttpClient(mapHandler)), + CreateConfig(), + NullLogger.Instance); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + Assert.Null(mapHandler.SeenAuthorization); + } + + [Fact] + public async Task CreateSkipsUploadWhenMapIsOversized() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, new byte[(8 * 1024 * 1024) + 1])); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + Assert.False(discord.WasMultipart); + Assert.Equal(MapUrl, Embed(discord).GetProperty("image").GetProperty("url").GetString()); + } + + // ── Review card contents ─────────────────────────────────────── + + [Fact] + public async Task EmbedLeadsWithSizeRegionAndPublicName() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + var fields = Embed(discord).GetProperty("fields"); + Assert.Equal("Size", fields[0].GetProperty("name").GetString()); + Assert.Contains("4.2 km²", fields[0].GetProperty("value").GetString(), StringComparison.Ordinal); + Assert.Contains("neighbourhood", fields[0].GetProperty("value").GetString(), StringComparison.Ordinal); + Assert.Equal("Region", fields[1].GetProperty("name").GetString()); + Assert.Equal("Publishes as", fields[2].GetProperty("name").GetString()); + Assert.Equal("`my park`", fields[2].GetProperty("value").GetString()); + } + + [Fact] + public async Task EmbedDropsVertexCountAndMovesSubmitterToAuthor() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + var embed = Embed(discord); + var names = embed.GetProperty("fields").EnumerateArray() + .Select(f => f.GetProperty("name").GetString()).ToList(); + + Assert.DoesNotContain("Points", names); + Assert.DoesNotContain("Submitted By", names); + Assert.Equal("Tester", embed.GetProperty("author").GetProperty("name").GetString()); + + // The mention moves to the message content so it stays clickable. + Assert.Contains("<@user1>", Message(discord).GetProperty("content").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task EmbedOmitsTheAuthorBlockRatherThanShowingARawId() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + var post = Post() with { UserName = null }; + await sut.CreateGeofenceSubmissionPostAsync(post); + + Assert.False(Embed(discord).TryGetProperty("author", out _)); + + // The mention still identifies the submitter. + Assert.Contains("<@user1>", Message(discord).GetProperty("content").GetString(), StringComparison.Ordinal); + } + + [Fact] + public async Task EmbedLinksTheCentroidToAMap() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + var location = Field(discord, "Location").GetProperty("value").GetString()!; + Assert.Contains("38.9412, -76.7305", location, StringComparison.Ordinal); + Assert.Contains("[Open in maps](https://www.google.com/maps/search/?api=1&query=38.9412,-76.7305)", location, StringComparison.Ordinal); + } + + [Fact] + public async Task EmbedSaysRegionNotDetectedWhenGroupIsBlank() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post(groupName: " ")); + + Assert.Equal("Not detected", Field(discord, "Region").GetProperty("value").GetString()); + } + + [Fact] + public async Task EmbedFlagsAnOverlappingPublicArea() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post(overlaps: "US - MD - Bowie")); + + Assert.Contains("US - MD - Bowie", Field(discord, "Already covered by").GetProperty("value").GetString()!, StringComparison.Ordinal); + } + + [Fact] + public async Task EmbedOmitsOverlapFieldWhenThereIsNone() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + Assert.Null(TryField(discord, "Already covered by")); + } + + [Fact] + public async Task EmbedLinksTitleToTheReviewPageOnlyWhenConfigured() + { + var withUrl = new DiscordHandler(); + await CreateSut(withUrl, new MapHandler(HttpStatusCode.OK, PngBytes)) + .CreateGeofenceSubmissionPostAsync(Post(reviewUrl: "https://alerts.example.test/admin/geofence-submissions")); + Assert.Equal("https://alerts.example.test/admin/geofence-submissions", Embed(withUrl).GetProperty("url").GetString()); + + var without = new DiscordHandler(); + await CreateSut(without, new MapHandler(HttpStatusCode.OK, PngBytes)) + .CreateGeofenceSubmissionPostAsync(Post()); + Assert.False(Embed(without).TryGetProperty("url", out _)); + } + + [Theory] + [InlineData(0.4, "a block or two")] + [InlineData(4.2, "neighbourhood")] + [InlineData(30, "district")] + [InlineData(120, "city-sized")] + [InlineData(900, "very large")] + public async Task EmbedBandsTheAreaInPlainLanguage(double areaSqKm, string expected) + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post(areaSqKm: areaSqKm)); + + Assert.Contains(expected, Field(discord, "Size").GetProperty("value").GetString()!, StringComparison.Ordinal); + } + + // ── Status colour and outcome edit ───────────────────────────── + + [Fact] + public async Task PendingPostIsAmber() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.CreateGeofenceSubmissionPostAsync(Post()); + + Assert.Equal(16096779, Embed(discord).GetProperty("color").GetInt32()); // #f59e0b + } + + [Fact] + public async Task ApprovalRewritesTheOpeningEmbedGreenAndKeepsTheMap() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.PostReviewOutcomeAsync(ThreadId, Post(GeofenceReviewState.Approved)); + + Assert.Equal($"channels/{ThreadId}/messages/{ThreadId}", discord.PatchedMessagePath); + + var edited = JsonDocument.Parse(discord.PatchedMessageJson!).RootElement; + var embed = edited.GetProperty("embeds")[0]; + Assert.Equal(2278750, embed.GetProperty("color").GetInt32()); // #22c55e + Assert.Equal("Published as", embed.GetProperty("fields")[2].GetProperty("name").GetString()); + Assert.Equal("Approved", embed.GetProperty("footer").GetProperty("text").GetString()); + + // Discord folds an attachment:// attachment into the embed, so the edited message lists no + // attachments and there is no ID to carry forward -- the map must be re-uploaded. + Assert.True(discord.PatchWasMultipart); + Assert.Equal(PngBytes, discord.PatchedBytes); + Assert.Equal("attachment://geofence-map.png", embed.GetProperty("image").GetProperty("url").GetString()); + Assert.Equal("0", edited.GetProperty("attachments")[0].GetProperty("id").GetString()); + } + + [Fact] + public async Task OutcomeFallsBackToLinkingTheMapWhenItCannotBeReuploaded() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.NotFound, [])); + + await sut.PostReviewOutcomeAsync(ThreadId, Post(GeofenceReviewState.Approved)); + + var edited = JsonDocument.Parse(discord.PatchedMessageJson!).RootElement; + Assert.False(discord.PatchWasMultipart); + Assert.Equal(MapUrl, edited.GetProperty("embeds")[0].GetProperty("image").GetProperty("url").GetString()); + Assert.Empty(edited.GetProperty("attachments").EnumerateArray()); + } + + [Fact] + public async Task RejectionRewritesTheOpeningEmbedRedAndCarriesTheReason() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.PostReviewOutcomeAsync(ThreadId, Post(GeofenceReviewState.Rejected, reviewNotes: "Too large")); + + var embed = JsonDocument.Parse(discord.PatchedMessageJson!).RootElement.GetProperty("embeds")[0]; + Assert.Equal(15680580, embed.GetProperty("color").GetInt32()); // #ef4444 + Assert.Equal("Rejected", embed.GetProperty("footer").GetProperty("text").GetString()); + + var reason = embed.GetProperty("fields").EnumerateArray() + .First(f => f.GetProperty("name").GetString() == "Reason"); + Assert.Equal("Too large", reason.GetProperty("value").GetString()); + } + + [Fact] + public async Task OutcomeStillPostsTheVerdictWhenTheEmbedEditFails() + { + var discord = new DiscordHandler { FailMessageEdit = true }; + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.PostReviewOutcomeAsync(ThreadId, Post(GeofenceReviewState.Approved)); + + Assert.Contains("Approved", discord.PostedMessageContent!, StringComparison.Ordinal); + Assert.True(discord.ThreadArchived); + } + + [Fact] + public async Task OutcomeLocksAndArchivesTheThread() + { + var discord = new DiscordHandler(); + var sut = CreateSut(discord, new MapHandler(HttpStatusCode.OK, PngBytes)); + + await sut.PostReviewOutcomeAsync(ThreadId, Post(GeofenceReviewState.Approved)); + + Assert.True(discord.ThreadLocked); + Assert.True(discord.ThreadArchived); + } + + // ── Helpers ──────────────────────────────────────────────────── + + private static JsonElement Message(DiscordHandler discord) => + JsonDocument.Parse(discord.PayloadJson!).RootElement.GetProperty("message"); + + private static JsonElement Embed(DiscordHandler discord) => Message(discord).GetProperty("embeds")[0]; + + private static JsonElement Field(DiscordHandler discord, string name) => + TryField(discord, name) ?? throw new InvalidOperationException($"No '{name}' field on the embed."); + + private static JsonElement? TryField(DiscordHandler discord, string name) => + Embed(discord).GetProperty("fields").EnumerateArray() + .Where(field => field.GetProperty("name").GetString() == name) + .Cast() + .FirstOrDefault(); + + private sealed class StubHttpClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + /// + /// Answers the forum-tag lookup with all three tags already present (so no tag PATCH is issued) and + /// records every request the service makes. + /// + private sealed class DiscordHandler : HttpMessageHandler + { + public bool FailMessageEdit { get; init; } + + public string? PayloadJson { get; private set; } + + public bool WasMultipart { get; private set; } + + public string? UploadedFileName { get; private set; } + + public byte[]? UploadedBytes { get; private set; } + + public string? PatchedMessagePath { get; private set; } + + public string? PatchedMessageJson { get; private set; } + + public bool PatchWasMultipart { get; private set; } + + public byte[]? PatchedBytes { get; private set; } + + public string? PostedMessageContent { get; private set; } + + public bool ThreadLocked { get; private set; } + + public bool ThreadArchived { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var path = request.RequestUri!.AbsolutePath.Replace("/api/v9/", string.Empty, StringComparison.Ordinal); + + if (request.Method == HttpMethod.Get) + { + return Json(""" + {"available_tags":[{"id":"1","name":"Geofence - Pending"},{"id":"2","name":"Geofence - Approved"},{"id":"3","name":"Geofence - Rejected"}]} + """); + } + + if (request.Method == HttpMethod.Patch && path.Contains("/messages/", StringComparison.Ordinal)) + { + if (this.FailMessageEdit) + { + return new HttpResponseMessage(HttpStatusCode.Forbidden) { Content = new StringContent("{}") }; + } + + this.PatchedMessagePath = path; + + if (request.Content is MultipartFormDataContent patchParts) + { + this.PatchWasMultipart = true; + var parts = patchParts.ToList(); + this.PatchedMessageJson = await parts[0].ReadAsStringAsync(cancellationToken); + this.PatchedBytes = await parts[1].ReadAsByteArrayAsync(cancellationToken); + } + else + { + this.PatchedMessageJson = await request.Content!.ReadAsStringAsync(cancellationToken); + } + + return Json("{}"); + } + + if (request.Method == HttpMethod.Patch) + { + var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement; + this.ThreadLocked = body.TryGetProperty("locked", out var l) && l.GetBoolean(); + this.ThreadArchived = body.TryGetProperty("archived", out var a) && a.GetBoolean(); + return Json("{}"); + } + + if (path.EndsWith("/messages", StringComparison.Ordinal)) + { + var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement; + this.PostedMessageContent = body.GetProperty("content").GetString(); + return Json("""{"id":"reply-1"}"""); + } + + if (request.Content is MultipartFormDataContent multipart) + { + this.WasMultipart = true; + var parts = multipart.ToList(); + this.PayloadJson = await parts[0].ReadAsStringAsync(cancellationToken); + this.UploadedBytes = await parts[1].ReadAsByteArrayAsync(cancellationToken); + this.UploadedFileName = parts[1].Headers.ContentDisposition?.FileName?.Trim('"'); + } + else + { + this.PayloadJson = await request.Content!.ReadAsStringAsync(cancellationToken); + } + + return Json($$"""{"id":"{{ThreadId}}"}"""); + } + + private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json"), + }; + } + + private sealed class MapHandler(HttpStatusCode statusCode, byte[] body) : HttpMessageHandler + { + public string? SeenAuthorization { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.SeenAuthorization = request.Headers.Authorization?.ToString(); + + var response = new HttpResponseMessage(statusCode) { Content = new ByteArrayContent(body) }; + response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png"); + + return Task.FromResult(response); + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/EggServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/EggServiceTests.cs index 46f55725..d8a5b267 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/EggServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/EggServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using Moq; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -17,11 +18,12 @@ public class EggServiceTests private readonly EggService _sut; private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); public EggServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new EggService(this._proxy.Object, this._featureGate.Object); + this._sut = new EggService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); } [Fact] @@ -124,19 +126,22 @@ public async Task UpdateDistanceByUserAsyncReturnsCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }, new { uid = 3, id = "u", - distance = 0 + distance = 0, + template = "ZZrow3" }); this._proxy.Setup(p => p.GetByUserAsync("egg", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("egg", "u", It.IsAny())) @@ -213,4 +218,63 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Duplicate-on-edit --- + // PoracleNG dedups egg tracking by a natural key. When an edit changes a field in that key it INSERTS + // instead of upserting, leaving the pre-edit row behind as a second live alarm firing the old filter. + + [Fact] + public async Task UpdateAsyncDeletesTheSupersededRowWhenPoracleNgInsertsInsteadOfUpdating() + { + var model = new Egg { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("egg", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("egg", "user1", 41)).Returns(Task.CompletedTask); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("egg", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncKeepsTheUidAndDeletesNothingWhenPoracleNgUpserts() + { + var model = new Egg { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("egg", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(41, result.Uid); + } + + [Fact] + public async Task UpdateAsyncStillSucceedsWhenDeletingTheSupersededRowFails() + { + var model = new Egg { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("egg", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("egg", "user1", 41)) + .ThrowsAsync(new HttpRequestException("boom")); + + // The inserted row already carries the user's settings, so the edit must not fail. + var result = await this._sut.UpdateAsync("user1", model); + + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotAttemptAStaleDelete() + { + var model = new Egg { Uid = 0 }; + this._proxy.Setup(p => p.CreateAsync("egg", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/FeatureGateTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/FeatureGateTests.cs index 5e078f59..e819958b 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/FeatureGateTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/FeatureGateTests.cs @@ -9,9 +9,24 @@ namespace Pgan.PoracleWebNet.Tests.Services; public class FeatureGateTests { private readonly Mock _settings = new(); + private readonly Mock _upstreamFlags = new(); private readonly FeatureGate _sut; - public FeatureGateTests() => this._sut = new FeatureGate(this._settings.Object, NullLogger.Instance); + public FeatureGateTests() + { + // The prod default: Poracle reports "disabledHooks": [], so nothing is forced off upstream + // and the site settings are in sole charge. Individual tests override this. + this._upstreamFlags + .Setup(f => f.GetDisabledKeysAsync()) + .ReturnsAsync(new HashSet(StringComparer.Ordinal)); + + this._sut = new FeatureGate(this._settings.Object, this._upstreamFlags.Object, NullLogger.Instance); + } + + private void UpstreamDisables(params string[] keys) => + this._upstreamFlags + .Setup(f => f.GetDisabledKeysAsync()) + .ReturnsAsync(new HashSet(keys, StringComparer.Ordinal)); [Fact] public async Task IsEnabledReturnsTrueWhenSettingFalse() @@ -45,4 +60,107 @@ public async Task EnsureEnabledThrowsFeatureDisabledExceptionWithKey() var ex = await Assert.ThrowsAsync(() => this._sut.EnsureEnabledAsync("disable_mons")); Assert.Equal("disable_mons", ex.DisableKey); } + + // --- Poracle's own flags act as a floor under the site settings (#769) --- + + /// + /// The whole point: the site setting says the type is on, Poracle says it is off, and Poracle + /// wins. Its processor drops the webhook and its bot refuses the command, so an alarm created + /// here could only ever save and then never fire. + /// + [Fact] + public async Task IsEnabledReturnsFalseWhenPoracleDisablesTheTypeAndTheSiteSettingDoesNot() + { + this._settings.Setup(s => s.GetBoolAsync(DisableFeatureKeys.Raids)).ReturnsAsync(false); + this.UpstreamDisables(DisableFeatureKeys.Raids); + + Assert.False(await this._sut.IsEnabledAsync(DisableFeatureKeys.Raids)); + } + + [Fact] + public async Task EnsureEnabledThrowsWithTheSameKeyWhenPoracleDisablesTheType() + { + this._settings.Setup(s => s.GetBoolAsync(DisableFeatureKeys.Quests)).ReturnsAsync(false); + this.UpstreamDisables(DisableFeatureKeys.Quests); + + var ex = await Assert.ThrowsAsync( + () => this._sut.EnsureEnabledAsync(DisableFeatureKeys.Quests)); + + // The SPA's 403 interceptor keys off disableKey, so the wire format must not differ by source. + Assert.Equal(DisableFeatureKeys.Quests, ex.DisableKey); + } + + /// + /// A floor, not a switch: Poracle disabling raids must not re-enable anything else, and must not + /// touch the keys it has no opinion about at all (areas, profiles, location, geocoding). + /// + [Theory] + [InlineData(DisableFeatureKeys.Pokemon)] + [InlineData(DisableFeatureKeys.Quests)] + [InlineData(DisableFeatureKeys.Invasions)] + [InlineData(DisableFeatureKeys.Lures)] + [InlineData(DisableFeatureKeys.Nests)] + [InlineData(DisableFeatureKeys.Gyms)] + [InlineData(DisableFeatureKeys.MaxBattles)] + [InlineData(DisableFeatureKeys.FortChanges)] + [InlineData(DisableFeatureKeys.Areas)] + [InlineData(DisableFeatureKeys.Profiles)] + [InlineData(DisableFeatureKeys.Location)] + [InlineData(DisableFeatureKeys.Geocoding)] + [InlineData(DisableFeatureKeys.UserGeofences)] + public async Task OneUpstreamDisabledTypeLeavesEveryOtherKeyAlone(string key) + { + this._settings.Setup(s => s.GetBoolAsync(It.IsAny())).ReturnsAsync(false); + this.UpstreamDisables(DisableFeatureKeys.Raids); + + Assert.True(await this._sut.IsEnabledAsync(key)); + } + + /// + /// What prod actually serves — "disabledHooks": [] — must leave every type creatable. + /// This is the half that catches the regression, not the refusal tests above. + /// + [Theory] + [InlineData(DisableFeatureKeys.Pokemon)] + [InlineData(DisableFeatureKeys.Raids)] + [InlineData(DisableFeatureKeys.Quests)] + [InlineData(DisableFeatureKeys.Invasions)] + [InlineData(DisableFeatureKeys.Lures)] + [InlineData(DisableFeatureKeys.Nests)] + [InlineData(DisableFeatureKeys.Gyms)] + [InlineData(DisableFeatureKeys.MaxBattles)] + [InlineData(DisableFeatureKeys.FortChanges)] + public async Task EveryAlarmTypeStaysEnabledWhenPoracleDisablesNothing(string key) + { + this._settings.Setup(s => s.GetBoolAsync(It.IsAny())).ReturnsAsync(false); + + Assert.True(await this._sut.IsEnabledAsync(key)); + await this._sut.EnsureEnabledAsync(key); + } + + /// + /// The site setting keeps working on its own. Poracle's flags are additive — they never enable + /// something an admin has switched off here. + /// + [Fact] + public async Task SiteSettingStillDisablesWhenPoracleDisablesNothing() + { + this._settings.Setup(s => s.GetBoolAsync(DisableFeatureKeys.Lures)).ReturnsAsync(true); + + Assert.False(await this._sut.IsEnabledAsync(DisableFeatureKeys.Lures)); + } + + /// + /// The cheap check runs first and short-circuits: an admin-disabled feature must not cost an + /// upstream HTTP round-trip on every gated request. + /// + [Fact] + public async Task DoesNotConsultPoracleWhenTheSiteSettingAlreadyDisablesTheFeature() + { + this._settings.Setup(s => s.GetBoolAsync(DisableFeatureKeys.Gyms)).ReturnsAsync(true); + + await Assert.ThrowsAsync(() => this._sut.EnsureEnabledAsync(DisableFeatureKeys.Gyms)); + + this._upstreamFlags.Verify(f => f.GetDisabledKeysAsync(), Times.Never); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/FortChangeServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/FortChangeServiceTests.cs index 0eae82cd..9cd4686b 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/FortChangeServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/FortChangeServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using System.ComponentModel.DataAnnotations; using System.Text.Json; using Moq; @@ -16,13 +17,14 @@ public class FortChangeServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly FortChangeService _sut; private static readonly string[] stringArray = ["name", "location"]; public FortChangeServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new FortChangeService(this._proxy.Object, this._featureGate.Object); + this._sut = new FortChangeService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); } [Fact] @@ -70,9 +72,65 @@ public async Task GetByUidAsyncNotFound() Assert.Null(await this._sut.GetByUidAsync("u1", 99)); } + /// + /// PoracleNG's dedup key for this type ignores distance, so a create matching an existing alarm's + /// settings overwrote that alarm's radius while PoracleWeb answered 201 with a fresh uid -- the user + /// believed they had two alarms and the configured radius was gone. See #502. + /// + [Fact] + public async Task CreateAsyncRefusesAnAlarmThatWouldOverwriteAnExistingOne() + { + this._proxy.Setup(p => p.GetByUserAsync("fort", "u1")).ReturnsAsync(CreateJsonArray(new + { + uid = 37, + id = "u1", + fort_type = "everything", + include_empty = 1, + change_types = "[\"new\"]", + distance = 700, + })); + + await Assert.ThrowsAsync( + () => this._sut.CreateAsync("u1", new FortChange + { + FortType = "everything", + IncludeEmpty = 1, + ChangeTypes = ["new"], + Distance = 900, + })); + + this._proxy.Verify(p => p.CreateAsync("fort", "u1", It.IsAny()), Times.Never); + } + + /// Different settings still add a second alarm. + [Fact] + public async Task CreateAsyncAllowsADifferentFortType() + { + this._proxy.Setup(p => p.GetByUserAsync("fort", "u1")).ReturnsAsync(CreateJsonArray(new + { + uid = 37, + id = "u1", + fort_type = "everything", + include_empty = 1, + change_types = "[\"new\"]", + })); + this._proxy.Setup(p => p.CreateAsync("fort", "u1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([38L], 0, 0, 1)); + + var result = await this._sut.CreateAsync("u1", new FortChange + { + FortType = "gym", + IncludeEmpty = 1, + ChangeTypes = ["new"], + }); + + Assert.Equal(38, result.Uid); + } + [Fact] public async Task CreateAsyncSetsUserId() { + this._proxy.Setup(p => p.GetByUserAsync("fort", "u1")).ReturnsAsync(CreateJsonArray()); this._proxy.Setup(p => p.CreateAsync("fort", "u1", It.IsAny())) .ReturnsAsync(new TrackingCreateResult([10L], 0, 0, 1)); var model = new FortChange { FortType = "pokestop" }; @@ -121,13 +179,15 @@ public async Task UpdateDistanceByUserAsyncCount() { uid = 1, id = "u1", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u1", - distance = 0 + distance = 0, + template = "ZZrow2" }); this._proxy.Setup(p => p.GetByUserAsync("fort", "u1")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("fort", "u1", It.IsAny())) @@ -158,6 +218,31 @@ public void FortChangeCreateValidatesFortType(string fortType, bool expected) Assert.Equal(expected, isValid); } + [Theory] + [InlineData(new[] { "name", "location", "image_url", "removal", "new" }, true)] + [InlineData(new[] { "name", "name" }, false)] + [InlineData(new[] { "name", "NAME" }, false)] + public void FortChangeCreateRefusesRepeatedAndOverLongChangeTypes(string[] changeTypes, bool expected) + { + // Five legal values, so a longer list or a repeat cannot mean anything and used to reach the + // database as an over-long JSON string. See #612. + var model = new FortChangeCreate { FortType = "everything", ChangeTypes = [.. changeTypes] }; + var results = new List(); + var isValid = Validator.TryValidateObject(model, new ValidationContext(model), results, validateAllProperties: true); + Assert.Equal(expected, isValid); + } + + [Fact] + public void FortChangeCreateRefusesMoreThanFiveChangeTypes() + { + var model = new FortChangeCreate + { + FortType = "everything", + ChangeTypes = ["name", "location", "image_url", "removal", "new", "name2"], + }; + var results = new List(); + Assert.False(Validator.TryValidateObject(model, new ValidationContext(model), results, validateAllProperties: true)); + } [Theory] [InlineData(new[] { "name", "location" }, true)] [InlineData(new[] { "image_url", "removal", "new" }, true)] @@ -204,4 +289,63 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Duplicate-on-edit --- + // PoracleNG dedups fort tracking by a natural key. When an edit changes a field in that key it INSERTS + // instead of upserting, leaving the pre-edit row behind as a second live alarm firing the old filter. + + [Fact] + public async Task UpdateAsyncDeletesTheSupersededRowWhenPoracleNgInsertsInsteadOfUpdating() + { + var model = new FortChange { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("fort", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("fort", "user1", 41)).Returns(Task.CompletedTask); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("fort", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncKeepsTheUidAndDeletesNothingWhenPoracleNgUpserts() + { + var model = new FortChange { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("fort", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(41, result.Uid); + } + + [Fact] + public async Task UpdateAsyncStillSucceedsWhenDeletingTheSupersededRowFails() + { + var model = new FortChange { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("fort", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("fort", "user1", 41)) + .ThrowsAsync(new HttpRequestException("boom")); + + // The inserted row already carries the user's settings, so the edit must not fail. + var result = await this._sut.UpdateAsync("user1", model); + + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotAttemptAStaleDelete() + { + var model = new FortChange { Uid = 0 }; + this._proxy.Setup(p => p.CreateAsync("fort", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/GeoJsonServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/GeoJsonServiceTests.cs index 500bd858..07263b3e 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/GeoJsonServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/GeoJsonServiceTests.cs @@ -411,4 +411,37 @@ public async Task ImportAsyncCreateAsyncThrowsRecordsError() Assert.Single(result.Errors); Assert.Contains("Maximum 10", result.Errors[0].Reason); } + + // ── Malformed stored polygons (#410) ──────────────────────────────────────── + // Rows written before create validated point arity are still in the database. Projecting one threw + // IndexOutOfRangeException and 500'd the caller's entire export until they worked out which geofence + // to delete. + + [Fact] + public async Task ExportAsyncSkipsAMalformedPolygonInsteadOfThrowing() + { + this._userGeofenceService.Setup(u => u.GetByUserAsync("u1")).ReturnsAsync( + [ + new UserGeofence { KojiName = "good", DisplayName = "Good", GroupName = "g", Polygon = TriangleLatLon() }, + new UserGeofence { KojiName = "bad", DisplayName = "Bad", GroupName = "g", Polygon = [[1.0], [2.0], [3.0]] } + ]); + + var result = await this._sut.ExportAsync("u1"); + + Assert.Single(result.Features); + Assert.Equal("good", result.Features[0].Properties!["name"].GetString()); + } + + [Fact] + public async Task ExportAsyncSkipsOutOfRangeCoordinates() + { + this._userGeofenceService.Setup(u => u.GetByUserAsync("u1")).ReturnsAsync( + [ + new UserGeofence { KojiName = "bad", DisplayName = "Bad", GroupName = "g", Polygon = [[999, -999], [998, -998], [997, -997]] } + ]); + + var result = await this._sut.ExportAsync("u1"); + + Assert.Empty(result.Features); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/GeoMathTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/GeoMathTests.cs new file mode 100644 index 00000000..8b8776e7 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/GeoMathTests.cs @@ -0,0 +1,119 @@ +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// is a hand-port of the frontend's geo.utils.ts. If the two drift, the area a +/// user sees while drawing stops matching the one a reviewer sees in Discord, so the expected values here +/// are derived independently rather than from the implementation. +/// +public class GeoMathTests +{ + /// + /// On a sphere of radius 6371 km one degree is 2πR/360 = 111.1949 km in both axes at the equator, so a + /// 0.1° square there is (11.11949)² ≈ 123.64 km². Derived from the radius rather than read off the + /// implementation. + /// + private const double DegreeKm = 2 * Math.PI * 6371 / 360; + + [Fact] + public void AreaMatchesTheSphericalExpectationAtTheEquator() + { + double[][] square = [[0, 0], [0, 0.1], [0.1, 0.1], [0.1, 0]]; + + var area = GeoMath.AreaSqKm(square); + + var expected = 0.1 * DegreeKm * (0.1 * DegreeKm); // ≈ 123.64 km² + Assert.InRange(area, expected * 0.999, expected * 1.001); + } + + /// The same square at latitude 60 must shrink by cos(latitude) as meridians converge. + [Fact] + public void AreaShrinksWithLatitude() + { + double[][] square = [[60, 0], [60, 0.1], [60.1, 0.1], [60.1, 0]]; + + var area = GeoMath.AreaSqKm(square); + + var expected = 0.1 * DegreeKm * (0.1 * DegreeKm) * Math.Cos(60.05 * Math.PI / 180); + Assert.InRange(area, expected * 0.999, expected * 1.001); + } + + [Fact] + public void AreaIgnoresWindingDirection() + { + double[][] clockwise = [[0, 0], [0, 0.1], [0.1, 0.1], [0.1, 0]]; + double[][] counterClockwise = [[0.1, 0], [0.1, 0.1], [0, 0.1], [0, 0]]; + + Assert.Equal(GeoMath.AreaSqKm(clockwise), GeoMath.AreaSqKm(counterClockwise), 6); + } + + [Fact] + public void AreaIsZeroForDegeneratePolygons() + { + Assert.Equal(0, GeoMath.AreaSqKm([])); + Assert.Equal(0, GeoMath.AreaSqKm([[1, 2]])); + Assert.Equal(0, GeoMath.AreaSqKm([[1, 2], [3, 4]])); + } + + [Fact] + public void CentroidAveragesTheVertices() + { + double[][] square = [[0, 0], [0, 2], [2, 2], [2, 0]]; + + var (lat, lon) = GeoMath.Centroid(square); + + Assert.Equal(1, lat, 6); + Assert.Equal(1, lon, 6); + } + + [Fact] + public void CentroidOfAnEmptyPolygonIsOrigin() + { + var (lat, lon) = GeoMath.Centroid([]); + + Assert.Equal(0, lat); + Assert.Equal(0, lon); + } + + [Theory] + [InlineData(1, 1, true)] // interior + [InlineData(5, 5, false)] // outside + [InlineData(-1, 1, false)] // south of it + [InlineData(1, -1, false)] // west of it + public void ContainsTestsPointsAgainstThePolygon(double lat, double lon, bool expected) + { + double[][] square = [[0, 0], [0, 2], [2, 2], [2, 0]]; + + Assert.Equal(expected, GeoMath.Contains(square, lat, lon)); + } + + [Fact] + public void ContainsHandlesAConcavePolygon() + { + // A "C" shape open to the east: the notch between the arms is outside the polygon. + double[][] cShape = [[0, 0], [0, 3], [1, 3], [1, 1], [2, 1], [2, 3], [3, 3], [3, 0]]; + + Assert.True(GeoMath.Contains(cShape, 0.5, 1.5)); // inside the lower arm + Assert.False(GeoMath.Contains(cShape, 1.5, 2)); // inside the notch + Assert.True(GeoMath.Contains(cShape, 1.5, 0.5)); // inside the spine + } + + [Fact] + public void ContainsIsFalseForDegeneratePolygons() => + Assert.False(GeoMath.Contains([[0, 0], [1, 1]], 0.5, 0.5)); + + [Theory] + [InlineData(0, "a block or two")] + [InlineData(0.99, "a block or two")] + [InlineData(1, "neighbourhood")] + [InlineData(9.99, "neighbourhood")] + [InlineData(10, "district")] + [InlineData(49.9, "district")] + [InlineData(50, "city-sized")] + [InlineData(199, "city-sized")] + [InlineData(200, "very large")] + [InlineData(10000, "very large")] + public void DescribeAreaBandsOnTheDocumentedBoundaries(double areaSqKm, string expected) => + Assert.Equal(expected, GeoMath.DescribeArea(areaSqKm)); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/GymServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/GymServiceTests.cs index 182c696a..3b364dee 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/GymServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/GymServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using Moq; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -15,12 +16,13 @@ public class GymServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly GymService _sut; public GymServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new GymService(this._proxy.Object, this._featureGate.Object); + this._sut = new GymService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); } [Fact] @@ -125,31 +127,36 @@ public async Task UpdateDistanceByUserAsyncCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }, new { uid = 3, id = "u", - distance = 0 + distance = 0, + template = "ZZrow3" }, new { uid = 4, id = "u", - distance = 0 + distance = 0, + template = "ZZrow4" }, new { uid = 5, id = "u", - distance = 0 + distance = 0, + template = "ZZrow5" }); this._proxy.Setup(p => p.GetByUserAsync("gym", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("gym", "u", It.IsAny())) @@ -200,4 +207,63 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Duplicate-on-edit --- + // PoracleNG dedups gym tracking by a natural key. When an edit changes a field in that key it INSERTS + // instead of upserting, leaving the pre-edit row behind as a second live alarm firing the old filter. + + [Fact] + public async Task UpdateAsyncDeletesTheSupersededRowWhenPoracleNgInsertsInsteadOfUpdating() + { + var model = new Gym { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("gym", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("gym", "user1", 41)).Returns(Task.CompletedTask); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("gym", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncKeepsTheUidAndDeletesNothingWhenPoracleNgUpserts() + { + var model = new Gym { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("gym", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(41, result.Uid); + } + + [Fact] + public async Task UpdateAsyncStillSucceedsWhenDeletingTheSupersededRowFails() + { + var model = new Gym { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("gym", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("gym", "user1", 41)) + .ThrowsAsync(new HttpRequestException("boom")); + + // The inserted row already carries the user's settings, so the edit must not fail. + var result = await this._sut.UpdateAsync("user1", model); + + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotAttemptAStaleDelete() + { + var model = new Gym { Uid = 0 }; + this._proxy.Setup(p => p.CreateAsync("gym", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/HumanServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/HumanServiceTests.cs index b777da17..f3581fce 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/HumanServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/HumanServiceTests.cs @@ -1,211 +1,246 @@ -using System.Text.Json; -using Moq; -using Pgan.PoracleWebNet.Core.Abstractions.Repositories; -using Pgan.PoracleWebNet.Core.Abstractions.Services; -using Pgan.PoracleWebNet.Core.Models; -using Pgan.PoracleWebNet.Core.Services; - -namespace Pgan.PoracleWebNet.Tests.Services; - -public class HumanServiceTests -{ - private readonly Mock _repository = new(); - private readonly Mock _humanProxy = new(); - private readonly Mock _trackingProxy = new(); - private readonly HumanService _sut; - - public HumanServiceTests() => this._sut = new HumanService( - this._repository.Object, - this._humanProxy.Object, - this._trackingProxy.Object); - - [Fact] - public async Task GetAllAsyncReturnsHumansFromRepository() - { - // GetAllAsync still uses direct DB (no proxy equivalent) - this._repository.Setup(r => r.GetAllAsync()).ReturnsAsync( - [ - new() { Id = "u1", Name = "User1" }, - new() { Id = "u2", Name = "User2" } - ]); - - var result = (await this._sut.GetAllAsync()).ToList(); - Assert.Equal(2, result.Count); - } - - [Fact] - public async Task GetByIdAsyncReturnsHumanFromProxy() - { - var json = CreateHumanJson("u1", "User1"); - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); - - var result = await this._sut.GetByIdAsync("u1"); - Assert.NotNull(result); - Assert.Equal("u1", result!.Id); - Assert.Equal("User1", result.Name); - } - - [Fact] - public async Task GetByIdAsyncReturnsNullWhenProxyReturnsNull() - { - this._humanProxy.Setup(p => p.GetHumanAsync("unknown")).ReturnsAsync((JsonElement?)null); - Assert.Null(await this._sut.GetByIdAsync("unknown")); - } - - [Fact] - public async Task GetByIdAsyncThrowsOnProxyError() - { - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ThrowsAsync(new HttpRequestException("fail")); - await Assert.ThrowsAsync(() => this._sut.GetByIdAsync("u1")); - } - - [Fact] - public async Task GetByIdAndProfileAsyncReturnsHumanWhenProfileMatches() - { - var json = CreateHumanJson("u1", "User1", currentProfileNo: 1); - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); - - var result = await this._sut.GetByIdAndProfileAsync("u1", 1); - Assert.NotNull(result); - } - - [Fact] - public async Task GetByIdAndProfileAsyncReturnsNullWhenProfileDoesNotMatch() - { - var json = CreateHumanJson("u1", "User1", currentProfileNo: 1); - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); - - var result = await this._sut.GetByIdAndProfileAsync("u1", 99); - Assert.Null(result); - } - - [Fact] - public async Task CreateAsyncUsesProxy() - { - var human = new Human { Id = "u1", Name = "New", Type = "discord:user" }; - this._humanProxy.Setup(p => p.CreateHumanAsync(It.IsAny())).Returns(Task.CompletedTask); - - // After create, re-fetch returns the human - var json = CreateHumanJson("u1", "New"); - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); - - var result = await this._sut.CreateAsync(human); - Assert.Equal("New", result.Name); - this._humanProxy.Verify(p => p.CreateHumanAsync(It.IsAny()), Times.Once); - } - - [Fact] - public async Task UpdateAsyncDelegatesToRepository() - { - // UpdateAsync still uses direct DB for general updates - var human = new Human { Id = "u1", Name = "Updated" }; - this._repository.Setup(r => r.UpdateAsync(human)).ReturnsAsync(human); - - await this._sut.UpdateAsync(human); - this._repository.Verify(r => r.UpdateAsync(human), Times.Once); - } - - [Fact] - public async Task ExistsAsyncReturnsTrueViaProxy() - { - var json = CreateHumanJson("u1", "User1"); - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); - Assert.True(await this._sut.ExistsAsync("u1")); - } - - [Fact] - public async Task ExistsAsyncReturnsFalseViaProxy() - { - this._humanProxy.Setup(p => p.GetHumanAsync("unknown")).ReturnsAsync((JsonElement?)null); - Assert.False(await this._sut.ExistsAsync("unknown")); - } - - [Fact] - public async Task ExistsAsyncThrowsOnProxyError() - { - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ThrowsAsync(new HttpRequestException("fail")); - await Assert.ThrowsAsync(() => this._sut.ExistsAsync("u1")); - } - - [Fact] - public async Task DeleteAllAlarmsByUserAsyncUsesTrackingProxy() - { - // Set up tracking responses with UIDs for each alarm type - var pokemonJson = CreateTrackingArray([1, 2]); - var raidJson = CreateTrackingArray([3]); - var emptyJson = CreateTrackingArray([]); - - this._trackingProxy.Setup(p => p.GetByUserAsync("pokemon", "u1")).ReturnsAsync(pokemonJson); - this._trackingProxy.Setup(p => p.GetByUserAsync("raid", "u1")).ReturnsAsync(raidJson); - this._trackingProxy.Setup(p => p.GetByUserAsync("egg", "u1")).ReturnsAsync(emptyJson); - this._trackingProxy.Setup(p => p.GetByUserAsync("quest", "u1")).ReturnsAsync(emptyJson); - this._trackingProxy.Setup(p => p.GetByUserAsync("invasion", "u1")).ReturnsAsync(emptyJson); - this._trackingProxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(emptyJson); - this._trackingProxy.Setup(p => p.GetByUserAsync("nest", "u1")).ReturnsAsync(emptyJson); - this._trackingProxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(emptyJson); - - var count = await this._sut.DeleteAllAlarmsByUserAsync("u1"); - - Assert.Equal(3, count); - this._trackingProxy.Verify(p => p.BulkDeleteByUidsAsync("pokemon", "u1", It.Is>(uids => uids.Count() == 2)), Times.Once); - this._trackingProxy.Verify(p => p.BulkDeleteByUidsAsync("raid", "u1", It.Is>(uids => uids.Count() == 1)), Times.Once); - } - - [Fact] - public async Task DeleteAllAlarmsByUserAsyncThrowsOnProxyError() - { - this._trackingProxy.Setup(p => p.GetByUserAsync(It.IsAny(), "u1")) - .ThrowsAsync(new HttpRequestException("fail")); - await Assert.ThrowsAsync(() => this._sut.DeleteAllAlarmsByUserAsync("u1")); - } - - [Fact] - public async Task DeleteUserAsyncDelegatesToRepository() - { - this._repository.Setup(r => r.DeleteUserAsync("u1")).ReturnsAsync(true); - Assert.True(await this._sut.DeleteUserAsync("u1")); - } - - [Fact] - public async Task DeleteUserAsyncReturnsFalse() - { - this._repository.Setup(r => r.DeleteUserAsync("unknown")).ReturnsAsync(false); - Assert.False(await this._sut.DeleteUserAsync("unknown")); - } - - /// - /// Creates a JsonElement representing a human record from the PoracleNG API (snake_case). - /// - private static JsonElement CreateHumanJson(string id, string name, int enabled = 1, int adminDisable = 0, int currentProfileNo = 1) - { - var json = JsonSerializer.Serialize(new - { - id, - name, - type = "discord:user", - enabled, - admin_disable = adminDisable, - current_profile_no = currentProfileNo, - area = "[]", - latitude = 0.0, - longitude = 0.0, - fails = 0, - language = "en", - community_membership = (string?)null, - }); - - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } - - /// - /// Creates a JsonElement representing a tracking array response with the given UIDs. - /// - private static JsonElement CreateTrackingArray(int[] uids) - { - var items = uids.Select(uid => new { uid }).ToArray(); - var json = JsonSerializer.Serialize(items); - using var doc = JsonDocument.Parse(json); - return doc.RootElement.Clone(); - } -} +using System.Text.Json; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class HumanServiceTests +{ + private readonly Mock _repository = new(); + private readonly Mock _humanProxy = new(); + private readonly Mock _trackingProxy = new(); + private readonly HumanService _sut; + + public HumanServiceTests() => this._sut = new HumanService( + this._repository.Object, + this._humanProxy.Object, + this._trackingProxy.Object); + + [Fact] + public async Task GetAllAsyncReturnsHumansFromRepository() + { + // GetAllAsync still uses direct DB (no proxy equivalent) + this._repository.Setup(r => r.GetAllAsync()).ReturnsAsync( + [ + new() { Id = "u1", Name = "User1" }, + new() { Id = "u2", Name = "User2" } + ]); + + var result = (await this._sut.GetAllAsync()).ToList(); + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task GetByIdAsyncReturnsHumanFromProxy() + { + var json = CreateHumanJson("u1", "User1"); + this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); + + var result = await this._sut.GetByIdAsync("u1"); + Assert.NotNull(result); + Assert.Equal("u1", result!.Id); + Assert.Equal("User1", result.Name); + } + + [Fact] + public async Task GetByIdAsyncReturnsNullWhenProxyReturnsNull() + { + this._humanProxy.Setup(p => p.GetHumanAsync("unknown")).ReturnsAsync((JsonElement?)null); + Assert.Null(await this._sut.GetByIdAsync("unknown")); + } + + [Fact] + public async Task GetByIdAsyncThrowsOnProxyError() + { + this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ThrowsAsync(new HttpRequestException("fail")); + await Assert.ThrowsAsync(() => this._sut.GetByIdAsync("u1")); + } + + + [Fact] + public async Task CreateAsyncUsesProxy() + { + var human = new Human { Id = "u1", Name = "New", Type = "discord:user" }; + this._humanProxy.Setup(p => p.CreateHumanAsync(It.IsAny())).Returns(Task.CompletedTask); + + // After create, re-fetch returns the human + var json = CreateHumanJson("u1", "New"); + this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); + + var result = await this._sut.CreateAsync(human); + Assert.Equal("New", result.Name); + this._humanProxy.Verify(p => p.CreateHumanAsync(It.IsAny()), Times.Once); + } + + /// + /// PoracleNG's createHumanRequest declares Enabled and AdminDisable as *bool. + /// These are stored as int here, and sending the raw int failed with + /// "json: cannot unmarshal number into Go struct field createHumanRequest.enabled of type bool", + /// so admin webhook creation could never succeed. The old test only asserted that the proxy was called + /// with any JsonElement, which is why the drift went unnoticed. + /// + [Theory] + [InlineData(1, 0, true, false)] + [InlineData(0, 1, false, true)] + public async Task CreateAsyncSendsEnabledAndAdminDisableAsBooleans( + int enabled, int adminDisable, bool expectedEnabled, bool expectedAdminDisable) + { + var human = new Human { Id = "u1", Name = "Hook", Type = "webhook", Enabled = enabled, AdminDisable = adminDisable }; + + JsonElement sent = default; + this._humanProxy.Setup(p => p.CreateHumanAsync(It.IsAny())) + .Callback(b => sent = b.Clone()) + .Returns(Task.CompletedTask); + this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(CreateHumanJson("u1", "Hook")); + + await this._sut.CreateAsync(human); + + // JSON booleans, not numbers — a number is what PoracleNG rejected. + Assert.NotEqual(JsonValueKind.Number, sent.GetProperty("enabled").ValueKind); + Assert.NotEqual(JsonValueKind.Number, sent.GetProperty("admin_disable").ValueKind); + Assert.Equal(expectedEnabled, sent.GetProperty("enabled").GetBoolean()); + Assert.Equal(expectedAdminDisable, sent.GetProperty("admin_disable").GetBoolean()); + } + + [Fact] + public async Task UpdateAsyncDelegatesToRepository() + { + // UpdateAsync still uses direct DB for general updates + var human = new Human { Id = "u1", Name = "Updated" }; + this._repository.Setup(r => r.UpdateAsync(human)).ReturnsAsync(human); + + await this._sut.UpdateAsync(human); + this._repository.Verify(r => r.UpdateAsync(human), Times.Once); + } + + [Fact] + public async Task ExistsAsyncReturnsTrueViaProxy() + { + var json = CreateHumanJson("u1", "User1"); + this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(json); + Assert.True(await this._sut.ExistsAsync("u1")); + } + + [Fact] + public async Task ExistsAsyncReturnsFalseViaProxy() + { + this._humanProxy.Setup(p => p.GetHumanAsync("unknown")).ReturnsAsync((JsonElement?)null); + Assert.False(await this._sut.ExistsAsync("unknown")); + } + + [Fact] + public async Task ExistsAsyncThrowsOnProxyError() + { + this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ThrowsAsync(new HttpRequestException("fail")); + await Assert.ThrowsAsync(() => this._sut.ExistsAsync("u1")); + } + + [Fact] + public async Task DeleteAllAlarmsByUserAsyncUsesTrackingProxy() + { + // Set up tracking responses with UIDs for each alarm type + var pokemonJson = CreateTrackingArray([1, 2]); + var raidJson = CreateTrackingArray([3]); + var emptyJson = CreateTrackingArray([]); + + this._trackingProxy.Setup(p => p.GetByUserAsync("pokemon", "u1")).ReturnsAsync(pokemonJson); + this._trackingProxy.Setup(p => p.GetByUserAsync("raid", "u1")).ReturnsAsync(raidJson); + this._trackingProxy.Setup(p => p.GetByUserAsync("egg", "u1")).ReturnsAsync(emptyJson); + this._trackingProxy.Setup(p => p.GetByUserAsync("quest", "u1")).ReturnsAsync(emptyJson); + this._trackingProxy.Setup(p => p.GetByUserAsync("invasion", "u1")).ReturnsAsync(emptyJson); + this._trackingProxy.Setup(p => p.GetByUserAsync("lure", "u1")).ReturnsAsync(emptyJson); + this._trackingProxy.Setup(p => p.GetByUserAsync("nest", "u1")).ReturnsAsync(emptyJson); + this._trackingProxy.Setup(p => p.GetByUserAsync("gym", "u1")).ReturnsAsync(emptyJson); + + var count = await this._sut.DeleteAllAlarmsByUserAsync("u1"); + + Assert.Equal(3, count); + this._trackingProxy.Verify(p => p.BulkDeleteByUidsAsync("pokemon", "u1", It.Is>(uids => uids.Count() == 2)), Times.Once); + this._trackingProxy.Verify(p => p.BulkDeleteByUidsAsync("raid", "u1", It.Is>(uids => uids.Count() == 1)), Times.Once); + } + + [Fact] + public async Task DeleteAllAlarmsByUserAsyncThrowsOnProxyError() + { + this._trackingProxy.Setup(p => p.GetByUserAsync(It.IsAny(), "u1")) + .ThrowsAsync(new HttpRequestException("fail")); + await Assert.ThrowsAsync(() => this._sut.DeleteAllAlarmsByUserAsync("u1")); + } + + [Fact] + public async Task DeleteUserAsyncDelegatesToRepository() + { + this._repository.Setup(r => r.DeleteUserAsync("u1")).ReturnsAsync(true); + Assert.True(await this._sut.DeleteUserAsync("u1")); + } + + [Fact] + public async Task DeleteUserAsyncReturnsFalse() + { + this._repository.Setup(r => r.DeleteUserAsync("unknown")).ReturnsAsync(false); + Assert.False(await this._sut.DeleteUserAsync("unknown")); + } + + /// + /// Creates a JsonElement representing a human record from the PoracleNG API (snake_case). + /// + private static JsonElement CreateHumanJson(string id, string name, int enabled = 1, int adminDisable = 0, int currentProfileNo = 1) + { + var json = JsonSerializer.Serialize(new + { + id, + name, + type = "discord:user", + enabled, + admin_disable = adminDisable, + current_profile_no = currentProfileNo, + area = "[]", + latitude = 0.0, + longitude = 0.0, + fails = 0, + language = "en", + community_membership = (string?)null, + }); + + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + /// + /// Creates a JsonElement representing a tracking array response with the given UIDs. + /// + private static JsonElement CreateTrackingArray(int[] uids) + { + var items = uids.Select(uid => new { uid }).ToArray(); + var json = JsonSerializer.Serialize(items); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + /// + /// humans is keyed on id alone, so a lookup that also filtered on + /// current_profile_no could only ever return a spurious null when the JWT's profile claim was + /// stale — which PoracleNG's active-hours scheduler and the bot's !profile command both cause + /// routinely. That method produced three separate user-visible bugs (a raw Discord ID on the geofence + /// review card, a 404 on the language endpoints, and a silent no-op in the geofence approval area swap) + /// and has been removed. This test fails the build if it is reintroduced. + /// + [Fact] + public void NoProfileFilteredHumanLookupExists() + { + var offenders = new[] { typeof(IHumanService), typeof(IHumanRepository) } + .SelectMany(t => t.GetMethods()) + .Where(m => m.Name.Contains("AndProfile", StringComparison.Ordinal)) + .Select(m => $"{m.DeclaringType!.Name}.{m.Name}") + .ToList(); + + Assert.True( + offenders.Count == 0, + "A human lookup filtered by profile number is back: " + string.Join(", ", offenders) + + ". humans has one row per id — use GetByIdAsync."); + } + +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/InvasionServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/InvasionServiceTests.cs index 00b3dd3b..718aafc5 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/InvasionServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/InvasionServiceTests.cs @@ -17,12 +17,18 @@ public class InvasionServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly InvasionService _sut; public InvasionServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new InvasionService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance); + this._sut = new InvasionService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); + // The natural-key replace strategy reads the original row and frees the key first. + this._proxy.Setup(p => p.GetByUserAsync("invasion", It.IsAny())) + .ReturnsAsync(JsonSerializer.SerializeToElement(Array.Empty())); + this._proxy.Setup(p => p.DeleteByUidAsync("invasion", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); } [Fact] @@ -63,16 +69,18 @@ public async Task CreateAsyncSetsUserId() this._proxy.Setup(p => p.CreateAsync("invasion", "user1", It.IsAny())) .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); - var result = await this._sut.CreateAsync("user1", new Invasion()); + var result = await this._sut.CreateAsync("user1", new Invasion { GruntType = "fire" }); Assert.Equal("user1", result.Id); } [Fact] public async Task UpdateAsyncDelegates() { - var i = new Invasion { Uid = 1 }; + var i = new Invasion { Uid = 1, GruntType = "fire" }; + // A real replace frees the natural key then inserts, so insert=1. A response of insert=0 now + // means PoracleNG merged into a DIFFERENT alarm, which is a conflict rather than success (#462). this._proxy.Setup(p => p.CreateAsync("invasion", "user1", It.IsAny())) - .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + .ReturnsAsync(new TrackingCreateResult([2], 0, 0, 1)); await this._sut.UpdateAsync("user1", i); this._proxy.Verify(p => p.CreateAsync("invasion", "user1", It.IsAny()), Times.Once); @@ -134,25 +142,29 @@ public async Task UpdateDistanceByUserAsyncCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }, new { uid = 3, id = "u", - distance = 0 + distance = 0, + template = "ZZrow3" }, new { uid = 4, id = "u", - distance = 0 + distance = 0, + template = "ZZrow4" }); this._proxy.Setup(p => p.GetByUserAsync("invasion", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("invasion", "u", It.IsAny())) @@ -170,26 +182,45 @@ public async Task CountByUserAsyncCount() Assert.Equal(12, await this._sut.CountByUserAsync("u", 1)); } - [Fact] - public async Task CreateAsyncNormalizesNullGruntType() + // These two used to assert that a missing grunt type is coalesced to "". That was the bug: + // PoracleNG rejects an empty grunt_type with 400 "Grunt type mandatory" and has no catch-all, + // so the coalesce guaranteed a failure that reached the user as a generic 500. See #416. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task CreateAsyncRejectsMissingGruntTypeInsteadOfSendingItUpstream(string? gruntType) { this._proxy.Setup(p => p.CreateAsync("invasion", "u1", It.IsAny())) .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); - var model = new Invasion { GruntType = null }; - var result = await this._sut.CreateAsync("u1", model); - Assert.Equal("", result.GruntType); + await Assert.ThrowsAsync( + () => this._sut.CreateAsync("u1", new Invasion { GruntType = gruntType })); + + this._proxy.Verify(p => p.CreateAsync("invasion", "u1", It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task UpdateAsyncRejectsMissingGruntType(string? gruntType) + { + await Assert.ThrowsAsync( + () => this._sut.UpdateAsync("u1", new Invasion { Uid = 1, GruntType = gruntType })); } [Fact] - public async Task UpdateAsyncNormalizesNullGruntType() + public async Task BulkCreateAsyncRejectsMissingGruntTypeWithoutPartiallyCreating() { - this._proxy.Setup(p => p.CreateAsync("invasion", "u1", It.IsAny())) - .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + var models = new List + { + new() { GruntType = "fire" }, + new() { GruntType = null }, + }; - var model = new Invasion { Uid = 1, GruntType = null }; - var result = await this._sut.UpdateAsync("u1", model); - Assert.Equal("", result.GruntType); + await Assert.ThrowsAsync(() => this._sut.BulkCreateAsync("u1", models)); + + this._proxy.Verify(p => p.CreateAsync("invasion", "u1", It.IsAny()), Times.Never); } [Fact] @@ -199,7 +230,7 @@ public async Task BulkCreateAsyncSetsUserIds() { new() { GruntType = "mixed" }, new() { GruntType = "dark" }, - new() { GruntType = null }, + new() { GruntType = "giovanni" }, }; this._proxy.Setup(p => p.CreateAsync("invasion", "u1", It.IsAny())) .ReturnsAsync(new TrackingCreateResult([10, 11, 12], 0, 0, 3)); @@ -213,63 +244,6 @@ public async Task BulkCreateAsyncSetsUserIds() Assert.Equal(12, results[2].Uid); } - [Fact] - public async Task UpdateAsyncDeletesStaleUidWhenNaturalKeyChanges() - { - // PoracleNG dedups invasions by (grunt_type, gender); changing either triggers an insert - // with a new uid. The service must delete the old uid to avoid a stale duplicate row. - this._proxy.Setup(p => p.CreateAsync("invasion", "u1", It.IsAny())) - .ReturnsAsync(new TrackingCreateResult([999], 0, 0, 1)); - - var model = new Invasion { Uid = 497, GruntType = "water", Gender = 2 }; - var result = await this._sut.UpdateAsync("u1", model); - - this._proxy.Verify(p => p.DeleteByUidAsync("invasion", "u1", 497), Times.Once); - Assert.Equal(999, result.Uid); - } - - [Fact] - public async Task UpdateAsyncDoesNotDeleteWhenProxyReportsInPlaceUpdate() - { - this._proxy.Setup(p => p.CreateAsync("invasion", "u1", It.IsAny())) - .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); - - var model = new Invasion { Uid = 497, GruntType = "water", Gender = 1 }; - await this._sut.UpdateAsync("u1", model); - - this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - } - - [Theory] - [InlineData(typeof(HttpRequestException))] - [InlineData(typeof(TaskCanceledException))] - public async Task UpdateAsyncSwallowsStaleDeleteFailureAndLogsWarning(Type exceptionType) - { - // Network failure or timeout during the cleanup delete must not fail the update — - // the new row is already correct; log at Warning so the stale dup is discoverable. - var logger = new Mock>(); - logger.Setup(l => l.IsEnabled(It.IsAny())).Returns(true); - var sut = new InvasionService(this._proxy.Object, this._featureGate.Object, logger.Object); - var ex = (Exception)Activator.CreateInstance(exceptionType, "proxy unavailable")!; - - this._proxy.Setup(p => p.CreateAsync("invasion", "u1", It.IsAny())) - .ReturnsAsync(new TrackingCreateResult([999], 0, 0, 1)); - this._proxy.Setup(p => p.DeleteByUidAsync("invasion", "u1", 497)).ThrowsAsync(ex); - - var model = new Invasion { Uid = 497, GruntType = "water", Gender = 2 }; - var result = await sut.UpdateAsync("u1", model); - - Assert.Equal(999, result.Uid); - logger.Verify( - l => l.Log( - LogLevel.Warning, - It.IsAny(), - It.IsAny(), - ex, - It.IsAny>()), - Times.Once); - } - [Fact] public async Task CreateAsyncThrowsFeatureDisabledExceptionWhenGated() { @@ -303,4 +277,92 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Natural-key replace (#401) --- + // PoracleNG guards invasion with a unique natural key and its create has no upsert path, so editing a field + // OUTSIDE that key made it INSERT, collide (Error 1062) and return 500 while discarding the edit. + + [Fact] + public async Task UpdateAsyncDeletesTheOldRowBeforeRecreatingIt() + { + var model = new Invasion { Uid = 41, GruntType = "grunt" }; + this._proxy.Setup(p => p.GetByUserAsync("invasion", "user1")).ReturnsAsync(ItemsJson(41)); + this._proxy.Setup(p => p.DeleteByUidAsync("invasion", "user1", 41)).Returns(Task.CompletedTask); + this._proxy.Setup(p => p.CreateAsync("invasion", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("invasion", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncRestoresTheOriginalWhenRecreatingFails() + { + var model = new Invasion { Uid = 41, GruntType = "grunt" }; + this._proxy.Setup(p => p.GetByUserAsync("invasion", "user1")).ReturnsAsync(ItemsJson(41)); + this._proxy.Setup(p => p.DeleteByUidAsync("invasion", "user1", 41)).Returns(Task.CompletedTask); + this._proxy.SetupSequence(p => p.CreateAsync("invasion", "user1", It.IsAny())) + .ThrowsAsync(new HttpRequestException("upstream 500")) + .ReturnsAsync(new TrackingCreateResult([43], 0, 0, 1)); + + await Assert.ThrowsAsync(() => this._sut.UpdateAsync("user1", model)); + + // Two creates: the failed edit, then the restore of the original row. + this._proxy.Verify(p => p.CreateAsync("invasion", "user1", It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotDeleteAnything() + { + var model = new Invasion { Uid = 0, GruntType = "grunt" }; + this._proxy.Setup(p => p.CreateAsync("invasion", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([44], 0, 0, 1)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(44, result.Uid); + } + + private static JsonElement ItemsJson(int uid) => + JsonSerializer.SerializeToElement(new[] { new { uid, id = "user1" } }); + + + [Theory] + [InlineData("fi\u0000re")] + [InlineData("fire\u001bx")] + [InlineData("fire\n")] + public async Task CreateAsyncRefusesControlCharactersInGruntType(string gruntType) + { + var ex = await Assert.ThrowsAsync( + () => this._sut.CreateAsync("user1", new Invasion { GruntType = gruntType })); + + Assert.Contains("control characters", ex.Message, StringComparison.OrdinalIgnoreCase); + this._proxy.Verify(p => p.CreateAsync("invasion", It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateAsyncRefusesAGruntTypeLongerThanTheColumn() + { + // varchar(255) upstream. This asserted 35, an invented limit the fix's own rationale forbade. + // See #661. + var ex = await Assert.ThrowsAsync( + () => this._sut.CreateAsync("user1", new Invasion { GruntType = new string('a', 256) })); + + Assert.Contains("255", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task CreateAsyncAcceptsAGruntTypeTheColumnCanHold() + { + // The legitimate-case-still-passes half. See #661. + this._proxy.Setup(p => p.CreateAsync("invasion", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([12], 0, 0, 1)); + + var result = await this._sut.CreateAsync("user1", new Invasion { GruntType = new string('a', 255) }); + + Assert.Equal(12, result.Uid); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/KojiServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/KojiServiceTests.cs new file mode 100644 index 00000000..5db4c82d --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/KojiServiceTests.cs @@ -0,0 +1,94 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class KojiServiceTests +{ + private const string ApiAddress = "http://localhost:8080"; + + private static IConfiguration CreateConfig() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Koji:ApiAddress"] = ApiAddress, + ["Koji:ProjectId"] = "5", + ["Koji:ProjectName"] = "PoracleJS" + }) + .Build(); + + private static KojiService CreateSut(CapturingHandler handler) => + new(new HttpClient(handler), CreateConfig(), new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance); + + private static readonly double[][] s_polygon = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]; + + // Koji resolves __parent as a geofence id. parentId 0 (a region-less geofence, issue #314) must be + // serialized as JSON null, otherwise Koji returns HTTP 500 "[GEOFENCE]: Does not exist". + [Fact] + public async Task SaveGeofenceAsyncSendsNullParentWhenParentIdIsZero() + { + var handler = new CapturingHandler(); + var sut = CreateSut(handler); + + await sut.SaveGeofenceAsync("downtown", "Downtown", string.Empty, parentId: 0, polygon: s_polygon, isPublic: true); + + var parent = GetParentProperty(handler.LastBody!); + Assert.Equal(JsonValueKind.Null, parent.ValueKind); + } + + [Fact] + public async Task SaveGeofenceAsyncSendsNullParentWhenParentIdNegative() + { + var handler = new CapturingHandler(); + var sut = CreateSut(handler); + + await sut.SaveGeofenceAsync("downtown", "Downtown", string.Empty, parentId: -1, polygon: s_polygon); + + var parent = GetParentProperty(handler.LastBody!); + Assert.Equal(JsonValueKind.Null, parent.ValueKind); + } + + [Fact] + public async Task SaveGeofenceAsyncSendsNumericParentWhenParentIdPositive() + { + var handler = new CapturingHandler(); + var sut = CreateSut(handler); + + await sut.SaveGeofenceAsync("downtown", "Downtown", "City", parentId: 42, polygon: s_polygon, isPublic: true); + + var parent = GetParentProperty(handler.LastBody!); + Assert.Equal(JsonValueKind.Number, parent.ValueKind); + Assert.Equal(42, parent.GetInt32()); + } + + private static JsonElement GetParentProperty(string body) + { + using var doc = JsonDocument.Parse(body); + var properties = doc.RootElement + .GetProperty("area") + .GetProperty("features")[0] + .GetProperty("properties"); + return properties.GetProperty("__parent").Clone(); + } + + private sealed class CapturingHandler : HttpMessageHandler + { + public string? LastBody + { + get; private set; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"status":"ok"}""", Encoding.UTF8, "application/json") + }; + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/LureServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/LureServiceTests.cs index e7900cc3..e0c5a31c 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/LureServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/LureServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using Moq; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -15,12 +16,18 @@ public class LureServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly LureService _sut; public LureServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new LureService(this._proxy.Object, this._featureGate.Object); + this._sut = new LureService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); + // The natural-key replace strategy reads the original row and frees the key first. + this._proxy.Setup(p => p.GetByUserAsync("lure", It.IsAny())) + .ReturnsAsync(JsonSerializer.SerializeToElement(Array.Empty())); + this._proxy.Setup(p => p.DeleteByUidAsync("lure", It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); } [Fact] @@ -105,13 +112,15 @@ public async Task UpdateDistanceByUserAsyncCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }); this._proxy.Setup(p => p.GetByUserAsync("lure", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("lure", "u", It.IsAny())) @@ -182,4 +191,77 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Duplicate-on-edit --- + // PoracleNG dedups lure tracking by a natural key. When an edit changes a field in that key it INSERTS + // instead of upserting, leaving the pre-edit row behind as a second live alarm firing the old filter. + + + // --- Natural-key replace (#401) --- + // PoracleNG guards lure with a unique natural key and its create has no upsert path, so editing a field + // OUTSIDE that key made it INSERT, collide (Error 1062) and return 500 while discarding the edit. + + [Fact] + public async Task UpdateAsyncDeletesTheOldRowBeforeRecreatingIt() + { + var model = new Lure { Uid = 41, LureId = 501 }; + this._proxy.Setup(p => p.GetByUserAsync("lure", "user1")).ReturnsAsync(ItemsJson(41)); + this._proxy.Setup(p => p.DeleteByUidAsync("lure", "user1", 41)).Returns(Task.CompletedTask); + this._proxy.Setup(p => p.CreateAsync("lure", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("lure", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncRestoresTheOriginalWhenRecreatingFails() + { + var model = new Lure { Uid = 41, LureId = 501 }; + this._proxy.Setup(p => p.GetByUserAsync("lure", "user1")).ReturnsAsync(ItemsJson(41)); + this._proxy.Setup(p => p.DeleteByUidAsync("lure", "user1", 41)).Returns(Task.CompletedTask); + this._proxy.SetupSequence(p => p.CreateAsync("lure", "user1", It.IsAny())) + .ThrowsAsync(new HttpRequestException("upstream 500")) + .ReturnsAsync(new TrackingCreateResult([43], 0, 0, 1)); + + await Assert.ThrowsAsync(() => this._sut.UpdateAsync("user1", model)); + + // Two creates: the failed edit, then the restore of the original row. + this._proxy.Verify(p => p.CreateAsync("lure", "user1", It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotDeleteAnything() + { + var model = new Lure { Uid = 0, LureId = 501 }; + this._proxy.Setup(p => p.CreateAsync("lure", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([44], 0, 0, 1)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(44, result.Uid); + } + + private static JsonElement ItemsJson(int uid) => + JsonSerializer.SerializeToElement(new[] { new { uid, id = "user1" } }); + + + // A rotated uid orphans any quick pick that created the alarm. See #403. + [Fact] + public async Task UpdateAsyncRepointsQuickPickTrackedUidAtTheReplacementRow() + { + this._proxy.Setup(p => p.GetByUserAsync("lure", "user1")) + .ReturnsAsync(CreateJsonArray(new { uid = 239, id = "user1", lure_id = 501, distance = 500 })); + this._proxy.Setup(p => p.DeleteByUidAsync("lure", "user1", 239)).Returns(Task.CompletedTask); + this._proxy.Setup(p => p.CreateAsync("lure", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([240], 0, 0, 1)); + + var result = await this._sut.UpdateAsync("user1", new Lure { Uid = 239, LureId = 501, Distance = 600 }); + + Assert.Equal(240, result.Uid); + this._uidRemapper.Verify(r => r.RemapAsync("user1", "lure", 239, 240), Times.Once); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/MasterDataServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/MasterDataServiceTests.cs index aa9c3dcf..9b54b9d7 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/MasterDataServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/MasterDataServiceTests.cs @@ -89,6 +89,60 @@ public async Task GetItemDataAsyncReturnsCachedDataAfterSuccessfulFetch() Assert.Contains("Poke Ball", result); } + [Fact] + public async Task GetMoveDataAsyncReturnsNullWhenCacheEmptyAndFetchFails() + { + this._httpClientFactory.Setup(f => f.CreateClient(It.IsAny())) + .Returns(new HttpClient(new FailingHandler())); + + var result = await this._sut.GetMoveDataAsync(); + + Assert.Null(result); + } + + /// + /// Masterfile moves are { "13": { "name": "Wrap", "type": "Normal" } }; only the name is kept, + /// so the cached shape matches the id-to-name map the Max Battle list consumes. + /// + [Fact] + public async Task GetMoveDataAsyncReturnsIdToNameMapAfterSuccessfulFetch() + { + var masterJson = /*lang=json,strict*/ """ + { + "monsters": {}, + "items": {}, + "moves": { + "13": { "name": "Wrap", "type": "Normal" }, + "14": { "name": "Hyper Beam", "type": "Normal" } + } + } + """; + + this._httpClientFactory.Setup(f => f.CreateClient(It.IsAny())) + .Returns(new HttpClient(new FakeHandler(masterJson))); + + var result = await this._sut.GetMoveDataAsync(); + + Assert.NotNull(result); + var map = System.Text.Json.JsonSerializer.Deserialize>(result); + Assert.NotNull(map); + Assert.Equal("Wrap", map["13"]); + Assert.Equal("Hyper Beam", map["14"]); + } + + [Fact] + public async Task GetMoveDataAsyncReturnsEmptyMapWhenMasterfileHasNoMoves() + { + var masterJson = /*lang=json,strict*/ """{"monsters":{},"items":{}}"""; + + this._httpClientFactory.Setup(f => f.CreateClient(It.IsAny())) + .Returns(new HttpClient(new FakeHandler(masterJson))); + + var result = await this._sut.GetMoveDataAsync(); + + Assert.Equal("{}", result); + } + [Fact] public async Task RefreshCacheAsyncHandlesExceptionGracefully() { diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/MaxBattleServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/MaxBattleServiceTests.cs index 4568b05a..7efc1041 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/MaxBattleServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/MaxBattleServiceTests.cs @@ -16,12 +16,13 @@ public class MaxBattleServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly MaxBattleService _sut; public MaxBattleServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new MaxBattleService(this._proxy.Object, this._featureGate.Object, Mock.Of>()); + this._sut = new MaxBattleService(this._proxy.Object, this._featureGate.Object, Mock.Of>(), this._uidRemapper.Object); } [Fact] @@ -72,10 +73,57 @@ public async Task GetByUidAsyncReturnsNullWhenNotFound() Assert.Null(await this._sut.GetByUidAsync("user1", 999)); } + /// + /// Max battles are the one type PoracleNG does not dedup, so pressing Add twice stacked identical + /// alarms forever and the user got two of every notification. See #521. + /// + [Fact] + public async Task CreateAsyncRefusesAnExactDuplicate() + { + this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "user1")).ReturnsAsync(CreateJsonArray(new + { + uid = 5, + id = "user1", + pokemon_id = 150, + distance = 500, + level = 3, + })); + + await Assert.ThrowsAsync( + () => this._sut.CreateAsync("user1", new MaxBattle { PokemonId = 150, Distance = 500, Level = 3 })); + + this._proxy.Verify( + p => p.CreateAsync("maxbattle", "user1", It.IsAny()), Times.Never); + } + + /// + /// Upstream has no key that would merge two alarms differing by radius, so refusing those would block + /// something that genuinely works. + /// + [Fact] + public async Task CreateAsyncStillAllowsTheSameBossAtADifferentRadius() + { + this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "user1")).ReturnsAsync(CreateJsonArray(new + { + uid = 5, + id = "user1", + pokemon_id = 150, + distance = 500, + level = 3, + })); + this._proxy.Setup(p => p.CreateAsync("maxbattle", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([6], 0, 0, 1)); + + var result = await this._sut.CreateAsync("user1", new MaxBattle { PokemonId = 150, Distance = 900, Level = 3 }); + + Assert.Equal(6, result.Uid); + } + [Fact] public async Task CreateAsyncSetsUserId() { var maxBattle = new MaxBattle { PokemonId = 9000, Gmax = 1, StationId = "station123", Level = 3 }; + this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "user1")).ReturnsAsync(CreateJsonArray()); this._proxy.Setup(p => p.CreateAsync("maxbattle", "user1", It.IsAny())) .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); @@ -87,6 +135,7 @@ public async Task CreateAsyncSetsUserId() [Fact] public async Task UpdateAsyncUsesDeleteThenCreate() { + this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "user1")).ReturnsAsync(CreateJsonArray()); var maxBattle = new MaxBattle { Uid = 1, PokemonId = 9000, Gmax = 1 }; var callOrder = new List(); @@ -157,13 +206,15 @@ public async Task UpdateDistanceByUserAsyncReturnsCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }); this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.BulkDeleteByUidsAsync("maxbattle", "u", It.IsAny>())) @@ -182,19 +233,22 @@ public async Task UpdateDistanceByUidsAsyncUpdatesMatchingOnly() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }, new { uid = 3, id = "u", - distance = 0 + distance = 0, + template = "ZZrow3" }); this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.BulkDeleteByUidsAsync("maxbattle", "u", It.IsAny>())) @@ -322,4 +376,20 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // MaxBattle is insert-only upstream, so every edit rotates the uid and orphans any quick pick + // that created the alarm. See #403. + [Fact] + public async Task UpdateAsyncRepointsQuickPickTrackedUidAtTheReplacementRow() + { + this._proxy.Setup(p => p.GetByUserAsync("maxbattle", "user1")).ReturnsAsync(CreateJsonArray()); + this._proxy.Setup(p => p.DeleteByUidAsync("maxbattle", "user1", 82)).Returns(Task.CompletedTask); + this._proxy.Setup(p => p.CreateAsync("maxbattle", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([83], 0, 0, 1)); + + var result = await this._sut.UpdateAsync("user1", new MaxBattle { Uid = 82 }); + + Assert.Equal(83, result.Uid); + this._uidRemapper.Verify(r => r.RemapAsync("user1", "maxbattle", 82, 83), Times.Once); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/NestServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/NestServiceTests.cs index d19f56a5..947cbefb 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/NestServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/NestServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using Moq; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -15,12 +16,13 @@ public class NestServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly NestService _sut; public NestServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new NestService(this._proxy.Object, this._featureGate.Object); + this._sut = new NestService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); } [Fact] @@ -115,19 +117,22 @@ public async Task UpdateDistanceByUserAsyncCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }, new { uid = 3, id = "u", - distance = 0 + distance = 0, + template = "ZZrow3" }); this._proxy.Setup(p => p.GetByUserAsync("nest", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("nest", "u", It.IsAny())) @@ -223,4 +228,63 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Duplicate-on-edit --- + // PoracleNG dedups nest tracking by a natural key. When an edit changes a field in that key it INSERTS + // instead of upserting, leaving the pre-edit row behind as a second live alarm firing the old filter. + + [Fact] + public async Task UpdateAsyncDeletesTheSupersededRowWhenPoracleNgInsertsInsteadOfUpdating() + { + var model = new Nest { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("nest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("nest", "user1", 41)).Returns(Task.CompletedTask); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("nest", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncKeepsTheUidAndDeletesNothingWhenPoracleNgUpserts() + { + var model = new Nest { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("nest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(41, result.Uid); + } + + [Fact] + public async Task UpdateAsyncStillSucceedsWhenDeletingTheSupersededRowFails() + { + var model = new Nest { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("nest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("nest", "user1", 41)) + .ThrowsAsync(new HttpRequestException("boom")); + + // The inserted row already carries the user's settings, so the edit must not fail. + var result = await this._sut.UpdateAsync("user1", model); + + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotAttemptAStaleDelete() + { + var model = new Nest { Uid = 0 }; + this._proxy.Setup(p => p.CreateAsync("nest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/OidcClientTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/OidcClientTests.cs new file mode 100644 index 00000000..c53bc053 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/OidcClientTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services.Oidc; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Provider-agnostic behavior of the OIDC HTTP client: the configurable token-endpoint auth method +/// and tolerance of optional / non-rotating refresh tokens and missing expires_in. +/// +public class OidcClientTests +{ + private sealed class RecordingHandler(string responseJson, HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + public string? CapturedBody + { + get; private set; + } + + public System.Net.Http.Headers.AuthenticationHeaderValue? CapturedAuth + { + get; private set; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.CapturedAuth = request.Headers.Authorization; + this.CapturedBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); + return new HttpResponseMessage(status) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json"), + }; + } + } + + private static OidcClient CreateSut(RecordingHandler handler, OidcSettings settings) => + new(new HttpClient(handler), Options.Create(settings), new Mock>().Object); + + private static OidcSettings BaseSettings(string authMethod) => new() + { + ClientId = "client-abc", + ClientSecret = "secret-xyz", + TokenUrl = "https://idp.example/token", + UserInfoUrl = "https://idp.example/userinfo", + TokenEndpointAuthMethod = authMethod, + UsePkce = false, + }; + + [Fact] + public async Task ClientSecretPost_PutsCredentialsInBody_NoBasicHeader() + { + var handler = new RecordingHandler("""{"access_token":"at","refresh_token":"rt","expires_in":1800}"""); + var sut = CreateSut(handler, BaseSettings("client_secret_post")); + + var result = await sut.ExchangeCodeAsync("code", "https://rp/cb", null); + + Assert.NotNull(result); + Assert.Null(handler.CapturedAuth); + Assert.Contains("client_id=client-abc", handler.CapturedBody); + Assert.Contains("client_secret=secret-xyz", handler.CapturedBody); + } + + [Fact] + public async Task ClientSecretBasic_SetsBasicHeader_OmitsSecretFromBody() + { + var handler = new RecordingHandler("""{"access_token":"at","refresh_token":"rt","expires_in":1800}"""); + var sut = CreateSut(handler, BaseSettings("client_secret_basic")); + + var result = await sut.RefreshAsync("old-rt"); + + Assert.NotNull(result); + Assert.NotNull(handler.CapturedAuth); + Assert.Equal("Basic", handler.CapturedAuth!.Scheme); + var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes("client-abc:secret-xyz")); + Assert.Equal(expected, handler.CapturedAuth.Parameter); + Assert.DoesNotContain("client_secret=", handler.CapturedBody); + Assert.Contains("client_id=client-abc", handler.CapturedBody); + } + + [Fact] + public async Task NonRotatingProvider_ReturnsNullRefreshToken() + { + var handler = new RecordingHandler("""{"access_token":"at","expires_in":1800}"""); + var sut = CreateSut(handler, BaseSettings("client_secret_post")); + + var result = await sut.RefreshAsync("old-rt"); + + Assert.NotNull(result); + Assert.Equal("at", result!.AccessToken); + Assert.Null(result.RefreshToken); + Assert.Equal(1800, result.ExpiresIn); + } + + [Fact] + public async Task MissingExpiresIn_ReturnsNullExpiry() + { + var handler = new RecordingHandler("""{"access_token":"at"}"""); + var sut = CreateSut(handler, BaseSettings("client_secret_post")); + + var result = await sut.ExchangeCodeAsync("code", "https://rp/cb", null); + + Assert.NotNull(result); + Assert.Null(result!.ExpiresIn); + } + + [Fact] + public async Task MissingAccessToken_ReturnsNull() + { + var handler = new RecordingHandler("""{"refresh_token":"rt"}"""); + var sut = CreateSut(handler, BaseSettings("client_secret_post")); + + var result = await sut.ExchangeCodeAsync("code", "https://rp/cb", null); + + Assert.Null(result); + } + + [Fact] + public async Task ErrorResponse_ReturnsNull() + { + var handler = new RecordingHandler("""{"error":"invalid_grant"}""", HttpStatusCode.BadRequest); + var sut = CreateSut(handler, BaseSettings("client_secret_post")); + + var result = await sut.RefreshAsync("old-rt"); + + Assert.Null(result); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/OidcSessionServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/OidcSessionServiceTests.cs new file mode 100644 index 00000000..7a67786e --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/OidcSessionServiceTests.cs @@ -0,0 +1,184 @@ +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services.Oidc; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Security-core tests for the OIDC refresh-session mechanics: opaque-token hashing, encrypted +/// storage of the provider refresh token, atomic rotation, and replay/cap family revocation. +/// +public class OidcSessionServiceTests +{ + private const string Purpose = "Pgan.PoracleWebNet.OidcRefresh.v1"; + + private readonly Mock _repo = new(); + private readonly EphemeralDataProtectionProvider _dpProvider = new(); + private readonly OidcSettings _settings = new() { UseRefreshTokens = true, RefreshTokenLifetimeDays = 30, AccessTokenMinutes = 30 }; + + private OidcSessionService CreateSut() => new( + this._repo.Object, + this._dpProvider, + Options.Create(this._settings), + new Mock>().Object); + + private string Protect(string value) => this._dpProvider.CreateProtector(Purpose).Protect(value); + + [Fact] + public async Task IssueAsync_StoresHashAndEncryptedToken_AndReturnsOpaque() + { + OidcSession? captured = null; + this._repo.Setup(r => r.AddAsync(It.IsAny())) + .Callback(s => captured = s) + .Returns(Task.CompletedTask); + + var sut = this.CreateSut(); + var opaque = await sut.IssueAsync("user-1", "idp-refresh-token", "1.2.3.4", "agent"); + + Assert.False(string.IsNullOrWhiteSpace(opaque)); + Assert.NotNull(captured); + // The raw opaque token is never stored — only its hash. + Assert.NotEqual(opaque, captured!.SessionTokenHash); + Assert.Equal(64, captured.SessionTokenHash.Length); // SHA-256 hex + // The provider refresh token is encrypted, not stored in clear. + Assert.NotEqual("idp-refresh-token", captured.EncryptedRefreshToken); + Assert.Equal("idp-refresh-token", this._dpProvider.CreateProtector(Purpose).Unprotect(captured.EncryptedRefreshToken)); + Assert.Equal("user-1", captured.UserId); + Assert.Null(captured.RevokedAt); + } + + [Fact] + public async Task StartRotationAsync_HappyPath_RevokesPresentedAndReturnsDecryptedToken() + { + var session = new OidcSession + { + SessionTokenHash = "hash", + FamilyId = "fam-1", + FamilyIssuedAt = DateTime.UtcNow.AddDays(-1), + UserId = "user-1", + EncryptedRefreshToken = this.Protect("idp-rt"), + ExpiresAt = DateTime.UtcNow.AddDays(29), + }; + this._repo.Setup(r => r.GetByHashAsync(It.IsAny())).ReturnsAsync(session); + this._repo.Setup(r => r.TryRevokeForRotationAsync(It.IsAny(), It.IsAny())).ReturnsAsync(1); + + var sut = this.CreateSut(); + var ticket = await sut.StartRotationAsync("opaque", null, null); + + Assert.Equal("idp-rt", ticket.DecryptedRefreshToken); + Assert.Equal("fam-1", ticket.FamilyId); + Assert.False(string.IsNullOrWhiteSpace(ticket.NewOpaqueToken)); + Assert.Equal(64, ticket.NewTokenHash.Length); + this._repo.Verify(r => r.TryRevokeForRotationAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StartRotationAsync_ReplayedToken_RevokesFamilyAndThrows() + { + var session = new OidcSession + { + FamilyId = "fam-1", + UserId = "user-1", + EncryptedRefreshToken = this.Protect("idp-rt"), + FamilyIssuedAt = DateTime.UtcNow.AddDays(-1), + ExpiresAt = DateTime.UtcNow.AddDays(29), + RevokedAt = DateTime.UtcNow.AddMinutes(-5), // already revoked ⇒ replay + }; + this._repo.Setup(r => r.GetByHashAsync(It.IsAny())).ReturnsAsync(session); + + var sut = this.CreateSut(); + await Assert.ThrowsAsync(() => sut.StartRotationAsync("opaque", null, null)); + this._repo.Verify(r => r.RevokeFamilyAsync("fam-1", "replay_detected"), Times.Once); + } + + [Fact] + public async Task StartRotationAsync_PastAbsoluteCap_RevokesFamilyAndThrows() + { + var session = new OidcSession + { + FamilyId = "fam-1", + UserId = "user-1", + EncryptedRefreshToken = this.Protect("idp-rt"), + FamilyIssuedAt = DateTime.UtcNow.AddDays(-31), // beyond the 30-day cap + ExpiresAt = DateTime.UtcNow.AddDays(1), + }; + this._repo.Setup(r => r.GetByHashAsync(It.IsAny())).ReturnsAsync(session); + + var sut = this.CreateSut(); + await Assert.ThrowsAsync(() => sut.StartRotationAsync("opaque", null, null)); + this._repo.Verify(r => r.RevokeFamilyAsync("fam-1", "absolute_cap"), Times.Once); + } + + [Fact] + public async Task StartRotationAsync_ConcurrentRotation_TreatedAsReplay() + { + var session = new OidcSession + { + FamilyId = "fam-1", + UserId = "user-1", + EncryptedRefreshToken = this.Protect("idp-rt"), + FamilyIssuedAt = DateTime.UtcNow.AddDays(-1), + ExpiresAt = DateTime.UtcNow.AddDays(29), + }; + this._repo.Setup(r => r.GetByHashAsync(It.IsAny())).ReturnsAsync(session); + this._repo.Setup(r => r.TryRevokeForRotationAsync(It.IsAny(), It.IsAny())).ReturnsAsync(0); // lost the race + + var sut = this.CreateSut(); + await Assert.ThrowsAsync(() => sut.StartRotationAsync("opaque", null, null)); + this._repo.Verify(r => r.RevokeFamilyAsync("fam-1", "replay_detected"), Times.Once); + } + + [Fact] + public async Task StartRotationAsync_UnknownToken_Throws() + { + this._repo.Setup(r => r.GetByHashAsync(It.IsAny())).ReturnsAsync((OidcSession?)null); + var sut = this.CreateSut(); + await Assert.ThrowsAsync(() => sut.StartRotationAsync("opaque", null, null)); + this._repo.Verify(r => r.RevokeFamilyAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CompleteRotationAsync_InsertsSuccessorWithCarriedToken() + { + OidcSession? captured = null; + this._repo.Setup(r => r.AddAsync(It.IsAny())) + .Callback(s => captured = s) + .Returns(Task.CompletedTask); + + var ticket = new OidcRotationTicket + { + UserId = "user-1", + FamilyId = "fam-1", + FamilyIssuedAt = DateTime.UtcNow.AddDays(-2), + DecryptedRefreshToken = "old", + NewOpaqueToken = "new-opaque", + NewTokenHash = "new-hash", + }; + + var sut = this.CreateSut(); + await sut.CompleteRotationAsync(ticket, "rotated-idp-rt"); + + Assert.NotNull(captured); + Assert.Equal("new-hash", captured!.SessionTokenHash); + Assert.Equal("fam-1", captured.FamilyId); + Assert.Equal("rotated-idp-rt", this._dpProvider.CreateProtector(Purpose).Unprotect(captured.EncryptedRefreshToken)); + // Successor expires at the family's absolute cap (fixed window). + Assert.Equal(ticket.FamilyIssuedAt.AddDays(30), captured.ExpiresAt, TimeSpan.FromSeconds(2)); + } + + [Fact] + public async Task RevokeAsync_RevokesFamilyForKnownToken() + { + var session = new OidcSession { FamilyId = "fam-9" }; + this._repo.Setup(r => r.GetByHashAsync(It.IsAny())).ReturnsAsync(session); + + var sut = this.CreateSut(); + await sut.RevokeAsync("opaque", "logout"); + this._repo.Verify(r => r.RevokeFamilyAsync("fam-9", "logout"), Times.Once); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PokemonFilterRoundTripTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PokemonFilterRoundTripTests.cs new file mode 100644 index 00000000..b7828978 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PokemonFilterRoundTripTests.cs @@ -0,0 +1,148 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Mappings; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// The mega-evolution and time-remaining filters, from the JSON the browser sends to the row PoracleNG +/// stores and back. +/// +/// +/// Deliberately bound from JSON rather than constructed, because that is where the evolution selector +/// was lost: the property existed in the Angular request and in no C# model, so model binding dropped it +/// silently and the specs that "covered" it only ever checked what the component put in the request. +/// Validation attributes live on the *Create DTO, so a test that builds a +/// validates nothing (#548, #555, #565). +/// +public class PokemonFilterRoundTripTests +{ + private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); + + private readonly Mock _proxy = new(); + private readonly Mock _featureGate = new(); + private JsonElement _sent; + + public PokemonFilterRoundTripTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._proxy + .Setup(p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, body) => this._sent = body.Clone()) + .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); + } + + /// The body the PVP tab posts for "Great league, rank 1-100, Mega X, 5 minutes left". + private const string BrowserBody = + """ + { + "pokemonId": 6, + "distance": 0, + "pvpRankingLeague": 1500, + "pvpRankingBest": 1, + "pvpRankingWorst": 100, + "pvpRankingEvolution": 2, + "minTime": 300 + } + """; + + private async Task WriteAsync(MonsterCreate create) + { + await new MonsterService(this._proxy.Object, this._featureGate.Object).CreateAsync("u1", create.ToMonster()); + + return this._sent.ValueKind == JsonValueKind.Array ? this._sent.EnumerateArray().First() : this._sent; + } + + [Fact] + public async Task TheBrowsersEvolutionChoiceReachesPoracleNg() + { + var create = JsonSerializer.Deserialize(BrowserBody, Web)!; + + Assert.Equal(2, create.PvpRankingEvolution); + Assert.Equal(2, (await this.WriteAsync(create)).GetProperty("pvp_ranking_evolution").GetInt32()); + } + + [Fact] + public async Task TheBrowsersTimeRemainingReachesPoracleNg() + { + var create = JsonSerializer.Deserialize(BrowserBody, Web)!; + + Assert.Equal(300, create.MinTime); + Assert.Equal(300, (await this.WriteAsync(create)).GetProperty("min_time").GetInt32()); + } + + [Fact] + public async Task ARuleWithNeitherFilterStillWritesTheDefaults() + { + // The legitimate-case half. Both fields default to "no filter", and PoracleNG stores what it is + // sent, so a plain rule must not arrive carrying someone's leftover mega or a 5-minute floor. + var row = await this.WriteAsync(new MonsterCreate { PokemonId = 201 }); + + Assert.Equal(0, row.GetProperty("pvp_ranking_evolution").GetInt32()); + Assert.Equal(0, row.GetProperty("min_time").GetInt32()); + } + + [Fact] + public async Task AStoredRuleReadsBackWithBothFilters() + { + // Without this the card cannot say what the rule does, which is how the selector looked like it + // worked: the value was dropped on the way out as well as on the way in. + this._proxy + .Setup(p => p.GetByUserAsync("pokemon", "u1")) + .ReturnsAsync(JsonDocument.Parse( + """[{"uid":7,"pokemon_id":6,"pvp_ranking_evolution":3,"min_time":600}]""").RootElement.Clone()); + + var monster = await new MonsterService(this._proxy.Object, this._featureGate.Object).GetByUidAsync("u1", 7); + + Assert.Equal(3, monster!.PvpRankingEvolution); + Assert.Equal(600, monster.MinTime); + } + + [Fact] + public void AnEditThatSaysNothingAboutThemLeavesThemAlone() + { + var existing = new Monster { Uid = 7, PokemonId = 6, PvpRankingEvolution = 2, MinTime = 300 }; + + new MonsterUpdate { Distance = 1000 }.ApplyUpdate(existing); + + Assert.Equal(2, existing.PvpRankingEvolution); + Assert.Equal(300, existing.MinTime); + } + + [Fact] + public void AnEditThatChangesThemChangesThem() + { + var existing = new Monster { Uid = 7, PokemonId = 6, PvpRankingEvolution = 2, MinTime = 300 }; + + new MonsterUpdate { PvpRankingEvolution = 0, MinTime = 0 }.ApplyUpdate(existing); + + Assert.Equal(0, existing.PvpRankingEvolution); + Assert.Equal(0, existing.MinTime); + } + + [Theory] + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(2, true)] + [InlineData(3, true)] + [InlineData(4, false)] + [InlineData(-1, false)] + public void EvolutionAcceptsOnlyTheFourFormsPoracleNgRanks(int value, bool accepted) => + Assert.Equal(accepted, Validate(new MonsterCreate(), nameof(MonsterCreate.PvpRankingEvolution), value)); + + [Theory] + [InlineData(0, true)] + [InlineData(300, true)] + [InlineData(3600, true)] + [InlineData(3601, false)] + [InlineData(-1, false)] + public void TimeRemainingIsCappedAtASpawnsLongestPossibleLife(int value, bool accepted) => + Assert.Equal(accepted, Validate(new MonsterCreate(), nameof(MonsterCreate.MinTime), value)); + + private static bool Validate(object instance, string member, object? value) => + Validator.TryValidateProperty(value, new ValidationContext(instance) { MemberName = member }, []); +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyDisableFlagTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyDisableFlagTests.cs new file mode 100644 index 00000000..f43170e0 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyDisableFlagTests.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Configuration; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Parsing of the two upstream disable signals: the disabledHooks array on +/// /api/config/poracleWeb, and general.disable_fort_update on +/// /api/config/values. See #769. +/// +public class PoracleApiProxyDisableFlagTests +{ + private const string ApiAddress = "http://localhost:3030"; + + private static PoracleApiProxy CreateSut(MockHttpMessageHandler handler) => new( + new HttpClient(handler), + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = "test-secret" + }) + .Build()); + + /// The body prod actually returns, trimmed to the field under test. + [Fact] + public async Task EmptyDisabledHooksParsesAsAnEmptyListNotNull() + { + var sut = CreateSut(new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"disabledHooks":[]}""")); + + var config = await sut.GetConfigAsync(); + + // Empty and absent must stay distinguishable: empty means "upstream disables nothing", + // absent means "upstream has no opinion". Only the caller gets to collapse them. + Assert.NotNull(config?.DisabledHooks); + Assert.Empty(config.DisabledHooks); + } + + [Fact] + public async Task DisabledHooksEntriesAreParsed() + { + var sut = CreateSut(new MockHttpMessageHandler( + HttpStatusCode.OK, /*lang=json,strict*/ """{"disabledHooks":["raid","quest","pokestop"]}""")); + + var config = await sut.GetConfigAsync(); + + Assert.Equal(["raid", "quest", "pokestop"], config?.DisabledHooks); + } + + [Fact] + public async Task AbsentDisabledHooksLeavesTheListNull() + { + var sut = CreateSut(new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"locale":"en"}""")); + + var config = await sut.GetConfigAsync(); + + Assert.NotNull(config); + Assert.Null(config.DisabledHooks); + } + + [Fact] + public async Task NonArrayDisabledHooksLeavesTheListNull() + { + var sut = CreateSut(new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"disabledHooks":null}""")); + + Assert.Null((await sut.GetConfigAsync())?.DisabledHooks); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task FortUpdateDisabledIsReadFromTheGeneralSection(bool disabled) + { + var body = """{"values":{"general":{"disable_fort_update":VALUE}}}""" + .Replace("VALUE", disabled ? "true" : "false", StringComparison.Ordinal); + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, body); + var sut = CreateSut(handler); + + Assert.Equal(disabled, await sut.GetFortUpdateDisabledAsync()); + Assert.Equal($"{ApiAddress}/api/config/values", handler.LastRequest?.RequestUri?.ToString()); + } + + /// + /// PoracleJS and older PoracleNG builds do not carry the key. Null means "cannot determine", + /// which the caller must not read as "disabled". + /// + [Fact] + public async Task AbsentFortUpdateFlagReturnsNull() + { + var sut = CreateSut(new MockHttpMessageHandler( + HttpStatusCode.OK, /*lang=json,strict*/ """{"values":{"general":{}}}""")); + + Assert.Null(await sut.GetFortUpdateDisabledAsync()); + } + + [Fact] + public async Task QuestSummaryFlagStillReadsFromTheTrackingSection() + { + // The two config-values reads share one helper; this is the sibling that already existed. + var sut = CreateSut(new MockHttpMessageHandler( + HttpStatusCode.OK, /*lang=json,strict*/ """{"values":{"tracking":{"quest_summary_enabled":true}}}""")); + + Assert.True(await sut.GetQuestSummaryEnabledAsync()); + } + + private sealed class MockHttpMessageHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest + { + get; private set; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequest = request; + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyGruntsTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyGruntsTests.cs new file mode 100644 index 00000000..4e577515 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleApiProxyGruntsTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Text; +using Microsoft.Extensions.Configuration; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// GetGruntsAsync requested /api/config/grunts, a path neither supported backend serves. +/// Every call 404'd, EnsureSuccessStatusCode() threw, and GET /api/masterdata/grunts +/// returned 500 to anonymous callers — while the controller's own "Grunt data not available" branch +/// sat unreachable. See #419. +/// +public class PoracleApiProxyGruntsTests +{ + private const string ApiAddress = "http://localhost:3030"; + + private static PoracleApiProxy CreateSut(MockHttpMessageHandler handler) => new( + new HttpClient(handler), + new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = "test-secret" + }) + .Build()); + + [Fact] + public async Task GetGruntsAsyncRequestsTheMasterdataPath() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"10":{"type":"Dark"}}"""); + var sut = CreateSut(handler); + + await sut.GetGruntsAsync(); + + Assert.Equal($"{ApiAddress}/api/masterdata/grunts", handler.LastRequest?.RequestUri?.ToString()); + } + + [Fact] + public async Task GetGruntsAsyncReturnsTheBodyOn200() + { + const string body = /*lang=json,strict*/ """{"10":{"type":"Dark","gender":2}}"""; + var sut = CreateSut(new MockHttpMessageHandler(HttpStatusCode.OK, body)); + + Assert.Equal(body, await sut.GetGruntsAsync()); + } + + [Theory] + [InlineData(HttpStatusCode.NotFound)] + [InlineData(HttpStatusCode.Unauthorized)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task GetGruntsAsyncReturnsNullInsteadOfThrowing(HttpStatusCode status) + { + // The old EnsureSuccessStatusCode() turned every upstream failure into a 500 from our own API + // and made the controller's 404 branch dead code. + var sut = CreateSut(new MockHttpMessageHandler(status, "{}")); + + Assert.Null(await sut.GetGruntsAsync()); + } + + private sealed class MockHttpMessageHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest + { + get; private set; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequest = request; + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleHumanProxyTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleHumanProxyTests.cs index f51493a1..9d1a9636 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleHumanProxyTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleHumanProxyTests.cs @@ -1,583 +1,585 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Configuration; -using Pgan.PoracleWebNet.Core.Services; - -namespace Pgan.PoracleWebNet.Tests.Services; - -public class PoracleHumanProxyTests -{ - private const string ApiAddress = "http://localhost:3030"; - private const string ApiSecret = "test-secret"; - - private static IConfiguration CreateConfig() => new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Poracle:ApiAddress"] = ApiAddress, - ["Poracle:ApiSecret"] = ApiSecret - }) - .Build(); - - private static IConfiguration CreateConfigNoSecret() => new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Poracle:ApiAddress"] = ApiAddress, - ["Poracle:ApiSecret"] = "" - }) - .Build(); - - private static PoracleHumanProxy CreateSut(MockHttpMessageHandler handler, IConfiguration? config = null) - { - var client = new HttpClient(handler); - return new PoracleHumanProxy(client, config ?? CreateConfig()); - } - - // ────────────────────────────────────────────────────────────── - // GetHumanAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task GetHumanAsyncReturnsJsonOn200() - { - var responseBody = /*lang=json,strict*/ """{"id":"user1","name":"TestUser","enabled":1}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.GetHumanAsync("user1"); - - Assert.NotNull(result); - Assert.Equal("user1", result.Value.GetProperty("id").GetString()); - } - - [Fact] - public async Task GetHumanAsyncReturnsNullOn404() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); - var sut = CreateSut(handler); - - var result = await sut.GetHumanAsync("nonexistent"); - - Assert.Null(result); - } - - [Fact] - public async Task GetHumanAsyncReturnsNullOnOtherErrors() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - var result = await sut.GetHumanAsync("user1"); - - Assert.Null(result); - } - - [Fact] - public async Task GetHumanAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"id":"u1"}"""); - var sut = CreateSut(handler); - - await sut.GetHumanAsync("user42"); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans/one/user42", handler.LastRequest.RequestUri?.ToString()); - } - - // ────────────────────────────────────────────────────────────── - // CreateHumanAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task CreateHumanAsyncSendsPostWithBody() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("""{"id":"newuser","name":"New"}""").RootElement; - await sut.CreateHumanAsync(body); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans", handler.LastRequest.RequestUri?.ToString()); - - var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); - Assert.Contains("newuser", sentBody); - } - - [Fact] - public async Task CreateHumanAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.Conflict, "{}"); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("{}").RootElement; - await Assert.ThrowsAsync(() => sut.CreateHumanAsync(body)); - } - - // ────────────────────────────────────────────────────────────── - // StartAsync / StopAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task StartAsyncCallsCorrectEndpoint() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.StartAsync("user1"); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans/user1/start", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task StartAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.StartAsync("user1")); - } - - [Fact] - public async Task StopAsyncCallsCorrectEndpoint() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.StopAsync("user1"); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans/user1/stop", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task StopAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.StopAsync("user1")); - } - - // ────────────────────────────────────────────────────────────── - // AdminDisabledAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task AdminDisabledAsyncSendsDisableBody() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.AdminDisabledAsync("user1", true); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans/user1/adminDisabled", handler.LastRequest.RequestUri?.ToString()); - - var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); - Assert.Contains("\"adminDisable\":1", sentBody); - } - - [Fact] - public async Task AdminDisabledAsyncSendsEnableBody() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.AdminDisabledAsync("user1", false); - - var sentBody = await handler.LastRequest!.Content!.ReadAsStringAsync(); - Assert.Contains("\"adminDisable\":0", sentBody); - } - - [Fact] - public async Task AdminDisabledAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.AdminDisabledAsync("user1", true)); - } - - // ────────────────────────────────────────────────────────────── - // SetLocationAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task SetLocationAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.SetLocationAsync("user1", 40.7128, -74.006); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Contains("/api/humans/user1/setLocation/40.7128/-74.006", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task SetLocationAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.SetLocationAsync("user1", 0, 0)); - } - - // ────────────────────────────────────────────────────────────── - // SetAreasAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task SetAreasAsyncSendsAreaArray() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.SetAreasAsync("user1", ["downtown", "west end"]); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans/user1/setAreas", handler.LastRequest.RequestUri?.ToString()); - - var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); - Assert.Contains("downtown", sentBody); - Assert.Contains("west end", sentBody); - } - - [Fact] - public async Task SetAreasAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.SetAreasAsync("user1", ["area1"])); - } - - // ────────────────────────────────────────────────────────────── - // GetAreasAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task GetAreasAsyncReturnsJsonOn200() - { - var responseBody = /*lang=json,strict*/ """{"area":["downtown","west end"]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.GetAreasAsync("user1"); - - Assert.NotNull(result); - Assert.True(result.Value.TryGetProperty("area", out var areas)); - Assert.Equal(2, areas.GetArrayLength()); - } - - [Fact] - public async Task GetAreasAsyncReturnsNullOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); - var sut = CreateSut(handler); - - var result = await sut.GetAreasAsync("user1"); - - Assert.Null(result); - } - - [Fact] - public async Task GetAreasAsyncCallsCorrectUrl() - { - // GetAreasAsync delegates to GetHumanAsync which calls /api/humans/one/{id} - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"human":{"id":"user1","area":"[]"}}"""); - var sut = CreateSut(handler); - - await sut.GetAreasAsync("user1"); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans/one/user1", handler.LastRequest.RequestUri?.ToString()); - } - - // ────────────────────────────────────────────────────────────── - // SwitchProfileAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task SwitchProfileAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.SwitchProfileAsync("user1", 3); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/humans/user1/switchProfile/3", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task SwitchProfileAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.SwitchProfileAsync("user1", 1)); - } - - // ────────────────────────────────────────────────────────────── - // GetProfilesAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task GetProfilesAsyncReturnsJsonResponse() - { - var responseBody = /*lang=json,strict*/ """[{"profileNo":1,"name":"default"},{"profileNo":2,"name":"alt"}]"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.GetProfilesAsync("user1"); - - Assert.Equal(JsonValueKind.Array, result.ValueKind); - Assert.Equal(2, result.GetArrayLength()); - } - - [Fact] - public async Task GetProfilesAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "[]"); - var sut = CreateSut(handler); - - await sut.GetProfilesAsync("user1"); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/profiles/user1", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task GetProfilesAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.GetProfilesAsync("user1")); - } - - // ────────────────────────────────────────────────────────────── - // AddProfileAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task AddProfileAsyncCallsCorrectUrlWithBody() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("""{"name":"new profile"}""").RootElement; - await sut.AddProfileAsync("user1", body); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/profiles/user1/add", handler.LastRequest.RequestUri?.ToString()); - - var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); - Assert.Contains("new profile", sentBody); - } - - [Fact] - public async Task AddProfileAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("{}").RootElement; - await Assert.ThrowsAsync(() => sut.AddProfileAsync("user1", body)); - } - - // ────────────────────────────────────────────────────────────── - // UpdateProfileAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task UpdateProfileAsyncCallsCorrectUrlWithBody() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("""{"profileNo":2,"name":"renamed"}""").RootElement; - await sut.UpdateProfileAsync("user1", body); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/profiles/user1/update", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task UpdateProfileAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("{}").RootElement; - await Assert.ThrowsAsync(() => sut.UpdateProfileAsync("user1", body)); - } - - // ────────────────────────────────────────────────────────────── - // DeleteProfileAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task DeleteProfileAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.DeleteProfileAsync("user1", 2); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Delete, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/profiles/user1/byProfileNo/2", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task DeleteProfileAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.DeleteProfileAsync("user1", 1)); - } - - // ────────────────────────────────────────────────────────────── - // CheckLocationAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task CheckLocationAsyncReturnsJsonOn200() - { - var responseBody = /*lang=json,strict*/ """{"areas":["downtown"]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.CheckLocationAsync("user1", 40.7128, -74.006); - - Assert.NotNull(result); - Assert.True(result.Value.TryGetProperty("areas", out _)); - } - - [Fact] - public async Task CheckLocationAsyncReturnsNullOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); - var sut = CreateSut(handler); - - var result = await sut.CheckLocationAsync("user1", 0, 0); - - Assert.Null(result); - } - - [Fact] - public async Task CheckLocationAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.CheckLocationAsync("user1", 51.5, -0.12); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - Assert.Contains("/api/humans/user1/checkLocation/51.5/-0.12", handler.LastRequest.RequestUri?.ToString()); - } - - // ────────────────────────────────────────────────────────────── - // Auth header - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task AllRequestsIncludePoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"id":"u1"}"""); - var sut = CreateSut(handler); - - await sut.GetHumanAsync("user1"); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - Assert.Equal(ApiSecret, handler.LastRequest.Headers.GetValues("X-Poracle-Secret").Single()); - } - - [Fact] - public async Task AllRequestsOmitSecretHeaderWhenEmpty() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"id":"u1"}"""); - var sut = CreateSut(handler, CreateConfigNoSecret()); - - await sut.GetHumanAsync("user1"); - - Assert.NotNull(handler.LastRequest); - Assert.False(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - [Fact] - public async Task StartAsyncIncludesPoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.StartAsync("user1"); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - [Fact] - public async Task SetAreasAsyncIncludesPoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.SetAreasAsync("user1", ["area"]); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - [Fact] - public async Task DeleteProfileAsyncIncludesPoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.DeleteProfileAsync("user1", 1); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - // ────────────────────────────────────────────────────────────── - // Mock handler - // ────────────────────────────────────────────────────────────── - - private sealed class MockHttpMessageHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler - { - public HttpRequestMessage? LastRequest - { - get; private set; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - this.LastRequest = request; - return Task.FromResult(new HttpResponseMessage(statusCode) - { - Content = new StringContent(responseBody, Encoding.UTF8, "application/json") - }); - } - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class PoracleHumanProxyTests +{ + private const string ApiAddress = "http://localhost:3030"; + private const string ApiSecret = "test-secret"; + + private static IConfiguration CreateConfig() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = ApiSecret + }) + .Build(); + + private static IConfiguration CreateConfigNoSecret() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = "" + }) + .Build(); + + private static PoracleHumanProxy CreateSut(MockHttpMessageHandler handler, IConfiguration? config = null) + { + var client = new HttpClient(handler); + return new PoracleHumanProxy(client, config ?? CreateConfig()); + } + + // ────────────────────────────────────────────────────────────── + // GetHumanAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetHumanAsyncReturnsJsonOn200() + { + var responseBody = /*lang=json,strict*/ """{"id":"user1","name":"TestUser","enabled":1}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetHumanAsync("user1"); + + Assert.NotNull(result); + Assert.Equal("user1", result.Value.GetProperty("id").GetString()); + } + + [Fact] + public async Task GetHumanAsyncReturnsNullOn404() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); + var sut = CreateSut(handler); + + var result = await sut.GetHumanAsync("nonexistent"); + + Assert.Null(result); + } + + [Fact] + public async Task GetHumanAsyncReturnsNullOnOtherErrors() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + var result = await sut.GetHumanAsync("user1"); + + Assert.Null(result); + } + + [Fact] + public async Task GetHumanAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"id":"u1"}"""); + var sut = CreateSut(handler); + + await sut.GetHumanAsync("user42"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans/one/user42", handler.LastRequest.RequestUri?.ToString()); + } + + // ────────────────────────────────────────────────────────────── + // CreateHumanAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateHumanAsyncSendsPostWithBody() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("""{"id":"newuser","name":"New"}""").RootElement; + await sut.CreateHumanAsync(body); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans", handler.LastRequest.RequestUri?.ToString()); + + var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); + Assert.Contains("newuser", sentBody); + } + + [Fact] + public async Task CreateHumanAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.Conflict, "{}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + await Assert.ThrowsAsync(() => sut.CreateHumanAsync(body)); + } + + // ────────────────────────────────────────────────────────────── + // StartAsync / StopAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task StartAsyncCallsCorrectEndpoint() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.StartAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans/user1/start", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task StartAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.StartAsync("user1")); + } + + [Fact] + public async Task StopAsyncCallsCorrectEndpoint() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.StopAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans/user1/stop", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task StopAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.StopAsync("user1")); + } + + // ────────────────────────────────────────────────────────────── + // AdminDisabledAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task AdminDisabledAsyncSendsDisableBody() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.AdminDisabledAsync("user1", true); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans/user1/adminDisabled", handler.LastRequest.RequestUri?.ToString()); + + var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); + Assert.Contains("\"state\":true", sentBody); + Assert.DoesNotContain("adminDisable", sentBody); + } + + [Fact] + public async Task AdminDisabledAsyncSendsEnableBody() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.AdminDisabledAsync("user1", false); + + var sentBody = await handler.LastRequest!.Content!.ReadAsStringAsync(); + Assert.Contains("\"state\":false", sentBody); + Assert.DoesNotContain("adminDisable", sentBody); + } + + [Fact] + public async Task AdminDisabledAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.AdminDisabledAsync("user1", true)); + } + + // ────────────────────────────────────────────────────────────── + // SetLocationAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task SetLocationAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.SetLocationAsync("user1", 40.7128, -74.006); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Contains("/api/humans/user1/setLocation/40.7128/-74.006", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SetLocationAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.SetLocationAsync("user1", 0, 0)); + } + + // ────────────────────────────────────────────────────────────── + // SetAreasAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task SetAreasAsyncSendsAreaArray() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.SetAreasAsync("user1", ["downtown", "west end"]); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans/user1/setAreas", handler.LastRequest.RequestUri?.ToString()); + + var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); + Assert.Contains("downtown", sentBody); + Assert.Contains("west end", sentBody); + } + + [Fact] + public async Task SetAreasAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.SetAreasAsync("user1", ["area1"])); + } + + // ────────────────────────────────────────────────────────────── + // GetAreasAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetAreasAsyncReturnsJsonOn200() + { + var responseBody = /*lang=json,strict*/ """{"area":["downtown","west end"]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetAreasAsync("user1"); + + Assert.NotNull(result); + Assert.True(result.Value.TryGetProperty("area", out var areas)); + Assert.Equal(2, areas.GetArrayLength()); + } + + [Fact] + public async Task GetAreasAsyncReturnsNullOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); + var sut = CreateSut(handler); + + var result = await sut.GetAreasAsync("user1"); + + Assert.Null(result); + } + + [Fact] + public async Task GetAreasAsyncCallsCorrectUrl() + { + // GetAreasAsync delegates to GetHumanAsync which calls /api/humans/one/{id} + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"human":{"id":"user1","area":"[]"}}"""); + var sut = CreateSut(handler); + + await sut.GetAreasAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans/one/user1", handler.LastRequest.RequestUri?.ToString()); + } + + // ────────────────────────────────────────────────────────────── + // SwitchProfileAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task SwitchProfileAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.SwitchProfileAsync("user1", 3); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/humans/user1/switchProfile/3", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task SwitchProfileAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.SwitchProfileAsync("user1", 1)); + } + + // ────────────────────────────────────────────────────────────── + // GetProfilesAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetProfilesAsyncReturnsJsonResponse() + { + var responseBody = /*lang=json,strict*/ """[{"profileNo":1,"name":"default"},{"profileNo":2,"name":"alt"}]"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetProfilesAsync("user1"); + + Assert.Equal(JsonValueKind.Array, result.ValueKind); + Assert.Equal(2, result.GetArrayLength()); + } + + [Fact] + public async Task GetProfilesAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "[]"); + var sut = CreateSut(handler); + + await sut.GetProfilesAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/profiles/user1", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task GetProfilesAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.GetProfilesAsync("user1")); + } + + // ────────────────────────────────────────────────────────────── + // AddProfileAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task AddProfileAsyncCallsCorrectUrlWithBody() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("""{"name":"new profile"}""").RootElement; + await sut.AddProfileAsync("user1", body); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/profiles/user1/add", handler.LastRequest.RequestUri?.ToString()); + + var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); + Assert.Contains("new profile", sentBody); + } + + [Fact] + public async Task AddProfileAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + await Assert.ThrowsAsync(() => sut.AddProfileAsync("user1", body)); + } + + // ────────────────────────────────────────────────────────────── + // UpdateProfileAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task UpdateProfileAsyncCallsCorrectUrlWithBody() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("""{"profileNo":2,"name":"renamed"}""").RootElement; + await sut.UpdateProfileAsync("user1", body); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/profiles/user1/update", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task UpdateProfileAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + await Assert.ThrowsAsync(() => sut.UpdateProfileAsync("user1", body)); + } + + // ────────────────────────────────────────────────────────────── + // DeleteProfileAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteProfileAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.DeleteProfileAsync("user1", 2); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Delete, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/profiles/user1/byProfileNo/2", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task DeleteProfileAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.DeleteProfileAsync("user1", 1)); + } + + // ────────────────────────────────────────────────────────────── + // CheckLocationAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task CheckLocationAsyncReturnsJsonOn200() + { + var responseBody = /*lang=json,strict*/ """{"areas":["downtown"]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.CheckLocationAsync("user1", 40.7128, -74.006); + + Assert.NotNull(result); + Assert.True(result.Value.TryGetProperty("areas", out _)); + } + + [Fact] + public async Task CheckLocationAsyncReturnsNullOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); + var sut = CreateSut(handler); + + var result = await sut.CheckLocationAsync("user1", 0, 0); + + Assert.Null(result); + } + + [Fact] + public async Task CheckLocationAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.CheckLocationAsync("user1", 51.5, -0.12); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Contains("/api/humans/user1/checkLocation/51.5/-0.12", handler.LastRequest.RequestUri?.ToString()); + } + + // ────────────────────────────────────────────────────────────── + // Auth header + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task AllRequestsIncludePoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"id":"u1"}"""); + var sut = CreateSut(handler); + + await sut.GetHumanAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + Assert.Equal(ApiSecret, handler.LastRequest.Headers.GetValues("X-Poracle-Secret").Single()); + } + + [Fact] + public async Task AllRequestsOmitSecretHeaderWhenEmpty() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"id":"u1"}"""); + var sut = CreateSut(handler, CreateConfigNoSecret()); + + await sut.GetHumanAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.False(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task StartAsyncIncludesPoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.StartAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task SetAreasAsyncIncludesPoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.SetAreasAsync("user1", ["area"]); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task DeleteProfileAsyncIncludesPoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.DeleteProfileAsync("user1", 1); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + // ────────────────────────────────────────────────────────────── + // Mock handler + // ────────────────────────────────────────────────────────────── + + private sealed class MockHttpMessageHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest + { + get; private set; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequest = request; + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleServerProfileTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleServerProfileTests.cs new file mode 100644 index 00000000..f4fb8fa6 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleServerProfileTests.cs @@ -0,0 +1,235 @@ +using System.Net; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Reading which PoracleNG is on the other end. +/// +/// +/// The rule every one of these encodes: not knowing is not the same as supporting. A server that does +/// not answer, a version that will not parse, a schema that cannot be read — each unlocks nothing. The +/// opposite default would offer controls that write columns which do not exist, which is the silent +/// no-op this whole thing exists to make loud. +/// +public class PoracleServerProfileTests +{ + /// The real payload from production, 5.1.0. + private const string HealthyResponse = + """ + {"capabilities":{"buttons":true,"snapshots":true,"autocreate":true,"tomlDts":true, + "buttonResponseObject":true},"status":"healthy","version":"5.1.0"} + """; + + private readonly Mock _schema = new(); + + private PoracleServerProfileService Service( + HttpStatusCode status = HttpStatusCode.OK, + string body = HealthyResponse, + Exception? throws = null, + string apiAddress = "http://poracle:3030") + { + var handler = new Mock(); + var setup = handler.Protected() + .Setup>( + "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()); + + if (throws is not null) + { + setup.ThrowsAsync(throws); + } + else + { + // A fresh response per call: the service disposes what it reads, which is correct, and a + // single shared instance makes the second probe fail on a disposed stream rather than on + // anything real. + setup.ReturnsAsync(() => new HttpResponseMessage(status) { Content = new StringContent(body) }); + } + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary { ["Poracle:ApiAddress"] = apiAddress }) + .Build(); + + return new PoracleServerProfileService( + new HttpClient(handler.Object), + this._schema.Object, + new MemoryCache(new MemoryCacheOptions()), + configuration, + NullLogger.Instance); + } + + [Fact] + public async Task ReadsTheVersionAndTheWholeCapabilityMap() + { + this._schema.Setup(s => s.GetAppliedMigrationAsync(It.IsAny())).ReturnsAsync(5L); + + var profile = await this.Service().GetAsync(); + + Assert.True(profile.Reachable); + Assert.Equal("5.1.0", profile.Version); + Assert.Equal(5L, profile.SchemaVersion); + Assert.Equal(5, profile.Capabilities.Count); + Assert.True(profile.Supports("buttons")); + } + + [Fact] + public async Task KeepsACapabilityItHasNeverHeardOf() + { + // derivedDtsTypes exists on PoracleNG's develop branch and in no release. Reading the map into a + // fixed set of known keys would drop whatever lands next, which is the opposite of the point. + var profile = await this.Service(body: """{"version":"5.2.0","capabilities":{"derivedDtsTypes":true}}""").GetAsync(); + + Assert.True(profile.Supports("derivedDtsTypes")); + } + + [Fact] + public async Task ACapabilityNobodyMentionedIsOff() + { + // PoracleNG's own contract for the map: clients default-false on a missing key. + var profile = await this.Service().GetAsync(); + + Assert.False(profile.Supports("derivedDtsTypes")); + Assert.False(profile.Supports("somethingInvented")); + } + + [Fact] + public async Task AServerThatDoesNotAnswerSupportsNothing() + { + var profile = await this.Service(throws: new HttpRequestException("connection refused")).GetAsync(); + + Assert.False(profile.Reachable); + Assert.Null(profile.Version); + Assert.False(profile.Supports("buttons")); + Assert.False(profile.IsBelowMinimum); // unknown, not old + } + + [Fact] + public async Task AnUnconfiguredAddressIsNotProbed() + { + var profile = await this.Service(apiAddress: "").GetAsync(); + + Assert.False(profile.Reachable); + this._schema.Verify(s => s.GetAppliedMigrationAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task TheSchemaIsStillReadWhenTheProcessIsDown() + { + // A stopped PoracleNG leaves its migrated database behind, and that still says which columns + // exist. Losing it would turn a restart into "every gated feature disappears". + this._schema.Setup(s => s.GetAppliedMigrationAsync(It.IsAny())).ReturnsAsync(8L); + + var profile = await this.Service(throws: new HttpRequestException("down")).GetAsync(); + + Assert.Equal(8L, profile.SchemaVersion); + } + + [Fact] + public async Task AnUnreadableSchemaUnlocksNothing() + { + this._schema.Setup(s => s.GetAppliedMigrationAsync(It.IsAny())).ReturnsAsync((long?)null); + + var profile = await this.Service().GetAsync(); + + Assert.Null(profile.SchemaVersion); + Assert.False(profile.HasSchema(6)); + } + + [Fact] + public async Task TheProbeHappensOncePerCacheWindow() + { + var service = this.Service(); + + await service.GetAsync(); + await service.GetAsync(); + + this._schema.Verify(s => s.GetAppliedMigrationAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task InvalidatingForcesAFreshRead() + { + var service = this.Service(); + + await service.GetAsync(); + service.Invalidate(); + await service.GetAsync(); + + this._schema.Verify(s => s.GetAppliedMigrationAsync(It.IsAny()), Times.Exactly(2)); + } + + [Theory] + [InlineData("5.1.0", false)] + [InlineData("5.2.0", false)] + [InlineData("6.0.0", false)] + [InlineData("5.0.9", true)] + [InlineData("4.9.9", true)] + public void OnlyAVersionKnownToBeOlderCountsAsTooOld(string version, bool tooOld) + { + var profile = new PoracleServerProfile { Version = version, Reachable = true }; + + Assert.Equal(tooOld, profile.IsBelowMinimum); + } + + [Theory] + [InlineData("0.0.0")] // ldflags not injected: a local build, not an ancient one + [InlineData("")] + [InlineData("dev")] + [InlineData(null)] + public void AVersionThatSaysNothingIsNotTreatedAsOld(string? version) + { + // Shouting "upgrade PoracleNG" at someone running a local build would teach them to ignore the + // banner, and the banner has one job. + var profile = new PoracleServerProfile { Version = version, Reachable = true }; + + Assert.Null(profile.ParsedVersion); + Assert.False(profile.IsBelowMinimum); + } + + [Theory] + [InlineData("5.2.0-rc1", 5, 2, 0)] + [InlineData("5.1.0", 5, 1, 0)] + public void ASuffixedVersionStillParses(string raw, int major, int minor, int build) + { + var profile = new PoracleServerProfile { Version = raw, Reachable = true }; + + Assert.Equal(new System.Version(major, minor, build), profile.ParsedVersion); + } + + [Theory] + [InlineData(5, 5, true)] + [InlineData(8, 6, true)] + [InlineData(5, 6, false)] + public void SchemaComparisonsAreInclusive(long applied, long required, bool satisfied) + { + var profile = new PoracleServerProfile { SchemaVersion = applied }; + + Assert.Equal(satisfied, profile.HasSchema(required)); + } + + [Fact] + public async Task GarbageInsteadOfHealthIsTreatedAsNoAnswer() + { + var profile = await this.Service(body: "502 Bad Gateway").GetAsync(); + + Assert.False(profile.Reachable); + } + + [Fact] + public async Task AHealthPayloadWithNoCapabilitiesStillGivesTheVersion() + { + // What an older PoracleNG answers: the map arrived with 5.1.0. + var profile = await this.Service(body: """{"status":"healthy","version":"5.0.4"}""").GetAsync(); + + Assert.Equal("5.0.4", profile.Version); + Assert.Empty(profile.Capabilities); + Assert.True(profile.IsBelowMinimum); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleSummaryProxyTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleSummaryProxyTests.cs new file mode 100644 index 00000000..df8db1b7 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleSummaryProxyTests.cs @@ -0,0 +1,345 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class PoracleSummaryProxyTests +{ + private const string ApiAddress = "http://localhost:3030"; + private const string ApiSecret = "test-secret"; + + private static IConfiguration CreateConfig() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = ApiSecret + }) + .Build(); + + private static IConfiguration CreateConfigNoSecret() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = "" + }) + .Build(); + + private static PoracleSummaryProxy CreateSut(MockHttpMessageHandler handler, IConfiguration? config = null) + { + var client = new HttpClient(handler); + return new PoracleSummaryProxy(client, config ?? CreateConfig()); + } + + // ────────────────────────────────────────────────────────────── + // GetSchedulesAsync — unwraps { "schedules": [...] } + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetSchedulesAsyncUnwrapsSchedulesArrayOn200() + { + var responseBody = /*lang=json,strict*/ """{"status":"ok","schedules":[{"id":"user1","alert_type":"quest","active_hours":"[]"}]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetSchedulesAsync("user1"); + + Assert.NotNull(result); + Assert.Equal(JsonValueKind.Array, result.Value.ValueKind); + Assert.Equal(1, result.Value.GetArrayLength()); + Assert.Equal("quest", result.Value[0].GetProperty("alert_type").GetString()); + } + + [Fact] + public async Task GetSchedulesAsyncReturnsNullOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + var result = await sut.GetSchedulesAsync("user1"); + + Assert.Null(result); + } + + [Fact] + public async Task GetSchedulesAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"schedules":[]}"""); + var sut = CreateSut(handler); + + await sut.GetSchedulesAsync("user42"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/summaries/user42", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task GetSchedulesAsyncThrowsBackendUnavailableOn503() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.ServiceUnavailable, /*lang=json,strict*/ """{"status":"error","message":"store not constructed"}"""); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.GetSchedulesAsync("user1")); + } + + // ────────────────────────────────────────────────────────────── + // GetScheduleAsync — unwraps { "schedule": {...} }; 404 -> null + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetScheduleAsyncUnwrapsScheduleObjectOn200() + { + var responseBody = /*lang=json,strict*/ """{"status":"ok","schedule":{"id":"user1","alert_type":"quest","active_hours":"[{\"day\":1,\"hours\":9,\"mins\":0}]"}}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetScheduleAsync("user1", "quest"); + + Assert.NotNull(result); + Assert.Equal("quest", result.Value.GetProperty("alert_type").GetString()); + } + + [Fact] + public async Task GetScheduleAsyncReturnsNullOn404() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, /*lang=json,strict*/ """{"status":"error","message":"schedule not found"}"""); + var sut = CreateSut(handler); + + var result = await sut.GetScheduleAsync("user1", "quest"); + + Assert.Null(result); + } + + [Fact] + public async Task GetScheduleAsyncReturnsNullOnOtherNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + var result = await sut.GetScheduleAsync("user1", "quest"); + + Assert.Null(result); + } + + [Fact] + public async Task GetScheduleAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"schedule":{}}"""); + var sut = CreateSut(handler); + + await sut.GetScheduleAsync("user1", "quest"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/summaries/user1/quest", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task GetScheduleAsyncThrowsBackendUnavailableOn503() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.GetScheduleAsync("user1", "quest")); + } + + // ────────────────────────────────────────────────────────────── + // SetScheduleAsync — POST { "active_hours": }; upsert + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task SetScheduleAsyncSendsPostWithRawActiveHoursBody() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + var activeHours = /*lang=json,strict*/ "[{\"day\":1,\"hours\":9,\"mins\":0}]"; + await sut.SetScheduleAsync("user1", "quest", activeHours); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/summaries/user1/quest", handler.LastRequest.RequestUri?.ToString()); + + var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); + // Raw JSON array literal embedded directly — NOT snake_case re-serialized, NOT escaped as a string. + Assert.Equal(/*lang=json,strict*/ "{\"active_hours\":[{\"day\":1,\"hours\":9,\"mins\":0}]}", sentBody); + } + + [Fact] + public async Task SetScheduleAsyncEmptyOrWhitespaceCoercesToEmptyArrayLiteral() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.SetScheduleAsync("user1", "quest", " "); + + var sentBody = await handler.LastRequest!.Content!.ReadAsStringAsync(); + Assert.Equal(/*lang=json,strict*/ "{\"active_hours\":[]}", sentBody); + } + + [Fact] + public async Task SetScheduleAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.SetScheduleAsync("user1", "quest", "[]")); + } + + [Fact] + public async Task SetScheduleAsyncThrowsBackendUnavailableOn503() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.SetScheduleAsync("user1", "quest", "[]")); + } + + // ────────────────────────────────────────────────────────────── + // DeleteScheduleAsync — idempotent + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteScheduleAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.DeleteScheduleAsync("user1", "quest"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Delete, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/summaries/user1/quest", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task DeleteScheduleAsyncSucceedsOn200ForMissingSchedule() + { + // Upstream returns 200 ok for deleting a missing schedule (idempotent) — proxy must not throw. + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"status":"ok"}"""); + var sut = CreateSut(handler); + + await sut.DeleteScheduleAsync("user1", "quest"); + } + + [Fact] + public async Task DeleteScheduleAsyncThrowsBackendUnavailableOn503() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.DeleteScheduleAsync("user1", "quest")); + } + + // ────────────────────────────────────────────────────────────── + // TriggerAsync — flush-and-deliver-now + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task TriggerAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.TriggerAsync("user1", "quest"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/summaries/user1/quest/trigger", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task TriggerAsyncThrowsBackendUnavailableOn503() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.ServiceUnavailable, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.TriggerAsync("user1", "quest")); + } + + // ────────────────────────────────────────────────────────────── + // userId encoding (webhook-style ids contain slashes) + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetScheduleAsyncEncodesUserIdInPath() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"schedule":{}}"""); + var sut = CreateSut(handler); + + await sut.GetScheduleAsync("http://hook:1/abc", "quest"); + + Assert.NotNull(handler.LastRequest); + var url = handler.LastRequest.RequestUri?.ToString(); + Assert.NotNull(url); + // The raw userId slashes/colon must be percent-encoded so they don't become path segments. + Assert.DoesNotContain("/api/summaries/http://hook:1/abc/quest", url); + Assert.Contains("http%3A%2F%2Fhook%3A1%2Fabc", url); + } + + // ────────────────────────────────────────────────────────────── + // Auth header (X-Poracle-Secret) + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task RequestsIncludePoracleSecretHeaderWhenConfigured() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"schedules":[]}"""); + var sut = CreateSut(handler); + + await sut.GetSchedulesAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + Assert.Equal(ApiSecret, handler.LastRequest.Headers.GetValues("X-Poracle-Secret").Single()); + } + + [Fact] + public async Task RequestsOmitSecretHeaderWhenEmpty() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"schedules":[]}"""); + var sut = CreateSut(handler, CreateConfigNoSecret()); + + await sut.GetSchedulesAsync("user1"); + + Assert.NotNull(handler.LastRequest); + Assert.False(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task TriggerIncludesPoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.TriggerAsync("user1", "quest"); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + // ────────────────────────────────────────────────────────────── + // Mock handler + // ────────────────────────────────────────────────────────────── + + private sealed class MockHttpMessageHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest + { + get; private set; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequest = request; + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleTrackingProxyTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleTrackingProxyTests.cs index 44156cd1..77ea39a1 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleTrackingProxyTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/PoracleTrackingProxyTests.cs @@ -1,442 +1,463 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; -using Moq; -using Pgan.PoracleWebNet.Core.Services; - -namespace Pgan.PoracleWebNet.Tests.Services; - -public class PoracleTrackingProxyTests -{ - private const string ApiAddress = "http://localhost:3030"; - private const string ApiSecret = "test-secret"; - - private static IConfiguration CreateConfig() => new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Poracle:ApiAddress"] = ApiAddress, - ["Poracle:ApiSecret"] = ApiSecret - }) - .Build(); - - private static IConfiguration CreateConfigNoSecret() => new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - ["Poracle:ApiAddress"] = ApiAddress, - ["Poracle:ApiSecret"] = "" - }) - .Build(); - - private static PoracleTrackingProxy CreateSut(MockHttpMessageHandler handler, IConfiguration? config = null) - { - var client = new HttpClient(handler); - return new PoracleTrackingProxy( - client, - config ?? CreateConfig(), - Mock.Of>()); - } - - // ────────────────────────────────────────────────────────────── - // GetByUserAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task GetByUserAsyncExtractsArrayByTypeKey() - { - var responseBody = /*lang=json,strict*/ """{"pokemon":[{"uid":1},{"uid":2}]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.GetByUserAsync("pokemon", "user1"); - - Assert.Equal(JsonValueKind.Array, result.ValueKind); - Assert.Equal(2, result.GetArrayLength()); - Assert.Equal(1, result[0].GetProperty("uid").GetInt32()); - } - - [Fact] - public async Task GetByUserAsyncReturnsEmptyArrayWhenKeyMissing() - { - var responseBody = /*lang=json,strict*/ """{"other":[{"uid":1}]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.GetByUserAsync("pokemon", "user1"); - - Assert.Equal(JsonValueKind.Array, result.ValueKind); - Assert.Equal(0, result.GetArrayLength()); - } - - [Fact] - public async Task GetByUserAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"raid":[]}"""); - var sut = CreateSut(handler); - - await sut.GetByUserAsync("raid", "user42"); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/tracking/raid/user42", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task GetByUserAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.GetByUserAsync("pokemon", "user1")); - } - - [Theory] - [InlineData("pokemon", "pokemon")] - [InlineData("raid", "raid")] - [InlineData("egg", "egg")] - [InlineData("quest", "quest")] - [InlineData("invasion", "invasion")] - [InlineData("lure", "lure")] - [InlineData("nest", "nest")] - [InlineData("gym", "gym")] - [InlineData("fort", "fort")] - [InlineData("maxbattle", "maxbattle")] - [InlineData("unknown_type", "unknown_type")] - public async Task GetByUserAsyncResolvesCorrectResponseKey(string type, string expectedKey) - { - var responseBody = $$"""{"{{expectedKey}}":[{"uid":99}]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.GetByUserAsync(type, "user1"); - - Assert.Equal(1, result.GetArrayLength()); - } - - // ────────────────────────────────────────────────────────────── - // CreateAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task CreateAsyncSendsCorrectUrlWithSilentParam() - { - var responseBody = /*lang=json,strict*/ """{"newUids":[10,11],"alreadyPresent":0,"updates":0,"insert":2}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("""[{"pokemon_id":25}]""").RootElement; - await sut.CreateAsync("pokemon", "user1", body); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/tracking/pokemon/user1?silent=true", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task CreateAsyncSendsJsonBody() - { - var responseBody = /*lang=json,strict*/ """{"newUids":[],"alreadyPresent":1,"updates":0,"insert":0}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("""[{"pokemon_id":25,"min_iv":90}]""").RootElement; - await sut.CreateAsync("pokemon", "user1", body); - - Assert.NotNull(handler.LastRequest?.Content); - var sentBody = await handler.LastRequest.Content.ReadAsStringAsync(); - Assert.Contains("pokemon_id", sentBody); - } - - [Fact] - public async Task CreateAsyncParsesNewUids() - { - var responseBody = /*lang=json,strict*/ """{"newUids":[100,200,300],"alreadyPresent":1,"updates":2,"insert":3}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("{}").RootElement; - var result = await sut.CreateAsync("pokemon", "user1", body); - - Assert.Equal(3, result.NewUids.Count); - Assert.Equal(100L, result.NewUids[0]); - Assert.Equal(200L, result.NewUids[1]); - Assert.Equal(300L, result.NewUids[2]); - Assert.Equal(1, result.AlreadyPresent); - Assert.Equal(2, result.Updates); - Assert.Equal(3, result.Inserts); - } - - [Fact] - public async Task CreateAsyncHandlesResponseWithoutOptionalFields() - { - var responseBody = /*lang=json,strict*/ """{"newUids":[5]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("{}").RootElement; - var result = await sut.CreateAsync("pokemon", "user1", body); - - Assert.Single(result.NewUids); - Assert.Equal(5L, result.NewUids[0]); - Assert.Equal(0, result.AlreadyPresent); - Assert.Equal(0, result.Updates); - Assert.Equal(0, result.Inserts); - } - - [Fact] - public async Task CreateAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.BadRequest, "{}"); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("{}").RootElement; - await Assert.ThrowsAsync(() => sut.CreateAsync("pokemon", "user1", body)); - } - - // ────────────────────────────────────────────────────────────── - // DeleteByUidAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task DeleteByUidAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.DeleteByUidAsync("raid", "user1", 42); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Delete, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/tracking/raid/user1/byUid/42", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task DeleteByUidAsyncHandles404Gracefully() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); - var sut = CreateSut(handler); - - // Should not throw - await sut.DeleteByUidAsync("raid", "user1", 999); - } - - [Fact] - public async Task DeleteByUidAsyncThrowsOnOtherErrors() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.DeleteByUidAsync("raid", "user1", 42)); - } - - // ────────────────────────────────────────────────────────────── - // BulkDeleteByUidsAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task BulkDeleteByUidsAsyncSendsUidArray() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.BulkDeleteByUidsAsync("pokemon", "user1", [1, 2, 3]); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/tracking/pokemon/user1/delete", handler.LastRequest.RequestUri?.ToString()); - - var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); - var uids = JsonSerializer.Deserialize>(sentBody); - Assert.Equal(3, uids!.Count); - Assert.Contains(1L, uids); - Assert.Contains(2L, uids); - Assert.Contains(3L, uids); - } - - [Fact] - public async Task BulkDeleteByUidsAsyncSkipsRequestWhenEmpty() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.BulkDeleteByUidsAsync("pokemon", "user1", []); - - Assert.Null(handler.LastRequest); - } - - [Fact] - public async Task BulkDeleteByUidsAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.BulkDeleteByUidsAsync("pokemon", "user1", [1])); - } - - // ────────────────────────────────────────────────────────────── - // GetAllTrackingAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task GetAllTrackingAsyncReturnsFullResponse() - { - var responseBody = /*lang=json,strict*/ """{"pokemon":[{"uid":1}],"raid":[{"uid":2}]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var result = await sut.GetAllTrackingAsync("user1"); - - Assert.Equal(JsonValueKind.Object, result.ValueKind); - Assert.True(result.TryGetProperty("pokemon", out _)); - Assert.True(result.TryGetProperty("raid", out _)); - } - - [Fact] - public async Task GetAllTrackingAsyncCallsCorrectUrl() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.GetAllTrackingAsync("user99"); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/tracking/all/user99", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task GetAllTrackingAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(() => sut.GetAllTrackingAsync("user1")); - } - - // ────────────────────────────────────────────────────────────── - // ReloadStateAsync - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task ReloadStateAsyncCallsReloadEndpoint() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.ReloadStateAsync(); - - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); - Assert.Equal($"{ApiAddress}/api/reload", handler.LastRequest.RequestUri?.ToString()); - } - - [Fact] - public async Task ReloadStateAsyncThrowsOnNon2xx() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); - var sut = CreateSut(handler); - - await Assert.ThrowsAsync(sut.ReloadStateAsync); - } - - // ────────────────────────────────────────────────────────────── - // Auth header - // ────────────────────────────────────────────────────────────── - - [Fact] - public async Task AllRequestsIncludePoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"pokemon":[]}"""); - var sut = CreateSut(handler); - - await sut.GetByUserAsync("pokemon", "user1"); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - Assert.Equal(ApiSecret, handler.LastRequest.Headers.GetValues("X-Poracle-Secret").Single()); - } - - [Fact] - public async Task AllRequestsOmitSecretHeaderWhenEmpty() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"pokemon":[]}"""); - var sut = CreateSut(handler, CreateConfigNoSecret()); - - await sut.GetByUserAsync("pokemon", "user1"); - - Assert.NotNull(handler.LastRequest); - Assert.False(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - [Fact] - public async Task CreateAsyncIncludesPoracleSecretHeader() - { - var responseBody = /*lang=json,strict*/ """{"newUids":[1]}"""; - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); - var sut = CreateSut(handler); - - var body = JsonDocument.Parse("{}").RootElement; - await sut.CreateAsync("pokemon", "user1", body); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - [Fact] - public async Task DeleteByUidAsyncIncludesPoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.DeleteByUidAsync("pokemon", "user1", 1); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - [Fact] - public async Task BulkDeleteByUidsAsyncIncludesPoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.BulkDeleteByUidsAsync("pokemon", "user1", [1]); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - [Fact] - public async Task ReloadStateAsyncIncludesPoracleSecretHeader() - { - var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); - var sut = CreateSut(handler); - - await sut.ReloadStateAsync(); - - Assert.NotNull(handler.LastRequest); - Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); - } - - // ────────────────────────────────────────────────────────────── - // Mock handler - // ────────────────────────────────────────────────────────────── - - private sealed class MockHttpMessageHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler - { - public HttpRequestMessage? LastRequest - { - get; private set; - } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - this.LastRequest = request; - return Task.FromResult(new HttpResponseMessage(statusCode) - { - Content = new StringContent(responseBody, Encoding.UTF8, "application/json") - }); - } - } -} +using Pgan.PoracleWebNet.Core.Models; +using System.Net; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class PoracleTrackingProxyTests +{ + private const string ApiAddress = "http://localhost:3030"; + private const string ApiSecret = "test-secret"; + + private static IConfiguration CreateConfig() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = ApiSecret + }) + .Build(); + + private static IConfiguration CreateConfigNoSecret() => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Poracle:ApiAddress"] = ApiAddress, + ["Poracle:ApiSecret"] = "" + }) + .Build(); + + private static PoracleTrackingProxy CreateSut(MockHttpMessageHandler handler, IConfiguration? config = null) + { + var client = new HttpClient(handler); + return new PoracleTrackingProxy( + client, + config ?? CreateConfig(), + Mock.Of>()); + } + + // ────────────────────────────────────────────────────────────── + // GetByUserAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetByUserAsyncExtractsArrayByTypeKey() + { + var responseBody = /*lang=json,strict*/ """{"pokemon":[{"uid":1},{"uid":2}]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetByUserAsync("pokemon", "user1"); + + Assert.Equal(JsonValueKind.Array, result.ValueKind); + Assert.Equal(2, result.GetArrayLength()); + Assert.Equal(1, result[0].GetProperty("uid").GetInt32()); + } + + [Fact] + public async Task GetByUserAsyncReturnsEmptyArrayWhenKeyMissing() + { + var responseBody = /*lang=json,strict*/ """{"other":[{"uid":1}]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetByUserAsync("pokemon", "user1"); + + Assert.Equal(JsonValueKind.Array, result.ValueKind); + Assert.Equal(0, result.GetArrayLength()); + } + + [Fact] + public async Task GetByUserAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"raid":[]}"""); + var sut = CreateSut(handler); + + await sut.GetByUserAsync("raid", "user42"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/tracking/raid/user42", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task GetByUserAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.GetByUserAsync("pokemon", "user1")); + } + + [Theory] + [InlineData("pokemon", "pokemon")] + [InlineData("raid", "raid")] + [InlineData("egg", "egg")] + [InlineData("quest", "quest")] + [InlineData("invasion", "invasion")] + [InlineData("lure", "lure")] + [InlineData("nest", "nest")] + [InlineData("gym", "gym")] + [InlineData("fort", "fort")] + [InlineData("maxbattle", "maxbattle")] + [InlineData("unknown_type", "unknown_type")] + public async Task GetByUserAsyncResolvesCorrectResponseKey(string type, string expectedKey) + { + var responseBody = $$"""{"{{expectedKey}}":[{"uid":99}]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetByUserAsync(type, "user1"); + + Assert.Equal(1, result.GetArrayLength()); + } + + // ────────────────────────────────────────────────────────────── + // CreateAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task CreateAsyncSendsCorrectUrlWithSilentParam() + { + var responseBody = /*lang=json,strict*/ """{"newUids":[10,11],"alreadyPresent":0,"updates":0,"insert":2}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("""[{"pokemon_id":25}]""").RootElement; + await sut.CreateAsync("pokemon", "user1", body); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/tracking/pokemon/user1?silent=true", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task CreateAsyncSendsJsonBody() + { + var responseBody = /*lang=json,strict*/ """{"newUids":[],"alreadyPresent":1,"updates":0,"insert":0}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("""[{"pokemon_id":25,"min_iv":90}]""").RootElement; + await sut.CreateAsync("pokemon", "user1", body); + + Assert.NotNull(handler.LastRequest?.Content); + var sentBody = await handler.LastRequest.Content.ReadAsStringAsync(); + Assert.Contains("pokemon_id", sentBody); + } + + [Fact] + public async Task CreateAsyncParsesNewUids() + { + var responseBody = /*lang=json,strict*/ """{"newUids":[100,200,300],"alreadyPresent":1,"updates":2,"insert":3}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + var result = await sut.CreateAsync("pokemon", "user1", body); + + Assert.Equal(3, result.NewUids.Count); + Assert.Equal(100L, result.NewUids[0]); + Assert.Equal(200L, result.NewUids[1]); + Assert.Equal(300L, result.NewUids[2]); + Assert.Equal(1, result.AlreadyPresent); + Assert.Equal(2, result.Updates); + Assert.Equal(3, result.Inserts); + } + + [Fact] + public async Task CreateAsyncHandlesResponseWithoutOptionalFields() + { + var responseBody = /*lang=json,strict*/ """{"newUids":[5]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + var result = await sut.CreateAsync("pokemon", "user1", body); + + Assert.Single(result.NewUids); + Assert.Equal(5L, result.NewUids[0]); + Assert.Equal(0, result.AlreadyPresent); + Assert.Equal(0, result.Updates); + Assert.Equal(0, result.Inserts); + } + + [Fact] + public async Task CreateAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + await Assert.ThrowsAsync(() => sut.CreateAsync("pokemon", "user1", body)); + } + + /// + /// A 400 is the caller's problem. It used to throw HttpRequestException, which the global handler + /// flattened into 500 "An unexpected error occurred", so the user was told the server broke instead of + /// what was wrong with their input. See #539. + /// + [Fact] + public async Task CreateAsyncSurfacesPoracleNgsOwnExplanationForABadRequest() + { + var handler = new MockHttpMessageHandler( + HttpStatusCode.BadRequest, + /*lang=json,strict*/ "{\"message\":\"Invalid level (must be specified if no pokemon_id)\"}"); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + + var ex = await Assert.ThrowsAsync( + () => sut.CreateAsync("raid", "user1", body)); + Assert.Contains("Invalid level", ex.Message, StringComparison.Ordinal); + } + + // ────────────────────────────────────────────────────────────── + // DeleteByUidAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteByUidAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.DeleteByUidAsync("raid", "user1", 42); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Delete, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/tracking/raid/user1/byUid/42", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task DeleteByUidAsyncHandles404Gracefully() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.NotFound, "{}"); + var sut = CreateSut(handler); + + // Should not throw + await sut.DeleteByUidAsync("raid", "user1", 999); + } + + [Fact] + public async Task DeleteByUidAsyncThrowsOnOtherErrors() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.DeleteByUidAsync("raid", "user1", 42)); + } + + // ────────────────────────────────────────────────────────────── + // BulkDeleteByUidsAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task BulkDeleteByUidsAsyncSendsUidArray() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.BulkDeleteByUidsAsync("pokemon", "user1", [1, 2, 3]); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/tracking/pokemon/user1/delete", handler.LastRequest.RequestUri?.ToString()); + + var sentBody = await handler.LastRequest.Content!.ReadAsStringAsync(); + var uids = JsonSerializer.Deserialize>(sentBody); + Assert.Equal(3, uids!.Count); + Assert.Contains(1L, uids); + Assert.Contains(2L, uids); + Assert.Contains(3L, uids); + } + + [Fact] + public async Task BulkDeleteByUidsAsyncSkipsRequestWhenEmpty() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.BulkDeleteByUidsAsync("pokemon", "user1", []); + + Assert.Null(handler.LastRequest); + } + + [Fact] + public async Task BulkDeleteByUidsAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.BulkDeleteByUidsAsync("pokemon", "user1", [1])); + } + + // ────────────────────────────────────────────────────────────── + // GetAllTrackingAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task GetAllTrackingAsyncReturnsFullResponse() + { + var responseBody = /*lang=json,strict*/ """{"pokemon":[{"uid":1}],"raid":[{"uid":2}]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var result = await sut.GetAllTrackingAsync("user1"); + + Assert.Equal(JsonValueKind.Object, result.ValueKind); + Assert.True(result.TryGetProperty("pokemon", out _)); + Assert.True(result.TryGetProperty("raid", out _)); + } + + [Fact] + public async Task GetAllTrackingAsyncCallsCorrectUrl() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.GetAllTrackingAsync("user99"); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/tracking/all/user99", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task GetAllTrackingAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(() => sut.GetAllTrackingAsync("user1")); + } + + // ────────────────────────────────────────────────────────────── + // ReloadStateAsync + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task ReloadStateAsyncCallsReloadEndpoint() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.ReloadStateAsync(); + + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Get, handler.LastRequest.Method); + Assert.Equal($"{ApiAddress}/api/reload", handler.LastRequest.RequestUri?.ToString()); + } + + [Fact] + public async Task ReloadStateAsyncThrowsOnNon2xx() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.InternalServerError, "{}"); + var sut = CreateSut(handler); + + await Assert.ThrowsAsync(sut.ReloadStateAsync); + } + + // ────────────────────────────────────────────────────────────── + // Auth header + // ────────────────────────────────────────────────────────────── + + [Fact] + public async Task AllRequestsIncludePoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"pokemon":[]}"""); + var sut = CreateSut(handler); + + await sut.GetByUserAsync("pokemon", "user1"); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + Assert.Equal(ApiSecret, handler.LastRequest.Headers.GetValues("X-Poracle-Secret").Single()); + } + + [Fact] + public async Task AllRequestsOmitSecretHeaderWhenEmpty() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, /*lang=json,strict*/ """{"pokemon":[]}"""); + var sut = CreateSut(handler, CreateConfigNoSecret()); + + await sut.GetByUserAsync("pokemon", "user1"); + + Assert.NotNull(handler.LastRequest); + Assert.False(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task CreateAsyncIncludesPoracleSecretHeader() + { + var responseBody = /*lang=json,strict*/ """{"newUids":[1]}"""; + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, responseBody); + var sut = CreateSut(handler); + + var body = JsonDocument.Parse("{}").RootElement; + await sut.CreateAsync("pokemon", "user1", body); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task DeleteByUidAsyncIncludesPoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.DeleteByUidAsync("pokemon", "user1", 1); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task BulkDeleteByUidsAsyncIncludesPoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.BulkDeleteByUidsAsync("pokemon", "user1", [1]); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + [Fact] + public async Task ReloadStateAsyncIncludesPoracleSecretHeader() + { + var handler = new MockHttpMessageHandler(HttpStatusCode.OK, "{}"); + var sut = CreateSut(handler); + + await sut.ReloadStateAsync(); + + Assert.NotNull(handler.LastRequest); + Assert.True(handler.LastRequest.Headers.Contains("X-Poracle-Secret")); + } + + // ────────────────────────────────────────────────────────────── + // Mock handler + // ────────────────────────────────────────────────────────────── + + private sealed class MockHttpMessageHandler(HttpStatusCode statusCode, string responseBody) : HttpMessageHandler + { + public HttpRequestMessage? LastRequest + { + get; private set; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + this.LastRequest = request; + return Task.FromResult(new HttpResponseMessage(statusCode) + { + Content = new StringContent(responseBody, Encoding.UTF8, "application/json") + }); + } + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestRewardAmountTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestRewardAmountTests.cs new file mode 100644 index 00000000..e8638578 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestRewardAmountTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Mappings; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// "At least three of them", and the one reward type that expresses its floor somewhere else. +/// +/// +/// PoracleNG compares amount against the quantity for items, candy and mega energy. Stardust is +/// the exception: singleRewardMatches reads reward as the dust floor for reward type 3 and +/// ignores amount entirely, which is why the stardust rule carries its number in a different +/// field from every other reward tab. +/// +public class QuestRewardAmountTests +{ + private static readonly JsonSerializerOptions Web = new(JsonSerializerDefaults.Web); + + private readonly Mock _proxy = new(); + private readonly Mock _featureGate = new(); + private readonly Mock _remapper = new(); + private JsonElement _sent; + + public QuestRewardAmountTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._remapper + .Setup(r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + this._proxy + .Setup(p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, body) => this._sent = body.Clone()) + .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); + } + + private async Task WriteAsync(QuestCreate create) + { + await new QuestService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", create.ToQuest()); + + return this._sent.ValueKind == JsonValueKind.Array ? this._sent.EnumerateArray().First() : this._sent; + } + + [Fact] + public async Task AnItemRuleCarriesItsMinimumToPoracleNg() + { + // Bound from JSON because that is the DTO the controller binds; constructing the domain model + // skips both the binding and the validation attributes (#548, #555, #565). + var create = JsonSerializer.Deserialize( + """{"reward":1301,"rewardType":2,"amount":3}""", Web)!; + + Assert.Equal(3, (await this.WriteAsync(create)).GetProperty("amount").GetInt32()); + } + + [Fact] + public async Task ARuleWithNoMinimumAsksForNone() + { + var create = JsonSerializer.Deserialize("""{"reward":25,"rewardType":7}""", Web)!; + + Assert.Equal(0, (await this.WriteAsync(create)).GetProperty("amount").GetInt32()); + } + + [Fact] + public async Task AStardustRuleKeepsItsFloorInRewardWhereMatchingLooksForIt() + { + var create = JsonSerializer.Deserialize( + """{"reward":1500,"rewardType":3,"amount":0}""", Web)!; + + var row = await this.WriteAsync(create); + + Assert.Equal(1500, row.GetProperty("reward").GetInt32()); + Assert.Equal(0, row.GetProperty("amount").GetInt32()); + } + + [Fact] + public void AnEditThatSaysNothingAboutTheMinimumLeavesItAlone() + { + var existing = new Quest { Uid = 7, Reward = 1301, RewardType = 2, Amount = 3 }; + + new QuestUpdate { Distance = 1000 }.ApplyUpdate(existing); + + Assert.Equal(3, existing.Amount); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs index bdbb56b3..3c8c2214 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuestServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using Moq; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -15,12 +16,13 @@ public class QuestServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly QuestService _sut; public QuestServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new QuestService(this._proxy.Object, this._featureGate.Object); + this._sut = new QuestService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); } [Fact] @@ -109,7 +111,8 @@ public async Task UpdateDistanceByUserAsyncCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }); this._proxy.Setup(p => p.GetByUserAsync("quest", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("quest", "u", It.IsAny())) @@ -200,4 +203,63 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Duplicate-on-edit --- + // PoracleNG dedups quest tracking by a natural key. When an edit changes a field in that key it INSERTS + // instead of upserting, leaving the pre-edit row behind as a second live alarm firing the old filter. + + [Fact] + public async Task UpdateAsyncDeletesTheSupersededRowWhenPoracleNgInsertsInsteadOfUpdating() + { + var model = new Quest { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("quest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("quest", "user1", 41)).Returns(Task.CompletedTask); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("quest", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncKeepsTheUidAndDeletesNothingWhenPoracleNgUpserts() + { + var model = new Quest { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("quest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(41, result.Uid); + } + + [Fact] + public async Task UpdateAsyncStillSucceedsWhenDeletingTheSupersededRowFails() + { + var model = new Quest { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("quest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("quest", "user1", 41)) + .ThrowsAsync(new HttpRequestException("boom")); + + // The inserted row already carries the user's settings, so the edit must not fail. + var result = await this._sut.UpdateAsync("user1", model); + + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotAttemptAStaleDelete() + { + var model = new Quest { Uid = 0 }; + this._proxy.Setup(p => p.CreateAsync("quest", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickDefaultsTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickDefaultsTests.cs index 7ebcb168..ba4e3f29 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickDefaultsTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickDefaultsTests.cs @@ -36,8 +36,18 @@ private static QuickPickService BuildSut( new Mock().Object, new Mock().Object, new Mock().Object, + FeatureGateAlwaysOn(), new Mock>().Object); + /// A gate with every feature on, so these tests exercise the pick logic itself. + private static IFeatureGate FeatureGateAlwaysOn() + { + var gate = new Mock(); + gate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + gate.Setup(g => g.IsEnabledAsync(It.IsAny())).ReturnsAsync(true); + return gate.Object; + } + [Fact] public async Task DefaultInvasionPicksUseValidGruntTypes() { diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickServiceSecurityTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickServiceSecurityTests.cs index 90641649..404597e6 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickServiceSecurityTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/QuickPickServiceSecurityTests.cs @@ -1,175 +1,466 @@ -using Microsoft.Extensions.Logging; -using Moq; -using Pgan.PoracleWebNet.Core.Abstractions.Repositories; -using Pgan.PoracleWebNet.Core.Abstractions.Services; -using Pgan.PoracleWebNet.Core.Models; -using Pgan.PoracleWebNet.Core.Services; - -namespace Pgan.PoracleWebNet.Tests.Services; - -public class QuickPickServiceSecurityTests -{ - private readonly Mock _definitionRepository = new(); - private readonly Mock _appliedStateRepository = new(); - private readonly Mock _monsterService = new(); - private readonly Mock _raidService = new(); - private readonly Mock _eggService = new(); - private readonly Mock _questService = new(); - private readonly Mock _invasionService = new(); - private readonly Mock _lureService = new(); - private readonly Mock _nestService = new(); - private readonly Mock _gymService = new(); - private readonly Mock _maxBattleService = new(); - private readonly Mock _masterDataService = new(); - private readonly Mock> _logger = new(); - private readonly QuickPickService _sut; - - public QuickPickServiceSecurityTests() => this._sut = new QuickPickService( - this._definitionRepository.Object, - this._appliedStateRepository.Object, - this._monsterService.Object, - this._raidService.Object, - this._eggService.Object, - this._questService.Object, - this._invasionService.Object, - this._lureService.Object, - this._nestService.Object, - this._gymService.Object, - this._maxBattleService.Object, - this._masterDataService.Object, - this._logger.Object); - - [Fact] - public async Task ApplyAsyncIgnoresIdAndUidInMonsterFilters() - { - // Arrange: a QuickPick definition with malicious Id/Uid/ProfileNo in filters - var definition = new QuickPickDefinition - { - Name = "Malicious Pick", - AlarmType = "monster", - Filters = new Dictionary - { - ["id"] = "victim_user", - ["uid"] = 99999, - ["profileNo"] = 42, - ["minIv"] = 90, - }, - }; - this._definitionRepository.Setup(r => r.GetByIdAsync(definition.Id)) - .ReturnsAsync(definition); - - Monster? capturedMonster = null; - this._monsterService.Setup(s => s.CreateAsync("real_user", It.IsAny())) - .Callback((_, m) => capturedMonster = m) - .ReturnsAsync((string _, Monster m) => { m.Uid = 1; return m; }); - - var request = new QuickPickApplyRequest(); - - // Act - await this._sut.ApplyAsync("real_user", 1, definition.Id, request); - - // Assert: Id should be set by CreateAsync (not from filters), Uid is auto-generated, - // ProfileNo is set by BuildMonster (not from filters), minIv should be applied - this._monsterService.Verify(s => s.CreateAsync("real_user", It.Is(m => - m.MinIv == 90 && m.ProfileNo == 1)), Times.Once); - - Assert.NotNull(capturedMonster); - // The service layer sets Id = userId in CreateAsync, but BuildMonster should NOT have set it - // from filters. ProfileNo should be 1 (from the method param), not 42. - Assert.Equal(1, capturedMonster.ProfileNo); - } - - [Fact] - public async Task RemoveAsyncPassesCallerUserIdToServiceDeletes() - { - // Arrange: an applied state with tracked UIDs - var quickPickId = Guid.NewGuid().ToString(); - var appliedState = new QuickPickAppliedState - { - UserId = "real_user", - ProfileNo = 1, - QuickPickId = quickPickId, - AlarmType = "monster", - TrackedUids = [10, 20, 30], - }; - this._appliedStateRepository.Setup(r => r.GetAsync("real_user", 1, quickPickId)) - .ReturnsAsync(appliedState); - - // Act - await this._sut.RemoveAsync("real_user", 1, quickPickId); - - // Assert: DeleteAsync is called with the caller's userId, not some other user - this._monsterService.Verify(s => s.DeleteAsync("real_user", 10), Times.Once); - this._monsterService.Verify(s => s.DeleteAsync("real_user", 20), Times.Once); - this._monsterService.Verify(s => s.DeleteAsync("real_user", 30), Times.Once); - this._appliedStateRepository.Verify(r => r.DeleteAsync("real_user", 1, quickPickId), Times.Once); - } - - [Fact] - public async Task RemoveAsyncReturnsFalseWhenAppliedStateNotFound() - { - this._appliedStateRepository.Setup(r => r.GetAsync("user1", 1, "missing")) - .ReturnsAsync((QuickPickAppliedState?)null); - - var result = await this._sut.RemoveAsync("user1", 1, "missing"); - - Assert.False(result); - this._monsterService.Verify(s => s.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task DeleteUserPickAsyncRejectsDeleteForNonOwner() - { - // Arrange: definition owned by another user - this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("pick1", "attacker")) - .ReturnsAsync((QuickPickDefinition?)null); - - // Act - var result = await this._sut.DeleteUserPickAsync("attacker", "pick1"); - - // Assert: returns false and does not delete - Assert.False(result); - this._definitionRepository.Verify(r => r.DeleteAsync(It.IsAny()), Times.Never); - this._definitionRepository.Verify(r => r.DeleteByIdAndOwnerAsync(It.IsAny(), It.IsAny()), Times.Never); - } - - [Fact] - public async Task DeleteUserPickAsyncAllowsDeleteForOwner() - { - var definition = new QuickPickDefinition - { - Id = "pick1", - Name = "My Pick", - AlarmType = "monster", - Scope = "user", - OwnerUserId = "owner1", - }; - this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("pick1", "owner1")) - .ReturnsAsync(definition); - - var result = await this._sut.DeleteUserPickAsync("owner1", "pick1"); - - Assert.True(result); - this._definitionRepository.Verify(r => r.DeleteByIdAndOwnerAsync("pick1", "owner1"), Times.Once); - } - - [Fact] - public async Task RemoveAsyncPassesCallerUserIdForRaidDeletes() - { - var quickPickId = Guid.NewGuid().ToString(); - var appliedState = new QuickPickAppliedState - { - UserId = "real_user", - ProfileNo = 1, - QuickPickId = quickPickId, - AlarmType = "raid", - TrackedUids = [5], - }; - this._appliedStateRepository.Setup(r => r.GetAsync("real_user", 1, quickPickId)) - .ReturnsAsync(appliedState); - - await this._sut.RemoveAsync("real_user", 1, quickPickId); - - this._raidService.Verify(s => s.DeleteAsync("real_user", 5), Times.Once); - } -} +using Microsoft.Extensions.Logging; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class QuickPickServiceSecurityTests +{ + private readonly Mock _definitionRepository = new(); + private readonly Mock _appliedStateRepository = new(); + private readonly Mock _monsterService = new(); + private readonly Mock _raidService = new(); + private readonly Mock _eggService = new(); + private readonly Mock _questService = new(); + private readonly Mock _invasionService = new(); + private readonly Mock _lureService = new(); + private readonly Mock _nestService = new(); + private readonly Mock _gymService = new(); + private readonly Mock _maxBattleService = new(); + private readonly Mock _masterDataService = new(); + private readonly Mock _featureGate = new(); + private readonly Mock> _logger = new(); + private readonly QuickPickService _sut; + + public QuickPickServiceSecurityTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._featureGate.Setup(g => g.IsEnabledAsync(It.IsAny())).ReturnsAsync(true); + this._sut = new QuickPickService( + this._definitionRepository.Object, + this._appliedStateRepository.Object, + this._monsterService.Object, + this._raidService.Object, + this._eggService.Object, + this._questService.Object, + this._invasionService.Object, + this._lureService.Object, + this._nestService.Object, + this._gymService.Object, + this._maxBattleService.Object, + this._masterDataService.Object, + this._featureGate.Object, + this._logger.Object); + } + + // --- Ownership on the write path --- + // CreateOrUpdateAsync upserts on Id alone and the Id comes from the request body, so without an + // ownership check a user could post a global pick's well-known id and take the row over. + + [Fact] + public async Task SaveUserPickRejectsAnIdThatBelongsToAGlobalPick() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("hundo")) + .ReturnsAsync(new QuickPickDefinition { Id = "hundo", Scope = "global", OwnerUserId = null }); + + await Assert.ThrowsAsync(() => + this._sut.SaveUserPickAsync("attacker", new QuickPickDefinition { Id = "hundo", Name = "HIJACKED" })); + + this._definitionRepository.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SaveUserPickRejectsAnotherUsersPrivatePick() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("victims-pick")) + .ReturnsAsync(new QuickPickDefinition { Id = "victims-pick", Scope = "user", OwnerUserId = "victim" }); + + await Assert.ThrowsAsync(() => + this._sut.SaveUserPickAsync("attacker", new QuickPickDefinition { Id = "victims-pick" })); + + this._definitionRepository.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SaveUserPickAllowsOverwritingYourOwnPick() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("mine")) + .ReturnsAsync(new QuickPickDefinition { Id = "mine", Scope = "user", OwnerUserId = "owner" }); + + var saved = await this._sut.SaveUserPickAsync("owner", new QuickPickDefinition { Id = "mine", Name = "Renamed" }); + + Assert.Equal("user", saved.Scope); + Assert.Equal("owner", saved.OwnerUserId); + this._definitionRepository.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SaveUserPickAllowsABrandNewId() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("brand-new")).ReturnsAsync((QuickPickDefinition?)null); + + var saved = await this._sut.SaveUserPickAsync("owner", new QuickPickDefinition { Id = "brand-new" }); + + Assert.Equal("owner", saved.OwnerUserId); + this._definitionRepository.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Once); + } + + // --- Ownership on the read/apply path --- + + [Fact] + public async Task GetVisibleByIdReturnsGlobalPicksToAnyone() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("hundo")) + .ReturnsAsync(new QuickPickDefinition { Id = "hundo", Scope = "global" }); + + Assert.NotNull(await this._sut.GetVisibleByIdAsync("anyone", "hundo")); + } + + [Fact] + public async Task GetVisibleByIdHidesAnotherUsersPrivatePick() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("victims-pick")) + .ReturnsAsync(new QuickPickDefinition { Id = "victims-pick", Scope = "user", OwnerUserId = "victim" }); + this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("victims-pick", "attacker")) + .ReturnsAsync((QuickPickDefinition?)null); + + Assert.Null(await this._sut.GetVisibleByIdAsync("attacker", "victims-pick")); + } + + [Fact] + public async Task GetVisibleByIdReturnsYourOwnPick() + { + var mine = new QuickPickDefinition { Id = "mine", Scope = "user", OwnerUserId = "owner" }; + this._definitionRepository.Setup(r => r.GetByIdAsync("mine")).ReturnsAsync(mine); + this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("mine", "owner")).ReturnsAsync(mine); + + Assert.NotNull(await this._sut.GetVisibleByIdAsync("owner", "mine")); + } + + [Fact] + public async Task ApplyAsyncRefusesAnotherUsersPrivatePick() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("victims-pick")) + .ReturnsAsync(new QuickPickDefinition { Id = "victims-pick", Scope = "user", OwnerUserId = "victim" }); + this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("victims-pick", "attacker")) + .ReturnsAsync((QuickPickDefinition?)null); + + await Assert.ThrowsAsync(() => + this._sut.ApplyAsync("attacker", 1, "victims-pick", null)); + + this._monsterService.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + this._monsterService.Verify(s => s.BulkCreateAsync(It.IsAny(), It.IsAny>()), Times.Never); + } + + [Fact] + public async Task ApplyAsyncIgnoresIdAndUidInMonsterFilters() + { + // Arrange: a QuickPick definition with malicious Id/Uid/ProfileNo in filters + var definition = new QuickPickDefinition + { + Name = "Malicious Pick", + AlarmType = "monster", + Filters = new Dictionary + { + ["id"] = "victim_user", + ["uid"] = 99999, + ["profileNo"] = 42, + ["minIv"] = 90, + }, + }; + this._definitionRepository.Setup(r => r.GetByIdAsync(definition.Id)) + .ReturnsAsync(definition); + + Monster? capturedMonster = null; + this._monsterService.Setup(s => s.CreateAsync("real_user", It.IsAny())) + .Callback((_, m) => capturedMonster = m) + .ReturnsAsync((string _, Monster m) => { m.Uid = 1; return m; }); + + var request = new QuickPickApplyRequest(); + + // Act + await this._sut.ApplyAsync("real_user", 1, definition.Id, request); + + // Assert: Id should be set by CreateAsync (not from filters), Uid is auto-generated, + // ProfileNo is set by BuildMonster (not from filters), minIv should be applied + this._monsterService.Verify(s => s.CreateAsync("real_user", It.Is(m => + m.MinIv == 90 && m.ProfileNo == 1)), Times.Once); + + Assert.NotNull(capturedMonster); + // The service layer sets Id = userId in CreateAsync, but BuildMonster should NOT have set it + // from filters. ProfileNo should be 1 (from the method param), not 42. + Assert.Equal(1, capturedMonster.ProfileNo); + } + + [Fact] + public async Task RemoveAsyncPassesCallerUserIdToServiceDeletes() + { + // Arrange: an applied state with tracked UIDs + var quickPickId = Guid.NewGuid().ToString(); + var appliedState = new QuickPickAppliedState + { + UserId = "real_user", + ProfileNo = 1, + QuickPickId = quickPickId, + AlarmType = "monster", + TrackedUids = [10, 20, 30], + }; + this._appliedStateRepository.Setup(r => r.GetAsync("real_user", 1, quickPickId)) + .ReturnsAsync(appliedState); + + // Act + await this._sut.RemoveAsync("real_user", 1, quickPickId); + + // Assert: DeleteAsync is called with the caller's userId, not some other user + this._monsterService.Verify(s => s.DeleteAsync("real_user", 10), Times.Once); + this._monsterService.Verify(s => s.DeleteAsync("real_user", 20), Times.Once); + this._monsterService.Verify(s => s.DeleteAsync("real_user", 30), Times.Once); + this._appliedStateRepository.Verify(r => r.DeleteAsync("real_user", 1, quickPickId), Times.Once); + } + + [Fact] + public async Task RemoveAsyncReturnsFalseWhenAppliedStateNotFound() + { + this._appliedStateRepository.Setup(r => r.GetAsync("user1", 1, "missing")) + .ReturnsAsync((QuickPickAppliedState?)null); + + var result = await this._sut.RemoveAsync("user1", 1, "missing"); + + Assert.False(result); + this._monsterService.Verify(s => s.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteUserPickAsyncRejectsDeleteForNonOwner() + { + // Arrange: definition owned by another user + this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("pick1", "attacker")) + .ReturnsAsync((QuickPickDefinition?)null); + + // Act + var result = await this._sut.DeleteUserPickAsync("attacker", "pick1"); + + // Assert: returns false and does not delete + Assert.False(result); + this._definitionRepository.Verify(r => r.DeleteAsync(It.IsAny()), Times.Never); + this._definitionRepository.Verify(r => r.DeleteByIdAndOwnerAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteUserPickAsyncAllowsDeleteForOwner() + { + var definition = new QuickPickDefinition + { + Id = "pick1", + Name = "My Pick", + AlarmType = "monster", + Scope = "user", + OwnerUserId = "owner1", + }; + this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("pick1", "owner1")) + .ReturnsAsync(definition); + + var result = await this._sut.DeleteUserPickAsync("owner1", "pick1"); + + Assert.True(result); + this._definitionRepository.Verify(r => r.DeleteByIdAndOwnerAsync("pick1", "owner1"), Times.Once); + } + + /// + /// Deleting a definition left its applied state behind, and nothing could reach it: the listing walks + /// definitions. It leaked, and a pick re-created under the same id inherited a stale "applied" badge + /// pointing at the old alarm uids. See #470. + /// + [Fact] + public async Task DeleteUserPickAsyncClearsTheOwnersAppliedState() + { + this._definitionRepository.Setup(r => r.GetByIdAndOwnerAsync("pick1", "owner1")) + .ReturnsAsync(new QuickPickDefinition + { + Id = "pick1", + Name = "My Pick", + AlarmType = "monster", + Scope = "user", + OwnerUserId = "owner1", + }); + + await this._sut.DeleteUserPickAsync("owner1", "pick1"); + + this._appliedStateRepository.Verify(r => r.DeleteByQuickPickIdAsync("pick1", "owner1"), Times.Once); + } + + /// + /// A global pick can be applied by anyone, so every user's state for it goes with the definition. + /// + [Fact] + public async Task DeleteAdminPickAsyncClearsAppliedStateForEveryUser() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("global1")) + .ReturnsAsync(new QuickPickDefinition + { + Id = "global1", + Name = "Global Pick", + AlarmType = "monster", + Scope = "global", + }); + + await this._sut.DeleteAdminPickAsync("global1"); + + this._appliedStateRepository.Verify(r => r.DeleteByQuickPickIdAsync("global1", null), Times.Once); + } + [Fact] + public async Task RemoveAsyncPassesCallerUserIdForRaidDeletes() + { + var quickPickId = Guid.NewGuid().ToString(); + var appliedState = new QuickPickAppliedState + { + UserId = "real_user", + ProfileNo = 1, + QuickPickId = quickPickId, + AlarmType = "raid", + TrackedUids = [5], + }; + this._appliedStateRepository.Setup(r => r.GetAsync("real_user", 1, quickPickId)) + .ReturnsAsync(appliedState); + + await this._sut.RemoveAsync("real_user", 1, quickPickId); + + this._raidService.Verify(s => s.DeleteAsync("real_user", 5), Times.Once); + } + + // --- Generated ids (#413) --- + // The create dialog has no id field and sends "", which was stored verbatim. Every id-bearing route + // then collapsed to /api/quick-picks/ so the pick could not be deleted or applied through any path. + + [Fact] + public async Task SeedingIsNotBlockedByAUserPickHoldingABuiltInId() + { + // SeedDefaultsAsync creates the built-ins through SaveAdminPickAsync, so the ownership guard + // added alongside it ran there too: one user-scoped pick holding a built-in id aborted the seed + // partway -- the same partial-preset-list failure the same commit had just fixed. See #659. + this._definitionRepository.Setup(r => r.GetAllGlobalAsync()).ReturnsAsync([]); + this._definitionRepository.Setup(r => r.GetByIdAsync(It.IsAny())) + .ReturnsAsync(new QuickPickDefinition { Id = "nundo", Scope = "user", OwnerUserId = "u2" }); + var created = new List(); + this._definitionRepository.Setup(r => r.CreateOrUpdateAsync(It.IsAny())) + .Callback(d => created.Add(d.Id)) + .Returns(Task.CompletedTask); + + await this._sut.SeedDefaultsAsync(); + + var expected = (await this._sut.GetDefaultPicksAsync()).Count(); + Assert.Equal(expected, created.Count); + } + + [Fact] + public async Task SaveAdminPickRefusesToTakeOverAUsersPrivatePick() + { + // Given an id it converted whatever it found into a global pick -- so an admin editing their own + // personal pick republished it to everyone, and an admin could take over anybody's. See #631. + this._definitionRepository.Setup(r => r.GetByIdAsync("someones-pick")) + .ReturnsAsync(new QuickPickDefinition { Id = "someones-pick", Scope = "user", OwnerUserId = "u2" }); + + await Assert.ThrowsAsync( + () => this._sut.SaveAdminPickAsync(new QuickPickDefinition { Id = "someones-pick", Name = "Mine now" })); + + this._definitionRepository.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SaveAdminPickStillUpdatesAnExistingGlobalPick() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("raid-5star")) + .ReturnsAsync(new QuickPickDefinition { Id = "raid-5star", Scope = "global" }); + + var saved = await this._sut.SaveAdminPickAsync(new QuickPickDefinition { Id = "raid-5star", Name = "Five star" }); + + Assert.Equal("global", saved.Scope); + this._definitionRepository.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SeedingDefaultsCreatesEveryBuiltInPick() + { + // Save-time filter validation (#604) rejected all-invasions and invasion-leader, whose filter + // sets are empty on purpose, so seeding aborted partway and left a partial preset list behind + // both the first-visit auto-seed and Reset to Defaults. See #637. + this._definitionRepository.Setup(r => r.GetAllGlobalAsync()).ReturnsAsync([]); + this._definitionRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((QuickPickDefinition?)null); + var created = new List(); + this._definitionRepository.Setup(r => r.CreateOrUpdateAsync(It.IsAny())) + .Callback(d => created.Add(d.Id)) + .Returns(Task.CompletedTask); + + await this._sut.SeedDefaultsAsync(); + + var expected = (await this._sut.GetDefaultPicksAsync()).Select(d => d.Id).ToList(); + Assert.Equal(expected.Count, created.Count); + Assert.Contains("all-invasions", created); + Assert.Contains("invasion-leader", created); + } + + [Fact] + public async Task SeedingDefaultsClearsTheAppliedStateOfEveryPickItRemoves() + { + // Left behind, that state named a definition that no longer exists: never listed, never cleaned, + // and the alarms it owned lost their Remove button for good. See #630. + this._definitionRepository.Setup(r => r.GetAllGlobalAsync()) + .ReturnsAsync([ + new QuickPickDefinition { Id = "old-one", Scope = "global" }, + new QuickPickDefinition { Id = "old-two", Scope = "global" }, + ]); + this._definitionRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((QuickPickDefinition?)null); + + await this._sut.SeedDefaultsAsync(); + + this._appliedStateRepository.Verify(r => r.DeleteByQuickPickIdAsync("old-one", null), Times.Once); + this._appliedStateRepository.Verify(r => r.DeleteByQuickPickIdAsync("old-two", null), Times.Once); + } + + [Fact] + public async Task SaveAdminPickGeneratesASlugWhenNoIdIsSupplied() + { + this._definitionRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((QuickPickDefinition?)null); + + var saved = await this._sut.SaveAdminPickAsync(new QuickPickDefinition { Id = "", Name = "Hundo IV!" }); + + Assert.Equal("hundo-iv", saved.Id); + } + + [Fact] + public async Task SaveUserPickGeneratesASlugWhenNoIdIsSupplied() + { + this._definitionRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((QuickPickDefinition?)null); + + var saved = await this._sut.SaveUserPickAsync("owner", new QuickPickDefinition { Id = " ", Name = "My Pick" }); + + Assert.Equal("my-pick", saved.Id); + Assert.Equal("owner", saved.OwnerUserId); + } + + [Fact] + public async Task GeneratedIdsAvoidCollidingWithAnExistingPick() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("hundo")) + .ReturnsAsync(new QuickPickDefinition { Id = "hundo", Scope = "global" }); + this._definitionRepository.Setup(r => r.GetByIdAsync("hundo-2")).ReturnsAsync((QuickPickDefinition?)null); + + var saved = await this._sut.SaveAdminPickAsync(new QuickPickDefinition { Id = "", Name = "Hundo" }); + + Assert.Equal("hundo-2", saved.Id); + } + + [Fact] + public async Task AnExplicitIdIsLeftAlone() + { + this._definitionRepository.Setup(r => r.GetByIdAsync("raid-5star")).ReturnsAsync((QuickPickDefinition?)null); + + var saved = await this._sut.SaveAdminPickAsync(new QuickPickDefinition { Id = "raid-5star", Name = "Whatever" }); + + Assert.Equal("raid-5star", saved.Id); + } + + [Fact] + public async Task AnUnnamedPickStillGetsAUsableId() + { + this._definitionRepository.Setup(r => r.GetByIdAsync(It.IsAny())).ReturnsAsync((QuickPickDefinition?)null); + + var saved = await this._sut.SaveAdminPickAsync(new QuickPickDefinition { Id = "", Name = "" }); + + Assert.False(string.IsNullOrWhiteSpace(saved.Id)); + } + +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs new file mode 100644 index 00000000..8fda6d33 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs @@ -0,0 +1,78 @@ +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class RaidLevelServiceTests +{ + [Fact] + public async Task GetAllAsyncReturnsNineteenLevelsInOrder() + { + var sut = new RaidLevelService(); + + var levels = await sut.GetAllAsync(); + + Assert.Equal(19, levels.Count); + for (var i = 0; i < 19; i++) + { + Assert.Equal(i + 1, levels[i].Value); + } + } + + [Fact] + public async Task GetAllAsyncAssignsCategoriesPerMasterfile() + { + var sut = new RaidLevelService(); + + var levels = (await sut.GetAllAsync()).ToDictionary(l => l.Value); + + // Star tiers 1-5 + for (var v = 1; v <= 5; v++) Assert.Equal("star", levels[v].Category); + // Mega 6, Mega Legendary 7 + Assert.Equal("mega", levels[6].Category); + Assert.Equal("mega", levels[7].Category); + // Ultra Beast 8, Elite 9, Primal 10 + Assert.Equal("special", levels[8].Category); + Assert.Equal("special", levels[9].Category); + Assert.Equal("special", levels[10].Category); + // Shadow 11-15 + for (var v = 11; v <= 15; v++) Assert.Equal("shadow", levels[v].Category); + // Super Mega 16-17 + Assert.Equal("superMega", levels[16].Category); + Assert.Equal("superMega", levels[17].Category); + // Coordinated 18-19 + Assert.Equal("coordinated", levels[18].Category); + Assert.Equal("coordinated", levels[19].Category); + } + + [Fact] + public async Task GetAllAsyncUsesMasterfileNamesWithRaidSuffixStripped() + { + var sut = new RaidLevelService(); + + var levels = (await sut.GetAllAsync()).ToDictionary(l => l.Value); + + // Fixes the prior Elite mislabel: level 7 is Mega Legendary, NOT Elite + Assert.Equal("Mega Legendary", levels[7].Name); + // Elite is at level 9 + Assert.Equal("Elite", levels[9].Name); + // Level 5 is Legendary + Assert.Equal("Legendary", levels[5].Name); + // Star tiers carry the literal star nomenclature minus the redundant suffix + Assert.Equal("1 Star", levels[1].Name); + Assert.Equal("4 Star", levels[4].Name); + } + + [Fact] + public async Task GetAllAsyncPluralNamesKeepTheFullPhrase() + { + var sut = new RaidLevelService(); + + var levels = (await sut.GetAllAsync()).ToDictionary(l => l.Value); + + // Plural form is used in standalone phrases like card titles where the + // "Raids" suffix completes the sentence. + Assert.Equal("Mega Raids", levels[6].NamePlural); + Assert.Equal("Elite Raids", levels[9].NamePlural); + Assert.Equal("Legendary Raids", levels[5].NamePlural); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/RaidServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/RaidServiceTests.cs index 2c154adc..95e5aa86 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/RaidServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/RaidServiceTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using System.Text.Json; using Moq; using Pgan.PoracleWebNet.Core.Abstractions.Services; @@ -15,12 +16,13 @@ public class RaidServiceTests private readonly Mock _proxy = new(); private readonly Mock _featureGate = new(); + private readonly Mock _uidRemapper = new(); private readonly RaidService _sut; public RaidServiceTests() { this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); - this._sut = new RaidService(this._proxy.Object, this._featureGate.Object); + this._sut = new RaidService(this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._uidRemapper.Object); } [Fact] @@ -73,12 +75,55 @@ public async Task CreateAsyncSetsUserId() var raid = new Raid { PokemonId = 150 }; this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); + this._proxy.Setup(p => p.GetByUserAsync("raid", "user1")).ReturnsAsync(CreateJsonArray(new + { + uid = 1, + id = "user1", + pokemon_id = 150, + level = 9000, + })); var result = await this._sut.CreateAsync("user1", raid); Assert.Equal("user1", result.Id); } + /// + /// PoracleNG rewrites level to 9000 when the alarm names a specific boss, so echoing the submitted + /// model advertised a level the stored row does not have. See #523. + /// + [Fact] + public async Task CreateAsyncReportsTheStoredRowRatherThanTheRequest() + { + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([7], 0, 0, 1)); + this._proxy.Setup(p => p.GetByUserAsync("raid", "user1")).ReturnsAsync(CreateJsonArray(new + { + uid = 7, + id = "user1", + pokemon_id = 150, + level = 9000, + })); + + var result = await this._sut.CreateAsync("user1", new Raid { PokemonId = 150, Level = 5 }); + + Assert.Equal(9000, result.Level); + Assert.Equal(7, result.Uid); + } + + /// A read-back that fails must not fail the create, which already succeeded. + [Fact] + public async Task CreateAsyncStillAnswersWhenTheReadBackFindsNothing() + { + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([7], 0, 0, 1)); + this._proxy.Setup(p => p.GetByUserAsync("raid", "user1")).ReturnsAsync(CreateJsonArray()); + + var result = await this._sut.CreateAsync("user1", new Raid { PokemonId = 150, Level = 5 }); + + Assert.Equal(7, result.Uid); + } + [Fact] public async Task UpdateAsyncCallsProxy() { @@ -124,6 +169,25 @@ public async Task DeleteAllByUserAsyncReturnsCount() Assert.Equal(3, await this._sut.DeleteAllByUserAsync("u", 1)); } + /// + /// Two rows that differ only by radius become the same alarm once both are set to the same radius, + /// and PoracleNG resolves that inside the batch -- so the user ends up with fewer alarms than they + /// selected, one still at its old radius, and a response claiming every one was updated. See #580. + /// + [Fact] + public async Task UpdateDistanceRefusesWhenTwoSelectedAlarmsWouldBecomeIdentical() + { + this._proxy.Setup(p => p.GetByUserAsync("raid", "u1")).ReturnsAsync(CreateJsonArray( + new { uid = 1, id = "u1", level = 5, distance = 500, template = "1" }, + new { uid = 2, id = "u1", level = 5, distance = 900, template = "1" })); + + await Assert.ThrowsAsync( + () => this._sut.UpdateDistanceByUidsAsync([1, 2], "u1", 700)); + + this._proxy.Verify( + p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task UpdateDistanceByUserAsyncReturnsCount() { @@ -132,13 +196,15 @@ public async Task UpdateDistanceByUserAsyncReturnsCount() { uid = 1, id = "u", - distance = 0 + distance = 0, + template = "ZZrow1" }, new { uid = 2, id = "u", - distance = 0 + distance = 0, + template = "ZZsecond" }); this._proxy.Setup(p => p.GetByUserAsync("raid", "u")).ReturnsAsync(json); this._proxy.Setup(p => p.CreateAsync("raid", "u", It.IsAny())) @@ -224,4 +290,127 @@ private static JsonElement CreateJsonArray(params object[] items) using var doc = JsonDocument.Parse(jsonStr); return doc.RootElement.Clone(); } + + // --- Duplicate-on-edit --- + // PoracleNG dedups raid tracking by a natural key. When an edit changes a field in that key it INSERTS + // instead of upserting, leaving the pre-edit row behind as a second live alarm firing the old filter. + + [Fact] + public async Task UpdateAsyncDeletesTheSupersededRowWhenPoracleNgInsertsInsteadOfUpdating() + { + var model = new Raid { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("raid", "user1", 41)).Returns(Task.CompletedTask); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync("raid", "user1", 41), Times.Once); + Assert.Equal(42, result.Uid); + } + + // A rotated uid orphans any quick pick that created the alarm: removal deletes by the stored uid, + // finds nothing, reports success, and the alarm keeps firing. See #403. + + [Fact] + public async Task UpdateAsyncRepointsQuickPickTrackedUidAtTheNewRow() + { + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + await this._sut.UpdateAsync("user1", new Raid { Uid = 41 }); + + this._uidRemapper.Verify(r => r.RemapAsync("user1", "raid", 41, 42), Times.Once); + } + + [Fact] + public async Task UpdateAsyncDoesNotRemapWhenTheUidSurvivesTheUpsert() + { + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + await this._sut.UpdateAsync("user1", new Raid { Uid = 41 }); + + this._uidRemapper.Verify( + r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateAsyncKeepsTheUidAndDeletesNothingWhenPoracleNgUpserts() + { + var model = new Raid { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(41, result.Uid); + } + + [Fact] + public async Task UpdateAsyncStillSucceedsWhenDeletingTheSupersededRowFails() + { + var model = new Raid { Uid = 41 }; + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + this._proxy.Setup(p => p.DeleteByUidAsync("raid", "user1", 41)) + .ThrowsAsync(new HttpRequestException("boom")); + + // The inserted row already carries the user's settings, so the edit must not fail. + var result = await this._sut.UpdateAsync("user1", model); + + Assert.Equal(42, result.Uid); + } + + [Fact] + public async Task UpdateAsyncOnANewRecordDoesNotAttemptAStaleDelete() + { + var model = new Raid { Uid = 0 }; + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([42], 0, 0, 1)); + + await this._sut.UpdateAsync("user1", model); + + this._proxy.Verify(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // PoracleNG re-keys a row on edit while reporting insert:0 / updates:1 and putting the new uid in + // newUids -- verified against it directly. The reconciler used to gate on Inserts > 0, so it + // returned the DEAD uid and skipped the remap. See #460, #464. + + [Fact] + public async Task UpdateAsyncReportsTheNewUidWhenPoracleNgReKeysWithoutReportingAnInsert() + { + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([418], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", new Raid { Uid = 417 }); + + Assert.Equal(418, result.Uid); + } + + [Fact] + public async Task UpdateAsyncRemapsQuickPickUidsWhenPoracleNgReKeysWithoutReportingAnInsert() + { + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([418], 0, 1, 0)); + + await this._sut.UpdateAsync("user1", new Raid { Uid = 417 }); + + this._uidRemapper.Verify(r => r.RemapAsync("user1", "raid", 417, 418), Times.Once); + } + + [Fact] + public async Task UpdateAsyncStillLeavesTheUidAloneWhenPoracleNgReturnsNoNewUid() + { + this._proxy.Setup(p => p.CreateAsync("raid", "user1", It.IsAny())) + .ReturnsAsync(new TrackingCreateResult([], 0, 1, 0)); + + var result = await this._sut.UpdateAsync("user1", new Raid { Uid = 417 }); + + Assert.Equal(417, result.Uid); + this._uidRemapper.Verify( + r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/SiteSettingServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/SiteSettingServiceTests.cs index 9ee2faef..4c470df1 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/SiteSettingServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/SiteSettingServiceTests.cs @@ -100,6 +100,22 @@ public async Task GetPublicAsyncIncludesSignupUrlSetting() Assert.Equal("https://signup.example.com", result.First(s => s.Key == "signup_url").Value); } + /// + /// The signed-out language menu honours the admin's restriction only if the key reaches the login page, + /// and the login page can read nothing but this endpoint. + /// + [Fact] + public async Task GetPublicAsyncIncludesAllowedLanguages() + { + this._repository.Setup(r => r.GetByKeyAsync("allowed_languages")) + .ReturnsAsync(new SiteSetting { Key = "allowed_languages", Value = "en,fr", Category = "branding" }); + + var result = (await this._sut.GetPublicAsync()).ToList(); + + Assert.Contains(result, s => s.Key == "allowed_languages"); + Assert.Equal("en,fr", result.First(s => s.Key == "allowed_languages").Value); + } + [Fact] public async Task GetPublicAsyncSkipsMissingLoginMethodSettings() { diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/SummaryCapabilityServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/SummaryCapabilityServiceTests.cs new file mode 100644 index 00000000..93a566aa --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/SummaryCapabilityServiceTests.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Caching.Memory; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Capability resolution for quest summary delivery. The flag is read from PoracleNG's effective +/// config values (tracking.quest_summary_enabled via /api/config/values). It resolves +/// to false when the flag can't be determined (endpoint shape changed) or on any fault, so the +/// UI stays hidden unless the bot has the feature enabled. The result is cached for 5 minutes. +/// +public class SummaryCapabilityServiceTests : IDisposable +{ + private readonly Mock _apiProxy = new(); + + // Real MemoryCache per test instance — xUnit gives each fact a fresh class instance, so cache + // state never leaks across tests. + private readonly MemoryCache _cache = new(new MemoryCacheOptions()); + private readonly SummaryCapabilityService _sut; + + public SummaryCapabilityServiceTests() => this._sut = new SummaryCapabilityService(this._apiProxy.Object, this._cache); + + public void Dispose() + { + this._cache.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task ReturnsTrueWhenFlagEnabled() + { + this._apiProxy.Setup(p => p.GetQuestSummaryEnabledAsync()).ReturnsAsync(true); + + Assert.True(await this._sut.IsQuestSummaryEnabledAsync()); + } + + [Fact] + public async Task ReturnsFalseWhenFlagDisabled() + { + this._apiProxy.Setup(p => p.GetQuestSummaryEnabledAsync()).ReturnsAsync(false); + + Assert.False(await this._sut.IsQuestSummaryEnabledAsync()); + } + + [Fact] + public async Task DefaultsToFalseWhenFlagCannotBeDetermined() + { + // Endpoint reachable but the flag isn't present in the expected shape -> null -> hidden, + // rather than a dead-end where nothing is ever delivered. + this._apiProxy.Setup(p => p.GetQuestSummaryEnabledAsync()).ReturnsAsync((bool?)null); + + Assert.False(await this._sut.IsQuestSummaryEnabledAsync()); + } + + [Fact] + public async Task DegradesToFalseWhenProxyThrows() + { + this._apiProxy.Setup(p => p.GetQuestSummaryEnabledAsync()).ThrowsAsync(new HttpRequestException("upstream down")); + + Assert.False(await this._sut.IsQuestSummaryEnabledAsync()); + } + + [Fact] + public async Task CachesResultAndDoesNotReprobe() + { + this._apiProxy.Setup(p => p.GetQuestSummaryEnabledAsync()).ReturnsAsync(true); + + var first = await this._sut.IsQuestSummaryEnabledAsync(); + var second = await this._sut.IsQuestSummaryEnabledAsync(); + + Assert.True(first); + Assert.True(second); + this._apiProxy.Verify(p => p.GetQuestSummaryEnabledAsync(), Times.Once); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/TrackedUidRemapperCoverageTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackedUidRemapperCoverageTests.cs new file mode 100644 index 00000000..17013124 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackedUidRemapperCoverageTests.cs @@ -0,0 +1,49 @@ +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// A new alarm service that rotates uids but never remaps them reintroduces #403 silently: quick-pick +/// removal reports success, deletes nothing, and the summary read then wipes the applied state so the +/// user cannot retry. Nothing about that failure is visible without knowing to look, so it is pinned here. +/// +public class TrackedUidRemapperCoverageTests +{ + /// + /// Every service whose update rotates the uid — all of them except monsters, which PoracleNG + /// genuinely upserts in place. + /// + public static TheoryData RotatingServices => + [ + typeof(RaidService), typeof(EggService), typeof(QuestService), typeof(NestService), + typeof(GymService), typeof(FortChangeService), typeof(InvasionService), typeof(LureService), + typeof(MaxBattleService) + ]; + + [Theory] + [MemberData(nameof(RotatingServices))] + public void RotatingServicesTakeTheUidRemapper(Type serviceType) + { + var takesRemapper = serviceType + .GetConstructors() + .Any(c => c.GetParameters().Any(p => p.ParameterType == typeof(ITrackedUidRemapper))); + + Assert.True( + takesRemapper, + $"{serviceType.Name} rewrites the row on update, so its uid changes. Without ITrackedUidRemapper " + + "any quick pick that created the alarm keeps pointing at the dead uid and can never remove it."); + } + + [Fact] + public void MonsterServiceDoesNotNeedIt() + { + // Documented deliberately: monsters are the one type PoracleNG updates in place, so there is no + // rotation to follow. If that ever changes, this test failing is the reminder. + var takesRemapper = typeof(MonsterService) + .GetConstructors() + .Any(c => c.GetParameters().Any(p => p.ParameterType == typeof(ITrackedUidRemapper))); + + Assert.False(takesRemapper); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/TrackedUidRemapperTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackedUidRemapperTests.cs new file mode 100644 index 00000000..17bb0121 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackedUidRemapperTests.cs @@ -0,0 +1,112 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// PoracleNG rewrites a tracking row on edit, so the uid changes. Quick-pick applied state stores the +/// uids captured at apply time, so without this the stored uids went stale: removal deleted nothing, +/// reported 204, and the summary read then wiped the applied state — leaving an alarm that fired forever +/// with no UI to remove it. See #403. +/// +public class TrackedUidRemapperTests +{ + private readonly Mock _repo = new(); + private readonly TrackedUidRemapper _sut; + + public TrackedUidRemapperTests() => + this._sut = new TrackedUidRemapper(this._repo.Object, NullLogger.Instance); + + private static QuickPickAppliedState State(string quickPickId, string alarmType, params int[] uids) => new() + { + UserId = "u1", + ProfileNo = 1, + QuickPickId = quickPickId, + AlarmType = alarmType, + TrackedUids = [.. uids] + }; + + [Fact] + public async Task RewritesTheRotatedUidInPlace() + { + var state = State("lure-glacial", "lure", 239); + this._repo.Setup(r => r.GetByUserAsync("u1")).ReturnsAsync([state]); + + await this._sut.RemapAsync("u1", "lure", 239, 240); + + Assert.Equal([240], state.TrackedUids); + this._repo.Verify(r => r.CreateOrUpdateAsync(state), Times.Once); + } + + [Fact] + public async Task LeavesTheOtherTrackedUidsAlone() + { + var state = State("raid-legendary", "raid", 10, 11, 12); + this._repo.Setup(r => r.GetByUserAsync("u1")).ReturnsAsync([state]); + + await this._sut.RemapAsync("u1", "raid", 11, 99); + + Assert.Equal([10, 99, 12], state.TrackedUids); + } + + [Fact] + public async Task IgnoresAppliedStateForAnotherAlarmType() + { + // uids are only unique within a type, so a raid uid 5 must not rewrite a lure's uid 5. + var lure = State("lure-glacial", "lure", 5); + this._repo.Setup(r => r.GetByUserAsync("u1")).ReturnsAsync([lure]); + + await this._sut.RemapAsync("u1", "raid", 5, 6); + + Assert.Equal([5], lure.TrackedUids); + this._repo.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RemapsAcrossEveryProfileTheUserHas() + { + // An alarm edit does not say which profile the row belongs to, and a uid is unique per user, + // so whichever profile's state references it is the right one. + var p2 = State("gym-team", "gym", 77); + p2.ProfileNo = 2; + this._repo.Setup(r => r.GetByUserAsync("u1")).ReturnsAsync([p2]); + + await this._sut.RemapAsync("u1", "gym", 77, 78); + + Assert.Equal([78], p2.TrackedUids); + } + + [Fact] + public async Task DoesNothingWhenNoQuickPickTracksTheUid() + { + this._repo.Setup(r => r.GetByUserAsync("u1")).ReturnsAsync([State("quest-stardust", "quest", 1)]); + + await this._sut.RemapAsync("u1", "quest", 500, 501); + + this._repo.Verify(r => r.CreateOrUpdateAsync(It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(0, 5)] + [InlineData(5, 0)] + [InlineData(7, 7)] + public async Task SkipsNonRotations(int oldUid, int newUid) + { + await this._sut.RemapAsync("u1", "lure", oldUid, newUid); + + this._repo.Verify(r => r.GetByUserAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SwallowsRepositoryFailures() + { + // The edit already succeeded upstream. Failing here would fail a request that worked, and cost + // the user their edit to save a "remove" button. + this._repo.Setup(r => r.GetByUserAsync("u1")).ThrowsAsync(new InvalidOperationException("db down")); + + await this._sut.RemapAsync("u1", "lure", 1, 2); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/TrackingFieldCoverageTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackingFieldCoverageTests.cs new file mode 100644 index 00000000..5aef3304 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/TrackingFieldCoverageTests.cs @@ -0,0 +1,230 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Mappings; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Every column PoracleNG stores, for every alarm type, is either written by PoracleWeb or listed here +/// with a reason. +/// +/// +/// +/// pvp_ranking_evolution shipped as a control in the PVP tab, a field in the Angular request and +/// four passing component specs, while no C# model carried the property — so model binding dropped it on +/// the way in, the typed deserialize dropped it on the way back, and the selector changed nothing. The +/// component specs passed because they assert the shape of the request the component builds, which says +/// nothing about whether the API accepts it. +/// +/// +/// A field PoracleWeb does not send is not automatically a bug: PoracleNG fills its own default, and +/// carries a stored value forward, so a value set with the bot +/// survives a web edit either way. What is a bug is not noticing. When PoracleNG grows a column, this +/// test fails and the choice gets made deliberately rather than by omission. +/// +/// +/// The column lists are the *TrackingAPI structs in +/// processor/internal/db/tracking_queries.go at 5.1.0 (c5e08cb4), which is the commit +/// production runs. Ten types, because a guarantee that covers two of them is the same guarantee that +/// let the mega picker ship broken. +/// +/// +public class TrackingFieldCoverageTests +{ + private static readonly Dictionary PoracleNgColumns = new(StringComparer.Ordinal) + { + ["pokemon"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "pokemon_id", "form", + "min_iv", "max_iv", "min_cp", "max_cp", "min_level", "max_level", + "atk", "def", "sta", "max_atk", "max_def", "max_sta", + "gender", "min_weight", "max_weight", "min_time", "rarity", "max_rarity", "size", "max_size", + "pvp_ranking_league", "pvp_ranking_best", "pvp_ranking_worst", + "pvp_ranking_min_cp", "pvp_ranking_cap", "pvp_ranking_evolution", + "override_location_label", "override_areas", + ], + ["raid"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "team", "pokemon_id", + "form", "level", "exclusive", "move", "evolution", "gym_id", "rsvp_changes", + "override_location_label", "override_areas", + ], + ["egg"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "team", "level", + "exclusive", "gym_id", "rsvp_changes", "override_location_label", "override_areas", + ], + ["quest"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "reward_type", "reward", + "form", "shiny", "amount", "override_location_label", "override_areas", + ], + ["invasion"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "gender", "grunt_type", + "override_location_label", "override_areas", + ], + ["lure"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "lure_id", + "override_location_label", "override_areas", + ], + ["nest"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "pokemon_id", + "min_spawn_avg", "form", "override_location_label", "override_areas", + ], + ["gym"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "team", "slot_changes", + "battle_changes", "gym_id", "override_location_label", "override_areas", + ], + ["fort"] = + [ + "uid", "id", "profile_no", "ping", "distance", "template", "fort_type", "include_empty", + "change_types", "override_location_label", "override_areas", + ], + ["maxbattle"] = + [ + "uid", "id", "profile_no", "ping", "clean", "distance", "template", "pokemon_id", "form", + "level", "move", "gmax", "evolution", "station_id", "override_location_label", "override_areas", + ], + }; + + /// Columns PoracleWeb leaves to PoracleNG, keyed by type.column, and why. + private static readonly Dictionary NotSent = new(StringComparer.Ordinal) + { + ["*.uid"] = "Only carried on an edit. PoracleNG treats uid: 0 as an update of a row with that uid, " + + "so an insert drops it and lets PoracleNG assign one.", + ["*.profile_no"] = "Stamped from a JWT claim that goes stale; omitting it files the alarm under the live profile. #411", + ["pokemon.rarity"] = "Rarity is a per-species tier PoracleNG recomputes from rolling sighting stats, so on the " + + "species-specific rules PoracleWeb creates the filter is a constant: no-op or permanent mute. It would " + + "mean something on a track-everything rule, which only the bot can create — and zero of production's " + + "17,420 pokemon rules set it. Values set with the bot survive a web edit either way.", + ["pokemon.max_rarity"] = "See pokemon.rarity.", + }; + + private readonly Mock _proxy = new(); + private readonly Mock _featureGate = new(); + private readonly Mock _remapper = new(); + private JsonElement _sent; + + public TrackingFieldCoverageTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._remapper + .Setup(r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + // Several services read the user's existing rules before writing, to refuse a create that + // PoracleNG would resolve into an update of a different alarm (#561). No rules, no collision. + this._proxy + .Setup(p => p.GetByUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => JsonDocument.Parse("[]").RootElement.Clone()); + this._proxy + .Setup(p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, body) => this._sent = body.Clone()) + .ReturnsAsync(new TrackingCreateResult([1], 0, 0, 1)); + } + + public static TheoryData TrackingTypes() => + [ + "pokemon", "raid", "egg", "quest", "invasion", "lure", "nest", "gym", "fort", "maxbattle", + ]; + + /// Creates one alarm of the given type and returns the row that reached the proxy. + private async Task> WrittenColumnsAsync(string type) + { + await this.CreateAsync(type); + + var row = this._sent.ValueKind == JsonValueKind.Array ? this._sent.EnumerateArray().First() : this._sent; + return row.EnumerateObject().Select(p => p.Name).ToHashSet(StringComparer.Ordinal); + } + + private Task CreateAsync(string type) => type switch + { + "pokemon" => new MonsterService(this._proxy.Object, this._featureGate.Object) + .CreateAsync("u1", new MonsterCreate { PokemonId = 201 }.ToMonster()), + "raid" => new RaidService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new RaidCreate { Level = 5, PokemonId = 9000 }.ToRaid()), + "egg" => new EggService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new EggCreate { Level = 5 }.ToEgg()), + "quest" => new QuestService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new QuestCreate { Reward = 25, RewardType = 7 }.ToQuest()), + "invasion" => new InvasionService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new InvasionCreate { GruntType = "blanche" }.ToInvasion()), + "lure" => new LureService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new LureCreate { LureId = 501 }.ToLure()), + "nest" => new NestService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new NestCreate { PokemonId = 201 }.ToNest()), + "gym" => new GymService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new GymCreate { Team = 0 }.ToGym()), + "fort" => new FortChangeService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new FortChangeCreate { FortType = "everything" }.ToFortChange()), + "maxbattle" => new MaxBattleService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object) + .CreateAsync("u1", new MaxBattleCreate { Level = 5 }.ToMaxBattle()), + _ => throw new ArgumentOutOfRangeException(nameof(type), type, "No fixture for this tracking type."), + }; + + private static bool IsExcused(string type, string column) => + NotSent.ContainsKey($"{type}.{column}") || NotSent.ContainsKey($"*.{column}"); + + [Theory] + [MemberData(nameof(TrackingTypes))] + public async Task EveryColumnIsEitherWrittenOrExcusedInWriting(string type) + { + var written = await this.WrittenColumnsAsync(type); + + var unaccounted = PoracleNgColumns[type] + .Where(c => !written.Contains(c) && !IsExcused(type, c)) + .ToList(); + + Assert.True( + unaccounted.Count == 0, + $"PoracleNG stores these {type} columns and PoracleWeb neither writes them nor explains why: " + + string.Join(", ", unaccounted) + + ". Add the property to the model, the Create and Update DTOs and the mapping, or add it to " + + "NotSent with the reason."); + } + + [Theory] + [MemberData(nameof(TrackingTypes))] + public async Task TheExcusedColumnsAreReallyAbsent(string type) + { + // The other half: an excuse that no longer matches the code is worse than no excuse, because it + // reads as a decision someone made. uid is dropped only when zero, which the fixtures are. + var written = await this.WrittenColumnsAsync(type); + + var contradicted = PoracleNgColumns[type].Where(c => IsExcused(type, c) && written.Contains(c)).ToList(); + + Assert.True(contradicted.Count == 0, $"Listed as not sent for {type}, but sent: " + string.Join(", ", contradicted)); + } + + [Fact] + public void TheExcuseListOnlyNamesColumnsPoracleNgHas() + { + var unknown = NotSent.Keys + .Where(key => + { + var (type, column) = (key[..key.IndexOf('.', StringComparison.Ordinal)], key[(key.IndexOf('.', StringComparison.Ordinal) + 1)..]); + return type == "*" + ? !PoracleNgColumns.Values.Any(columns => columns.Contains(column, StringComparer.Ordinal)) + : !PoracleNgColumns.TryGetValue(type, out var columns) || !columns.Contains(column, StringComparer.Ordinal); + }) + .ToList(); + + Assert.True(unknown.Count == 0, "Excused a column PoracleNG does not have: " + string.Join(", ", unknown)); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UnmodelledFieldPreservationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UnmodelledFieldPreservationTests.cs new file mode 100644 index 00000000..03b5a62e --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UnmodelledFieldPreservationTests.cs @@ -0,0 +1,302 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// A write must not erase what PoracleWeb cannot see. +/// +/// The bulk and edit paths built their body by serializing the typed alarm model, so any column PoracleNG +/// grew that PoracleWeb never modelled was absent from the write. Because the POST carries a uid, +/// PoracleNG upserted the row and stored the column default over the user's value. PoracleNG 5.1.0 added +/// override_location_label, override_areas and pvp_ranking_evolution; 5.2.0 adds +/// costume. Set one with the bot, press Update Distance on the web, and it was gone. See #730. +/// +/// +/// The fields asserted here are the ones PoracleWeb still does not model — rarity, which it +/// deliberately does not offer, and costume, which 5.2.0 has not shipped yet. Once a field is +/// modelled its value comes from the caller, which is a different guarantee: see +/// . +/// +/// +/// These assert the legitimate case still works (the distance genuinely changes, the count is still +/// reported) alongside the preservation, because a helper that dropped the change on the floor would +/// preserve everything perfectly and do nothing useful. +/// +/// +public class UnmodelledFieldPreservationTests +{ + private readonly Mock _proxy = new(); + private readonly Mock _featureGate = new(); + private readonly Mock _remapper = new(); + private readonly List _sent = []; + + /// A stored row as PoracleNG 5.1.0 returns it, carrying fields PoracleWeb has no model for. + private const string StoredRow = + "[{" + + "\"uid\": 7," + + "\"id\": \"u1\"," + + "\"profile_no\": 0," + + "\"pokemon_id\": 201," + + "\"distance\": 500," + + "\"clean\": 0," + + "\"template\": \"1\"," + + "\"level\": 5," + + "\"grunt_type\": \"blanche\"," + + "\"gender\": 0," + + "\"lure_id\": 501," + + "\"override_location_label\": \"work\"," + + "\"override_areas\": [\"terrigal\"]," + + "\"pvp_ranking_evolution\": 2," + + "\"rarity\": 3," + + "\"costume\": 9000" + + "}]"; + + public UnmodelledFieldPreservationTests() + { + this._featureGate.Setup(g => g.EnsureEnabledAsync(It.IsAny())).Returns(Task.CompletedTask); + this._remapper + .Setup(r => r.RemapAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + this._proxy + .Setup(p => p.GetByUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => JsonDocument.Parse(StoredRow).RootElement.Clone()); + this._proxy + .Setup(p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, body) => this._sent.Add(body.Clone())) + .ReturnsAsync(new TrackingCreateResult([7], 0, 0, 1)); + this._proxy + .Setup(p => p.DeleteByUidAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + this._proxy + .Setup(p => p.BulkDeleteByUidsAsync(It.IsAny(), It.IsAny(), It.IsAny>())) + .Returns(Task.CompletedTask); + } + + /// Every alarm type's bulk distance write, by the name PoracleNG knows it by. + public static TheoryData AllTrackingTypes() => + [ + "pokemon", "raid", "egg", "quest", "invasion", "lure", "nest", "gym", "fort", "maxbattle", + ]; + + [Theory] + [MemberData(nameof(AllTrackingTypes))] + public async Task BulkDistanceForEveryAlarmKeepsUnmodelledFields(string trackingType) + { + var changed = await UpdateAllDistance(this.ServiceFor(trackingType), 1500); + + Assert.Equal(1, changed); + var row = this.OnlyRowSent(); + Assert.Equal(1500, row.GetProperty("distance").GetInt32()); + AssertCarriedForward(row); + } + + [Theory] + [MemberData(nameof(AllTrackingTypes))] + public async Task BulkDistanceForSelectedAlarmsKeepsUnmodelledFields(string trackingType) + { + var changed = await UpdateSelectedDistance(this.ServiceFor(trackingType), 1500); + + Assert.Equal(1, changed); + var row = this.OnlyRowSent(); + Assert.Equal(1500, row.GetProperty("distance").GetInt32()); + AssertCarriedForward(row); + } + + [Theory] + [MemberData(nameof(AllTrackingTypes))] + public async Task BulkDistanceRewritesOnlyTheSelectedRow(string trackingType) + { + // The legitimate-case half: selection still has to work now that the rewrite runs off the stored + // rows rather than a filtered typed list. + await UpdateSelectedDistance(this.ServiceFor(trackingType), 1500); + + Assert.Equal(7, this.OnlyRowSent().GetProperty("uid").GetInt32()); + } + + [Theory] + [MemberData(nameof(AllTrackingTypes))] + public async Task BulkDistanceStillStripsProfileNo(string trackingType) + { + // The stored row carries profile_no and the rewrite passes properties through verbatim, so the + // strip has to survive the new path or #411 comes straight back. + await UpdateAllDistance(this.ServiceFor(trackingType), 1500); + + Assert.False(this.OnlyRowSent().TryGetProperty("profile_no", out _)); + } + + [Fact] + public async Task BulkDistanceOnNoAlarmsWritesNothing() + { + this._proxy + .Setup(p => p.GetByUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(JsonDocument.Parse("[]").RootElement.Clone()); + + Assert.Equal(0, await UpdateAllDistance(this.ServiceFor("pokemon"), 1500)); + Assert.Empty(this._sent); + } + + // The four edit tests below are the four distinct shapes UpdateAsync takes. Pokemon writes straight + // through; raid, egg, quest, nest, gym and fort share the reconciler shape verbatim; invasion and + // lure share the natural-key replace; maxbattle deletes then re-creates. The preservation call is + // the same line in all ten, so one test per shape covers the wiring without ten near-identical + // fixtures, each of which would have to satisfy that type's own natural-key guard. + + [Fact] + public async Task EditKeepsUnmodelledFieldsOnPokemon() + { + var service = new MonsterService(this._proxy.Object, this._featureGate.Object); + + await service.UpdateAsync("u1", new Monster { Uid = 7, PokemonId = 201, Distance = 1500 }); + + var row = this.OnlyRowSent(); + Assert.Equal(1500, row.GetProperty("distance").GetInt32()); + AssertCarriedForward(row); + } + + [Fact] + public async Task EditKeepsUnmodelledFieldsOnRaid() + { + var service = new RaidService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object); + + await service.UpdateAsync("u1", new Raid { Uid = 7, PokemonId = 9000, Level = 5, Distance = 1500 }); + + AssertCarriedForward(this.OnlyRowSent()); + } + + [Fact] + public async Task EditKeepsUnmodelledFieldsOnInvasion() + { + var service = new InvasionService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object); + + await service.UpdateAsync("u1", new Invasion { Uid = 7, GruntType = "blanche", Gender = 0, Distance = 1500 }); + + Assert.Contains( + this._sent, + b => b.ValueKind == JsonValueKind.Object + && b.TryGetProperty("override_location_label", out var label) + && label.GetString() == "work"); + } + + [Fact] + public async Task EditKeepsUnmodelledFieldsOnMaxBattle() + { + var service = new MaxBattleService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object); + + await service.UpdateAsync("u1", new MaxBattle { Uid = 7, PokemonId = 201, Distance = 1500 }); + + AssertCarriedForward(this.OnlyRowSent()); + } + + [Fact] + public async Task AnEmptyOverrideClearsItRatherThanBeingCarriedForward() + { + // The other half of the null rule. Null means "not stated, keep what is stored"; empty is how a + // person says "remove it". Without this, an override could be set but never taken off. + var service = new MonsterService(this._proxy.Object, this._featureGate.Object); + + await service.UpdateAsync("u1", new Monster + { + Uid = 7, + PokemonId = 201, + Distance = 1500, + OverrideLocationLabel = string.Empty, + OverrideAreas = [], + }); + + var row = this.OnlyRowSent(); + Assert.Equal(string.Empty, row.GetProperty("override_location_label").GetString()); + Assert.Empty(row.GetProperty("override_areas").EnumerateArray()); + } + + [Fact] + public async Task CreateDoesNotInventFieldsFromAnotherAlarm() + { + // uid 0 is a create. There is no stored row to carry anything forward from, and matching on + // "some row the user already has" would staple a stranger's location override onto a new alarm. + var service = new MonsterService(this._proxy.Object, this._featureGate.Object); + + await service.CreateAsync("u1", new Monster { PokemonId = 999, Distance = 1500 }); + + // The model states no override, so the write carries an explicit null rather than the "work" + // some other alarm of theirs holds. + Assert.Equal( + JsonValueKind.Null, + this.OnlyRowSent().GetProperty("override_location_label").ValueKind); + } + + private static void AssertCarriedForward(JsonElement row) + { + Assert.Equal("work", row.GetProperty("override_location_label").GetString()); + Assert.Equal("terrigal", row.GetProperty("override_areas").EnumerateArray().Single().GetString()); + Assert.Equal(3, row.GetProperty("rarity").GetInt32()); + Assert.Equal(9000, row.GetProperty("costume").GetInt32()); + } + + private static Task UpdateAllDistance(object service, int distance) => service switch + { + IMonsterService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + IRaidService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + IEggService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + IQuestService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + IInvasionService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + ILureService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + INestService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + IGymService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + IFortChangeService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + IMaxBattleService s => s.UpdateDistanceByUserAsync("u1", 0, distance), + _ => throw new ArgumentOutOfRangeException(nameof(service)), + }; + + private static Task UpdateSelectedDistance(object service, int distance) => service switch + { + IMonsterService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + IRaidService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + IEggService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + IQuestService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + IInvasionService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + ILureService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + INestService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + IGymService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + IFortChangeService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + IMaxBattleService s => s.UpdateDistanceByUidsAsync([7], "u1", distance), + _ => throw new ArgumentOutOfRangeException(nameof(service)), + }; + + private JsonElement OnlyRowSent() + { + var body = Assert.Single(this._sent); + return body.ValueKind == JsonValueKind.Array ? body.EnumerateArray().Single() : body; + } + + private object ServiceFor(string trackingType) => trackingType switch + { + "pokemon" => new MonsterService(this._proxy.Object, this._featureGate.Object), + "raid" => new RaidService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "egg" => new EggService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "quest" => new QuestService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "invasion" => new InvasionService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "lure" => new LureService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "nest" => new NestService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "gym" => new GymService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "fort" => new FortChangeService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + "maxbattle" => new MaxBattleService( + this._proxy.Object, this._featureGate.Object, NullLogger.Instance, this._remapper.Object), + _ => throw new ArgumentOutOfRangeException(nameof(trackingType), trackingType, "unknown tracking type"), + }; +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UpdateCheckServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UpdateCheckServiceTests.cs new file mode 100644 index 00000000..e6e1a224 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UpdateCheckServiceTests.cs @@ -0,0 +1,193 @@ +using System.Net; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Moq.Protected; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Telling an admin they are behind, without telling them so when they are not. +/// +/// +/// The two projects publish differently and are read differently: PoracleWeb cuts GitHub releases, and +/// PoracleNG has neither releases nor tags, so its released number is the constant in +/// processor/version.go on main. That file is also what identifies a development build, since +/// develop carries the next version before it ships. +/// +public class UpdateCheckServiceTests +{ + private const string ReleaseJson = """{"tag_name":"v2.16.0","name":"v2.16.0"}"""; + private const string VersionGo = + """ + // Package processor exposes the PoracleNG processor version. + package processor + + // Version is the PoracleNG processor version. Bump on each release. + const Version = "5.1.0" + """; + + private readonly Mock _siteSettings = new(); + + private UpdateCheckService Service( + string releaseBody = ReleaseJson, + string versionBody = VersionGo, + bool disabled = false, + Exception? throws = null) + { + this._siteSettings.Setup(s => s.GetBoolAsync(UpdateCheckService.DisableKey)).ReturnsAsync(disabled); + + var handler = new Mock(); + handler.Protected() + .Setup>( + "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns((request, _) => + { + if (throws is not null) + { + throw throws; + } + + var body = request.RequestUri!.Host.Contains("raw.githubusercontent", StringComparison.Ordinal) + ? versionBody + : releaseBody; + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(body) }); + }); + + return new UpdateCheckService( + new HttpClient(handler.Object), + this._siteSettings.Object, + new MemoryCache(new MemoryCacheOptions()), + NullLogger.Instance); + } + + [Fact] + public async Task SaysWhenBothAreBehind() + { + var (web, ng) = await this.Service().CheckAsync("2.15.3", "5.0.4"); + + Assert.Equal(UpdateState.Behind, web.State); + Assert.Equal("v2.16.0", web.Latest); + Assert.Equal(UpdateState.Behind, ng.State); + Assert.Equal("5.1.0", ng.Latest); + } + + [Fact] + public async Task SaysNothingIsDueWhenBothMatch() + { + // The legitimate twin. A banner that appears on an up-to-date deployment is a banner nobody + // reads by the second week. + var (web, ng) = await this.Service().CheckAsync("2.16.0", "5.1.0"); + + Assert.Equal(UpdateState.UpToDate, web.State); + Assert.Equal(UpdateState.UpToDate, ng.State); + } + + [Fact] + public async Task AVersionAheadOfTheReleaseIsADevelopmentBuild() + { + // PoracleNG's develop carries 5.2.0 while main still reads 5.1.0, so this is how a develop build + // gives itself away -- the branch name never leaves the binary. + var (_, ng) = await this.Service().CheckAsync("2.16.0", "5.2.0"); + + Assert.Equal(UpdateState.PreRelease, ng.State); + } + + [Fact] + public async Task PoracleWebsBetaChannelIsNotComparedToAReleaseNumber() + { + // Dev runs an image tagged beta, which is not a point on the release line. + var (web, _) = await this.Service().CheckAsync("beta", "5.1.0"); + + Assert.Equal(UpdateState.Unknown, web.State); + } + + [Fact] + public async Task ALocalBuildIsNotReportedAsBehind() + { + var (web, ng) = await this.Service().CheckAsync("unknown", "0.0.0"); + + Assert.Equal(UpdateState.Unknown, web.State); + Assert.Equal(UpdateState.Unknown, ng.State); + } + + [Fact] + public async Task NothingLeavesTheDeploymentWhenTheCheckIsSwitchedOff() + { + var handlerCalls = 0; + this._siteSettings.Setup(s => s.GetBoolAsync(UpdateCheckService.DisableKey)).ReturnsAsync(true); + + var handler = new Mock(); + handler.Protected() + .Setup>( + "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .Returns(() => + { + handlerCalls++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{}") }); + }); + + var service = new UpdateCheckService( + new HttpClient(handler.Object), + this._siteSettings.Object, + new MemoryCache(new MemoryCacheOptions()), + NullLogger.Instance); + + var (web, ng) = await service.CheckAsync("2.15.3", "5.0.4"); + + Assert.Equal(0, handlerCalls); + Assert.Equal(UpdateState.Unknown, web.State); + Assert.Equal(UpdateState.Unknown, ng.State); + } + + [Fact] + public async Task GitHubBeingUnreachableIsNotNews() + { + var (web, ng) = await this.Service(throws: new HttpRequestException("no route")).CheckAsync("2.15.3", "5.0.4"); + + Assert.Equal(UpdateState.Unknown, web.State); + Assert.Equal(UpdateState.Unknown, ng.State); + } + + [Fact] + public async Task AVersionFileItCannotParseSaysNothing() + { + var (_, ng) = await this.Service(versionBody: "package processor // nothing here").CheckAsync("2.16.0", "5.1.0"); + + Assert.Equal(UpdateState.Unknown, ng.State); + Assert.Null(ng.Latest); + } + + [Fact] + public async Task OneProjectFailingDoesNotHideTheOther() + { + var (web, ng) = await this.Service(releaseBody: "not json at all").CheckAsync("2.15.3", "5.0.4"); + + Assert.Equal(UpdateState.Unknown, web.State); + Assert.Equal(UpdateState.Behind, ng.State); + } + + [Theory] + [InlineData("v2.16.0", "2.16.0", UpdateState.UpToDate)] + [InlineData("2.16.0", "v2.16.0", UpdateState.UpToDate)] + public void ALeadingVeeIsNotAVersionDifference(string running, string latest, UpdateState expected) + { + // PoracleWeb tags releases as v2.16.0 and reports itself as 2.16.0. + Assert.Equal(expected, UpdateStatus.Compare(running, latest).State); + } + + [Fact] + public async Task TheAnswerIsCachedRatherThanAskedPerPageLoad() + { + var service = this.Service(); + + await service.CheckAsync("2.15.3", "5.1.0"); + await service.CheckAsync("2.15.3", "5.1.0"); + + this._siteSettings.Verify(s => s.GetBoolAsync(UpdateCheckService.DisableKey), Times.Exactly(2)); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UpstreamFeatureFlagServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UpstreamFeatureFlagServiceTests.cs new file mode 100644 index 00000000..798cbe3b --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UpstreamFeatureFlagServiceTests.cs @@ -0,0 +1,189 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Poracle's own per-type disable flags, read as disable_* keys. See #769. +/// +public class UpstreamFeatureFlagServiceTests +{ + private readonly Mock _proxy = new(); + private readonly MemoryCache _cache = new(new MemoryCacheOptions()); + + private UpstreamFeatureFlagService CreateSut() => + new(this._proxy.Object, this._cache, NullLogger.Instance); + + private void UpstreamHooks(params string[] hooks) => + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig { DisabledHooks = [.. hooks] }); + + /// + /// What prod serves. An empty array is a positive statement that nothing is disabled upstream, + /// and must leave every type enabled rather than being read as "no data, assume the worst". + /// + [Fact] + public async Task EmptyDisabledHooksDisablesNothing() + { + this.UpstreamHooks(); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + /// + /// The trap in the mapping table. pokestop reads like the parent hook for lures, invasions + /// and quests, but nothing in the PoracleNG 5.1.0 processor consumes DisablePokestop, so + /// mapping it would take three working types away for a flag that does nothing upstream. + /// + [Fact] + public async Task PokestopInDisabledHooksDisablesNothing() + { + this.UpstreamHooks("pokestop"); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + /// PoracleWeb has no weather alarms, so the hook has nowhere to land. + [Fact] + public async Task WeatherInDisabledHooksDisablesNothing() + { + this.UpstreamHooks("weather"); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + [Theory] + [InlineData("pokemon", DisableFeatureKeys.Pokemon)] + [InlineData("raid", DisableFeatureKeys.Raids)] + [InlineData("quest", DisableFeatureKeys.Quests)] + [InlineData("invasion", DisableFeatureKeys.Invasions)] + [InlineData("lure", DisableFeatureKeys.Lures)] + [InlineData("nest", DisableFeatureKeys.Nests)] + [InlineData("gym", DisableFeatureKeys.Gyms)] + [InlineData("maxbattle", DisableFeatureKeys.MaxBattles)] + public async Task EachMappedHookDisablesExactlyItsOwnKey(string hook, string expectedKey) + { + this.UpstreamHooks(hook); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + + var keys = await this.CreateSut().GetDisabledKeysAsync(); + + Assert.Equal([expectedKey], keys); + } + + [Fact] + public async Task UnknownHookNamesAreIgnoredRatherThanGuessedAt() + { + this.UpstreamHooks("something_new_upstream"); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + // --- fort changes: enforced upstream, but absent from disabledHooks --- + + [Fact] + public async Task FortUpdateDisabledFlagDisablesFortChanges() + { + this.UpstreamHooks(); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(true); + + Assert.Equal([DisableFeatureKeys.FortChanges], await this.CreateSut().GetDisabledKeysAsync()); + } + + [Fact] + public async Task UndeterminableFortUpdateFlagDisablesNothing() + { + this.UpstreamHooks(); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync((bool?)null); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + /// + /// PoracleJS does not serve /api/config/values at all. Losing that read must not discard + /// the hook list already in hand. + /// + [Fact] + public async Task FailedFortUpdateReadKeepsTheHooksAlreadyResolved() + { + this.UpstreamHooks("lure"); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ThrowsAsync(new HttpRequestException("no such route")); + + Assert.Equal([DisableFeatureKeys.Lures], await this.CreateSut().GetDisabledKeysAsync()); + } + + // --- degradation: the site settings must stay in sole charge --- + + /// + /// An older Poracle or PoracleJS omits the field entirely. Absent is not "everything is off". + /// + [Fact] + public async Task AbsentDisabledHooksFieldDisablesNothing() + { + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync(new PoracleConfig()); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync((bool?)null); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + [Fact] + public async Task NullConfigDisablesNothing() + { + this._proxy.Setup(p => p.GetConfigAsync()).ReturnsAsync((PoracleConfig?)null); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync((bool?)null); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + /// + /// Failing closed would let a Poracle outage disable every alarm type for every user - a far + /// worse failure than the one this feature exists to prevent. + /// + [Fact] + public async Task UnreachablePoracleDisablesNothing() + { + this._proxy.Setup(p => p.GetConfigAsync()).ThrowsAsync(new HttpRequestException("connection refused")); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ThrowsAsync(new HttpRequestException("connection refused")); + + Assert.Empty(await this.CreateSut().GetDisabledKeysAsync()); + } + + [Fact] + public async Task ResultIsCachedSoTheGateDoesNotCallUpstreamPerRequest() + { + this.UpstreamHooks("nest"); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + var sut = this.CreateSut(); + + await sut.GetDisabledKeysAsync(); + await sut.GetDisabledKeysAsync(); + await sut.GetDisabledKeysAsync(); + + this._proxy.Verify(p => p.GetConfigAsync(), Times.Once); + this._proxy.Verify(p => p.GetFortUpdateDisabledAsync(), Times.Once); + } + + /// + /// The cache is server-wide, not per-request: the service is scoped, so a fresh instance on the + /// next request must still hit the cached value. + /// + [Fact] + public async Task CacheIsSharedAcrossInstances() + { + this.UpstreamHooks("gym"); + this._proxy.Setup(p => p.GetFortUpdateDisabledAsync()).ReturnsAsync(false); + + await this.CreateSut().GetDisabledKeysAsync(); + var second = await this.CreateSut().GetDisabledKeysAsync(); + + Assert.Equal([DisableFeatureKeys.Gyms], second); + this._proxy.Verify(p => p.GetConfigAsync(), Times.Once); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs index 45cb2d50..e8965f97 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Moq; using Pgan.PoracleWebNet.Core.Abstractions.Repositories; @@ -17,10 +18,17 @@ public class UserGeofenceServiceTests private readonly Mock _humanRepo = new(); private readonly Mock _areaWriter = new(); private readonly Mock _discordNotificationService = new(); + private readonly Mock _featureGate = new(); + private readonly IConfiguration _configuration = new ConfigurationBuilder().Build(); private readonly Mock> _logger = new(); private readonly UserGeofenceService _sut; - public UserGeofenceServiceTests() => this._sut = new UserGeofenceService( + public UserGeofenceServiceTests() + { + // Features are on unless a test says otherwise. + this._featureGate.Setup(g => g.IsEnabledAsync(It.IsAny())).ReturnsAsync(true); + + this._sut = new UserGeofenceService( this._repository.Object, this._kojiService.Object, this._poracleApiProxy.Object, @@ -28,7 +36,10 @@ public class UserGeofenceServiceTests this._humanRepo.Object, this._areaWriter.Object, this._discordNotificationService.Object, + this._featureGate.Object, + this._configuration, this._logger.Object); + } /// /// Helper: creates a JsonElement matching what IPoracleHumanProxy.GetHumanAsync returns. @@ -65,6 +76,63 @@ public async Task GetByUserAsyncReturnsGeofencesFromRepositoryWithPolygons() Assert.Equal(polygon[0][1], result[0].Polygon![0][1]); } + // --- Feature gate (#214 disable_user_geofences) --- + + /// + /// Creating a geofence subscribes the current profile to it, which is an area write -- and it ran + /// straight past disable_areas while every documented area path was refused, so drawing and deleting + /// fences was a way to edit area subscriptions with the switch on. See #505. + /// + [Fact] + public async Task CreateAsyncDoesNotSubscribeTheProfileWhileAreasAreDisabled() + { + this._featureGate.Setup(g => g.IsEnabledAsync(DisableFeatureKeys.Areas)).ReturnsAsync(false); + this._repository.Setup(r => r.GetAllAsync()).ReturnsAsync([]); + this._repository.Setup(r => r.CreateAsync(It.IsAny())) + .ReturnsAsync((UserGeofence g) => g); + + await this._sut.CreateAsync("u1", 1, new UserGeofenceCreate + { + DisplayName = "Test", + Polygon = [[37.66, -77.60], [37.67, -77.60], [37.67, -77.59]], + }); + + this._areaWriter.Verify( + w => w.AddAreaToActiveProfileAsync(It.IsAny(), It.IsAny()), Times.Never); + // The geofence itself is not the gated feature -- it still gets created, just inactive. + this._repository.Verify(r => r.CreateAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task CreateAsyncThrowsFeatureDisabledWhenGateDisabled() + { + this._featureGate + .Setup(g => g.EnsureEnabledAsync(DisableFeatureKeys.UserGeofences)) + .ThrowsAsync(new FeatureDisabledException(DisableFeatureKeys.UserGeofences)); + + var ex = await Assert.ThrowsAsync( + () => this._sut.CreateAsync("u1", 1, new UserGeofenceCreate { DisplayName = "Test" })); + + Assert.Equal(DisableFeatureKeys.UserGeofences, ex.DisableKey); + + // Gate runs first — no repository work should happen. + this._repository.Verify(r => r.GetCountByHumanIdAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SubmitForReviewAsyncThrowsFeatureDisabledWhenGateDisabled() + { + this._featureGate + .Setup(g => g.EnsureEnabledAsync(DisableFeatureKeys.UserGeofences)) + .ThrowsAsync(new FeatureDisabledException(DisableFeatureKeys.UserGeofences)); + + var ex = await Assert.ThrowsAsync( + () => this._sut.SubmitForReviewAsync("u1", "downtown")); + + Assert.Equal(DisableFeatureKeys.UserGeofences, ex.DisableKey); + this._repository.Verify(r => r.GetByKojiNameAsync(It.IsAny()), Times.Never); + } + // --- CreateAsync --- [Fact] @@ -225,7 +293,7 @@ public async Task DeleteAsyncThrowsWhenGeofenceNotFound() { this._repository.Setup(r => r.GetByIdAsync(99)).ReturnsAsync((UserGeofence?)null); - await Assert.ThrowsAsync(() => this._sut.DeleteAsync("u1", 1, 99)); + await Assert.ThrowsAsync(() => this._sut.DeleteAsync("u1", 1, 99)); } // --- SubmitForReviewAsync --- @@ -236,9 +304,8 @@ public async Task SubmitForReviewAsyncUpdatesStatusToPendingReview() var geofence = new UserGeofence { Id = 1, HumanId = "u1", KojiName = "downtown", Status = "active", PolygonJson = "[[1,2],[3,4],[5,6]]" }; this._repository.Setup(r => r.GetByKojiNameAsync("downtown")).ReturnsAsync(geofence); this._repository.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync((UserGeofence g) => g); - this._humanRepo.Setup(r => r.GetByIdAndProfileAsync("u1", 1)).ReturnsAsync(new Human { Id = "u1", Name = "TestUser" }); - this._discordNotificationService.Setup(d => d.CreateGeofenceSubmissionPostAsync( - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + this._humanRepo.Setup(r => r.GetByIdAsync("u1")).ReturnsAsync(new Human { Id = "u1", Name = "TestUser" }); + this._discordNotificationService.Setup(d => d.CreateGeofenceSubmissionPostAsync(It.IsAny())) .ReturnsAsync((string?)null); var result = await this._sut.SubmitForReviewAsync("u1", "downtown"); @@ -270,7 +337,7 @@ public async Task SubmitForReviewAsyncThrowsWhenGeofenceNotFound() { this._repository.Setup(r => r.GetByKojiNameAsync("missing")).ReturnsAsync((UserGeofence?)null); - await Assert.ThrowsAsync(() => this._sut.SubmitForReviewAsync("u1", "missing")); + await Assert.ThrowsAsync(() => this._sut.SubmitForReviewAsync("u1", "missing")); } [Fact] @@ -279,9 +346,10 @@ public async Task SubmitForReviewAsyncSavesDiscordThreadId() var geofence = new UserGeofence { Id = 1, HumanId = "u1", KojiName = "downtown", DisplayName = "Downtown", GroupName = "City", Status = "active", PolygonJson = "[[1,2],[3,4],[5,6]]" }; this._repository.Setup(r => r.GetByKojiNameAsync("downtown")).ReturnsAsync(geofence); this._repository.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync((UserGeofence g) => g); - this._humanRepo.Setup(r => r.GetByIdAndProfileAsync("u1", 1)).ReturnsAsync(new Human { Id = "u1", Name = "TestUser" }); + this._humanRepo.Setup(r => r.GetByIdAsync("u1")).ReturnsAsync(new Human { Id = "u1", Name = "TestUser" }); this._discordNotificationService.Setup(d => d.CreateGeofenceSubmissionPostAsync( - "u1", "TestUser", "Downtown", "City", 3, It.IsAny())) + It.Is(p => p.UserId == "u1" && p.UserName == "TestUser" + && p.DisplayName == "Downtown" && p.GroupName == "City" && p.PublicName == "downtown"))) .ReturnsAsync("thread_123"); var result = await this._sut.SubmitForReviewAsync("u1", "downtown"); @@ -319,6 +387,66 @@ public async Task ApproveSubmissionAsyncSavesToKojiAndUpdatesStatus() this._poracleApiProxy.Verify(p => p.ReloadGeofencesAsync(), Times.Once); } + [Fact] + public async Task ApproveSubmissionAsyncAppliesAdminRegionOverride() + { + // A geofence created without a region (parentId 0, empty group) — issue #314 — that the admin + // assigns a region to at approval time. + var polygon = new[] { new[] { 1.0, 2.0 }, [3.0, 4.0], [5.0, 6.0] }; + var geofence = new UserGeofence + { + Id = 1, + HumanId = "u1", + KojiName = "downtown", + DisplayName = "Downtown", + GroupName = string.Empty, + ParentId = 0, + Status = "pending_review", + PolygonJson = JsonSerializer.Serialize(polygon) + }; + this._repository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(geofence); + UserGeofence? saved = null; + this._repository.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync((UserGeofence g) => + { + saved = g; + return g; + }); + + this._kojiService.Setup(k => k.GetRegionsAsync()).ReturnsAsync([new GeofenceRegion { Id = 42, Name = "city" }]); + + await this._sut.ApproveSubmissionAsync("admin1", 1, null, parentId: 42, groupName: "City"); + + // Override is sent to Koji and persisted on the record. + this._kojiService.Verify(k => k.SaveGeofenceAsync("downtown", "Downtown", "City", 42, It.IsAny(), true), Times.Once); + Assert.NotNull(saved); + Assert.Equal(42, saved!.ParentId); + Assert.Equal("City", saved.GroupName); + } + + [Fact] + public async Task ApproveSubmissionAsyncKeepsExistingRegionWhenOverrideOmitted() + { + var polygon = new[] { new[] { 1.0, 2.0 }, [3.0, 4.0], [5.0, 6.0] }; + var geofence = new UserGeofence + { + Id = 1, + HumanId = "u1", + KojiName = "downtown", + DisplayName = "Downtown", + GroupName = "City", + ParentId = 5, + Status = "pending_review", + PolygonJson = JsonSerializer.Serialize(polygon) + }; + this._repository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(geofence); + this._repository.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync((UserGeofence g) => g); + + await this._sut.ApproveSubmissionAsync("admin1", 1, null); + + // Null override args leave the submission's region untouched. + this._kojiService.Verify(k => k.SaveGeofenceAsync("downtown", "Downtown", "City", 5, It.IsAny(), true), Times.Once); + } + [Fact] public async Task ApproveSubmissionAsyncUsesPromotedNameForKoji() { @@ -344,15 +472,20 @@ public async Task ApproveSubmissionAsyncUsesPromotedNameForKoji() this._kojiService.Verify(k => k.SaveGeofenceAsync("Downtown Official", "Downtown", "City", 5, It.IsAny(), true), Times.Once); } - [Fact] - public async Task ApproveSubmissionAsyncSwapsAreaNameWhenPromotedNameDiffers() + // This used to assert that approve calls SetAreasAsync with a swapped list. That WAS the bug: + // PoracleNG intersects the submitted list against userSelectable=true fences for non-admins, so it + // stripped both the old name (user geofences are served userSelectable=false) and the promoted one + // (not yet in PoracleNG's fence list — the reload runs afterwards). The owner lost their whole + // custom-geofence subscription set and approve still returned 200. See #408. + + private UserGeofence SeedPendingGeofence(string kojiName = "downtown") { var polygon = new[] { new[] { 1.0, 2.0 }, [3.0, 4.0], [5.0, 6.0] }; var geofence = new UserGeofence { Id = 1, HumanId = "u1", - KojiName = "downtown", + KojiName = kojiName, DisplayName = "Downtown", GroupName = "City", ParentId = 5, @@ -361,13 +494,54 @@ public async Task ApproveSubmissionAsyncSwapsAreaNameWhenPromotedNameDiffers() }; this._repository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(geofence); this._repository.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync((UserGeofence g) => g); - this._humanProxy.Setup(p => p.GetHumanAsync("u1")).ReturnsAsync(MakeHumanJson("u1", "[\"downtown\",\"other\"]")); + return geofence; + } + + [Fact] + public async Task ApproveSubmissionAsyncMovesTheOwnerSubscriptionToThePromotedName() + { + this.SeedPendingGeofence(); + + await this._sut.ApproveSubmissionAsync("admin1", 1, "New Downtown"); + + this._areaWriter.Verify(w => w.RenameAreaInAllProfilesAsync("u1", "downtown", "New Downtown"), Times.Once); + } + + [Fact] + public async Task ApproveSubmissionAsyncNeverGoesThroughSetAreas() + { + this.SeedPendingGeofence(); await this._sut.ApproveSubmissionAsync("admin1", 1, "New Downtown"); - // Verify proxy was called with swapped area names - this._humanProxy.Verify(p => p.SetAreasAsync("u1", It.Is(a => - !a.Contains("downtown") && a.Contains("new downtown") && a.Contains("other"))), Times.Once); + this._humanProxy.Verify(p => p.SetAreasAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApproveSubmissionAsyncTouchesNoAreasWhenTheNameIsUnchanged() + { + this.SeedPendingGeofence(); + + await this._sut.ApproveSubmissionAsync("admin1", 1, null); + + this._areaWriter.Verify( + w => w.RenameAreaInAllProfilesAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + this._humanProxy.Verify(p => p.SetAreasAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ApproveSubmissionAsyncStillApprovesWhenTheSubscriptionMoveFails() + { + // The fence is already public in Koji by this point, so the approval stands; the owner losing + // their subscription is logged rather than rolled back into a failed approve. + this.SeedPendingGeofence(); + this._areaWriter + .Setup(w => w.RenameAreaInAllProfilesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("db down")); + + var result = await this._sut.ApproveSubmissionAsync("admin1", 1, "New Downtown"); + + Assert.Equal("approved", result.Status); } [Fact] @@ -417,7 +591,7 @@ public async Task ApproveSubmissionAsyncThrowsWhenNotFound() { this._repository.Setup(r => r.GetByIdAsync(99)).ReturnsAsync((UserGeofence?)null); - await Assert.ThrowsAsync(() => this._sut.ApproveSubmissionAsync("admin1", 99, null)); + await Assert.ThrowsAsync(() => this._sut.ApproveSubmissionAsync("admin1", 99, null)); } [Fact] @@ -449,7 +623,9 @@ public async Task ApproveSubmissionAsyncPostsDiscordApprovalWhenThreadExists() await this._sut.ApproveSubmissionAsync("admin1", 1, null); - this._discordNotificationService.Verify(d => d.PostApprovalMessageAsync("thread_456", "Downtown", "Downtown"), Times.Once); + this._discordNotificationService.Verify(d => d.PostReviewOutcomeAsync( + "thread_456", + It.Is(p => p.State == GeofenceReviewState.Approved && p.DisplayName == "Downtown")), Times.Once); } // --- RejectSubmissionAsync --- @@ -474,7 +650,7 @@ public async Task RejectSubmissionAsyncThrowsWhenNotFound() { this._repository.Setup(r => r.GetByIdAsync(99)).ReturnsAsync((UserGeofence?)null); - await Assert.ThrowsAsync(() => this._sut.RejectSubmissionAsync("admin1", 99, "Reason")); + await Assert.ThrowsAsync(() => this._sut.RejectSubmissionAsync("admin1", 99, "Reason")); } [Fact] @@ -494,7 +670,9 @@ public async Task RejectSubmissionAsyncPostsDiscordRejectionWhenThreadExists() await this._sut.RejectSubmissionAsync("admin1", 1, "Overlaps existing"); - this._discordNotificationService.Verify(d => d.PostRejectionMessageAsync("thread_789", "Downtown", "Overlaps existing"), Times.Once); + this._discordNotificationService.Verify(d => d.PostReviewOutcomeAsync( + "thread_789", + It.Is(p => p.State == GeofenceReviewState.Rejected && p.ReviewNotes == "Overlaps existing")), Times.Once); } [Fact] @@ -506,7 +684,8 @@ public async Task RejectSubmissionAsyncSkipsDiscordWhenNoThreadId() await this._sut.RejectSubmissionAsync("admin1", 1, "No good"); - this._discordNotificationService.Verify(d => d.PostRejectionMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + this._discordNotificationService.Verify(d => d.PostReviewOutcomeAsync( + It.IsAny(), It.IsAny()), Times.Never); } // --- AdminDeleteAsync --- @@ -553,7 +732,7 @@ public async Task AdminDeleteAsyncThrowsWhenNotFound() { this._repository.Setup(r => r.GetByIdAsync(99)).ReturnsAsync((UserGeofence?)null); - await Assert.ThrowsAsync(() => this._sut.AdminDeleteAsync("admin1", 99)); + await Assert.ThrowsAsync(() => this._sut.AdminDeleteAsync("admin1", 99)); } // --- GetAllAsync --- @@ -708,7 +887,10 @@ public async Task GetAllWithDetailsAsyncReturnsEmptyListWithoutErrors() var result = await this._sut.GetAllWithDetailsAsync(); Assert.Empty(result); - this._humanRepo.Verify(r => r.GetByIdsAsync(It.Is>(ids => !ids.Any())), Times.Once); + + // No rows, no lookup. The enrichment is shared with approve and reject now (#618) and returns + // early on an empty list rather than asking the humans table about nobody. + this._humanRepo.Verify(r => r.GetByIdsAsync(It.IsAny>()), Times.Never); } [Fact] @@ -952,7 +1134,7 @@ public async Task AddToProfileAsyncThrowsWhenGeofenceNotFound() { this._repository.Setup(r => r.GetByIdAsync(99)).ReturnsAsync((UserGeofence?)null); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => this._sut.AddToProfileAsync("u1", 1, 99)); } @@ -1080,4 +1262,410 @@ public async Task PreserveOwnedAreasInHumanAsyncReturnsEmptyWhenUserOwnsNothing( w => w.AddAreasToActiveProfileAsync(It.IsAny(), It.IsAny>()), Times.Never); } + + // ── Orphaned Koji geofences (#409) ────────────────────────────────────────── + // Approve pushes the polygon into the shared Koji project. Reject never undid that and accepted any + // status, and admin delete only cleaned Koji when status was exactly "approved" — so + // approve → reject → delete removed the local row and left a public, userSelectable fence in Koji + // that nothing could manage. Recovery meant hand-editing Koji. + + private UserGeofence SeedGeofence(string status, string? promotedName = null, string kojiName = "downtown") + { + var geofence = new UserGeofence + { + Id = 7, + HumanId = "u1", + KojiName = kojiName, + DisplayName = "Downtown", + Status = status, + PromotedName = promotedName, + PolygonJson = JsonSerializer.Serialize(new[] { new[] { 1.0, 2.0 }, [3.0, 4.0], [5.0, 6.0] }) + }; + this._repository.Setup(r => r.GetByIdAsync(7)).ReturnsAsync(geofence); + this._repository.Setup(r => r.UpdateAsync(It.IsAny())).ReturnsAsync((UserGeofence g) => g); + return geofence; + } + + [Theory] + [InlineData("active")] + [InlineData("approved")] + [InlineData("rejected")] + public async Task RejectSubmissionAsyncRefusesAnythingNotAwaitingReview(string status) + { + this.SeedGeofence(status); + + var ex = await Assert.ThrowsAsync( + () => this._sut.RejectSubmissionAsync("admin1", 7, "no")); + + Assert.Contains(status, ex.Message, StringComparison.Ordinal); + this._repository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RejectSubmissionAsyncStillWorksForAPendingSubmission() + { + this.SeedGeofence("pending_review"); + + var result = await this._sut.RejectSubmissionAsync("admin1", 7, "too small"); + + Assert.Equal("rejected", result.Status); + Assert.Equal("too small", result.ReviewNotes); + } + + [Fact] + public async Task AdminDeleteAsyncCleansKojiForAGeofenceThatWasApprovedThenRejected() + { + // The orphan case: status is no longer "approved" but the fence is still public in Koji. + this.SeedGeofence("rejected", promotedName: "Downtown Official"); + + await this._sut.AdminDeleteAsync("admin1", 7); + + this._kojiService.Verify(k => k.RemoveGeofenceFromProjectAsync("Downtown Official"), Times.Once); + } + + [Fact] + public async Task AdminDeleteAsyncRemovesThePromotedNameFromTheOwnersAreas() + { + // After approval the owner is subscribed under the promoted name, so removing the original + // left them subscribed to a fence that no longer exists. + this.SeedGeofence("rejected", promotedName: "Downtown Official"); + + await this._sut.AdminDeleteAsync("admin1", 7); + + this._areaWriter.Verify(w => w.RemoveAreaFromAllProfilesAsync("u1", "downtown official"), Times.Once); + } + + [Fact] + public async Task AdminDeleteAsyncTouchesKojiForANeverPromotedGeofence() + { + this.SeedGeofence("active"); + + await this._sut.AdminDeleteAsync("admin1", 7); + + this._kojiService.Verify(k => k.RemoveGeofenceFromProjectAsync(It.IsAny()), Times.Never); + this._areaWriter.Verify(w => w.RemoveAreaFromAllProfilesAsync("u1", "downtown"), Times.Once); + } + + [Fact] + public async Task DeleteAsyncCleansKojiWhenTheOwnerDeletesAPromotedGeofence() + { + // The owner's own delete had the identical hole. + this.SeedGeofence("approved", promotedName: "Downtown Official"); + + await this._sut.DeleteAsync("u1", 1, 7); + + this._kojiService.Verify(k => k.RemoveGeofenceFromProjectAsync("Downtown Official"), Times.Once); + this._areaWriter.Verify(w => w.RemoveAreaFromAllProfilesAsync("u1", "downtown official"), Times.Once); + } + + [Fact] + public async Task DeleteAsyncStillDeletesLocallyWhenKojiCleanupFails() + { + this.SeedGeofence("approved", promotedName: "Downtown Official"); + this._kojiService + .Setup(k => k.RemoveGeofenceFromProjectAsync(It.IsAny())) + .ThrowsAsync(new HttpRequestException("koji down")); + + await this._sut.DeleteAsync("u1", 1, 7); + + this._repository.Verify(r => r.DeleteAsync(7), Times.Once); + } + + // ── Polygon validation on create (#410) ───────────────────────────────────── + // Only the point count was checked, so malformed points reached the anonymous geofence feed — + // the single geofence source for PoracleJS — and crashed the owner's GeoJSON export. + + [Fact] + public async Task CreateAsyncRejectsPointsThatAreNotLatLonPairs() + { + var ex = await Assert.ThrowsAsync( + () => this._sut.CreateAsync("u1", 1, new UserGeofenceCreate + { + DisplayName = "ZZ Arity Chk", + Polygon = [[1.0], [2.0], [3.0]] + })); + + Assert.Contains("[latitude, longitude] pair", ex.Message, StringComparison.Ordinal); + this._repository.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateAsyncRejectsOutOfRangeCoordinates() + { + var ex = await Assert.ThrowsAsync( + () => this._sut.CreateAsync("u1", 1, new UserGeofenceCreate + { + DisplayName = "ZZ Range Chk", + Polygon = [[999, -999], [998, -998], [997, -997]] + })); + + Assert.Contains("out of valid range", ex.Message, StringComparison.Ordinal); + this._repository.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateAsyncStillEnforcesThePointCount() + { + await Assert.ThrowsAsync( + () => this._sut.CreateAsync("u1", 1, new UserGeofenceCreate + { + DisplayName = "ZZ Too Few", + Polygon = [[40, -75], [41, -75]] + })); + } + + // ── Approve state machine ─────────────────────────────────────────────────── + // Approve accepted any status, the same gap #409 closed on reject. + + [Theory] + [InlineData("pending_review")] + [InlineData("rejected")] + public async Task ApproveSubmissionAsyncAllowsAwaitingReviewAndReconsideredRejections(string status) + { + this.SeedGeofence(status); + + var result = await this._sut.ApproveSubmissionAsync("admin1", 7, null); + + Assert.Equal("approved", result.Status); + } + + [Fact] + public async Task ApproveSubmissionAsyncRefusesAGeofenceThatWasNeverSubmitted() + { + // No review thread, and the owner never asked for it to be public. + this.SeedGeofence("active"); + + var ex = await Assert.ThrowsAsync( + () => this._sut.ApproveSubmissionAsync("admin1", 7, null)); + + Assert.Contains("active", ex.Message, StringComparison.Ordinal); + this._kojiService.Verify( + k => k.SaveGeofenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApproveSubmissionAsyncRefusesAnAlreadyApprovedGeofence() + { + // Re-approving under a different promoted name would push a second Koji entry and strand the + // first — the exact leak #409 closed. + this.SeedGeofence("approved", promotedName: "Downtown Official"); + + await Assert.ThrowsAsync( + () => this._sut.ApproveSubmissionAsync("admin1", 7, "Downtown Renamed")); + + this._kojiService.Verify( + k => k.SaveGeofenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApproveSubmissionAsyncChecksTheStatusBeforeTouchingKoji() + { + // The guard has to run before SaveGeofenceAsync, or a refused approval still publishes the fence. + this.SeedGeofence("active"); + + await Assert.ThrowsAsync( + () => this._sut.ApproveSubmissionAsync("admin1", 7, null)); + + this._repository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + this._areaWriter.Verify( + w => w.RenameAreaInAllProfilesAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // ── Koji failures during approve (#422) ───────────────────────────────────── + // EnsureSuccessStatusCode threw a bare HttpRequestException that no controller caught, so an unknown + // parent id -- or Koji simply being down -- reached the admin as an opaque 500. + + [Fact] + public async Task ApproveSubmissionAsyncRejectsAParentRegionKojiDoesNotKnow() + { + this.SeedPendingGeofence(); + this._kojiService.Setup(k => k.GetRegionsAsync()) + .ReturnsAsync([new GeofenceRegion { Id = 5, Name = "city" }]); + + var ex = await Assert.ThrowsAsync( + () => this._sut.ApproveSubmissionAsync("admin1", 1, null, parentId: 999999999)); + + Assert.Contains("999999999", ex.Message, StringComparison.Ordinal); + this._kojiService.Verify( + k => k.SaveGeofenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ApproveSubmissionAsyncAcceptsAParentRegionKojiKnows() + { + this.SeedPendingGeofence(); + this._kojiService.Setup(k => k.GetRegionsAsync()) + .ReturnsAsync([new GeofenceRegion { Id = 42, Name = "city" }]); + + var result = await this._sut.ApproveSubmissionAsync("admin1", 1, null, parentId: 42); + + Assert.Equal("approved", result.Status); + } + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public async Task ApproveSubmissionAsyncTreatsNonPositiveParentsAsNoRegion(int parentId) + { + // Documented behaviour from #314: a region-less geofence sends null to Koji. -5 is not a new case. + this.SeedPendingGeofence(); + + var result = await this._sut.ApproveSubmissionAsync("admin1", 1, null, parentId: parentId); + + Assert.Equal("approved", result.Status); + this._kojiService.Verify(k => k.GetRegionsAsync(), Times.Never); + } + + [Fact] + public async Task ApproveSubmissionAsyncSurfacesAKojiFailureAsATypedError() + { + this.SeedPendingGeofence(); + this._kojiService + .Setup(k => k.SaveGeofenceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new KojiOperationException("geofence save", System.Net.HttpStatusCode.InternalServerError, "boom")); + + await Assert.ThrowsAsync( + () => this._sut.ApproveSubmissionAsync("admin1", 1, null)); + + // The submission must be left alone so the admin can retry once Koji is back. + this._repository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + // ── Name collisions across both geofence sources (#475) ───────────────── + // The comment claimed "our DB + Koji"; only user_geofences was ever consulted, so a user could + // take a name an admin area already held. Both then reach PoracleJS through one feed under one + // name, and approving the private one upserts Koji by __name - overwriting the real area. + + [Fact] + public async Task CreateAsyncSuffixesAroundAnExistingKojiAreaName() + { + this._repository.Setup(r => r.GetAllAsync()).ReturnsAsync([]); + this._kojiService.Setup(k => k.GetAdminGeofencesAsync()) + .ReturnsAsync([new AdminGeofence { Id = 1, Name = "nyack" }]); + this._repository.Setup(r => r.CreateAsync(It.IsAny())) + .ReturnsAsync((UserGeofence g) => g); + + var result = await this._sut.CreateAsync("u1", 1, new UserGeofenceCreate + { + DisplayName = "Nyack", + Polygon = [[40, -73], [41, -73], [41, -74]], + }); + + Assert.NotEqual("nyack", result.KojiName); + } + + [Fact] + public async Task CreateAsyncMatchesKojiNamesCaseInsensitively() + { + // Poracle area matching is case-insensitive in practice, so lowercasing alone does not + // separate "Nyack" from "nyack". + this._repository.Setup(r => r.GetAllAsync()).ReturnsAsync([]); + this._kojiService.Setup(k => k.GetAdminGeofencesAsync()) + .ReturnsAsync([new AdminGeofence { Id = 1, Name = "NYACK" }]); + this._repository.Setup(r => r.CreateAsync(It.IsAny())) + .ReturnsAsync((UserGeofence g) => g); + + var result = await this._sut.CreateAsync("u1", 1, new UserGeofenceCreate + { + DisplayName = "nyack", + Polygon = [[40, -73], [41, -73], [41, -74]], + }); + + Assert.NotEqual("nyack", result.KojiName); + } + + [Fact] + public async Task CreateAsyncStillWorksWhenKojiIsUnreachable() + { + // A Koji outage must not block geofence creation - the feed degrades the same way. + this._repository.Setup(r => r.GetAllAsync()).ReturnsAsync([]); + this._kojiService.Setup(k => k.GetAdminGeofencesAsync()) + .ThrowsAsync(new HttpRequestException("koji down")); + this._repository.Setup(r => r.CreateAsync(It.IsAny())) + .ReturnsAsync((UserGeofence g) => g); + + var result = await this._sut.CreateAsync("u1", 1, new UserGeofenceCreate + { + DisplayName = "Somewhere New", + Polygon = [[40, -73], [41, -73], [41, -74]], + }); + + Assert.Equal("somewhere new", result.KojiName); + } + + // ── Rename guards (#646, #648) ────────────────────────────────────── + + [Theory] + [InlineData("approved")] + [InlineData("pending_review")] + public async Task RenameIsRefusedOnceTheGeofenceHasLeftTheOwnersHands(string status) + { + // Approval moves the fence to Koji under PromotedName and the area lists hold that name, so a + // rename rewrote the subscription to a name nothing serves. See #646. + this._repository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new UserGeofence + { + Id = 1, + HumanId = "u1", + KojiName = "downtown", + DisplayName = "Downtown", + Status = status, + }); + + await Assert.ThrowsAsync( + () => this._sut.RenameAsync("u1", 1, "Uptown", null, null)); + + this._repository.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RenameKeepsTheRegionWhenTheDialogSendsNoSelection() + { + // 0 is the dialog's "nothing selected", not a request to clear the parent. Taken literally it + // wiped the region of every renamed geofence. See #648. + this._repository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new UserGeofence + { + Id = 1, + HumanId = "u1", + KojiName = "downtown", + DisplayName = "Downtown", + Status = "active", + ParentId = 42, + GroupName = "North", + }); + UserGeofence? saved = null; + this._repository.Setup(r => r.UpdateAsync(It.IsAny())) + .Callback(g => saved = g) + .ReturnsAsync((UserGeofence g) => g); + + await this._sut.RenameAsync("u1", 1, "Uptown", null, 0); + + Assert.Equal(42, saved?.ParentId); + Assert.Equal("North", saved?.GroupName); + } + + [Fact] + public async Task RenameStillAppliesARegionThatWasActuallyChosen() + { + this._repository.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(new UserGeofence + { + Id = 1, + HumanId = "u1", + KojiName = "downtown", + DisplayName = "Downtown", + Status = "active", + ParentId = 42, + }); + UserGeofence? saved = null; + this._repository.Setup(r => r.UpdateAsync(It.IsAny())) + .Callback(g => saved = g) + .ReturnsAsync((UserGeofence g) => g); + + await this._sut.RenameAsync("u1", 1, "Uptown", "South", 7); + + Assert.Equal(7, saved?.ParentId); + Assert.Equal("South", saved?.GroupName); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UserOwnedOverrideAreaProxyTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UserOwnedOverrideAreaProxyTests.cs new file mode 100644 index 00000000..2925812f --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UserOwnedOverrideAreaProxyTests.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Confining an alarm to a geofence the user drew themselves. +/// +/// PoracleNG validates override_areas against GetAvailableAreas, which filters on +/// userSelectable; PoracleWeb serves user-drawn fences with userSelectable: false, so +/// submitting one is refused with 400 and the whole write fails. Matching never consults that flag, so +/// the name is sent separately, straight into the column, and matches normally. +/// +/// +public class UserOwnedOverrideAreaProxyTests +{ + private const string User = "u1"; + + private readonly Mock _inner = new(); + private readonly Mock _geofences = new(); + private readonly Mock _writer = new(); + private readonly List _sentToPoracle = []; + + public UserOwnedOverrideAreaProxyTests() + { + this._inner + .Setup(p => p.CreateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, body) => this._sentToPoracle.Add(body.Clone())) + .ReturnsAsync(new TrackingCreateResult([7], 0, 0, 1)); + this._inner.Setup(p => p.ReloadStateAsync()).Returns(Task.CompletedTask); + this._geofences.Setup(r => r.GetByHumanIdAsync(It.IsAny())) + .ReturnsAsync([new UserGeofence { KojiName = "back garden", HumanId = User }]); + this._writer + .Setup(w => w.SetAlarmOverrideAreasAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync(true); + } + + private UserOwnedOverrideAreaProxy Proxy() => new( + this._inner.Object, + this._geofences.Object, + this._writer.Object, + NullLogger.Instance); + + private static JsonElement Row(string json) => JsonDocument.Parse(json).RootElement.Clone(); + + [Fact] + public async Task OwnedGeofenceIsStrippedFromThePoracleWriteAndWrittenDirectly() + { + await this.Proxy().CreateAsync("pokemon", User, Row( + """{"uid":7,"pokemon_id":201,"distance":0,"override_areas":["back garden"]}""")); + + // PoracleNG would refuse the whole request if it saw the name, so the property goes away entirely + // rather than being sent as an empty list. + Assert.False(Assert.Single(this._sentToPoracle).TryGetProperty("override_areas", out _)); + + this._writer.Verify( + w => w.SetAlarmOverrideAreasAsync( + User, "pokemon", 7, It.Is>(a => a.Contains("back garden"))), + Times.Once); + } + + [Fact] + public async Task PermittedAreasStillGoToPoracleAlongsideTheOwnedOne() + { + // The legitimate-case half: an admin area must keep travelling the normal path, and the column + // write has to carry BOTH names or the alarm quietly loses the public area. + await this.Proxy().CreateAsync("raid", User, Row( + """{"uid":7,"level":5,"distance":0,"override_areas":["terrigal","back garden"]}""")); + + var sent = Assert.Single(this._sentToPoracle).GetProperty("override_areas"); + Assert.Equal("terrigal", Assert.Single(sent.EnumerateArray()).GetString()); + + this._writer.Verify( + w => w.SetAlarmOverrideAreasAsync( + User, "raid", 7, + It.Is>(a => a.Count == 2 && a.Contains("terrigal") && a.Contains("back garden"))), + Times.Once); + } + + [Fact] + public async Task AdminOnlyAreasNeverTouchTheDatabase() + { + await this.Proxy().CreateAsync("pokemon", User, Row( + """{"uid":7,"pokemon_id":201,"distance":0,"override_areas":["terrigal"]}""")); + + var sent = Assert.Single(this._sentToPoracle).GetProperty("override_areas"); + Assert.Equal("terrigal", Assert.Single(sent.EnumerateArray()).GetString()); + this._writer.VerifyNoOtherCalls(); + } + + [Fact] + public async Task AWriteWithNoOverrideCostsNoGeofenceLookup() + { + // Every ordinary alarm write goes through here. It must not pay for a feature it does not use. + await this.Proxy().CreateAsync("pokemon", User, Row("""{"uid":7,"pokemon_id":201,"distance":500}""")); + + this._geofences.VerifyNoOtherCalls(); + this._writer.VerifyNoOtherCalls(); + Assert.Single(this._sentToPoracle); + } + + [Fact] + public async Task StateIsReloadedSoTheDirectWriteTakesEffect() + { + await this.Proxy().CreateAsync("pokemon", User, Row( + """{"uid":7,"pokemon_id":201,"distance":0,"override_areas":["back garden"]}""")); + + this._inner.Verify(p => p.ReloadStateAsync(), Times.Once); + } + + [Fact] + public async Task AMissingRowAfterTheWriteIsAnErrorRatherThanASilentlyWiderAlarm() + { + this._writer + .Setup(w => w.SetAlarmOverrideAreasAsync( + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())) + .ReturnsAsync(false); + + await Assert.ThrowsAsync(() => this.Proxy().CreateAsync("pokemon", User, Row( + """{"uid":7,"pokemon_id":201,"distance":0,"override_areas":["back garden"]}"""))); + } + + [Theory] + [InlineData("""{"override_location_label":"home","override_areas":["terrigal"],"distance":500}""", + "place or to areas")] + [InlineData("""{"override_areas":["terrigal"],"distance":500}""", "cannot also have a radius")] + [InlineData("""{"override_location_label":"home","distance":0}""", "needs a radius")] + public async Task AnIncoherentScopeIsRefusedBeforeAnythingIsWritten(string body, string expected) + { + var error = await Assert.ThrowsAsync( + () => this.Proxy().CreateAsync("pokemon", User, Row(body))); + + Assert.Contains(expected, error.Message, StringComparison.Ordinal); + Assert.Empty(this._sentToPoracle); + } + + [Theory] + [InlineData("""{"override_location_label":"home","distance":500}""")] + [InlineData("""{"override_areas":["terrigal"],"distance":0}""")] + [InlineData("""{"distance":500}""")] + public async Task ACoherentScopeIsLetThrough(string body) + { + // Each of the three refusals above needs its legitimate twin, or the guard is free to refuse + // everything and still pass its tests. + await this.Proxy().CreateAsync("pokemon", User, Row(body)); + + Assert.Single(this._sentToPoracle); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UserPurgeServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UserPurgeServiceTests.cs new file mode 100644 index 00000000..f339ac6e --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UserPurgeServiceTests.cs @@ -0,0 +1,120 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Pgan.PoracleWebNet.Core.Abstractions.Repositories; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// Deleting a user removed the humans row alone. Alarms, geofences, delegate grants and quick picks all +/// stayed — unreachable, so it looked deleted, until the same id was created again and adopted the lot. +/// See #510, #511, #512. +/// +public class UserPurgeServiceTests +{ + private readonly Mock _humans = new(); + private readonly Mock _geofences = new(); + private readonly Mock _geofenceService = new(); + private readonly Mock _delegates = new(); + private readonly Mock _quickPicks = new(); + private readonly Mock _appliedStates = new(); + private readonly Mock _humanService = new(); + private readonly UserPurgeService _sut; + + public UserPurgeServiceTests() + { + this._humans.Setup(r => r.ExistsAsync("u1")).ReturnsAsync(true); + this._humans.Setup(r => r.DeleteUserAsync("u1")).ReturnsAsync(true); + this._geofences.Setup(r => r.GetByHumanIdAsync("u1")).ReturnsAsync([]); + this._quickPicks.Setup(r => r.GetByOwnerAsync("u1")).ReturnsAsync([]); + + this._sut = new UserPurgeService( + this._humans.Object, + this._geofences.Object, + this._geofenceService.Object, + this._delegates.Object, + this._quickPicks.Object, + this._appliedStates.Object, + this._humanService.Object, + NullLogger.Instance); + } + + [Fact] + public async Task PurgeRemovesEverythingTheAccountOwns() + { + this._geofences.Setup(r => r.GetByHumanIdAsync("u1")) + .ReturnsAsync([new UserGeofence { Id = 7, HumanId = "u1", KojiName = "zz" }]); + this._quickPicks.Setup(r => r.GetByOwnerAsync("u1")) + .ReturnsAsync([new QuickPickDefinition { Id = "p1", Name = "P", AlarmType = "monster", OwnerUserId = "u1" }]); + + Assert.True(await this._sut.PurgeAsync("u1")); + + this._humanService.Verify(s => s.DeleteAllAlarmsByUserAsync("u1"), Times.Once); + // Through the service, not the repository: a promoted fence must also leave the shared Koji project, + // and Poracle has to re-read the feed. See #511. + this._geofenceService.Verify(s => s.AdminDeleteAsync("u1", 7), Times.Once); + this._delegates.Verify(r => r.RemoveAllForIdAsync("u1"), Times.Once); + this._appliedStates.Verify(r => r.DeleteByUserAsync("u1"), Times.Once); + this._quickPicks.Verify(r => r.DeleteByIdAndOwnerAsync("p1", "u1"), Times.Once); + this._humans.Verify(r => r.DeleteUserAsync("u1"), Times.Once); + } + + /// + /// Grants naming the id as the delegate go too, not only those naming it as the webhook — otherwise a + /// deleted user keeps rights over webhooks that still exist. + /// + [Fact] + public async Task PurgeClearsGrantsInBothDirections() + { + await this._sut.PurgeAsync("u1"); + + this._delegates.Verify(r => r.RemoveAllForIdAsync("u1"), Times.Once); + this._delegates.Verify(r => r.RemoveAllForWebhookAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task AnUnknownUserIsReportedRatherThanPartiallyPurged() + { + this._humans.Setup(r => r.ExistsAsync("ghost")).ReturnsAsync(false); + + Assert.False(await this._sut.PurgeAsync("ghost")); + + this._humanService.Verify(s => s.DeleteAllAlarmsByUserAsync(It.IsAny()), Times.Never); + this._delegates.Verify(r => r.RemoveAllForIdAsync(It.IsAny()), Times.Never); + } + + /// + /// One unreachable dependency must not strand the rest, and must not leave an account that cannot be + /// deleted. The failure is logged for the admin to clear by hand. + /// + [Fact] + public async Task OneFailedStepDoesNotStopTheOthers() + { + this._humanService.Setup(s => s.DeleteAllAlarmsByUserAsync("u1")) + .ThrowsAsync(new HttpRequestException("PoracleNG is down")); + + Assert.True(await this._sut.PurgeAsync("u1")); + + this._delegates.Verify(r => r.RemoveAllForIdAsync("u1"), Times.Once); + this._humans.Verify(r => r.DeleteUserAsync("u1"), Times.Once); + } + + /// The humans row goes last, so a part-way failure leaves an account still visible. + [Fact] + public async Task TheAccountItselfIsRemovedLast() + { + var order = new List(); + this._humanService.Setup(s => s.DeleteAllAlarmsByUserAsync("u1")) + .Callback(() => order.Add("alarms")).ReturnsAsync(0); + this._delegates.Setup(r => r.RemoveAllForIdAsync("u1")) + .Callback(() => order.Add("delegates")).ReturnsAsync(0); + this._humans.Setup(r => r.DeleteUserAsync("u1")) + .Callback(() => order.Add("human")).ReturnsAsync(true); + + await this._sut.PurgeAsync("u1"); + + Assert.Equal("human", order[^1]); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UserRoleResolverTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UserRoleResolverTests.cs new file mode 100644 index 00000000..8e9e06b7 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UserRoleResolverTests.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Services; + +/// +/// The resolver must distinguish "resolved: not an admin" from "could not resolve". Treating the second +/// as the first meant a PoracleNG blip during a profile switch stripped admin for the rest of the +/// session, and cached that answer for a minute. See #656. +/// +public class UserRoleResolverTests +{ + private readonly Mock _poracleApiProxy = new(); + private readonly Mock _webhookDelegateService = new(); + + private UserRoleResolver CreateSut(string adminIds = "") => new( + this._poracleApiProxy.Object, + this._webhookDelegateService.Object, + Options.Create(new PoracleSettings { AdminIds = adminIds }), + new MemoryCache(new MemoryCacheOptions()), + NullLogger.Instance); + + [Fact] + public async Task AnUnreachablePoracleIsReportedAsUnresolvedRatherThanAsNotAnAdmin() + { + this._poracleApiProxy.Setup(p => p.GetConfigAsync()).ThrowsAsync(new HttpRequestException("down")); + this._poracleApiProxy.Setup(p => p.GetAdminRolesAsync(It.IsAny())).ThrowsAsync(new HttpRequestException("down")); + this._webhookDelegateService.Setup(s => s.GetManagedWebhookIdsAsync(It.IsAny())).ReturnsAsync([]); + + var roles = await this.CreateSut().ResolveAsync("u1"); + + Assert.False(roles.Resolved); + } + + [Fact] + public async Task ADegradedAnswerIsNotCached() + { + // Caching it would hold the user at the wrong privilege level for the full minute after a + // momentary outage. + this._poracleApiProxy.Setup(p => p.GetConfigAsync()).ThrowsAsync(new HttpRequestException("down")); + this._poracleApiProxy.Setup(p => p.GetAdminRolesAsync(It.IsAny())).ThrowsAsync(new HttpRequestException("down")); + this._webhookDelegateService.Setup(s => s.GetManagedWebhookIdsAsync(It.IsAny())).ReturnsAsync([]); + var sut = this.CreateSut(); + + await sut.ResolveAsync("u1"); + await sut.ResolveAsync("u1"); + + this._poracleApiProxy.Verify(p => p.GetAdminRolesAsync("u1"), Times.Exactly(2)); + } + + [Fact] + public async Task AConfiguredAdminNeedsNoNetworkAndIsAlwaysResolved() + { + var roles = await this.CreateSut("u1,u2").ResolveAsync("u1"); + + Assert.True(roles.IsAdmin); + Assert.True(roles.Resolved); + this._poracleApiProxy.Verify(p => p.GetConfigAsync(), Times.Never); + } + + [Fact] + public async Task AGenuineNonAdminIsResolvedAndCached() + { + // The legitimate-case-still-passes half: a clean "no" must still be a usable answer, and must + // still be cached. + this._poracleApiProxy.Setup(p => p.GetConfigAsync()).ReturnsAsync((PoracleConfig?)null!); + this._poracleApiProxy.Setup(p => p.GetAdminRolesAsync(It.IsAny())).ReturnsAsync("{}"); + this._webhookDelegateService.Setup(s => s.GetManagedWebhookIdsAsync(It.IsAny())).ReturnsAsync([]); + var sut = this.CreateSut(); + + var roles = await sut.ResolveAsync("u1"); + await sut.ResolveAsync("u1"); + + Assert.False(roles.IsAdmin); + Assert.True(roles.Resolved); + this._poracleApiProxy.Verify(p => p.GetAdminRolesAsync("u1"), Times.Once); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Validation/ActiveHoursValidationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Validation/ActiveHoursValidationTests.cs index 25eb1f06..82e7b103 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Validation/ActiveHoursValidationTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Validation/ActiveHoursValidationTests.cs @@ -1,4 +1,4 @@ -using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Models; namespace Pgan.PoracleWebNet.Tests.Validation; @@ -8,7 +8,7 @@ public class ActiveHoursValidationTests public void ValidSingleEntry() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"09\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.True(isValid); Assert.Null(error); } @@ -16,7 +16,7 @@ public void ValidSingleEntry() [Fact] public void ValidNull() { - var (isValid, error) = ProfileController.ValidateActiveHours(null); + var (isValid, error) = ActiveHoursValidator.Validate(null); Assert.True(isValid); Assert.Null(error); } @@ -24,7 +24,7 @@ public void ValidNull() [Fact] public void ValidEmptyString() { - var (isValid, error) = ProfileController.ValidateActiveHours(""); + var (isValid, error) = ActiveHoursValidator.Validate(""); Assert.True(isValid); Assert.Null(error); } @@ -32,7 +32,7 @@ public void ValidEmptyString() [Fact] public void ValidEmptyArray() { - var (isValid, error) = ProfileController.ValidateActiveHours("[]"); + var (isValid, error) = ActiveHoursValidator.Validate("[]"); Assert.True(isValid); Assert.Null(error); } @@ -41,7 +41,7 @@ public void ValidEmptyArray() public void ValidMultipleEntries() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"09\",\"mins\":\"00\"},{\"day\":2,\"hours\":\"18\",\"mins\":\"30\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.True(isValid); Assert.Null(error); } @@ -50,7 +50,7 @@ public void ValidMultipleEntries() public void ValidBoundaryDay1() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"00\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.True(isValid); Assert.Null(error); } @@ -59,7 +59,7 @@ public void ValidBoundaryDay1() public void ValidBoundaryDay7() { var json = /*lang=json,strict*/ "[{\"day\":7,\"hours\":\"23\",\"mins\":\"59\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.True(isValid); Assert.Null(error); } @@ -68,7 +68,7 @@ public void ValidBoundaryDay7() public void InvalidDay0() { var json = /*lang=json,strict*/ "[{\"day\":0,\"hours\":\"09\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("day", error!, StringComparison.OrdinalIgnoreCase); } @@ -77,7 +77,7 @@ public void InvalidDay0() public void InvalidDay8() { var json = /*lang=json,strict*/ "[{\"day\":8,\"hours\":\"09\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("day", error!, StringComparison.OrdinalIgnoreCase); } @@ -86,7 +86,7 @@ public void InvalidDay8() public void InvalidHours25() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"25\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("hours", error!, StringComparison.OrdinalIgnoreCase); } @@ -95,7 +95,7 @@ public void InvalidHours25() public void InvalidMins60() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"09\",\"mins\":\"60\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("mins", error!, StringComparison.OrdinalIgnoreCase); } @@ -106,7 +106,7 @@ public void InvalidTooManyEntries() var entries = string.Join(",", Enumerable.Range(0, 29).Select(i => $"{{\"day\":{(i % 7) + 1},\"hours\":\"{i % 24:D2}\",\"mins\":\"00\"}}")); var json = $"[{entries}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("28", error!); } @@ -114,7 +114,7 @@ public void InvalidTooManyEntries() [Fact] public void InvalidMalformedJson() { - var (isValid, error) = ProfileController.ValidateActiveHours("{not json"); + var (isValid, error) = ActiveHoursValidator.Validate("{not json"); Assert.False(isValid); Assert.Contains("JSON", error!); } @@ -122,7 +122,7 @@ public void InvalidMalformedJson() [Fact] public void InvalidNotAnArray() { - var (isValid, error) = ProfileController.ValidateActiveHours(/*lang=json,strict*/ "{\"day\":1}"); + var (isValid, error) = ActiveHoursValidator.Validate(/*lang=json,strict*/ "{\"day\":1}"); Assert.False(isValid); Assert.Contains("array", error!, StringComparison.OrdinalIgnoreCase); } @@ -131,7 +131,7 @@ public void InvalidNotAnArray() public void InvalidMissingHoursField() { var json = /*lang=json,strict*/ "[{\"day\":1,\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("hours", error!, StringComparison.OrdinalIgnoreCase); } @@ -140,7 +140,7 @@ public void InvalidMissingHoursField() public void InvalidMissingMinsField() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"09\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("mins", error!, StringComparison.OrdinalIgnoreCase); } @@ -151,7 +151,7 @@ public void Valid28Entries() var entries = string.Join(",", Enumerable.Range(0, 28).Select(i => $"{{\"day\":{(i % 7) + 1},\"hours\":\"{i % 24:D2}\",\"mins\":\"00\"}}")); var json = $"[{entries}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.True(isValid); Assert.Null(error); } @@ -160,7 +160,7 @@ public void Valid28Entries() public void ValidWithWhitespace() { var json = /*lang=json,strict*/ " [{\"day\":1,\"hours\":\"09\",\"mins\":\"00\"}] "; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.True(isValid); Assert.Null(error); } @@ -168,7 +168,7 @@ public void ValidWithWhitespace() [Fact] public void ValidWhitespaceOnly() { - var (isValid, error) = ProfileController.ValidateActiveHours(" "); + var (isValid, error) = ActiveHoursValidator.Validate(" "); Assert.True(isValid); Assert.Null(error); } @@ -177,7 +177,7 @@ public void ValidWhitespaceOnly() public void ValidDayAsString() { var json = /*lang=json,strict*/ "[{\"day\":\"3\",\"hours\":\"09\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.True(isValid); Assert.Null(error); } @@ -186,7 +186,7 @@ public void ValidDayAsString() public void InvalidNegativeHours() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"-1\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("hours", error!, StringComparison.OrdinalIgnoreCase); } @@ -195,7 +195,7 @@ public void InvalidNegativeHours() public void InvalidNegativeMins() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"09\",\"mins\":\"-5\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("mins", error!, StringComparison.OrdinalIgnoreCase); } @@ -204,7 +204,7 @@ public void InvalidNegativeMins() public void InvalidNegativeDay() { var json = /*lang=json,strict*/ "[{\"day\":-1,\"hours\":\"09\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("day", error!, StringComparison.OrdinalIgnoreCase); } @@ -213,7 +213,7 @@ public void InvalidNegativeDay() public void InvalidFloatHours() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"9.5\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("hours", error!, StringComparison.OrdinalIgnoreCase); } @@ -222,7 +222,7 @@ public void InvalidFloatHours() public void InvalidBooleanHours() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":true,\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("hours", error!, StringComparison.OrdinalIgnoreCase); } @@ -231,7 +231,7 @@ public void InvalidBooleanHours() public void InvalidExtremelyLargeHours() { var json = /*lang=json,strict*/ "[{\"day\":1,\"hours\":\"999999\",\"mins\":\"00\"}]"; - var (isValid, error) = ProfileController.ValidateActiveHours(json); + var (isValid, error) = ActiveHoursValidator.Validate(json); Assert.False(isValid); Assert.Contains("hours", error!, StringComparison.OrdinalIgnoreCase); } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Validation/AllowedRoleIdsTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Validation/AllowedRoleIdsTests.cs new file mode 100644 index 00000000..9fe10c37 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Validation/AllowedRoleIdsTests.cs @@ -0,0 +1,109 @@ +using Pgan.PoracleWebNet.Api.Controllers; + +namespace Pgan.PoracleWebNet.Tests.Validation; + +public class AllowedRoleIdsTests +{ + [Fact] + public void ParsesBareCommaSeparatedIds() + { + var (roleIds, invalid) = AuthController.ParseAllowedRoleIds("123456789,987654321"); + + Assert.Equal(["123456789", "987654321"], roleIds.OrderBy(r => r)); + Assert.Empty(invalid); + } + + [Fact] + public void TrimsWhitespaceAroundIds() + { + var (roleIds, invalid) = AuthController.ParseAllowedRoleIds(" 123456789 , 987654321 "); + + Assert.Equal(["123456789", "987654321"], roleIds.OrderBy(r => r)); + Assert.Empty(invalid); + } + + // The setting's example was rendered with quotes, so admins pasted them in and every + // non-admin login was denied. See #367. + [Theory] + [InlineData("\"123456789,987654321\"")] + [InlineData("'123456789,987654321'")] + [InlineData("“123456789,987654321”")] + [InlineData("«123456789,987654321»")] + [InlineData("„123456789,987654321“")] + [InlineData("\"123456789\",\"987654321\"")] + public void StripsQuotesFromPastedValues(string value) + { + var (roleIds, invalid) = AuthController.ParseAllowedRoleIds(value); + + Assert.Equal(["123456789", "987654321"], roleIds.OrderBy(r => r)); + Assert.Empty(invalid); + } + + [Fact] + public void ReportsNonSnowflakeEntriesAsInvalid() + { + var (roleIds, invalid) = AuthController.ParseAllowedRoleIds("123456789,@Trainers,987654321"); + + Assert.Equal(["123456789", "987654321"], roleIds.OrderBy(r => r)); + Assert.Equal(["@Trainers"], invalid); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(",,")] + [InlineData("\"\"")] + public void YieldsNoRoleIdsForEmptyValues(string? value) + { + var (roleIds, invalid) = AuthController.ParseAllowedRoleIds(value); + + Assert.Empty(roleIds); + Assert.Empty(invalid); + } + + [Fact] + public void DeduplicatesRepeatedIds() + { + var (roleIds, _) = AuthController.ParseAllowedRoleIds("123456789,123456789"); + + Assert.Equal(["123456789"], roleIds); + } + + // The gate is an allow-list: one matching role is enough. It used to require every listed + // role (IsSubsetOf), which locked out anyone holding only some of them. See #367. + [Fact] + public void GrantsAccessWhenUserHasOneOfSeveralAllowedRoles() + { + var allowed = new HashSet { "123456789", "987654321" }; + var userRoles = new HashSet { "987654321", "555" }; + + Assert.True(AuthController.HasAllowedRole(allowed, userRoles)); + } + + [Fact] + public void GrantsAccessWhenUserHasEveryAllowedRole() + { + var allowed = new HashSet { "123456789", "987654321" }; + var userRoles = new HashSet { "123456789", "987654321" }; + + Assert.True(AuthController.HasAllowedRole(allowed, userRoles)); + } + + [Fact] + public void DeniesAccessWhenUserHasNoneOfTheAllowedRoles() + { + var allowed = new HashSet { "123456789", "987654321" }; + var userRoles = new HashSet { "555", "666" }; + + Assert.False(AuthController.HasAllowedRole(allowed, userRoles)); + } + + [Fact] + public void DeniesAccessWhenUserHasNoRoles() + { + var allowed = new HashSet { "123456789" }; + + Assert.False(AuthController.HasAllowedRole(allowed, [])); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Validation/CleanRangeValidationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Validation/CleanRangeValidationTests.cs new file mode 100644 index 00000000..c4042ae1 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Validation/CleanRangeValidationTests.cs @@ -0,0 +1,169 @@ +using System.ComponentModel.DataAnnotations; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Validation; + +/// +/// Guards the widened clean range on the 8 alarm Create/Update types that previously +/// capped it at [Range(0,1)]. PoracleNG reads clean as a 3-bit bitmask +/// (bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary), so a bot-set value up to 7 +/// must round-trip through a web edit instead of 400ing. Raid/Egg are covered separately by +/// . (#292) +/// +public class CleanRangeValidationTests +{ + private static bool ValidateClean(object instance, object? value) + { + var context = new ValidationContext(instance) { MemberName = "Clean" }; + var results = new List(); + return Validator.TryValidateProperty(value, context, results); + } + + public static TheoryData AcceptedValues => + new() { 0, 1, 2, 3, 4, 5, 6, 7 }; + + public static TheoryData RejectedValues => + new() { -1, 8 }; + + // ── Create types ──────────────────────────────────────── + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void MonsterCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new MonsterCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void MonsterCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new MonsterCreate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void QuestCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new QuestCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void QuestCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new QuestCreate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void InvasionCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new InvasionCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void InvasionCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new InvasionCreate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void LureCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new LureCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void LureCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new LureCreate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void NestCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new NestCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void NestCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new NestCreate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void GymCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new GymCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void GymCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new GymCreate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void MaxBattleCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new MaxBattleCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void MaxBattleCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new MaxBattleCreate(), value)); + + // ── Update types ──────────────────────────────────────── + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void MonsterUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new MonsterUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void MonsterUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new MonsterUpdate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void QuestUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new QuestUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void QuestUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new QuestUpdate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void InvasionUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new InvasionUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void InvasionUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new InvasionUpdate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void LureUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new LureUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void LureUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new LureUpdate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void NestUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new NestUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void NestUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new NestUpdate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void GymUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new GymUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void GymUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new GymUpdate(), value)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void MaxBattleUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new MaxBattleUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void MaxBattleUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new MaxBattleUpdate(), value)); + + // Update types allow null (null-skip merge semantics) — null must always validate. + + [Fact] + public void MonsterUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new MonsterUpdate(), null)); + + [Fact] + public void QuestUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new QuestUpdate(), null)); + + [Fact] + public void InvasionUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new InvasionUpdate(), null)); + + [Fact] + public void LureUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new LureUpdate(), null)); + + [Fact] + public void NestUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new NestUpdate(), null)); + + [Fact] + public void GymUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new GymUpdate(), null)); + + [Fact] + public void MaxBattleUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new MaxBattleUpdate(), null)); + + // FortChange has no Clean property: PoracleNG has no clean column for forts and its + // FortTracking struct has no Clean field, so the setting was discarded on save. Removed in #437. +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Validation/LocationCoordinateValidationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Validation/LocationCoordinateValidationTests.cs new file mode 100644 index 00000000..1db14577 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Validation/LocationCoordinateValidationTests.cs @@ -0,0 +1,65 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using Pgan.PoracleWebNet.Api.Controllers; + +namespace Pgan.PoracleWebNet.Tests.Validation; + +/// +/// As non-nullable doubles, an omitted latitude or longitude bound to 0.0 and passed [Range], so a request +/// that mentioned neither wrote 0,0 over the user's real location — the outcome the range checks were added +/// to prevent, reached by the one path they could not see. See #480. +/// +public class LocationCoordinateValidationTests +{ + private static List Validate(string json) + { + var request = JsonSerializer.Deserialize( + json, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + + var results = new List(); + Validator.TryValidateObject(request, new ValidationContext(request), results, validateAllProperties: true); + return results; + } + + [Fact] + public void AnEmptyBodyIsRejected() + { + Assert.NotEmpty(Validate("{}")); + } + + [Fact] + public void AnOmittedLongitudeIsRejected() + { + var errors = Validate(/*lang=json,strict*/ "{\"latitude\":37.684826}"); + + Assert.Contains(errors, e => e.MemberNames.Contains("Longitude")); + } + + [Fact] + public void AnOmittedLatitudeIsRejected() + { + var errors = Validate(/*lang=json,strict*/ "{\"longitude\":-77.6133}"); + + Assert.Contains(errors, e => e.MemberNames.Contains("Latitude")); + } + + [Fact] + public void ZeroZeroIsStillAcceptedWhenActuallyAskedFor() + { + // Null Island is a real coordinate. The defect was inferring it, not permitting it. + Assert.Empty(Validate(/*lang=json,strict*/ "{\"latitude\":0,\"longitude\":0}")); + } + + [Fact] + public void BothCoordinatesPresentAndInRangeIsAccepted() + { + Assert.Empty(Validate(/*lang=json,strict*/ "{\"latitude\":37.684826,\"longitude\":-77.6133}")); + } + + [Fact] + public void OutOfRangeIsStillRejected() + { + Assert.NotEmpty(Validate(/*lang=json,strict*/ "{\"latitude\":91,\"longitude\":0}")); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Validation/MonsterRangeValidationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Validation/MonsterRangeValidationTests.cs new file mode 100644 index 00000000..ba5ca8e2 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Validation/MonsterRangeValidationTests.cs @@ -0,0 +1,102 @@ +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Validation; + +/// +/// Per-property [Range] attributes checked each bound against the game's limits and never against its +/// partner, so a transposed pair saved clean and then matched nothing, with no error to explain the +/// silence. See #461. +/// +public class MonsterRangeValidationTests +{ + private static Monster Valid() => new() + { + MinIv = -1, + MaxIv = 100, + MinCp = 0, + MaxCp = 9000, + MinLevel = 0, + MaxLevel = 55, + MinWeight = 0, + MaxWeight = 9000000, + MaxAtk = 15, + MaxDef = 15, + MaxSta = 15, + Size = -1, + MaxSize = 5, + PvpRankingBest = 0, + PvpRankingWorst = 4096, + }; + + [Fact] + public void TheDefaultWindowsAreSatisfiable() + { + Assert.Null(MonsterRangeValidator.Validate(Valid())); + } + + [Fact] + public void AnInvertedIvWindowIsRejected() + { + var monster = Valid(); + monster.MinIv = 90; + monster.MaxIv = 10; + + Assert.Contains("minIv", MonsterRangeValidator.Validate(monster), StringComparison.Ordinal); + } + + [Theory] + [InlineData(nameof(Monster.MinCp), 9000, nameof(Monster.MaxCp), 10)] + [InlineData(nameof(Monster.MinLevel), 40, nameof(Monster.MaxLevel), 5)] + [InlineData(nameof(Monster.MinWeight), 500, nameof(Monster.MaxWeight), 10)] + [InlineData(nameof(Monster.Atk), 15, nameof(Monster.MaxAtk), 2)] + [InlineData(nameof(Monster.Size), 5, nameof(Monster.MaxSize), 1)] + public void EveryPairIsChecked(string minName, int min, string maxName, int max) + { + var monster = Valid(); + typeof(Monster).GetProperty(minName)!.SetValue(monster, min); + typeof(Monster).GetProperty(maxName)!.SetValue(monster, max); + + Assert.NotNull(MonsterRangeValidator.Validate(monster)); + } + + /// + /// PVP rankings count upward from the best, so "best" is the lower bound — reversing that check would + /// reject every ordinary alarm. + /// + [Fact] + public void PvpRankingIsCheckedInRankOrder() + { + var monster = Valid(); + monster.PvpRankingBest = 1; + monster.PvpRankingWorst = 100; + Assert.Null(MonsterRangeValidator.Validate(monster)); + + monster.PvpRankingBest = 4000; + monster.PvpRankingWorst = 1; + Assert.NotNull(MonsterRangeValidator.Validate(monster)); + } + + /// + /// minIv -1 with maxIv -1 tracks unencountered Pokemon only. It is a real filter, and it satisfies + /// min <= max, so the guard must leave it alone. + /// + [Fact] + public void TheUnencounteredOnlyFilterSurvives() + { + var monster = Valid(); + monster.MinIv = -1; + monster.MaxIv = -1; + + Assert.Null(MonsterRangeValidator.Validate(monster)); + } + + [Fact] + public void EqualBoundsAreSatisfiable() + { + var monster = Valid(); + monster.MinIv = 100; + monster.MaxIv = 100; + + Assert.Null(MonsterRangeValidator.Validate(monster)); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Validation/RsvpRangeValidationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Validation/RsvpRangeValidationTests.cs new file mode 100644 index 00000000..46f9bde8 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Validation/RsvpRangeValidationTests.cs @@ -0,0 +1,101 @@ +using System.ComponentModel.DataAnnotations; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Validation; + +public class RsvpRangeValidationTests +{ + private static bool ValidateProperty(object instance, string propertyName, object? value) + { + var context = new ValidationContext(instance) { MemberName = propertyName }; + var results = new List(); + return Validator.TryValidateProperty(value, context, results); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void RaidCreateRsvpChangesAcceptsValidRange(int value) => Assert.True(ValidateProperty(new RaidCreate(), nameof(RaidCreate.RsvpChanges), value)); + + [Theory] + [InlineData(-1)] + [InlineData(3)] + [InlineData(int.MaxValue)] + public void RaidCreateRsvpChangesRejectsOutOfRange(int value) => Assert.False(ValidateProperty(new RaidCreate(), nameof(RaidCreate.RsvpChanges), value)); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void EggCreateRsvpChangesAcceptsValidRange(int value) => Assert.True(ValidateProperty(new EggCreate(), nameof(EggCreate.RsvpChanges), value)); + + [Theory] + [InlineData(-1)] + [InlineData(3)] + [InlineData(int.MaxValue)] + public void EggCreateRsvpChangesRejectsOutOfRange(int value) => Assert.False(ValidateProperty(new EggCreate(), nameof(EggCreate.RsvpChanges), value)); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void RaidUpdateRsvpChangesAcceptsValidRange(int? value) => Assert.True(ValidateProperty(new RaidUpdate(), nameof(RaidUpdate.RsvpChanges), value)); + + [Fact] + public void RaidUpdateRsvpChangesAcceptsNull() => Assert.True(ValidateProperty(new RaidUpdate(), nameof(RaidUpdate.RsvpChanges), null)); + + [Theory] + [InlineData(-1)] + [InlineData(3)] + [InlineData(int.MaxValue)] + public void RaidUpdateRsvpChangesRejectsOutOfRange(int value) => Assert.False(ValidateProperty(new RaidUpdate(), nameof(RaidUpdate.RsvpChanges), value)); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void EggUpdateRsvpChangesAcceptsValidRange(int? value) => Assert.True(ValidateProperty(new EggUpdate(), nameof(EggUpdate.RsvpChanges), value)); + + [Fact] + public void EggUpdateRsvpChangesAcceptsNull() => Assert.True(ValidateProperty(new EggUpdate(), nameof(EggUpdate.RsvpChanges), null)); + + [Theory] + [InlineData(-1)] + [InlineData(3)] + [InlineData(int.MaxValue)] + public void EggUpdateRsvpChangesRejectsOutOfRange(int value) => Assert.False(ValidateProperty(new EggUpdate(), nameof(EggUpdate.RsvpChanges), value)); + + // clean is a PoracleNG bitmask (bit 1 = auto-delete, bit 2 = edit-in-place, bit 4 = summary), + // so the model must accept 0..7 — RSVP modes set the edit bit (clean = 2 or 3). + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(7)] + public void RaidCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateProperty(new RaidCreate(), nameof(RaidCreate.Clean), value)); + + [Theory] + [InlineData(-1)] + [InlineData(8)] + public void RaidCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateProperty(new RaidCreate(), nameof(RaidCreate.Clean), value)); + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(7)] + public void EggCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateProperty(new EggCreate(), nameof(EggCreate.Clean), value)); + + [Theory] + [InlineData(2)] + [InlineData(3)] + public void RaidUpdateCleanAcceptsEditBit(int? value) => Assert.True(ValidateProperty(new RaidUpdate(), nameof(RaidUpdate.Clean), value)); + + [Theory] + [InlineData(2)] + [InlineData(3)] + public void EggUpdateCleanAcceptsEditBit(int? value) => Assert.True(ValidateProperty(new EggUpdate(), nameof(EggUpdate.Clean), value)); +} diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 0550dc9c..2172975b 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -22,6 +22,10 @@ private static readonly JsonSerializerOptions SnakeCaseOptions = new() PoracleNG's tracking POST endpoint handles both creates and updates. When the request body includes a `uid` field, it updates the existing alarm. Services use the same `CreateAsync` proxy method for both operations. +An edit therefore sends the whole row, and the body is built by serializing the typed model — so every column PoracleWeb has no property for arrives absent and PoracleNG stores the default over what the user had. `TrackingFieldPreserver.PreserveStoredFieldsAsync` runs first on every update: it re-reads the stored row and copies across any property the submitted body lacks. Before it existed, editing an alarm on the web reset `override_location_label`, `override_areas` and `pvp_ranking_evolution` set from the bot (#730). A read failure returns the body untouched rather than failing the edit. + +The merge runs *before* the collision guards, because `TrackingUpdateReconciler.CountUpdatableDifferences` only compares properties present in the submission — an unmodelled property could not tell two alarms apart, so the guard refused edits PoracleNG would have accepted. See [PoracleNG API Proxy](poracleng-proxy.md#insert-update-or-duplicate) for what the guards are mirroring. + ## Repository layer (non-alarm entities) `HumanRepository` is used only for **admin bulk operations** (`GetAllAsync`, `DeleteUserAsync`, `UpdateAsync`) that lack PoracleNG API equivalents. Single-user human reads and writes go through `IPoracleHumanProxy`. `poracle_web`-owned entities (`SiteSettingRepository`, `WebhookDelegateRepository`, `QuickPickDefinitionRepository`, `QuickPickAppliedStateRepository`) use their own dedicated repository classes. @@ -29,15 +33,23 @@ PoracleNG's tracking POST endpoint handles both creates and updates. When the re !!! note "`BaseRepository` removed" The generic `BaseRepository` and all alarm repository classes have been removed. `EnsureNotNullDefaults()` is no longer needed -- PoracleNG handles NULL defaults for alarm writes, and the remaining repositories handle null normalization as needed. -## AutoMapper (non-alarm entities only) +## Mapping extensions + +Mapping is done with static extension methods in `Core.Mappings/`. There is no AutoMapper dependency. -AutoMapper is used for `humans` and `profiles` entities. Alarm tracking data flows as raw JSON through the PoracleNG API proxy and does not use AutoMapper. +`AlarmMappingExtensions` covers the alarm DTOs: `To*()` builds a model from a `*Create` DTO (`create.ToMonster()`), and `ApplyUpdate()` merges a `*Update` DTO onto an existing model (`update.ApplyUpdate(existing)`). -All `*Update` models for non-alarm entities use **nullable `int?`** properties so partial updates don't zero out unset fields. +`EntityMappingExtensions` covers `Human`, `Profile`, and the `poracle_web`-owned entities (user geofences, site settings, webhook delegates, quick picks) with `ToModel()`, `ToEntity()`, and `ApplyTo()`. + +All `*Update` models use **nullable** properties so partial updates don't zero out unset fields. `ApplyUpdate` skips nulls explicitly: ```csharp -// The mapping profile skips null properties -.ForAllMembers(opts => opts.Condition((_, _, srcMember) => srcMember != null)) +public static void ApplyUpdate(this MonsterUpdate src, Monster dest) +{ + if (src.Ping != null) dest.Ping = src.Ping; + if (src.Distance != null) dest.Distance = src.Distance.Value; + // ... one guarded assignment per field +} ``` ## Alarm field defaults @@ -57,6 +69,12 @@ PoracleNG's `cleanRow()` function applies field defaults on every create/update. !!! info "Defaults are now enforced server-side" Even if the frontend sends incomplete data, PoracleNG's `cleanRow()` fills in proper defaults. This eliminates the class of bugs where missing C# model defaults caused silent filter breakage. +## Raid level service + +`IRaidLevelService` / `RaidLevelService` is a singleton that serves the canonical Pokémon GO raid-type vocabulary to the frontend, mirroring the [WatWowMap masterfile](https://github.com/WatWowMap/Masterfile-Generator) without the locale-blind English strings leaking into the UI. The implementation returns a baked-in snapshot of 19 levels (1-Star through Coordinated 2) via `GET /api/masterdata/raid-levels`, with each entry exposing `{ value, category, name, namePlural }`. A `TODO` in `GetAllAsync` documents the upgrade path to a live masterfile fetch with on-disk caching under `DATA_DIR`; the wire contract will not change. The frontend `RaidLevelService` caches the response in a signal and falls back to a baked-in `KNOWN_LEVELS` constant on fetch error so the level picker always works, even offline. + +PoracleNG accepts any positive integer as a raid/egg level, so the picker's `+ Add` affordance lets users alarm on levels that haven't been added to the canonical list yet. The `[Range(0, int.MaxValue)]` attribute on the alarm `Create`/`Update` DTOs ensures custom integers and the `9000` "any" sentinel pass server-side validation. + ## Test alert service `TestAlertService` lets users trigger a sample notification for any configured alarm. It uses `Task.WhenAll` to fetch the alarm (via `IPoracleTrackingProxy`) and the human record (via `IPoracleHumanProxy`) in parallel. It then constructs a realistic mock webhook payload based on the alarm's filter fields (e.g., `pokemon_id`, `raid_level`, `quest_reward`) using the user's location as the event coordinates. The payload is sent to PoracleNG's `POST /api/test` endpoint, which formats and delivers the notification. Rate-limited at 5 requests per 60s per IP via the `test-alert` policy. @@ -89,8 +107,19 @@ All three endpoints go through the PoracleNG API proxy. Bulk distance updates fe Proxies all alarm CRUD operations to PoracleNG's `/api/tracking/*` endpoints. Authenticated via `X-Poracle-Secret` header. See [PoracleNG API Proxy](poracleng-proxy.md) for full details. -- Registered via `AddHttpClient()` -- Used by: all alarm services, `DashboardService`, `CleaningService` +- Registered as the concrete `PoracleTrackingProxy`, then decorated — what the container resolves for `IPoracleTrackingProxy` is `UserOwnedOverrideAreaProxy` wrapping it +- Used by: all alarm services, `DashboardService`, `CleaningService`, each of which therefore gets the decorated instance + +```csharp +services.AddHttpClient(); +services.AddScoped(sp => new UserOwnedOverrideAreaProxy( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); +``` + +The decorator only touches `CreateAsync`; everything else forwards untouched. See [Per-alarm areas](#per-alarm-areas). ### IPoracleHumanProxy (human/profile management) @@ -111,22 +140,100 @@ Wraps HttpClient calls for non-tracking Poracle API operations. `PoracleConfig` is parsed from Poracle's JSON configuration. The `defaultTemplateName` field can be a number or string — deserialization handles both via `JsonElement`. +## Server capability probe + +`IPoracleServerProfileService` / `PoracleServerProfileService` asks the PoracleNG instance what it is and +what it can store. PoracleWeb previously assumed 5.1.0 and never checked, so on an older server the +per-alarm scope, the PVP mega picker and the minimum-time filter wrote fields nothing stored and failed +without a word. + +Two reads, answering different questions: + +- `GET {Poracle:ApiAddress}/health` — the release number and PoracleNG's own capability map. It is + unauthenticated, so the probe carries no secret and still works when the API key is wrong, which is + itself worth knowing: "reachable but every write 401s" and "not running" look identical otherwise. The + map is read key-by-key rather than into a fixed type, because it is upstream's and it grows. +- `SELECT version FROM schema_migrations` on `PoracleContext`, via `PoracleSchemaVersionReader`. The + capability map covers bot and template-editor features and says nothing about alarm columns; the + applied migration number is what answers "can this server store that filter". 5.1.0 sits at migration + 5. A missing table or a permission error reports an unknown schema, which unlocks nothing. + +`PoracleServerProfile.MinimumSupported` is **5.1.0** — where `override_location_label`, `override_areas` +and `pvp_ranking_evolution` arrive. `IsBelowMinimum` is true only when the server is *known* to be older: +unreachable, unparseable, or the `0.0.0` a locally built binary reports are all unknown rather than too +old, so the banner is not shown on a guess. `Supports(capability)` defaults a missing key to false, per +PoracleNG's map contract; `HasSchema(n)` answers false on an unknown schema. + +The profile is cached in `IMemoryCache` for five minutes and the HttpClient's timeout is five seconds, so +an unreachable server answers "unknown" quickly instead of stalling the admin page. +`GET /api/admin/server-profile` serves it (admin only); `?refresh=true` invalidates the cache and the +GitHub update check first. + ## Areas -User areas are managed through `IPoracleHumanProxy.SetAreasAsync()`. PoracleNG handles the dual-write to both `humans.area` and `profiles.area` internally. +User areas are managed through `IPoracleHumanProxy.SetAreasAsync()`, and PoracleNG handles the dual-write +to both `humans.area` and `profiles.area` — **for admin areas**. + +User-drawn geofences are the exception. PoracleWeb serves them with `userSelectable=false` to keep them +off the bot's area picker, and PoracleNG's `HandleSetAreas` silently strips any name whose fence is not +user-selectable. So every user-geofence area mutation goes through `IUserAreaDualWriter`, which writes +both tables directly in a single `SaveChangesAsync`, and `AreaController.UpdateAreas` calls +`PreserveOwnedAreasInHumanAsync` after `SetAreasAsync` to re-add what was stripped. Every such site is +tagged `HACK: trusted-set-areas` — `grep -rn "HACK: trusted-set-areas" --include="*.cs"` lists them. Geofence polygons come from the Poracle API (via the unified feed), not the database. +### Per-alarm areas + +An alarm can confine itself to named areas of its own, stored in the row's `override_areas` column. That +is the same `userSelectable` problem one layer down, and it fails harder: PoracleNG's tracking write +validates every entry against `GetAvailableAreas` and answers **400 "area not permitted"**, failing the +whole request, where `setAreas` merely strips silently. + +Matching never consults `userSelectable` — `resolveOverride` hands the rule's areas to `areaOverlap`, +a name comparison against the fences the spawn fell in — so a name written straight into the column +matches exactly like a permitted one. `UserOwnedOverrideAreaProxy`, the decorator over +`IPoracleTrackingProxy`, does that: + +1. `EnsureScopeIsCoherent` refuses an incoherent scope before anything is written. A row cannot carry + both a place and a set of areas; areas cannot coexist with a radius; a place needs one. PoracleNG + enforces the same three rules, but only on the body it is sent, and it never sees the stripped names. +2. Any of the user's own geofence names are removed from `override_areas` for the POST. A list left + empty drops the property entirely, so PoracleNG sees no override rather than an empty one. +3. The full list is written into the row by `IUserAreaDualWriter.SetAlarmOverrideAreasAsync`, a raw + `UPDATE` scoped by both `id` and `uid`. An empty list stores NULL, not `[]` — `parseOverrideAreas` + reads `""` back as no override, while `[]` would be a list matching nothing. +4. `ReloadStateAsync` is called afterwards, because a direct column write is not a PoracleNG mutation + and would otherwise wait for the periodic reload. + +Writes that name no override at all skip all of this. Tagged `HACK: trusted-set-areas` like the rest. + ## Location `LocationController` uses `IPoracleHumanProxy.SetLocationAsync()` to set the user's location. No direct DB access or transactions are needed -- PoracleNG handles the write and state reload atomically. +### Saved places + +The same controller owns the places an alarm can be anchored to instead of the profile pin. PoracleNG +stores them in `user_locations`, keyed by (human, label), and they are reached only through +`IPoracleHumanProxy`. + +| Endpoint | Behaviour | +|---|---| +| `GET /api/location/places` | Returns `SavedPlaces { Default, Named }`. `Default` is the profile pin every alarm falls back to, null when the user has never set one. | +| `POST /api/location/places` | Saves a place, then returns the updated set. PoracleNG reports a rejected label inside a 200 because its endpoint answers per row, so the refusal is unwrapped and returned as a 400 the SPA can show against the field. | +| `DELETE /api/location/places/{label}` | 204 on success. **409** with `referencingRules` when alarms still point at the label — PoracleNG refuses rather than orphaning it, and naming the alarms is the difference between "could not delete" and knowing what to repoint. Carried as `PlaceInUseException`. | + +A label is what an alarm's `override_location_label` refers to. The whole controller carries `[RequireFeatureEnabled(DisableFeatureKeys.Location)]`, so `disable_location` takes the pin, the places API, the static and distance maps and weather with it. + ## Service lifetimes | Service | Lifetime | Reason | |---|---|---| | Most services | **Scoped** | Per-request | -| `MasterDataService` | **Singleton** | Cached game data | +| `MasterDataService` | **Singleton** | Cached game data. Serves move and item names from the WatWowMap masterfile, and the English monster map as the fallback for `GET /api/masterdata/monsters`, which normally proxies Poracle's translated one | +| `RaidLevelService` | **Singleton** | Stateless canonical-list provider; future live masterfile fetch will cache here | +| `UpstreamFeatureFlagService` | Scoped | Resolves Poracle's own per-type disable flags into `disable_*` keys, cached 5 min; fails open | !!! info "DashboardService uses the proxy" `DashboardService` calls `IPoracleTrackingProxy.GetAllTrackingAsync()` to fetch all alarm types in a single API call, then counts each type from the response. No direct DB queries. @@ -140,13 +247,15 @@ Geofence polygons come from the Poracle API (via the unified feed), not the data The `Profile` model includes an `ActiveHours` (`string?`) property representing a JSON array of time-window rules stored in the `active_hours` column of the `profiles` table. `ProfileService.DeserializeProfiles()` extracts `active_hours` from the PoracleNG proxy's `JsonElement` response and maps it onto the model. -`ProfileController` includes `active_hours` in the proxy payload for Create, Update, and Duplicate endpoints. A `ValidateActiveHours` internal static method validates the JSON structure before forwarding: +`ProfileController` includes `active_hours` in the proxy payload for Create, Update, and Duplicate +endpoints. Validation lives in `Core.Models/ActiveHoursValidator`, shared with `SummaryScheduleController` +so the two cannot drift: - Each entry must specify `day` (1--7), `hours` (0--23), `mins` (0--59) -- Maximum 28 entries per profile (one per 30-minute slot per day) +- Maximum 28 entries per profile (four per day across seven days) - Returns a `400 Bad Request` with details on validation failure -`InternalsVisibleTo` is added to the API `.csproj` so `ValidateActiveHours` can be tested directly from the xUnit project. +It accepts `hours` and `mins` as either numbers or strings, because PoracleNG stores them inconsistently. ## Scanner service @@ -189,6 +298,22 @@ The `ScannerGymEntity` in the scanner context maps the `url` column from the `gy `IScannerService` declares a static `PointInPolygon(double lat, double lon, double[][] polygon)` method using the ray-casting algorithm. The method tests if a point lies inside a polygon (where each entry is `[lat, lon]`) and returns `false` for degenerate polygons with fewer than 3 vertices. Used by `ScannerController` to determine which Koji geofence area a gym belongs to. +## GeoMath + +`Core.Services/GeoMath.cs` holds the polygon maths used outside the scanner path: `AreaSqKm` (spherical excess / Girard's theorem, R = 6371 km), `Centroid` (vertex mean), `Contains` (ray casting), and `DescribeArea` (plain-language size band). It backs the geofence review card's size, location and overlap fields. + +!!! warning "Keep in sync with the frontend" + `GeoMath` is a hand-port of the frontend's `shared/utils/geo.utils.ts`. If the two drift, the area a user sees while drawing a geofence stops matching the one an admin sees in the review thread. The tests derive their expected values from the sphere's radius (`2πR/360`) rather than from the implementation, so a port mistake fails rather than being enshrined. + +## Discord notifications + +`IDiscordNotificationService` opens and maintains the geofence review thread. Two mechanics are easy to get wrong: + +- **The map must be uploaded, not linked.** Poracle's `GET /api/geofence/{area}/map` returns a *pregenerated tileserver-cache* URL that the cache evicts, so an embed linking it goes blank within hours. The bytes are downloaded (via a separate unauthenticated named `HttpClient`, so the bot token never reaches the tileserver) and posted as a message attachment. +- **Editing the card re-uploads the map.** Discord folds an `attachment://` attachment into the embed, so the message reports an empty `attachments` array — there is no ID to carry forward, and the embed's resolved `cdn.discordapp.com` URL is a signed link that expires. A forum post's starter message shares the thread's ID, so the opening embed is edited with `PATCH /channels/{threadId}/messages/{threadId}`. + +One `BuildEmbed` produces the pending, approved and rejected cards so they cannot drift apart, and each piece degrades on its own: a failed download links the URL, a failed card rewrite still posts the verdict reply, and a Koji outage just omits the overlap line. + ## Golbat API proxy ### IGolbatApiProxy (Pokemon availability) diff --git a/docs/architecture/database.md b/docs/architecture/database.md index bc65c87b..bedb178e 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -9,8 +9,19 @@ PoracleWeb.NET uses two separate MySQL databases and optionally connects to a th The primary EF Core context connecting to the existing **Poracle database** managed by PoracleNG. - Connection string: `ConnectionStrings:PoracleDb` -- Contains: `humans`, `profiles` tables (direct access), plus alarm tables (read-only for legacy/fallback) -- **Limited direct access** — Alarm tracking is proxied through `IPoracleTrackingProxy`, and single-user human/profile operations go through `IPoracleHumanProxy`. Direct DB access is only used for admin bulk human operations (`GetAllAsync`, `DeleteUserAsync`, `UpdateAsync`). +- Contains: `humans` and `profiles` (direct access), ten alarm tables, PoracleNG's `schema_migrations`, and the deprecated `pweb_settings` KV table. `PoracleContext` maps entities for eleven of those — `humans`, `profiles`, `pweb_settings` and eight alarm tables; `forts`, `maxbattle` and `schema_migrations` are reached by raw SQL, which needs no entity +- **Limited direct access** — Alarm tracking is proxied through `IPoracleTrackingProxy`, and single-user human/profile operations go through `IPoracleHumanProxy`. Direct access is confined to: + +| Direct access | What and why | +|---|---| +| Admin bulk human operations | `GetAllAsync`, `DeleteUserAsync`, `UpdateAsync` — PoracleNG has no admin-list, admin-delete or generic update endpoint | +| Profile **rename** | `ProfileRepository.RenameAsync` — PoracleNG's profile update answers `{"status":"ok"}` and silently ignores `name` | +| User-geofence area writes | `IUserAreaDualWriter` on `humans.area` and `profiles.area` — PoracleNG's `setAreas` strips fences that are not user-selectable | +| Alarm `override_areas` | `IUserAreaDualWriter.SetAlarmOverrideAreasAsync` writes this one column on the ten alarm tables. It is the only alarm-table write PoracleWeb makes; everything else about a row goes through the proxy | +| `schema_migrations` read | `PoracleSchemaVersionReader` reads the applied migration number for the [server capability probe](backend.md#server-capability-probe) | +| `pweb_settings` | `PwebSettingRepository` still reads and writes the deprecated KV table, and startup runs one `ALTER TABLE pweb_settings MODIFY COLUMN value LONGTEXT NULL` so the old rows can hold JSON. Both exist only to feed `SettingsMigrationService` | + +The user-geofence area writes and the `override_areas` write are tagged `HACK: trusted-set-areas` in code and explained in [Backend](backend.md#areas). !!! warning "MySQL provider" This project uses `MySql.EntityFrameworkCore` (Oracle's official provider), **not** Pomelo (`Pomelo.EntityFrameworkCore.MySql`), which is incompatible with EF Core 10. Connection setup uses `options.UseMySQL(connectionString)` (capital SQL). @@ -35,7 +46,29 @@ The `active_hours` column stores a JSON array defining when alarm delivery is ac - `hours` / `mins` — stored as **strings** (zero-padded, e.g. `"09"`, `"00"`) !!! info "Managed by PoracleNG" - The `active_hours` column is part of Poracle's own schema (managed by PoracleJS/PoracleNG) — no PoracleWeb.NET migration is needed. PoracleWeb.NET reads and writes this field through the `IPoracleHumanProxy` API, not via direct DB access. + The `active_hours` column is part of Poracle's own schema (managed by PoracleNG) — no PoracleWeb.NET migration is needed. PoracleWeb.NET reads and writes this field through the `IPoracleHumanProxy` API, not via direct DB access. + +#### Notable columns on the alarm tables + +Per-alarm delivery scope rests on two columns, present on all ten tables (`monsters`, `raid`, `egg`, `quest`, `invasion`, `lures`, `nests`, `gym`, `forts`, `maxbattle`). Both arrived in PoracleNG 5.1.0. + +| Column | Type | Description | +|---|---|---| +| `override_location_label` | string, nullable | Saved-place label the alarm measures its radius from, instead of the profile pin. A label that no longer exists is not an error — PoracleNG falls through to the pin, so deleting a place widens its alarms rather than breaking them. | +| `override_areas` | JSON array, nullable | Areas the alarm is confined to, lowercase with spaces. Replaces the profile's area list outright rather than intersecting with it. **NULL when unset, never `[]`** — `parseOverrideAreas` reads an empty value back as no override, while `[]` is a list that matches nothing. | + +The two are mutually exclusive, areas cannot coexist with a radius, and a place requires one. PoracleNG refuses all three combinations, and so does PoracleWeb before the write (see [Backend](backend.md#per-alarm-areas)). + +Two more columns on `monsters` alone, also 5.1.0: + +| Column | Type | Description | +|---|---|---| +| `pvp_ranking_evolution` | int, default 0 | Which form the PVP ranks are read from: 0 base, 1 any mega, 2 Mega X, 3 Mega Y. Only consulted when a league is set. | +| `min_time` | int, default 0 | Seconds a spawn must still have left when it is found, or the alert is skipped. 0 means any. | + +#### `user_locations` + +Saved places live here, keyed by (human, label), and are written only through `IPoracleHumanProxy` — PoracleWeb makes no direct read or write to this table. See [Backend](backend.md#saved-places). ### PoracleWebContext @@ -54,11 +87,12 @@ A separate EF Core context for **application-owned data**. | `webhook_delegates` | Relational webhook-to-user delegation mappings | | `quick_pick_definitions` | Quick pick alarm presets (global and user-scoped) | | `quick_pick_applied_states` | Tracks which quick picks users have applied per profile | +| `oidc_sessions` | Refresh-token families for OIDC silent refresh, with rotation and replay detection | !!! info "MariaDB compatibility" `MySql.EntityFrameworkCore`'s `MigrateAsync()` uses `GET_LOCK(-1)` which returns NULL on MariaDB. The `MariaDbHistoryRepository` class overrides the lock to use `GET_LOCK(3600)` instead. This is registered via `ReplaceService()` on `PoracleWebContext`. -### ScannerDbContext (optional) +### ScannerContext (optional) Connects to a Golbat scanner database for nest, Pokemon, and gym data. @@ -174,7 +208,7 @@ Stores alarm presets (both admin-global and user-scoped): |---|---|---| | `id` | varchar(50) (PK) | Unique pick ID | | `name` | varchar(200) | Display name | -| `alarm_type` | varchar(20) | `monster`, `raid`, `egg`, `quest`, `invasion`, `lure`, `nest`, `gym` | +| `alarm_type` | varchar(20) | `monster`, `raid`, `egg`, `quest`, `invasion`, `lure`, `nest`, `gym`, `maxbattle` | | `scope` | varchar(10) | `global` (admin) or `user` | | `owner_user_id` | varchar(100) | NULL for global, user ID for user-scoped | | `filters_json` | JSON | Alarm filter parameters | diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index abcd1e2b..8655f9db 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -29,17 +29,21 @@ src/app/ │ ├── gyms/ Gym alarm management │ ├── fort-changes/ Fort change alarm management │ ├── max-battles/ Max Battle (Dynamax) alarm management -│ ├── areas/ Area selection with map +│ ├── areas/ Areas, the home pin, and saved places on one page │ ├── geofences/ Custom geofence drawing -│ ├── profiles/ Profile management +│ ├── profiles-overview/ Profile cards, the routed /profiles page +│ ├── profiles/ Profile add / edit / duplicate dialogs │ ├── cleaning/ Alarm cleanup tools │ ├── quick-picks/ Quick pick alarm templates -│ └── admin/ Admin panel (users, servers, geofences) +│ ├── help/ In-app help page +│ └── admin/ Admin panel (users, webhooks, settings, geofence submissions) └── shared/ ├── components/ Reusable UI components - └── utils/ Utility functions (geo.utils, etc.) + └── utils/ Utility functions (geo.utils, alarm-scope, etc.) ``` +`/places` is a redirect to `/areas` — places were folded into the Areas & Places page, beside the pin they belong with. `/admin` redirects to `/admin/users`. + ## Services ### ScannerService @@ -63,6 +67,31 @@ The `GymSearchResult` interface defines the shape: `id`, `name`, `url`, `lat`, ` The `active-hours.models.ts` file (`core/models/`) defines the `ActiveHoursEntry` interface and utility functions for working with time-window rules (serialization, display formatting, validation). +### PlacesService + +`PlacesService` (`core/services/places.service.ts`) holds the places an alarm can be aimed at: the named ones, plus the profile pin under `pin` (null when it is the 0,0 Poracle stores for "not set"). It is a signal rather than a per-caller fetch because the Where sheet, the Places section and every card carrying a where chip read the same list, and a place added in one has to appear in the others without a reload. + +- `load()` / `add(place)` — both set the signal from the response +- `remove(label)` — answers **409** with `referencingRules` when alarms still point at the place; the caller should name them rather than reporting a bare failure + +### AlertLanguageService + +`AlertLanguageService` (`core/services/alert-language.service.ts`) owns the language Poracle writes alerts in — DM text and Pokemon names in your notifications — which is a different setting from the display language. It writes optimistically to `localStorage('poracle-language')` and rolls back if the API call fails, and reconciles against the authoritative `human.Language`, since the bot can change it out of band. Its selected value is a computed: a language the user has actually been given, falling back to Poracle's configured locale, then `en`. + +### MasterDataService + +`MasterDataService` (`core/services/masterdata.service.ts`) holds the game data the pickers render: Pokemon names, types, form names, evolution chains, plus move and item names. + +Names, types and forms are fetched from `GET /api/masterdata/monsters?locale={display language}` — Poracle owns those translations — while moves and items come from `/api/masterdata/{moves,items}` in English. All four load in one `forkJoin`; only the monster call is wrapped in `catchError`, so a Poracle that cannot serve it leaves the English names in place instead of cancelling the rest. + +An `effect` on `I18nService.currentLang` re-fetches when the display language changes and re-emits on `ready$`, so a species picker that is already open updates in place rather than needing a reload. + +Type names are the subtlety. Poracle returns them translated, but the uicons file names and the type filter chips both key on the English name, so the **English name is kept as the value** — resolved from the stable type id via `shared/utils/pokemon-types.ts` — and the translated string is stored alongside as a display label, read through `getTypeLabel()`. Only the chip's text is localized; everything that identifies a type is not. + +### AlertDefaultsService + +`AlertDefaultsService` (`core/services/alert-defaults.service.ts`) remembers what scope a **new** alarm should open with: mode, default radius in km (clamped 0.1–100), and a default saved place. Stored in `localStorage` under `poracle-default-alert-mode`, `poracle-default-alert-distance-km` and `poracle-default-alert-place`. Client-side only — existing alarms are untouched. Edited from `AlertDefaultsDialogComponent` in the user menu. + ### PokemonAvailabilityService `PokemonAvailabilityService` (`core/services/pokemon-availability.service.ts`) provides Pokemon spawn availability data from the Golbat scanner API. It is a `providedIn: 'root'` singleton. @@ -115,6 +144,30 @@ Shows on the dashboard for new users until explicitly dismissed. Detects existin !!! note "`ProfileListComponent` removed" The unused `ProfileListComponent` has been removed. Profile management is handled entirely by `ProfileOverviewComponent`. +### Where an alarm reaches you + +`ScopePickerComponent` (`shared/components/scope-picker/`) is the one control for an alarm's delivery scope, wherever the question is asked. Three mutually exclusive options — inherit the profile's areas, a radius from a point or saved place, or only specific areas — modelled as a radio group because PoracleNG refuses every combination of them, so a state that would need validating cannot be expressed. It is rendered inline by every alarm add and edit dialog and by the quick-pick apply dialog. It previously existed as two copies, a two-option radio in the dialogs and a three-option sheet on the card, which drifted apart within a day and left no way to set a per-alarm area override before the alarm existed. + +`WhereChipComponent` (`shared/components/where-chip/`) states the answer on the alarm card as a sentence fragment — "Anywhere I get alerts", "Anywhere in my areas", "Within 2 km of Home", "Only in Terrigal, Erina" — and is the way into the sheet. It is rendered by six of the nine list templates (pokemon, gyms, invasions, lures, nests, fort changes); raid, quest and max-battle cards do not carry it yet. An `editable` input turns it into a plain statement where there is nothing to open, which is how `AlarmInfoComponent` uses it. + +`WhereSheetComponent` (`shared/components/where-sheet/`) is a dialog shell around the scope picker and nothing else, for changing scope from a card where there is no form to put the control in. + +### Places section + +`PlacesSectionComponent` (`shared/components/places-section/`) renders the user's named places as a section of the Areas & Places page, directly under the card holding the pin. The pin is not repeated in the grid, since the card above it is the pin. Adding a place borrows `LocationDialogComponent` as a coordinate picker rather than growing a second map, then asks for the name separately. + +### Server profile card + +`ServerProfileCardComponent` (`shared/components/server-profile-card/`) sits on the admin settings page and says which PoracleNG the deployment talks to, which capabilities are switched on, and whether the version is below what this build needs. Only enabled capabilities are listed — a key present and false means the binary knows the feature and has it off. See [Server capability probe](backend.md#server-capability-probe). + +### Level selector + +`LevelSelectorComponent` (`shared/components/level-selector/`) is the chip-based picker for raid, egg, and raid-boss levels. A single `pickerType: 'raid' | 'egg' | 'boss'` input drives layout and behavior — multi-select vs single-select, whether the `Any` (9000) chip is offered, and which canonical levels go in the primary chip row vs the "More raid types…" overflow menu. See [Raid level selector](../features/alarms.md#raid-level-selector) for the user-facing behavior. + +`RaidLevelService` (`core/services/raid-level.service.ts`) fetches the canonical raid-level list from `GET /api/masterdata/raid-levels` on first dialog/list usage and caches the result in a signal. A baked-in `KNOWN_LEVELS` constant in `core/models/raid-level.models.ts` is the fallback when the network call fails or hasn't resolved. The same constant powers the synchronous `resolveLevel(value)` helper used by `LevelLabelPipe` so alarm cards have a usable label even before the API response lands. The pipe detects ngx-translate's key-not-found pass-through and falls back to a generic "Level {n}" string so future masterfile additions don't leak raw translation keys into the UI. + +Custom integers typed via the `+ Add` chip live in the component's local signal — they are **not** persisted to localStorage. Closing the dialog (or refreshing the page) discards typed-but-not-saved chips. Existing alarms at custom levels re-seed the chip when the edit dialog opens via the `[value]` input setter. + ### Gym picker `GymPickerComponent` (`shared/components/gym-picker/`) is a standalone autocomplete for selecting a gym from the scanner database. It wraps a Material autocomplete input with debounced search (300ms, minimum 2 characters). Each option row displays the gym photo thumbnail, name, and area name. The component exposes a two-way `gymId` model binding so parent dialogs can read/write the selected gym ID directly. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 67ace4b2..bd6a45d6 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -19,7 +19,7 @@ Pgan.PoracleWebNet.slnx ├── Core/ │ ├── Core.Abstractions/ Interfaces (IService, IPoracleTrackingProxy, IPoracleHumanProxy) │ ├── Core.Models/ DTOs passed between layers -│ ├── Core.Mappings/ AutoMapper profiles (Human, Profile, PoracleWeb.NET tables) +│ ├── Core.Mappings/ Mapping extension methods (alarm DTOs, Human, Profile, PoracleWeb.NET tables) │ ├── Core.Repositories/ Data access (Human, Profile, PoracleWeb-owned tables) │ └── Core.Services/ Business logic + PoracleNG API proxies ├── Data/ @@ -78,21 +78,21 @@ graph TB ``` !!! info "All operations go through PoracleNG" - All alarm tracking CRUD (including Fort Change and Max Battle types) is proxied via `IPoracleTrackingProxy`. Single-user human/profile operations (reads, creates, location, areas, profile switch) are proxied via `IPoracleHumanProxy`. Direct database access is only used for admin bulk operations (`GetAllAsync`, `DeleteUserAsync`, `UpdateAsync`) and application-owned data (`poracle_web` database). Optional integrations include Pokemon availability from Golbat API and weather data from Poracle API. See [PoracleNG API Proxy](poracleng-proxy.md) for details. + All alarm tracking CRUD (including Fort Change and Max Battle types) is proxied via `IPoracleTrackingProxy`. Single-user human/profile operations (reads, creates, location, areas, profile switch) are proxied via `IPoracleHumanProxy`. Direct database access is used for admin bulk operations (`GetAllAsync`, `DeleteUserAsync`, `UpdateAsync`), for profile rename, for user-geofence area writes, for the per-alarm `override_areas` column, and for application-owned data (`poracle_web` database). PoracleNG's `schema_migrations` table is also read directly, by the server capability probe. See [Backend](backend.md#areas). Optional integrations include Pokemon availability from the Golbat API and weather from the optional scanner database via `IScannerService`. See [PoracleNG API Proxy](poracleng-proxy.md) for details. ## Key design decisions ### Operations proxied through PoracleNG -All alarm tracking writes (create, update, delete) and single-user human/profile operations go through the PoracleNG REST API, not directly to the database. PoracleNG applies field defaults (`cleanRow()`), detects duplicates, handles area dual-writes, and triggers immediate state reload. This eliminates data integrity bugs caused by missing defaults or stale state. Profile duplication uses PoracleNG's copy endpoint to clone all tracking rules atomically. Supported alarm types include Pokemon, Raids, Eggs, Quests, Invasions, Lures, Nests, Gyms, Fort Changes, and Max Battles. Test alerts are sent via PoracleNG's `POST /api/test` endpoint, which formats and delivers a mock notification to the user. See [PoracleNG API Proxy](poracleng-proxy.md). +All alarm tracking writes (create, update, delete) and single-user human/profile operations go through the PoracleNG REST API, not directly to the database. PoracleNG applies field defaults (`cleanRow()`), detects duplicates, handles area dual-writes, and triggers immediate state reload. This eliminates data integrity bugs caused by missing defaults or stale state. Profile duplication from the Profiles page (`POST /api/profile-overview/duplicate/{n}`) copies each alarm with its own `CreateAsync` call and rolls back on failure; only `POST /api/profiles/duplicate` uses PoracleNG's copy endpoint. Supported alarm types include Pokemon, Raids, Eggs, Quests, Invasions, Lures, Nests, Gyms, Fort Changes, and Max Battles. Test alerts are sent via PoracleNG's `POST /api/test` endpoint, which formats and delivers a mock notification to the user. See [PoracleNG API Proxy](poracleng-proxy.md). ### Separate databases PoracleWeb.NET does **not** modify the Poracle DB schema. The Poracle database is managed by PoracleNG. Application-owned data (user geofences, site settings, webhook delegates, quick pick definitions) lives in a separate `poracle_web` database managed by EF Core migrations. ### Unified geofence feed -PoracleWeb.NET acts as the single geofence source for PoracleJS. It fetches admin geofences from Koji, merges them with user-drawn geofences, and serves everything via one endpoint (`GET /api/geofence-feed`). No custom code needed in PoracleJS or Koji. User geofences support GeoJSON import/export for interoperability with external mapping tools. +PoracleWeb.NET acts as the single geofence source for PoracleNG. It fetches admin geofences from Koji, merges them with user-drawn geofences, and serves everything via one endpoint (`GET /api/geofence-feed`). No custom code needed in PoracleNG or Koji. User geofences support GeoJSON import/export for interoperability with external mapping tools. -### AutoMapper for partial updates -All update models use nullable `int?` properties so partial updates don't zero out unset fields. The mapping profile skips null properties automatically. Note: AutoMapper is now only used for non-alarm entities (humans, profiles). Alarm data flows as raw JSON through the PoracleNG API proxy. +### Manual mapping extensions +Mapping lives in static extension methods under `Core.Mappings/` -- there is no AutoMapper dependency. `AlarmMappingExtensions` provides `To*()` for `*Create` DTOs and `ApplyUpdate()` for `*Update` DTOs; `EntityMappingExtensions` provides `ToModel()`, `ToEntity()`, and `ApplyTo()` for Human, Profile, and the `poracle_web`-owned tables. Update models use nullable properties and `ApplyUpdate` skips nulls, so partial updates don't zero out unset fields. Alarm data itself flows as raw JSON through the PoracleNG API proxy. See [Backend Patterns](backend.md). ### Gym picker The `GymPickerComponent` (shared) lets users search for specific gyms when creating team, raid, or egg alarms. It calls the `ScannerService` (frontend) which hits scanner gym search endpoints on the backend (`ScannerController`). Search results use the `GymSearchResult` model and include photo thumbnails and area names resolved via the `PointInPolygon` geo utility. The scanner DB is optional — when not configured, the gym picker is hidden. diff --git a/docs/architecture/poracleng-proxy.md b/docs/architecture/poracleng-proxy.md index d4889006..ebb47ec2 100644 --- a/docs/architecture/poracleng-proxy.md +++ b/docs/architecture/poracleng-proxy.md @@ -2,6 +2,9 @@ All alarm tracking operations (create, read, update, delete) are proxied through the PoracleNG REST API instead of writing directly to the Poracle MySQL database. This ensures PoracleNG applies field defaults, deduplication, and immediate state reload on every mutation. +!!! warning "PoracleNG 5.1.0 or newer" + `PoracleServerProfile.MinimumSupported` is 5.1.0 — the release that adds `override_location_label`, `override_areas` and `pvp_ranking_evolution`. Below it those columns do not exist, so per-alarm delivery scope, the PVP mega picker and the minimum time-left filter write fields nothing stores and fail silently. The [server capability probe](backend.md#server-capability-probe) reports the running version and warns an admin when it is known to be older. + ## Why we migrated On March 31, 2026, a NULL `template` column written directly by PoracleWeb.NET crashed PoracleNG's state reload for 15 hours. PoracleNG's Go SQL scanner cannot handle `NULL` in the `template` column of the `monsters` table, causing the entire state reload to fail. All users received stale alarm state and unwanted DM floods until PoracleNG was manually restarted. @@ -56,13 +59,66 @@ Also proxied: - **Admin delete all alarms** -- fetches all UIDs per type, bulk deletes via the proxy - **Bulk distance update** -- fetches alarms, modifies `distance`, POSTs back via the proxy +### Read-only calls that shape the UI + +Three of PoracleNG's own endpoints are read for things other than tracking. All three degrade to a +usable default rather than failing the request: + +| Call | Used for | If it fails | +|---|---|---| +| `GET /api/masterdata/monsters?locale={code}` | Pokemon names, types, form names and evolution chains, translated into the display language | Falls back to the English [WatWowMap masterfile](https://github.com/WatWowMap/Masterfile-Generator) cached server-side, so the pickers stay populated | +| `GET /api/config/poracleWeb` → `disabledHooks` | The per-type disable flags Poracle sets in its own config, honoured here as a floor under the site settings | Empty set: the local `disable_*` settings are in sole charge. Fails **open**, deliberately | +| `GET /api/config/values` → `general.disable_fort_update` | Fort changes, which PoracleNG enforces but omits from `disabledHooks` | Same, and independently of the call above, so a Poracle without this route keeps the hook list it already has | + +The last row is a wart, not a design: see [PoracleNG enhancement requests](../poracleng-enhancement-requests.md). +The locale on the first row is the display language, which is why switching language re-fetches the +map — Poracle owns the translations, so this site does not carry Pokemon names of its own. + +## Insert, update or duplicate + +Every alarm write is a POST, and PoracleNG decides what to do with it by diffing the submitted row +against the ones already stored (`DiffTracking`). The outcome is not +obvious from the request: + +| Diff result | Outcome | +|---|---| +| No differences | Duplicate. Nothing is written, reported as `alreadyPresent` | +| Exactly one difference, and it is an updatable field | **Update of that existing row**, re-keyed to a new uid | +| Anything else | New insert | + +The updatable set is uniform: `clean`, `distance` and `template`, plus `slot_changes` and +`battle_changes` on gyms. Everything else identifies the alarm. + +The consequence that keeps biting: an Add or an Edit that differs from a **different** alarm by exactly +one updatable field takes that alarm over. The user gets a 201 or a 200, and one alarm exists where +there were two, with the victim's radius replaced. `TrackingUpdateReconciler.EnsureNoMergeIntoAnotherAlarmAsync` +mirrors the rule and refuses before the write, on create and update alike. Two or more updatable +differences genuinely coexist and must stay editable; an earlier version of the guard refused those too +and made alarms permanently uneditable. + +Two qualifications: + +- Pokemon edits cannot merge. `trackingMonster.go` splits rows on whether the uid is set and sends + uid-bearing ones straight to `UpdateMonsterByUID`, never reaching the diff, so the guard skips them. + It is the only type that does this. +- A field PoracleWeb does not supply cannot be compared. PoracleNG fills it with its own default, so a + null says nothing about what will be stored. That is why `TrackingFieldPreserver` merges the stored + row in before the guard runs — see [Backend → Update pattern](backend.md#update-pattern). + ## What stays on direct database access | Operation | Reason | |---|---| | Admin bulk human operations (`GetAllAsync`, `DeleteUserAsync`, `UpdateAsync`) | PoracleNG has no admin-list, admin-delete, or generic update endpoints | +| Profile **rename** (`ProfileRepository.RenameAsync`) | PoracleNG's profile update answers `{"status":"ok"}` and writes nothing for `name`, while honouring `active_hours` on the same request | +| User-geofence area writes (`IUserAreaDualWriter`, `humans.area` + `profiles.area`) | `setAreas` intersects the submitted list against `userSelectable=true` fences for non-admins, so a user's own geofence is silently stripped | +| Per-alarm `override_areas` (`IUserAreaDualWriter.SetAlarmOverrideAreasAsync`) | The tracking write validates the same names against `GetAvailableAreas` and answers 400 "area not permitted", failing the whole request. Matching never consults `userSelectable`, so the name is written into the column directly | +| `schema_migrations` read (`PoracleSchemaVersionReader`) | The applied migration number is what says whether a column exists; nothing in the `/health` capability map describes alarm columns | +| Deprecated `pweb_settings` KV table (`PwebSettingRepository`, plus one `ALTER TABLE ... MODIFY COLUMN value LONGTEXT NULL` at startup) | Legacy rows PoracleNG never knew about, kept alive only so `SettingsMigrationService` can copy them into `poracle_web` | | `poracle_web` database (geofences, settings, webhook delegates, quick picks) | Application-owned data, not managed by PoracleNG | -| Scanner database (gym search) | Read-only, separate database | +| Scanner database (gym search, weather) | Read-only, separate database | + +The user-geofence area writes and the per-alarm `override_areas` write are tagged `HACK: trusted-set-areas` in code — `grep -rn "HACK: trusted-set-areas" --include="*.cs"` lists every reversion point. See [Backend → Areas](backend.md#areas) for the mechanism; this table and the one in [Database](database.md#poraclecontext) describe the same set. !!! note "Single-user human/profile operations are fully proxied" `HumanService` reads, creates, and checks existence via `IPoracleHumanProxy` with **no DB fallback**. Location, areas, profile switch, profile CRUD, and profile copy all go through the proxy. Only admin bulk operations remain on direct DB. @@ -77,6 +133,7 @@ public interface IPoracleTrackingProxy Task DeleteByUidAsync(string type, string userId, int uid); Task BulkDeleteByUidsAsync(string type, string userId, IEnumerable uids); Task GetAllTrackingAsync(string userId); + Task GetAllTrackingAllProfilesAsync(string userId); Task ReloadStateAsync(); } ``` @@ -88,6 +145,11 @@ Key design points: - **`X-Poracle-Secret` header** -- authenticates requests to the PoracleNG API. Configured via `Poracle:ApiSecret`. - **Updates use POST** -- PoracleNG's tracking POST endpoint handles both creates and updates. When the request body includes a `uid` field, PoracleNG updates the existing alarm instead of creating a new one. - **`uid:0` stripped on create** -- `PoracleJsonHelper.SerializeToElement()` removes `"uid":0` from request bodies. PoracleNG treats `uid=0` as an update target instead of a new insert; omitting `uid` tells PoracleNG to create a new row. +- **`profile_no` stripped on every alarm write** -- the same helper removes it. PoracleNG takes a submitted + `profile_no` at face value on the pokemon type (creating a row on a profile that may not exist) while + scoping every read to `current_profile_no`. Since the JWT claim can be stale, stamping it onto writes + stranded alarms that were invisible and undeletable. Omitting it files each alarm under the live active + profile. - **URL-encoding for user IDs** -- Both `PoracleTrackingProxy` and `PoracleHumanProxy` use `Uri.EscapeDataString()` on user IDs in URL paths. Webhook IDs are full URLs containing slashes that would break routing without encoding. ## snake_case JSON serialization @@ -145,14 +207,43 @@ See [PoracleNG Enhancement Requests](../poracleng-enhancement-requests.md) for t 3. Register the service in `ServiceCollectionExtensions.cs`. 4. Create the corresponding controller under `Controllers/`. -No repository, entity, or AutoMapper mapping is needed for alarm types -- the proxy handles all database interaction through PoracleNG. +No repository or entity is needed for alarm types -- the proxy handles all database interaction through PoracleNG. ## Registration ```csharp // In ServiceCollectionExtensions.cs -services.AddHttpClient(); +services.AddHttpClient(); +services.AddScoped(sp => new UserOwnedOverrideAreaProxy( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + services.AddHttpClient(); ``` The `HttpClient` instances are managed by the .NET HTTP client factory, providing connection pooling and DNS rotation. + +### The tracking proxy is decorated + +`PoracleTrackingProxy` is registered as its concrete type. What the rest of the app resolves for +`IPoracleTrackingProxy` is `UserOwnedOverrideAreaProxy` wrapping it, so every alarm service gets the +decorated instance. It intercepts `CreateAsync` only; the other six methods forward untouched. + +On a create or an edit it: + +1. Refuses an incoherent scope up front (`EnsureScopeIsCoherent`): a place and a set of areas cannot + both be set, areas cannot coexist with a radius, and a place needs one. PoracleNG enforces the same + three rules, but only against the body it receives — which by step 2 may no longer mention the areas. +2. Strips the user's own geofence names out of `override_areas` before the POST, because PoracleNG + rejects them outright with a 400 rather than stripping them the way `setAreas` does. +3. Writes the full list into the row with `IUserAreaDualWriter.SetAlarmOverrideAreasAsync`, resolving + the uid from the create response for a single row and by re-reading and pairing on content for a + batch (PoracleNG returns `newUids` in its own order). A row that is not there to write to throws + rather than leaving an alarm that quietly covers the whole profile. +4. Calls `ReloadStateAsync`, since a direct column write is not a PoracleNG mutation and would otherwise + wait for the periodic reload. + +A write that names no override area skips steps 2 to 4 entirely, so the common path costs one extra +JSON scan and no queries. diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index ac13e056..8a028be6 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -37,28 +37,40 @@ services: ## Network requirements -The PoracleWeb.NET container must be able to reach: +Inside your own network, the PoracleWeb.NET container must be able to reach: - **PoracleNG API** (`Poracle:ApiAddress`) -- all alarm tracking writes are proxied through this endpoint. If the containers are on the same Docker network, use the service name (e.g., `http://poracleng:3030`). If on different hosts, use the host IP/domain. - **MySQL** -- for `humans`/`profiles` tables and the `poracle_web` database. -- **Golbat API** (`Golbat:ApiAddress`) -- optional. When configured, enables Pokemon availability indicators. The container must be able to reach the Golbat scanner API. +- **Golbat API** (`Golbat:ApiAddress`) -- optional. When configured, enables Pokemon availability indicators. +- **Scanner database** (`ConnectionStrings:ScannerDb`) -- optional. Backs the gym picker in the raid/gym/egg dialogs and the dashboard weather panel. Without it both are simply absent. +- **Koji** (`Koji:ApiAddress`) -- required for admin geofences and region lookups. A user can still draw and use a private [custom geofence](../features/custom-geofences/index.md) without it: if Koji is unreachable the feed still serves user geofences from the local database, but approving one to a public area, and the region auto-detection on the draw page, both need Koji. + +Outbound to the internet: + +- **`discordapp.com` and `cdn.discordapp.com`** -- Discord OAuth sign-in, role lookups, avatars, and the geofence review forum posts. Unavoidable if Discord login is enabled. +- **The geocoder** -- the address search and reverse lookup proxy to whatever `providerURL` PoracleNG's config names (usually a Nominatim instance). Switch it off with the `disable_nominatim` site setting. +- **`raw.githubusercontent.com`** (Masterfile-Generator) -- the game master data behind the move and item pickers, fetched on the first request after a cold start and cached in memory. Pokemon names, types and forms come from Poracle instead, so they are translated and need no outbound call; this remains their fallback when Poracle cannot serve them. Not switchable; without it the pickers fall back to whatever is already cached. The browser makes no request of its own here -- everything is proxied through the API. +- **`api.github.com` and `raw.githubusercontent.com`** -- the version check behind the Versions card on **Admin > Settings**. Two anonymous GETs, made when an admin opens the card and cached six hours afterwards, nothing sent. On a locked-down egress policy it fails silently and the card cannot tell you whether an update exists. Turn it off with the `disable_update_check` site setting. ## Volume mounts ### Data directory -The `./data` directory persists: +Mounted at `DATA_DIR` (`/app/data` in the image). It persists: +- DataProtection keys. These encrypt the stored OIDC refresh tokens, so losing them signs every SSO user out on the next container recreate. - Cached Discord avatars - Cached DTS template files +See [Paths](reference.md#paths) for the environment variables behind these. + ### Poracle config directory -Mount your PoracleJS `config/` directory as read-only for DTS template preview functionality: +Mount your PoracleNG `config/` directory as read-only for DTS template preview functionality: ```yaml volumes: - - /path/to/PoracleJS/config:/poracle-config:ro + - /path/to/PoracleNG/config:/poracle-config:ro ``` ## Building locally @@ -82,3 +94,6 @@ docker compose up -d docker compose up -d --force-recreate docker build --no-cache -t poracleweb.net:latest . ``` + +!!! warning "Building from source builds whatever is checked out" + These commands build your working tree. On a fresh clone that is `main`, which tracks releases; on `develop` it is unreleased work. Check out a release tag first (`git checkout "$(git describe --tags --abbrev=0)"`), or skip the build entirely and use the published `ghcr.io/pgan-dev/poracleweb.net:latest` image, which only moves when a release is published. diff --git a/docs/configuration/external-sso.md b/docs/configuration/external-sso.md new file mode 100644 index 00000000..6e8023b4 --- /dev/null +++ b/docs/configuration/external-sso.md @@ -0,0 +1,312 @@ +# External SSO / OpenID Connect Login + +PoracleWeb.NET can delegate **login** to a generic external OAuth2 / OpenID Connect +provider, so users sign in with your own identity provider instead of (or alongside) +Discord and Telegram. This page is the comprehensive, provider-agnostic guide to +configuring that login flow. + +!!! info "Provider-agnostic — PogoAlerts is just the reference" + The SSO flow is a configurable twin of the [Discord OAuth flow](../getting-started/discord-oauth.md), + parameterized entirely by `OIDC_*` config. **PogoAlerts** (PGAN's identity provider) is one + instance, but nothing in the flow is special to it — it rests only on spec-standard + OAuth2/OIDC (`/authorize`, `/token`, `/userinfo`, plus optional `/end-session`). It works with + **any** compliant provider: Keycloak, Authentik, Auth0, Google, Azure AD / Entra, Okta, and more. + +You can **ignore OIDC entirely** — it is off by default and the sign-in page stays in **Local** +mode (Discord / Telegram). Turn it on only when you want to point PoracleWeb at a provider. + +!!! warning "The one inherent constraint" + The identity claim returned by your provider's userinfo endpoint **must resolve to an existing + Poracle `human` id** — i.e. a Discord or Telegram id that already exists in your Poracle + database. PoracleWeb does not provision new users from SSO; it authenticates *existing* Poracle + users through your provider. If the claim doesn't match a registered `human`, login fails with + [`user_not_registered`](#error-codes). Set `OIDC_IDENTITY_CLAIM` to the userinfo claim that + carries that id (it falls back to the standard `sub` claim when the configured claim is absent). + +Silent session renewal and refresh-token handling are documented separately on the +**[OIDC Refresh Tokens](oidc-refresh-tokens.md)** page — this page covers the login itself. + +--- + +## How it works + +The flow is the standard OAuth2 authorization-code grant (with PKCE by default): + +```mermaid +sequenceDiagram + participant B as Browser + participant P as PoracleWeb API + participant I as Provider (IdP) + B->>P: GET /api/auth/oidc/login + P->>B: 302 to provider authorize (state cookie, PKCE S256) + B->>I: authorize → sign in / consent + I->>B: 302 /api/auth/oidc/callback?code&state + B->>P: callback(code, state) + P->>P: validate state (CSRF) + PKCE verifier + P->>I: POST /token (grant=authorization_code) + I-->>P: { access_token, ... } + P->>I: GET /userinfo (Bearer access_token) + I-->>P: { identity claim, username, avatar } + P->>P: resolve human → check enabled + roles + enable_oidc gate + P->>B: 302 /auth/oidc/callback#token=JWT +``` + +- **CSRF**: a random `state` value is stored in an `HttpOnly` cookie and verified on callback. +- **PKCE**: when `OIDC_USE_PKCE=true` (default), a code verifier is stored in an `HttpOnly` + cookie and only the S256 challenge is sent to the provider. +- **Redirect URI**: PoracleWeb always uses `{your-host}/api/auth/oidc/callback`. Register exactly + this URI at your provider. `{your-host}` comes from the incoming request unless `PUBLIC_URL` is + set, in which case it is that value — worth setting behind a reverse proxy, where the app + otherwise sees plain HTTP and sends an `http://` redirect URI the provider will refuse. +- **Identity resolution**: the configured identity claim (falling back to `sub`) is looked up + against the Poracle `human` table. The provider authenticates; Poracle authorizes. +- **JWT type claim**: successful logins mint PoracleWeb's internal JWT with the `type` claim set + to `OIDC_IDENTITY_TYPE` (default `discord:user`), so admin/role resolution treats the passed-through + Discord id consistently with a direct Discord login. + +--- + +## Step-by-step setup + +1. **Register a client at your identity provider.** Create an OAuth2 / OIDC application and set its + redirect (callback) URI to: + + ``` + {your-host}/api/auth/oidc/callback + ``` + + for example `https://poracle.example.com/api/auth/oidc/callback`. Note the client id and client + secret. Enable PKCE if your provider supports it (recommended). + +2. **Configure the `OIDC_*` variables** in your `.env` (see the [reference table](#login-configuration-reference)). + At minimum you need the three endpoint URLs, the client id, the client secret, and — unless your + provider's `sub` claim already holds the Poracle id — `OIDC_IDENTITY_CLAIM`. Restart PoracleWeb + so the server config takes effect. + +3. **Switch the sign-in mode to SSO.** In **Admin → Settings → Authentication**, flip the + **Local ⇄ SSO** segmented switch to **SSO**. This is the runtime opt-in (`enable_oidc=true`) and + is gated on OIDC being fully configured plus a confirmation dialog. See + [Auth mode](#auth-mode-local-vs-sso) below. + +!!! tip "Verify before flipping the switch" + `OIDC_ENABLED` is **auto-inferred true** when `OIDC_CLIENT_ID` and the three URLs + (`OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USERINFO_URL`) are all set — you don't have to + set it explicitly. The admin **Authentication** panel shows a read-only OIDC config card (from + `/api/settings/oidc-config`, with secrets masked) so you can confirm the server picked up your + config before switching everyone to SSO. + +--- + +## Login configuration reference + +All variables are read from `.env` (or the `Oidc__*` .NET convention) and require a restart to take +effect. The provider secret is **never stored in the database** and is only ever returned masked. + +| `.env` name | `.NET` env variable | Default | Description | +|---|---|---|---| +| `OIDC_ENABLED` | `Oidc__Enabled` | *(auto-inferred `true` when `OIDC_CLIENT_ID` + the three URLs are set)* | Master switch from server config. When false the provider is hidden regardless of other values. | +| `OIDC_PROVIDER_NAME` | `Oidc__ProviderName` | `""` | Display name shown on the login button, e.g. `PogoAlerts`. | +| `OIDC_AUTHORIZATION_URL` | `Oidc__AuthorizationUrl` | `""` | Browser-facing authorize endpoint. Any existing query string is **preserved** (e.g. a `?hide=…` filter, or Google's `?access_type=offline`). | +| `OIDC_TOKEN_URL` | `Oidc__TokenUrl` | `""` | Token endpoint that exchanges the authorization code for tokens. | +| `OIDC_USERINFO_URL` | `Oidc__UserInfoUrl` | `""` | Userinfo endpoint returning the user's claims. | +| `OIDC_END_SESSION_URL` | `Oidc__EndSessionUrl` | `""` | **Optional.** RP-initiated end-session endpoint. When set, enables OIDC single logout (SLO) — see [Single logout](#single-logout-slo). | +| `OIDC_CLIENT_ID` | `Oidc__ClientId` | `""` | OAuth2 client id. | +| `OIDC_CLIENT_SECRET` | `Oidc__ClientSecret` | `""` | OAuth2 client secret. **Never stored in the database**; returned masked by the admin config endpoint. | +| `OIDC_SCOPES` | `Oidc__Scopes` | `openid profile email` | Space-delimited OAuth scopes requested at authorization time. | +| `OIDC_IDENTITY_CLAIM` | `Oidc__IdentityClaim` | `discord_id` | Userinfo claim whose value is the user's Poracle `human` id (a Discord/Telegram id). Falls back to `sub` when the configured claim is absent. **Must resolve to an existing `human`** — see [the inherent constraint](#external-sso-openid-connect-login). | +| `OIDC_USERNAME_CLAIM` | `Oidc__UsernameClaim` | `preferred_username` | Userinfo claim used as the display username. | +| `OIDC_AVATAR_CLAIM` | `Oidc__AvatarClaim` | `picture` | Userinfo claim used as the avatar URL. | +| `OIDC_IDENTITY_TYPE` | `Oidc__IdentityType` | `discord:user` | Value written to the JWT `type` claim for SSO logins. | +| `OIDC_USE_PKCE` | `Oidc__UsePkce` | `true` | Use PKCE (S256) for the authorization-code exchange. Recommended. | +| `AUTH_FORCE_LOCAL` | `Auth__ForceLocal` | `false` | **Break-glass.** Forces the local login page regardless of SSO mode — see [Break-glass](#break-glass-auth_force_local). | + +The refresh-token variables (`OIDC_USE_REFRESH_TOKENS`, `OIDC_ACCESS_TOKEN_MINUTES`, +`OIDC_OFFLINE_ACCESS_SCOPE`, `OIDC_TOKEN_AUTH_METHOD`, …) are documented on the +[OIDC Refresh Tokens](oidc-refresh-tokens.md) page. See also the full +[Configuration Reference](reference.md). + +--- + +## Auth mode (Local vs SSO) + +The admin **Authentication** panel exposes a single segmented **Local ⇄ SSO** switch backed by the +one runtime [site setting](site-settings.md) `enable_oidc`: + +| `enable_oidc` | Sign-in mode | +|---|---| +| absent / `false` | **Local** — Discord / Telegram (the default; SSO is **opt-in**). | +| `true` | **SSO** — the login page auto-redirects to your OIDC provider. | + +- **OIDC is opt-in.** Unlike Discord/Telegram (where an absent setting means *enabled*), SSO is only + active when `enable_oidc` is explicitly `true`. The default sign-in mode is always Local. +- Switching to **SSO** is gated on OIDC being fully configured **and** a confirmation dialog, to + prevent locking yourself out against a misconfigured provider. +- In **SSO** mode the Discord and Telegram sections of the Authentication panel are hidden, replaced + by the read-only OIDC config card (from `/api/settings/oidc-config`, secrets masked), the + [single-logout toggle](#single-logout-slo) (when an end-session URL is set), and the silent-refresh + toggle (when refresh is configured — see the [refresh page](oidc-refresh-tokens.md)). + +!!! note "Admins can always reach the login page" + Even when `enable_oidc=false`, the `enable_oidc` gate is **not** an early block — it is enforced + only after the user is identified, and **admins bypass it**. This means an admin can always sign + in (via any configured method) to re-enable the setting, exactly like the Discord/Telegram gates. + +--- + +## Login page behavior + +- When SSO is active, the login page **auto-redirects** to your provider so users aren't shown an + unnecessary intermediate screen. +- `/login?loggedout=1` shows a **"Signed out"** panel and **suppresses** the auto-redirect, so a user + who just logged out isn't immediately re-logged-in. The single-logout flow redirects here. +- The `/api/auth/providers` endpoint drives the login UI. Its `oidc` block reports: + `configured`, `enabledByAdmin`, `providerName`, `endSession` (whether SLO is available), and + `refresh` (whether silent refresh is wired up). + +--- + +## Single logout (SLO) + +When `OIDC_END_SESSION_URL` is set, signing out can also end the user's session **at the provider** +(RP-initiated logout), not just locally. `GET /api/auth/oidc/logout` bounces the browser to the +provider's end-session endpoint with a `post_logout_redirect_uri` of `{origin}/login?loggedout=1`, +then returns to the signed-out panel. + +Single logout requires **both**: + +1. `OIDC_END_SESSION_URL` configured, **and** +2. the runtime toggle `enable_oidc_slo` not set to `false` (absent = **on** once the URL is wired). + +If either is missing, logout falls back to **local-only** — PoracleWeb clears its own session but the +provider session survives. + +--- + +## Break-glass (`AUTH_FORCE_LOCAL`) + +`AUTH_FORCE_LOCAL=true` (`Auth__ForceLocal`) forces the local login page **regardless** of the SSO +mode. It is a recovery mechanism: if an admin switches to SSO against a provider that is down or +misconfigured and everyone is locked out, set this env flag and restart to get the local Discord / +Telegram login back without touching the database. + +It overrides `enable_oidc` for the `/api/auth/providers` response (OIDC reports `enabledByAdmin=false` +while it is set). The admin OIDC config card surfaces a `forceLocal` flag so the UI can explain why +OIDC appears inactive even when enabled. + +--- + +## Error codes + +On any failure the browser is redirected to `/login#error=CODE`. The login page maps these to a +message. + +| `CODE` | Meaning | +|---|---| +| `oidc_disabled` | OIDC is not configured at all, **or** a non-admin user attempted SSO while `enable_oidc=false`. | +| `oidc_token_exchange_failed` | The `/token` code exchange failed (bad client secret, wrong redirect URI, expired code, auth-method mismatch). | +| `oidc_userinfo_failed` | The `/userinfo` call failed or returned no usable body. | +| `oidc_no_identity` | Neither the configured `OIDC_IDENTITY_CLAIM` nor the fallback `sub` claim was present in userinfo. | +| `user_not_registered` | The identity claim resolved, but no matching Poracle `human` exists. Register the user in Poracle first (the [inherent constraint](#external-sso-openid-connect-login)). | +| `not_in_guild` | Role gating reused from the Discord path: the (Discord) user isn't in the configured guild. | +| `missing_required_role` | Role gating: the user has none of the allowed roles. | +| `role_check_failed` | Role gating: the role check itself errored, or `allowed_role_ids` is set but holds no usable role IDs. | + +The `not_in_guild` / `missing_required_role` / `role_check_failed` codes only apply when role-based +access (`enable_roles`) is configured and the identity is a Discord id — they are shared verbatim with +the [Discord login path](../getting-started/discord-oauth.md). + +--- + +## Endpoints + +| Method & path | Purpose | +|---|---| +| `GET /api/auth/oidc/login` | Begins the flow; 302s to the provider's authorize endpoint. 404s when OIDC isn't configured. | +| `GET /api/auth/oidc/callback` | Handles the provider redirect: validates state + PKCE, exchanges the code, fetches userinfo, mints the JWT. | +| `GET /api/auth/oidc/logout` | RP-initiated end-session (single logout) when configured + enabled; otherwise redirects to the signed-out panel. | +| `GET /api/auth/providers` | Returns provider availability for the login page, including the `oidc` block (`configured`, `enabledByAdmin`, `providerName`, `endSession`, `refresh`). | +| `GET /api/settings/oidc-config` | **Admin-only.** Read-only server-side OIDC config for the admin panel; secrets masked. | + +`POST /api/auth/oidc/refresh` and `/refresh/revoke` belong to the silent-renewal feature — see the +[OIDC Refresh Tokens](oidc-refresh-tokens.md) page. + +--- + +## Provider matrix + +For **login**, the per-provider differences come down to the **identity claim**. (If you also enable +[refresh tokens](oidc-refresh-tokens.md), the token-endpoint auth method and offline-access scope also +matter — those columns are included for convenience.) + +| Provider | `OIDC_IDENTITY_CLAIM` | `OIDC_TOKEN_AUTH_METHOD` † | `OIDC_OFFLINE_ACCESS_SCOPE` † | +|---|---|---|---| +| PogoAlerts | `discord_id` | `client_secret_post` | `offline_access` | +| Keycloak | `sub` | `client_secret_basic` | `offline_access` | +| Authentik | `sub` | `client_secret_post` | `offline_access` | +| Auth0 | `sub` | `client_secret_post` | `offline_access` | +| Google | `sub` | `client_secret_post` | *(empty — append `?access_type=offline` to the authorize URL)* | +| Azure AD / Entra | `sub` or `oid` | `client_secret_post` | `offline_access` | +| Okta | `sub` | `client_secret_basic` | `offline_access` | + +† Only relevant if you also enable refresh tokens. For plain login, these are ignored. + +Copy-paste login snippets for the most common providers (replace the example URLs with your +provider's actual endpoints): + +### Keycloak + +```bash +OIDC_PROVIDER_NAME=Keycloak +OIDC_AUTHORIZATION_URL=https://kc.example.com/realms/poracle/protocol/openid-connect/auth +OIDC_TOKEN_URL=https://kc.example.com/realms/poracle/protocol/openid-connect/token +OIDC_USERINFO_URL=https://kc.example.com/realms/poracle/protocol/openid-connect/userinfo +OIDC_END_SESSION_URL=https://kc.example.com/realms/poracle/protocol/openid-connect/logout +OIDC_CLIENT_ID=poracleweb +OIDC_CLIENT_SECRET=your_client_secret +OIDC_SCOPES=openid profile email +OIDC_IDENTITY_CLAIM=sub +OIDC_USE_PKCE=true +``` + +!!! note "Mapping `sub` to a Poracle id" + For providers like Keycloak that key on `sub`, the user's `sub` must equal their Poracle `human` + id (their Discord/Telegram id). Configure your provider to expose the Discord/Telegram id as the + subject — or as a custom claim and point `OIDC_IDENTITY_CLAIM` at it — otherwise login resolves to + a non-existent user and fails with `user_not_registered`. + +### Auth0 + +```bash +OIDC_PROVIDER_NAME=Auth0 +OIDC_AUTHORIZATION_URL=https://your-tenant.us.auth0.com/authorize +OIDC_TOKEN_URL=https://your-tenant.us.auth0.com/oauth/token +OIDC_USERINFO_URL=https://your-tenant.us.auth0.com/userinfo +OIDC_END_SESSION_URL=https://your-tenant.us.auth0.com/oidc/logout +OIDC_CLIENT_ID=your_client_id +OIDC_CLIENT_SECRET=your_client_secret +OIDC_SCOPES=openid profile email +OIDC_IDENTITY_CLAIM=sub +OIDC_USE_PKCE=true +``` + +For Google, Azure AD / Entra, and Okta, use the same shape — set the three endpoint URLs and the +identity claim from the matrix above. Google needs `?access_type=offline` appended to +`OIDC_AUTHORIZATION_URL` only if you go on to enable refresh tokens. + +--- + +## Next: silent session renewal + +By default PoracleWeb mints a short-lived internal JWT and discards the provider's tokens at login. +To keep sessions alive in the background and propagate provider-side disable/logout to PoracleWeb, +enable refresh-token consumption: + +➡️ **[OIDC Refresh Tokens](oidc-refresh-tokens.md)** — opt-in silent renewal, revocation propagation, +the provider config matrix for refresh, and the security model. + +## Related pages + +- [Configuration Reference](reference.md) — the full `OIDC_*` variable list and every other env var. +- [Site Settings](site-settings.md) — the `enable_oidc` and `enable_oidc_slo` runtime toggles. +- [Discord OAuth](../getting-started/discord-oauth.md) — the login flow SSO mirrors, and the source of + the reused role-gating error codes. diff --git a/docs/configuration/oidc-refresh-tokens.md b/docs/configuration/oidc-refresh-tokens.md new file mode 100644 index 00000000..61a4b748 --- /dev/null +++ b/docs/configuration/oidc-refresh-tokens.md @@ -0,0 +1,419 @@ +# OIDC Refresh Tokens + +PoracleWeb.NET can log users in through External SSO / OIDC — a generic external OIDC / OAuth2 +provider. Once that login is working, it mints its own short-lived internal session token (a JWT) +and, by default, **discards** the provider's access and refresh tokens. + +This page documents an **opt-in** feature that makes PoracleWeb consume the provider's +**refresh token** so it can: + +- keep sessions alive without a hard 24-hour re-login (silent renewal in the background), +- **propagate provider-side revocation** — when an admin disables the user (or they "log out + everywhere") at the identity provider, PoracleWeb drops the session at the next refresh, and +- **re-validate the user on every refresh** — disabling a user takes effect within roughly one + access-token lifetime (~30 minutes). + +!!! warning "Default OFF and provider-agnostic" + This feature is **disabled by default** (`OIDC_USE_REFRESH_TOKENS=false`). When off, behavior + is byte-for-byte identical to today: the provider's tokens are discarded and the internal JWT + lives its full lifetime. **PogoAlerts is only the reference provider** — the mechanism rests + only on spec-standard OAuth2/OIDC (`/token` with `grant_type=refresh_token`, `/userinfo`, and + `expires_in`) and works with **any** compliant provider (Keycloak, Authentik, Auth0, Google, + Azure AD / Entra, Okta, …). See [For any OIDC provider](#for-any-oidc-provider-self-hosters) + below. + +!!! note "Prerequisite: configure External SSO / OIDC login first" + Refresh tokens are an **optional layer on top of a working OIDC login** — they add silent + session renewal and provider-side revocation propagation, but they do **not** set up login by + themselves. Configure base External SSO / OIDC first (provider URLs, client id/secret, identity + claim, PKCE, the `enable_oidc` auth-mode switch, single logout) on the + [External SSO (OIDC)](external-sso.md) page, confirm users can sign in, **then** opt into refresh + tokens here. + +## When to enable it + +Enable it when **all** of the following hold: + +- You authenticate via an external OIDC provider (`OIDC_ENABLED=true` and the provider configured). +- Your provider **issues refresh tokens**. Most providers only do this when the + `offline_access` scope is requested (handled automatically — see below). Google uses a + non-standard `access_type=offline` instead. +- You want seamless sessions and/or want provider-side disable/logout to terminate PoracleWeb + sessions promptly. + +## When NOT to enable it + +- You log in with **Discord, Telegram, or local** accounts only — those flows have no provider + refresh token and this feature does nothing for them (they stay on the existing path). +- Your provider **cannot issue refresh tokens**. If you turn the flag on but the provider returns + no refresh token, PoracleWeb **gracefully falls back** to the normal 24-hour JWT for that login + and logs `LogOidcRefreshUnavailable` so you can see why. Nothing breaks — the feature simply + no-ops. + +--- + +## Configuration reference + +All variables are optional and default-safe. Add them to your `.env` (or use the `Oidc__*` +.NET convention). They take effect only when `OIDC_USE_REFRESH_TOKENS=true`. + +| `.env` name | `.NET` env variable | Default | Description | +|---|---|---|---| +| `OIDC_USE_REFRESH_TOKENS` | `Oidc__UseRefreshTokens` | `false` | **Master opt-in.** When off, the provider's tokens are discarded and the internal JWT lives its full lifetime (24h). When on, refresh-backed OIDC sessions are created (if the provider issues a refresh token). | +| `OIDC_ACCESS_TOKEN_MINUTES` | `Oidc__AccessTokenMinutes` | `30` | Internal JWT lifetime (minutes) for **refresh-backed OIDC sessions only**. Kept short so a disable/revocation at the provider propagates within ~one access-token lifetime. Other logins are unaffected. | +| `OIDC_REFRESH_TOKEN_LIFETIME_DAYS` | `Oidc__RefreshTokenLifetimeDays` | `30` | PoracleWeb-side absolute cap (days) on a refresh session/family before a real re-login is forced. Independent of the provider's own refresh-token lifetime; if the provider's token expires first, the refresh call fails and the session is revoked. | +| `OIDC_SESSION_REVOKED_RETENTION_DAYS` | `Oidc__RevokedRetentionDays` | `2` | How long a revoked/rotated `oidc_sessions` row is kept (so a replayed old token is still detected and family-revoked) before the 6-hourly cleanup deletes it. Kept short and separate from the session cap so frequent rotation doesn't accumulate weeks of dead rows. Expired rows are deleted regardless. | +| `OIDC_OFFLINE_ACCESS_SCOPE` | `Oidc__OfflineAccessScope` | `offline_access` | Scope appended to the authorize request (only when `UseRefreshTokens` is on and the scope isn't already in `OIDC_SCOPES`) so a standards-compliant provider issues a refresh token (this includes PogoAlerts). **Set empty** only for providers that issue refresh tokens unconditionally regardless of scope, or that use a non-standard mechanism (e.g. Google's `access_type=offline`). | +| `OIDC_TOKEN_AUTH_METHOD` | `Oidc__TokenEndpointAuthMethod` | `client_secret_post` | How client credentials are presented at the token endpoint: `client_secret_post` (credentials in the form body — PogoAlerts, Authentik, Auth0, Google, Azure AD) or `client_secret_basic` (HTTP Basic header — Keycloak, Okta). Applies to **both** the code exchange and the refresh grant. | + +### Relationship to the existing `OIDC_*` variables + +These variables extend the base External SSO / OIDC login configuration — see +[External SSO (OIDC)](external-sso.md) for the provider URLs, client id/secret, identity claim, +scopes, and PKCE. The refresh feature reuses the same token and userinfo endpoints — no new +endpoints need to be configured on the provider side beyond enabling refresh tokens. The identity +claim (`OIDC_IDENTITY_CLAIM`, falls back to `sub`) is re-read on every refresh to re-validate the +user. + +### Relationship to the `enable_oidc` site settings + +Three runtime [site settings](site-settings.md) gate OIDC behavior independently of the env vars: + +| Site setting | Effect | +|---|---| +| `enable_oidc` | Runtime on/off for the OIDC login button. The env `OIDC_ENABLED` is the hard master switch; this toggle disables it at runtime without a restart. | +| `enable_oidc_slo` | Runtime toggle for OIDC single-logout (RP-initiated end-session). | + +Refresh-token consumption has **no** runtime site setting — it is controlled solely by the +`OIDC_USE_REFRESH_TOKENS` env flag. Refresh is coupled to the per-login JWT lifetime, so it's a +deploy-time decision (disabling it at runtime would strand the short-lived tokens of users who are +already signed in; single logout, by contrast, only affects the next logout, so it stays a runtime +toggle). + +The `/api/auth/providers` response exposes a read-only `oidc.refresh` boolean (`= OIDC is +configured AND OIDC_USE_REFRESH_TOKENS is on`) so the frontend knows whether silent refresh is +active. + +--- + +## Per-login JWT lifetime + +PoracleWeb's internal JWT lifetime (`Jwt__ExpirationMinutes`, default **1440** = 24h) is a +**global** setting today. This feature introduces a **per-login** lifetime so the two session +models can coexist: + +| Login type | Internal JWT lifetime | +|---|---| +| OIDC **with** an issued refresh token (refresh-backed session) | `OIDC_ACCESS_TOKEN_MINUTES` (default **30 min**) | +| Discord / Telegram / local | unchanged — **1440 min (24h)** | +| OIDC **without** a refresh token (provider issued none) | unchanged — **1440 min (24h)** | + +### Why short JWTs only for refresh-backed sessions + +A blanket cut of the global JWT lifetime to 30 minutes would log out Discord/Telegram/local users +every 30 minutes, because their flows have no way to silently renew. Only refresh-backed OIDC +sessions can renew in the background, so **only those** get the short lifetime. The short lifetime +is what makes revocation propagation prompt: a disabled user keeps a valid PoracleWeb session for +at most one access-token lifetime (~30 min) before the next refresh re-validates them and fails. + +--- + +## How it works + +PoracleWeb never sends the provider's refresh token to the browser. The browser holds an **opaque +PoracleWeb-minted token** (in `localStorage` as `poracle_refresh_token`) that keys a server-side +`oidc_sessions` row. That row's `EncryptedRefreshToken` column holds the *real* provider refresh +token, encrypted at rest via ASP.NET Core DataProtection. One **family** (`FamilyId`) is one login +session and one rotation chain. + +``` + Browser (localStorage: PoracleWeb API Provider (IdP) + poracle_token = short JWT /api/auth/oidc/callback /token + poracle_refresh_token = opaque) /api/auth/oidc/refresh ───────▶ grant=authorization_code + │ proactive ~60s before exp /api/auth/oidc/refresh/revoke grant=refresh_token + │ or reactive 401 /api/auth/oidc/logout /userinfo + ▼ │ + oidcRefreshInterceptor ──────────────▶ OidcRefreshService ──┐ + │ │ ┌──────────────────────────┐ + ▼ └──▶│ oidc_sessions (poracle_ │ + OidcSessionRepository │ web): SHA-256 hash, │ + (atomic rotate / revoke) │ FamilyId chain, │ + │ EncryptedRefreshToken │ + OidcSessionCleanupService │ (DataProtection) │ + (~6h set-based DELETE) └──────────────────────────┘ +``` + +### Login and refresh-token issuance + +```mermaid +sequenceDiagram + participant B as Browser + participant P as PoracleWeb API + participant I as Provider (IdP) + B->>P: GET /api/auth/oidc/login + P->>B: 302 to provider authorize (PKCE, offline_access if enabled) + B->>I: authorize → consent + I->>B: 302 /api/auth/oidc/callback?code + B->>P: callback(code) + P->>I: POST /token (grant=authorization_code) + I-->>P: { access_token, refresh_token, expires_in } + P->>I: GET /userinfo (Bearer access_token) + I-->>P: { identity claim, username, ... } + P->>P: validate human exists + enabled + roles + alt UseRefreshTokens AND refresh_token present + P->>P: encrypt(refresh_token); INSERT oidc_sessions (new family) + P->>P: mint JWT (OIDC_ACCESS_TOKEN_MINUTES ≈ 30m) + P->>B: 302 /auth/oidc/callback#token=JWT&refresh_token=OPAQUE + else flag off OR no refresh_token returned + P->>P: mint JWT (24h) %% graceful fallback; logs if flag on but no RT + P->>B: 302 /auth/oidc/callback#token=JWT + end +``` + +### Proactive silent refresh + +The browser interceptor refreshes proactively ~60 seconds before the JWT expires, so the user +never sees an interruption. + +```mermaid +sequenceDiagram + participant B as Browser (interceptor) + participant P as PoracleWeb API + participant I as Provider (IdP) + Note over B: ~60s before JWT expiry + B->>P: POST /api/auth/oidc/refresh { refreshToken: OPAQUE } + P->>P: hash; load session (active, not expired, not past cap) + P->>P: atomic rotate guard (ExecuteUpdateAsync) + P->>I: POST /token (grant=refresh_token, client_secret) + I-->>P: { access_token, refresh_token', expires_in } + P->>I: GET /userinfo + P->>P: re-validate human enabled + roles + P->>P: encrypt(refresh_token'); INSERT successor row (same family) + P->>P: mint fresh JWT (≈30m) + P-->>B: 200 { token, refreshToken: OPAQUE', expiresIn } +``` + +### Reactive 401 refresh + +If a request returns 401 before the proactive timer fires, the interceptor refreshes once and +retries the original request. Concurrent 401s are coalesced into a single refresh (single-flight). + +```mermaid +sequenceDiagram + participant B as Browser (interceptor) + participant P as PoracleWeb API + participant I as Provider (IdP) + B->>P: GET /api/... (expired JWT) + P-->>B: 401 Unauthorized + B->>P: POST /api/auth/oidc/refresh { refreshToken: OPAQUE } + P->>I: POST /token (grant=refresh_token) + I-->>P: { access_token, refresh_token', expires_in } + P-->>B: 200 { token, refreshToken: OPAQUE', expiresIn } + B->>P: retry GET /api/... (fresh JWT) + P-->>B: 200 OK + Note over B: refresh itself failing ⇒ logout (no retry loop) +``` + +### Revocation propagation + +When the provider disables the user (or its refresh token is revoked/expired), the next refresh +fails and PoracleWeb revokes the family and logs the user out — provider-side revocation reaches +PoracleWeb within one access-token lifetime. + +```mermaid +sequenceDiagram + participant B as Browser (interceptor) + participant P as PoracleWeb API + participant I as Provider (IdP) + Note over I: admin disables user / logout-everywhere + B->>P: POST /api/auth/oidc/refresh { refreshToken: OPAQUE } + P->>P: atomic rotate guard + P->>I: POST /token (grant=refresh_token) + I-->>P: error (invalid_grant — revoked/disabled) + P->>P: revoke family (same transaction) + P-->>B: 401 { error: "invalid_grant" } + B->>B: clear localStorage → logout +``` + +PoracleWeb also re-validates the `human` record (exists + enabled + role gating) on **every** +refresh, so disabling a user *inside Poracle* (not just at the provider) terminates the session +the same way. + +### Logout and family revoke + +```mermaid +sequenceDiagram + participant B as Browser + participant P as PoracleWeb API + participant I as Provider (IdP) + B->>P: POST /api/auth/oidc/refresh/revoke { refreshToken } + P->>P: revoke family server-side (delete stored provider RT path) + P-->>B: 204 No Content + B->>B: clear localStorage + opt enable_oidc_slo + B->>P: GET /api/auth/oidc/logout (RP-initiated end-session) + P->>B: 302 to provider end-session (single logout) + end +``` + +Replay protection: presenting an already-rotated (revoked) opaque token revokes the **entire +family** in the same transaction as the 401, defeating token theft/replay. + +--- + +## For any OIDC provider (self-hosters) + +The entire mechanism rests only on spec-standard OAuth2/OIDC. **No assumptions are special to +PogoAlerts.** The provider-specific behavior is captured by config plus graceful fallback. + +### The three real divergences + +| Divergence | How PoracleWeb handles it | +|---|---| +| **Getting a refresh token at all.** Most providers only issue one when the `offline_access` scope is requested. | `OIDC_OFFLINE_ACCESS_SCOPE` (default `offline_access`) is appended to the authorize request **only** when refresh is enabled and the scope isn't already present. Set it empty for providers that issue refresh tokens unconditionally, or that use a non-standard mechanism. | +| **Token-endpoint client auth.** Some providers read the secret from the form body; others require HTTP Basic. | `OIDC_TOKEN_AUTH_METHOD` = `client_secret_post` (body) or `client_secret_basic` (HTTP Basic header). Applies to both the code exchange and the refresh grant. | +| **Refresh-token rotation.** Some providers rotate the refresh token on every refresh; many return none and expect reuse of the original. | PoracleWeb uses `newProviderRt = response.refresh_token ?? currentProviderRt` — when the provider returns no new token it carries the existing one forward, re-encrypted. The **opaque** PoracleWeb token still rotates on every call regardless. | + +### Graceful no-refresh-token fallback + +If you enable the flag but the provider returns **no refresh token** (refused, or `offline_access` +not granted), the callback **falls back to the normal 24-hour JWT** with no opaque token, and logs +`LogOidcRefreshUnavailable`. The feature simply no-ops for that login — nothing breaks. This makes +first-time integration safe: turn it on, log in, and check the logs to confirm a refresh token +arrived. + +### Provider config matrix + +Copy-paste the matching block into your `.env`. All also require the base External SSO / OIDC login +variables (`OIDC_ENABLED`, `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USERINFO_URL`, +`OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET`) for your provider — see +[External SSO (OIDC)](external-sso.md). + +!!! note + The same provider matrix also appears on the [External SSO (OIDC)](external-sso.md) page; it is + repeated here with the refresh-specific columns (`OIDC_OFFLINE_ACCESS_SCOPE`, + `OIDC_TOKEN_AUTH_METHOD`) filled in. + +| Provider | `OIDC_SCOPES` | `OIDC_OFFLINE_ACCESS_SCOPE` | `OIDC_TOKEN_AUTH_METHOD` | `OIDC_IDENTITY_CLAIM` | +|---|---|---|---|---| +| PogoAlerts | `openid profile email` | `offline_access` | `client_secret_post` | `discord_id` | +| Keycloak | `openid profile email` | `offline_access` | `client_secret_basic` | `sub` | +| Authentik | `openid profile email` | `offline_access` | `client_secret_post` | `sub` | +| Auth0 | `openid profile email` | `offline_access` | `client_secret_post` | `sub` | +| Google | `openid profile email` | *(empty — use `access_type=offline`)* † | `client_secret_post` | `sub` | +| Azure AD / Entra | `openid profile email` | `offline_access` | `client_secret_post` | `sub` / `oid` | +| Okta | `openid profile email` | `offline_access` | `client_secret_basic` | `sub` | + +† **Google** uses a non-standard `access_type=offline` query parameter instead of the +`offline_access` scope. The authorize-URL builder preserves arbitrary query params on +`OIDC_AUTHORIZATION_URL`, so append `?access_type=offline` (and optionally `&prompt=consent`) +directly to the URL and leave `OIDC_OFFLINE_ACCESS_SCOPE` empty. + +#### Keycloak + +```bash +OIDC_USE_REFRESH_TOKENS=true +OIDC_SCOPES=openid profile email +OIDC_OFFLINE_ACCESS_SCOPE=offline_access +OIDC_TOKEN_AUTH_METHOD=client_secret_basic +OIDC_IDENTITY_CLAIM=sub +``` + +#### Authentik + +```bash +OIDC_USE_REFRESH_TOKENS=true +OIDC_SCOPES=openid profile email +OIDC_OFFLINE_ACCESS_SCOPE=offline_access +OIDC_TOKEN_AUTH_METHOD=client_secret_post +OIDC_IDENTITY_CLAIM=sub +``` + +#### Auth0 + +```bash +OIDC_USE_REFRESH_TOKENS=true +OIDC_SCOPES=openid profile email +OIDC_OFFLINE_ACCESS_SCOPE=offline_access +OIDC_TOKEN_AUTH_METHOD=client_secret_post +OIDC_IDENTITY_CLAIM=sub +``` + +#### Google + +```bash +OIDC_USE_REFRESH_TOKENS=true +OIDC_SCOPES=openid profile email +# Leave OFFLINE_ACCESS_SCOPE empty — Google uses access_type=offline instead: +OIDC_OFFLINE_ACCESS_SCOPE= +# Append ?access_type=offline (and &prompt=consent to force a refresh token) to the authorize URL: +OIDC_AUTHORIZATION_URL=https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent +OIDC_TOKEN_AUTH_METHOD=client_secret_post +OIDC_IDENTITY_CLAIM=sub +``` + +#### Azure AD / Entra + +```bash +OIDC_USE_REFRESH_TOKENS=true +OIDC_SCOPES=openid profile email +OIDC_OFFLINE_ACCESS_SCOPE=offline_access +OIDC_TOKEN_AUTH_METHOD=client_secret_post +OIDC_IDENTITY_CLAIM=sub # or oid +``` + +#### Okta + +```bash +OIDC_USE_REFRESH_TOKENS=true +OIDC_SCOPES=openid profile email +OIDC_OFFLINE_ACCESS_SCOPE=offline_access +OIDC_TOKEN_AUTH_METHOD=client_secret_basic +OIDC_IDENTITY_CLAIM=sub +``` + +### Tuning lifetimes + +- **`OIDC_ACCESS_TOKEN_MINUTES`** trades responsiveness of revocation against refresh frequency. + Shorter (e.g. 15) propagates a disable faster but refreshes more often; longer (e.g. 60) is + lighter but widens the revocation window. The default 30 minutes is a reasonable middle. +- **`OIDC_REFRESH_TOKEN_LIFETIME_DAYS`** is a PoracleWeb-side absolute cap that forces a real + re-login periodically. It is independent of the provider's own refresh-token lifetime — if the + provider's token dies first, the refresh fails and the session is revoked anyway. + +### What is NOT required + +No discovery document, `id_token`, or JWKS validation is needed — endpoints are configured +explicitly, which also supports plain OAuth2 providers without an OIDC discovery doc. PoracleWeb +relies solely on `/token` (`grant_type=refresh_token`), `/userinfo`, and `expires_in`. + +--- + +## Security model + +| Risk | Mitigation | +|---|---| +| **Provider refresh-token theft** | The provider refresh token is **encrypted at rest** via ASP.NET Core DataProtection (purpose `oidc-refresh-tokens`) and is **never** sent to the browser. The browser only ever holds an opaque PoracleWeb token that is useless without the server-side session row. | +| **Opaque-token XSS (localStorage)** | Exposure is bounded by the short (~30 min) JWT, rotate-on-use of the opaque token, and family-revoke on replay. **Recommended:** set a Content-Security-Policy (`default-src 'self'`) on your reverse proxy to reduce XSS surface, since the opaque token lives in `localStorage`. | +| **Replay / reuse** | The opaque token rotates on every refresh (rotate-on-use). Presenting an already-revoked token triggers a **family revoke in the same transaction as the 401**, killing the whole rotation chain. The rotation guard uses an atomic conditional `ExecuteUpdateAsync` (affected-rows classify), no row locks. | +| **Revocation propagation** | Userinfo is re-fetched and the `human` record re-checked (exists + enabled + roles) on **every** refresh. A provider refresh failure (revoked/disabled) revokes the family and logs the user out. An admin-disable hook (`RevokeAllForUserAsync`) revokes all of a user's sessions immediately, before the ~30 min window. | +| **Absolute-cap bypass** | `FamilyIssuedAt + RefreshTokenLifetimeDays` is enforced before each rotation, forcing periodic real re-auth. | +| **Rate-limit abuse** | `/api/auth/oidc/refresh` and `/api/auth/oidc/refresh/revoke` run under the per-IP `auth` rate-limit policy (30 requests / 60s per IP). | +| **Open redirect on callback** | Unchanged — the existing `oauth_origin` CORS validation still gates the fragment redirect. | +| **Other consumers' safety** | Default **off**, additive empty table, graceful fallback when the provider returns no refresh token — instances that don't opt in are byte-for-byte unchanged. | + +Token hashing uses **SHA-256** over 32 random bytes with a unique index — a full-entropy secret +correctly uses a fast hash (never bcrypt/PBKDF2) for O(1) indexed lookup. Expired and old-revoked +session rows are pruned by a background `OidcSessionCleanupService` (~every 6 hours, one set-based +`DELETE`). That delete is issued as raw SQL rather than EF's `ExecuteDeleteAsync`, which emits an +aliased `DELETE FROM oidc_sessions AS o` — valid on SQLite and MySQL 8, a 1064 syntax error on +MariaDB (#707). + +--- + +## Related pages + +- [External SSO (OIDC)](external-sso.md) — base External SSO / OIDC login setup (prerequisite for + this page). +- [Configuration Reference](reference.md) — base `OIDC_*` provider variables. +- [Site Settings](site-settings.md) — the `enable_oidc` and `enable_oidc_slo` runtime toggles. diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index d4de8b01..24ec6efc 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -36,10 +36,48 @@ All configuration can be provided via environment variables or `appsettings.json | Discord Bot Token | `DISCORD_BOT_TOKEN` | `Discord__BotToken` | — | Enables Discord avatar display | | Discord Guild ID | `DISCORD_GUILD_ID` | `Discord__GuildId` | — | Discord server ID | | Discord Geofence Forum | `DISCORD_GEOFENCE_FORUM_CHANNEL_ID` | `Discord__GeofenceForumChannelId` | — | Forum channel for geofence submission threads | +| Public site URL | `PUBLIC_URL` | `Site__PublicUrl` | — | Public URL users reach this site at, no trailing slash (e.g. `https://alerts.example.com`). Used to link geofence review threads to the admin review page; the link is omitted when unset. | | Telegram Enabled | `TELEGRAM_ENABLED` | `Telegram__Enabled` | `false` | Enable Telegram authentication | | Telegram Bot Token | `TELEGRAM_BOT_TOKEN` | `Telegram__BotToken` | — | Telegram bot token | | Telegram Bot Username | `TELEGRAM_BOT_USERNAME` | `Telegram__BotUsername` | — | Telegram bot username | +### External SSO / OIDC + +Optional. Delegates PoracleWeb.NET login to your own OAuth2/OIDC provider (e.g. PogoAlerts) for single sign-on. See [External SSO setup](external-sso.md) for the full setup guide. + +The provider is enabled automatically when `OIDC_CLIENT_ID` plus all three of `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, and `OIDC_USERINFO_URL` are set. Set `OIDC_ENABLED` explicitly to override the inference. + +| Setting | `.env` name | `.NET` env variable | Default | Description | +|---|---|---|---|---| +| Enabled | `OIDC_ENABLED` | `Oidc__Enabled` | — | Master switch. Auto-inferred `true` when `OIDC_CLIENT_ID` and the three endpoint URLs are all set. When `false`, the provider is hidden regardless of other values. | +| Provider Name | `OIDC_PROVIDER_NAME` | `Oidc__ProviderName` | `""` | Display name shown on the login button (e.g. `PogoAlerts`). | +| Authorization URL | `OIDC_AUTHORIZATION_URL` | `Oidc__AuthorizationUrl` | `""` | Browser-facing authorize endpoint. Any existing query string is preserved. | +| Token URL | `OIDC_TOKEN_URL` | `Oidc__TokenUrl` | `""` | Token endpoint that exchanges the authorization code for an access token. | +| UserInfo URL | `OIDC_USERINFO_URL` | `Oidc__UserInfoUrl` | `""` | UserInfo endpoint returning the user's claims. | +| End Session URL | `OIDC_END_SESSION_URL` | `Oidc__EndSessionUrl` | `""` | Optional RP-initiated logout (end-session) endpoint. When set, enables single logout (the provider's session is also ended). When empty, logout is local-only. | +| Client ID | `OIDC_CLIENT_ID` | `Oidc__ClientId` | `""` | OAuth2 client ID. | +| Client Secret | `OIDC_CLIENT_SECRET` | `Oidc__ClientSecret` | `""` | OAuth2 client secret. Read from config only — never stored in the database. | +| Scopes | `OIDC_SCOPES` | `Oidc__Scopes` | `openid profile email` | Space-delimited OAuth scopes requested at authorization time. | +| Identity Claim | `OIDC_IDENTITY_CLAIM` | `Oidc__IdentityClaim` | `discord_id` | UserInfo claim whose value maps to the Poracle human id (a Discord/Telegram id). Falls back to `sub` when the configured claim is absent. | +| Username Claim | `OIDC_USERNAME_CLAIM` | `Oidc__UsernameClaim` | `preferred_username` | UserInfo claim used as the display username. | +| Avatar Claim | `OIDC_AVATAR_CLAIM` | `Oidc__AvatarClaim` | `picture` | UserInfo claim used as the avatar URL. | +| Identity Type | `OIDC_IDENTITY_TYPE` | `Oidc__IdentityType` | `discord:user` | Value written to the JWT `type` claim for users logging in via this provider. | +| Use PKCE | `OIDC_USE_PKCE` | `Oidc__UsePkce` | `true` | Use PKCE (Proof Key for Code Exchange) for the authorization-code flow. | +| Force Local Login | `AUTH_FORCE_LOCAL` | `Auth__ForceLocal` | `false` | Break-glass override forcing the local login page regardless of the SSO mode. Recovery path when an admin switches to OIDC against a broken/unreachable provider and gets locked out. | + +#### OIDC refresh tokens + +Optional, opt-in. Enables silent server-side session renewal and provider-side revocation propagation. See [OIDC Refresh Tokens](oidc-refresh-tokens.md) for full detail. + +| Setting | `.env` name | `.NET` env variable | Default | Description | +|---|---|---|---|---| +| Use Refresh Tokens | `OIDC_USE_REFRESH_TOKENS` | `Oidc__UseRefreshTokens` | `false` | Master opt-in for brokering the provider's refresh token (silent renewal + revocation propagation). Requires the provider to issue a refresh token. | +| Access Token Minutes | `OIDC_ACCESS_TOKEN_MINUTES` | `Oidc__AccessTokenMinutes` | `30` | Internal JWT lifetime (minutes) for refresh-backed OIDC sessions only. Other logins keep `Jwt__ExpirationMinutes`. | +| Refresh Token Lifetime (days) | `OIDC_REFRESH_TOKEN_LIFETIME_DAYS` | `Oidc__RefreshTokenLifetimeDays` | `30` | PoracleWeb-side absolute cap (days) on a refresh session before a real re-login is forced. | +| Revoked Retention (days) | `OIDC_SESSION_REVOKED_RETENTION_DAYS` | `Oidc__RevokedRetentionDays` | `2` | How long (days) revoked/rotated session rows are retained for replay detection before cleanup deletes them. | +| Offline Access Scope | `OIDC_OFFLINE_ACCESS_SCOPE` | `Oidc__OfflineAccessScope` | `offline_access` | Scope appended to the authorize request so a compliant provider issues a refresh token. Set empty for providers that issue refresh tokens unconditionally or use a non-standard mechanism (e.g. Google's `access_type=offline`). | +| Token Auth Method | `OIDC_TOKEN_AUTH_METHOD` | `Oidc__TokenEndpointAuthMethod` | `client_secret_post` | How client credentials are presented at the token endpoint: `client_secret_post` (form body) or `client_secret_basic` (HTTP Basic header). | + ### Databases | Setting | `.env` name | `.NET` env variable | Description | @@ -73,6 +111,36 @@ Required for the custom geofences feature. |---|---|---|---| | CORS Origin | `CORS_ORIGIN` | `Cors__AllowedOrigins__0` | Allowed CORS origin. Required in production (empty = crash). Not required in development mode. | +### Public URL + +| Setting | `.env` name | `.NET` env variable | Description | +|---|---|---|---| +| Public URL | `PUBLIC_URL` | `PublicUrl` | The origin users reach this instance on (e.g. `https://poracle.example.com`). Sets the Discord and OIDC callback URLs directly instead of deriving them from each request. Origin only — a path, query or invalid URL stops the app at startup. Optional; unset follows the incoming request. | + +### Paths + +These three skip the short-name bridge, so there is no `__`-delimited alias. `DTS_SOURCE_DIR` and most +`DATA_DIR` reads go through `Environment.GetEnvironmentVariable`, which `appsettings.json` never +reaches. Set them in `.env` (the loader copies every line into the process environment) or in the +compose file, which is what the shipped example does. + +| Setting | `.env` name | Default | Description | +|---|---|---|---| +| Data directory | `DATA_DIR` | see note (Docker image sets `/app/data`) | Where the app keeps state that must survive a restart: DataProtection keys, the Discord avatar cache, and the DTS cache fallback file. Mount it as a volume in Docker or OAuth sessions break on every recreate. | +| DTS source directory | `DTS_SOURCE_DIR` | — | Container path where the PoracleNG `config/` directory is mounted. `DtsCacheService` reads the DTS files from here for template previews; without it, the service falls back to a `dts-cache.json` placed under `DATA_DIR` by hand. | +| Poracle config directory | `PORACLE_CONFIG_DIR` | `./data` | Host path that the shipped compose file mounts read-only at `/poracle-config`, which is what `DTS_SOURCE_DIR` points at. Standalone deployments do not use it. | + +With `DATA_DIR` unset the three consumers disagree: DataProtection falls back to `./data`, while `AvatarCacheService` and `DtsCacheService` fall back to the working directory itself, so `avatar-cache.json` and `dts-cache.json` land beside the binary and the keys land a directory deeper. Set it explicitly for any standalone run you intend to keep. + +### Reverse proxy + +Required if anything terminates TLS in front of PoracleWeb.NET. See [Behind a reverse proxy](../getting-started/standalone-setup.md#reverse-proxy-optional). + +| Setting | `.env` name | `.NET` env variable | Description | +|---|---|---|---| +| Known proxies | `PROXY_KNOWN_PROXIES` | `Proxy__KnownProxies` | Comma-separated proxy addresses whose `X-Forwarded-For` / `X-Forwarded-Proto` are believed | +| Known networks | `PROXY_KNOWN_NETWORKS` | `Proxy__KnownNetworks` | Comma-separated CIDR ranges, same effect (e.g. `172.18.0.0/16,10.0.0.0/8`) | + ## Configuration sources | Source | Use case | diff --git a/docs/configuration/site-settings.md b/docs/configuration/site-settings.md index 3def34e2..74b14c56 100644 --- a/docs/configuration/site-settings.md +++ b/docs/configuration/site-settings.md @@ -11,6 +11,7 @@ Site settings are admin-configurable runtime settings stored in the `poracle_web - The admin panel at **Admin > Settings** provides a grouped UI for editing all settings. - Changes take effect immediately — no app restart is needed. - Boolean settings use `"True"` / `"False"` string values. +- Keys beginning `disable_` store the *disabled* state, but the admin UI shows them as positive switches — the toggle is on when the feature is available. The stored value is inverted on read and write, so what the API and the tables below describe is the key, not the switch. - Some settings have conditional visibility (e.g., `allowed_role_ids` only appears when `enable_roles` is enabled). --- @@ -21,7 +22,7 @@ Customize the appearance and navigation of your PoracleWeb.NET instance. | Key | Label | Type | Description | |---|---|---|---| -| `custom_title` | Site Title | string | Name shown in the browser tab and page header. This is the only setting visible publicly (on the login page without authentication). | +| `custom_title` | Site Title | string | Name shown in the browser tab and page header. One of the five keys served without authentication, alongside `enable_discord`, `enable_telegram`, `favicon_url` and `signup_url`. | | `header_logo_url` | Header Logo URL | url | URL for a custom logo image in the header (replaces the default Pokeball). Leave empty for the default logo. | | `hide_header_logo` | Hide Header Logo | boolean | Hide the logo from the header entirely. | | `favicon_url` | Favicon URL | url | URL for the browser-tab icon. Square image recommended (32×32 minimum). Supports `.ico`, `.png`, and `.svg`. Leave empty to use the bundled default. Also loads on the public login page. See [Favicon caveats](#favicon-caveats) below. | @@ -44,15 +45,32 @@ Control which alarm categories are available to users. Disabling a type hides it | Key | Label | Type | Description | |---|---|---|---| -| `disable_mons` | Disable Pokémon | boolean | Hide Pokémon alarm management from all users. | -| `disable_raids` | Disable Raids | boolean | Hide raid alarm management from all users. | -| `disable_quests` | Disable Quests | boolean | Hide quest alarm management from all users. | -| `disable_invasions` | Disable Invasions | boolean | Hide invasion alarm management from all users. | -| `disable_lures` | Disable Lures | boolean | Hide lure alarm management from all users. | -| `disable_nests` | Disable Nests | boolean | Hide nest alarm management from all users. | -| `disable_gyms` | Disable Gyms | boolean | Hide gym alarm management from all users. | -| `disable_fort_changes` | Disable Fort Changes | boolean | Hide fort change alarm management from all users. | -| `disable_maxbattles` | Disable Max Battles | boolean | Hide max battle alarm management from all users. | +| `disable_mons` | Pokémon | boolean | Hide Pokémon alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_raids` | Raids | boolean | Hide raid alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_quests` | Quests | boolean | Hide quest alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_invasions` | Invasions | boolean | Hide invasion alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_lures` | Lures | boolean | Hide lure alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_nests` | Nests | boolean | Hide nest alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_gyms` | Gyms | boolean | Hide gym alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_fort_changes` | Fort Changes | boolean | Hide fort-change alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | +| `disable_maxbattles` | Max Battles | boolean | Hide max-battle alarms from all users. The page, the sidebar item and the API all go; rules already stored stay dormant and return if you switch it back on. | + +!!! info "A disabled type disappears completely" + The sidebar item, the dashboard card and the page all go, and every endpoint for that type answers + 403 — reads, writes and deletes alike. Admins are not exempt. + + Rules a user already had are **not deleted**. They stay in Poracle's database, dormant: a disabled + type's alerts are dropped upstream, so nothing fires. Switch the type back on and everything + reappears exactly as it was. + +!!! warning "Poracle can switch these off too, and it wins" + Poracle has its own per-type flags (`disable_pokemon`, `disable_raid`, `disable_quest`, `disable_invasion`, `disable_lure`, `disable_nest`, `disable_gym`, `disable_max_battle`, `disable_fort_update`). When one of those is set, its processor drops the webhook and its bot refuses the command, so the type can never fire — and this site now honours that. A type is off if **either** side disables it. + + The toggle for a type Poracle has disabled renders off and greyed, with a note saying where the decision came from; switching it on here would promise something every write refuses. This is also the case where leaving existing alarms deletable matters most: Poracle's bot refuses the matching command while the type is off, so this page is the only place left to clean up. + + ![The Lures toggle, off and greyed out, with a note reading "Disabled in Poracle's own config. Poracle drops these webhooks and its bot refuses the command, so this cannot be enabled here."](../screenshots/admin-forced-by-poracle.png) Your own toggles still work for everything Poracle leaves enabled, and they gate features Poracle has no opinion about. + + If Poracle is unreachable, or too old to report its flags, the settings on this page are in sole charge — the gate fails **open** rather than disabling every type because a server was down. --- @@ -62,39 +80,50 @@ Toggle user-facing features on or off. | Key | Label | Type | Description | |---|---|---|---| -| `disable_areas` | Disable Areas | boolean | Prevent users from managing their area subscriptions. | -| `disable_profiles` | Disable Profiles | boolean | Prevent users from creating and switching alarm profiles. | -| `disable_location` | Disable Location | boolean | Prevent users from setting a home location. | -| `disable_nominatim` | Disable Geocoding | boolean | Disable Nominatim address search for location picking. | -| `disable_geomap` | Disable Map View | boolean | Hide the interactive geofence map entirely. | -| `disable_geomap_select` | Disable Map Area Selection | boolean | Prevent users from selecting areas by clicking the map. Independent of `disable_geomap`. | -| `enable_templates` | Enable Templates | boolean | Allow users to choose notification message templates. | +| `disable_areas` | Areas | boolean | Prevent users from managing their area subscriptions. | +| `disable_profiles` | Profiles | boolean | Prevent users from creating and switching alarm profiles. | +| `disable_location` | Location | boolean | Gates the whole location API, not just the pin. Users cannot set their pin, and saved places, static and distance map images, and the weather lookups on the dashboard all stop with it. Since "Near a place" delivery scope measures from a saved place or the pin, alarms already using it keep working but nothing new can be pointed at a place. | +| `disable_nominatim` | Geocoding | boolean | Stops all outbound geocoding. The address search and reverse lookup return 403, and the location dialog hides its search box. Users can still set a pin by coordinates or on the map. Turn this on if you do not want your instance making requests to a third-party geocoder. | +| `disable_update_check` | Do not check for updates | boolean | Stops the version check against `api.github.com` and `raw.githubusercontent.com`, which runs when an admin opens the Versions card and is cached six hours afterwards. Two anonymous GETs, no identifiers and no payload. With it on, the Versions card on **Admin > Settings** still reports the running versions but cannot say whether they are current. | +| `disable_user_geofences` | Custom Geofences | boolean | Hides the My Geofences page and the admin review queue, and 403s the create, rename, import, submit, activate and deactivate endpoints. Delete is deliberately left open, so a user can still clear out a geofence they no longer want. Geofences that already exist keep being served in the [geofence feed](../features/custom-geofences/index.md) and keep matching. See [Admin operations](../features/custom-geofences/admin-operations.md). | +| `enable_templates` | Templates | boolean | Allow users to choose notification message templates. | +| `allowed_languages` | Allowed UI Languages | csv | Comma-separated language codes users can select (e.g., `en,de,fr`). Leave empty to show all 11 languages. Applies to the signed-out login page as well. English is always available. | + +Beneath that row the page reports Poracle's own configured locale, which is the language a +first-time visitor lands on when neither a stored choice nor their browser can answer. It is read +from Poracle and cannot be set here — see [Values that are not settings](#values-that-are-not-settings). + +![The Allowed UI Languages field, with a line beneath it reading "Default language for new users: en, taken from Poracle's own configuration."](../screenshots/admin-language-default.png) --- ## Administration -Access control and language restrictions. +Access control. | Key | Label | Type | Description | |---|---|---|---| | `enable_roles` | Enable Role-Based Access | boolean | Only allow users with specific Discord roles to log in. Requires `Discord:BotToken` and `Discord:GuildId` in [appsettings](reference.md). | -| `allowed_role_ids` | Allowed Role IDs | csv | Comma-separated Discord role IDs that grant access (e.g., `123456789,987654321`). Leave empty to allow all. Only visible when `enable_roles` is enabled. | -| `allowed_languages` | Allowed Languages | csv | Comma-separated language codes users can select (e.g., `en,de,fr`). Leave empty to show all available languages. | +| `allowed_role_ids` | Allowed Role IDs | csv | Comma-separated Discord role IDs (e.g., `123456789,987654321`). A user needs **at least one** of these roles to log in. Leave empty to allow all. Only visible when `enable_roles` is enabled. | !!! warning "Role-based access prerequisites" Role-based access requires `Discord:BotToken` and `Discord:GuildId` to be configured in appsettings. Without these, role checks cannot be performed and the setting has no effect. ---- +!!! note "Formatting `allowed_role_ids`" + Enter the IDs bare: `123456789,987654321`. Surrounding quotes are stripped, but any entry that is not a numeric Discord role ID is ignored and logged as a warning. If nothing usable is left, non-admin logins are denied with `role_check_failed` rather than silently allowing everyone. Admins can always log in, so a bad value can be corrected from the admin panel. -## Commands +--- -Poracle bot commands shown in help text and the onboarding wizard. +## Discord | Key | Label | Type | Description | |---|---|---|---| -| `register_command` | Register Command | string | The Poracle bot command users run to register (e.g., `$!register`). | -| `location_command` | Location Command | string | The Poracle bot command users run to set their location. | +| `enable_discord` | Enable Discord Login | boolean | Allow Discord sign-in. Requires `Discord:ClientId` and `Discord:ClientSecret` in [appsettings](reference.md). Nothing here affects PoracleNG's bot delivery — it only controls the login button. | + +!!! note "Admins are exempt" + The check runs only for non-admins, and only when the value is explicitly `"false"`. An absent key + allows Discord login. Admins can always sign in with Discord, so switching this off by mistake is + recoverable from the admin panel rather than a lockout. --- @@ -104,47 +133,60 @@ Configure Telegram authentication alongside or instead of Discord. | Key | Label | Type | Description | |---|---|---|---| -| `enable_telegram` | Enable Telegram | boolean | Allow users to log in and manage alarms via Telegram. | -| `telegram_bot` | Bot Username | string | Telegram bot username (without the `@` prefix). | +| `enable_telegram` | Enable Telegram Login | boolean | Allow users to log in and manage alarms via Telegram. | +| `telegram_bot` | Bot Username | string | Telegram bot username (without the `@` prefix). Used as a **fallback** — see the note below. | !!! note "Backend configuration also required" Enabling Telegram in site settings also requires `Telegram:BotToken` and `Telegram:BotUsername` to be set in [appsettings](reference.md). +!!! info "`telegram_bot` is a fallback, not an override" + The login widget takes its bot username from `Telegram:BotUsername` (`TELEGRAM_BOT_USERNAME`) when + that is set, and falls back to this setting when it is not. Configuration deliberately wins: before + v2.14.0 nothing read this field at all, so a deployment may hold a stale value someone typed in while + it was inert, and letting that override a working environment variable would break Telegram login. + --- -## Maps & Assets +## Authentication -Configure the map tile provider used for static map images. +Runtime toggles for the generic external SSO / OIDC sign-in flow. See [External SSO / OIDC](external-sso.md) for the full provider setup and [OIDC Refresh Tokens](oidc-refresh-tokens.md) for silent session refresh. | Key | Label | Type | Description | |---|---|---|---| -| `provider_url` | Map Tile URL | url | URL template for the map tile provider. Uses standard `{z}/{x}/{y}` placeholders. Example: `https://tile.openstreetmap.org/{z}/{x}/{y}.png` | +| `enable_oidc` | Authentication Mode (Local ⇄ SSO) | boolean | Controls the admin **Authentication** Local ⇄ SSO mode switch. **Opt-in:** SSO is active only when this is explicitly the string `"true"`. When the setting is **absent** (or `"false"`), the instance stays in **Local mode** — this is the default. Admins can always sign in via local auth even when SSO is on, so they can switch the mode back if the provider breaks. | +| `enable_oidc_slo` | Single Logout (Sign Out Everywhere) | boolean | Toggles RP-initiated single logout ("Sign out everywhere"). When **absent** this is **on** once an end-session endpoint is configured (`OIDC_END_SESSION_URL` in [appsettings](reference.md)). Set to `"false"` to disable single logout and fall back to a local logout. | + +!!! note "Absent = default" + These two settings differ from most boolean toggles on this page: their behaviour depends on whether the key is **present**. `enable_oidc` defaults **off** (Local mode) and must be explicitly `"true"` to enable SSO. `enable_oidc_slo` defaults **on** once an end-session endpoint is configured in [appsettings](reference.md), and only turns off when explicitly set to `"false"`. + +!!! info "Silent refresh has no runtime toggle" + Whether silent session refresh is active is controlled solely by the `OIDC_USE_REFRESH_TOKENS` [appsettings](reference.md) flag — there is intentionally **no** `enable_oidc_refresh` site setting. Refresh is coupled to the per-login JWT lifetime, so it's a deploy-time decision (turning it off at runtime would strand the short-lived tokens of users who are already signed in). See [OIDC Refresh Tokens](oidc-refresh-tokens.md). --- ## Analytics & Links -Optional analytics tracking and donation links. - | Key | Label | Type | Description | |---|---|---|---| -| `gAnalyticsId` | Google Analytics ID | string | GA4 measurement ID (e.g., `G-XXXXXXXXXX`). Leave blank to disable analytics. | -| `patreonUrl` | Patreon URL | url | Link to your Patreon page, shown in the UI when set. | -| `paypalUrl` | PayPal URL | url | Link to your PayPal donation page, shown in the UI when set. | +| `signup_url` | Signup URL | url | External registration page. When set, someone who reaches the login page without a Poracle account gets a sign-up button pointing here. Served on the public login page before anyone signs in, so treat it as public. Leave empty to hide the button. | --- -## Debug +## Retired keys -Development and troubleshooting settings. +Ten keys were withdrawn from the admin UI once it became clear nothing in the product read them. They +saved, persisted and read back while their descriptions promised behaviour the app does not have: -| Key | Label | Type | Description | -|---|---|---|---| -| `site_is_https` | Site Is HTTPS | boolean | Mark the site as running over HTTPS. Affects cookie security flags (`Secure`, `SameSite`). | -| `debug` | Debug Mode | boolean | Enable verbose debug logging. Not recommended in production. | +`disable_geomap`, `disable_geomap_select`, `register_command`, `location_command`, `provider_url`, +`gAnalyticsId`, `patreonUrl`, `paypalUrl`, `site_is_https`, `debug`. -!!! warning - Enabling debug mode in production can expose sensitive information in logs and degrade performance. +Existing rows are left in `site_settings` rather than deleted, and the settings UI filters them out of +its "Other" catch-all. Nothing reads them, so their values have no effect either way. See #547, #560 +and #589. + +`disable_geomap` and `disable_geomap_select` are legacy PoracleJS keys describing a map picker this app +does not have. `provider_url` still exists in the Poracle bot's own config, which is where the geocoder +URL is actually read from — the site setting was a duplicate that fed nothing. --- @@ -175,19 +217,58 @@ All repositories use the [UICONS](https://github.com/UIcons/UIcons) standard for ## Internal Settings -The following settings exist in the database but are **not shown** in the admin settings UI. They are managed automatically by the application and should not be modified manually. The API blocks both reads and writes for these keys. +The following settings exist in the database but are **not shown** in the admin settings UI. They are +managed automatically by the application and should not be modified manually. + +`migration_completed` is blocked by the API for both reads and writes. `quick_picks_seeded` is not: the +SPA's auto-seed guard has to read it, so admins receive it from `GET /api/settings` and it is hidden in +the UI layer instead. Neither is visible to non-admins. | Key | Category | Description | |---|---|---| | `migration_completed` | system | Sentinel flag indicating that the one-time data migration from `pweb_settings` to structured tables has completed. Set automatically by `SettingsMigrationStartupService`. | +| `quick_picks_seeded` | admin | Sentinel flag indicating that the built-in quick picks have been created once. Written by `POST /api/quick-picks/seed` after a successful seed, and backfilled at startup for installations that already hold global picks. Without it an admin who deliberately deletes every preset gets them all back on their next visit. Admin-readable via the API (the SPA guard needs it) but hidden from the settings UI. | + +## Values that are not settings + +Some keys arrive on the settings response without being stored anywhere. They are **projections** of +another system's configuration, present so the SPA can read them like any other value. + +| Key | Source | What it is | +|---|---|---| +| `poracle_locale` | Poracle's `general.locale` | The language a first-time visitor lands on, when neither a stored choice nor their browser can answer. See [Internationalization](../features/internationalization.md). | + +A projection is read fresh from Poracle, cached briefly, and **cannot be written**. `PUT /api/settings/poracle_locale` +answers 400, and the admin page renders the value as a read-only line under Allowed UI Languages +rather than as an editable box. The refusal matters more than it looks: a stored row would take +precedence over the projected value, so a single accidental save would pin the language default +permanently and stop the site tracking Poracle's configuration at all. + +There is also `GET /api/settings/upstream-disabled`, which lists the `disable_*` keys Poracle's own +config is forcing off. Any signed-in user can read it — the nav and the route guards need it — and it +is empty when Poracle is unreachable. + +--- ## Sensitive Settings -These settings are stored in the database but hidden from the admin settings UI groups. Unlike internal settings, they **are** accessible to admin users via the API. Prefer configuring them via [appsettings](reference.md) environment variables instead. +Credential-bearing rows carried over from PoracleJS installs. They are hidden from the admin settings +UI groups but **are** readable by admins through the API. Prefer configuring the equivalents via +[appsettings](reference.md) environment variables instead. | Key | Category | Description | |---|---|---| | `api_address` | api | Poracle API address. Prefer `Poracle:ApiAddress` in [appsettings](reference.md). | | `api_secret` | api | Poracle API shared secret. Prefer `Poracle:ApiSecret` in [appsettings](reference.md). | | `telegram_bot_token` | telegram | Telegram bot token. Prefer `Telegram:BotToken` in [appsettings](reference.md). | -| `scan_db` | database | Scanner database connection string. Prefer `ConnectionStrings:ScannerDb` in [appsettings](reference.md). | +| `scan_dbhost`, `scan_dbport`, `scan_dbname`, `scan_dbuser`, `scan_dbpass` | other | Scanner database host, port, name, user and password. Prefer `ConnectionStrings:ScannerDb` in [appsettings](reference.md). | +| `cf_id`, `cf_secret` | other | Cloudflare Access service token used by some PoracleJS deployments. | + +Neither the scanner keys nor the Cloudflare pair appear in `SettingsMigrationService.CategoryMap`, so rows migrated from `pweb_settings` land in the catch-all `other` category. + +`GET /api/settings` decides what a non-admin sees with an **allowlist**, not a denylist: the exact keys +in `SettingsController.UserVisibleKeys` plus anything beginning `disable_`, `enable_` or `uicons_`. +Everything else is admin-only. That direction matters — the previous denylist named a key `scan_db` +that matches no real row and never mentioned `cf_id` / `cf_secret`, so a scanner password and a +Cloudflare token were served to every signed-in session. With an allowlist, a new credential key is +hidden until someone deliberately adds it. diff --git a/docs/development/ci-cd.md b/docs/development/ci-cd.md index d1b78d42..3be48d3f 100644 --- a/docs/development/ci-cd.md +++ b/docs/development/ci-cd.md @@ -1,6 +1,18 @@ # CI/CD -Two GitHub Actions workflows run on push to `main` and pull requests. +## Branches + +| Branch | Purpose | +|---|---| +| `main` | Released code. Only moves when a release is merged. Publishing a release produces `:latest`, which self-hosters running watchtower auto-deploy. A plain `git clone` lands here, so cloning gives you released code. | +| `develop` | Integration. **Open pull requests against this.** Publishes `:beta` on every merge. | + +Cutting a release means merging `develop` into `main` and then publishing a GitHub release — the release +is what triggers the `:latest` build, so the merge alone ships nothing. `release-changelog.yml` opens a +PR promoting `[Unreleased]` to the new version section. + +GitHub Actions workflows run on pushes and pull requests for **both** branches. That matters: a workflow +filtered to one branch means PRs into the other run with no checks at all and merge looking green. ## ci.yml @@ -11,19 +23,40 @@ Runs on every push and PR: ## docker-publish.yml -Runs on push to `main`: +Runs when a **GitHub release is published**, on every push to `develop`, and on manual dispatch — *not* +on push to `main`. Publishing the release is what produces `:latest`, so merging `develop` into `main` +alone ships nothing. + +| Trigger | Tags produced | +|---|---| +| Release published | `:latest`, `:X.Y.Z`, `:X.Y`, `:` — note `docker/metadata-action` strips the leading `v`, so a `v2.14.0` tag publishes `:2.14.0` | +| Push to `develop` | `:beta`, `:develop-` | +| Manual dispatch | None — every `enable=` condition is false and the semver patterns need a tag ref, so no tags are emitted | -1. Builds the Docker image -2. Publishes to [`ghcr.io/pgan-dev/poracleweb.net`](https://github.com/PGAN-Dev/PoracleWeb.NET/pkgs/container/poracleweb.net) -3. Tags with `latest` and commit SHA +Images go to [`ghcr.io/pgan-dev/poracleweb.net`](https://github.com/PGAN-Dev/PoracleWeb.NET/pkgs/container/poracleweb.net). + +!!! info "How a release reaches production" + Publishing the GitHub release is the moment production changes — merging `develop` into `main` on its + own does nothing, because the image build is triggered by the `release` event. + + Deployment itself is by **watchtower**, which polls `:latest` every 60 seconds. The same applies to + the dev instance, which polls `:beta` and therefore updates on every merge to `develop`. + + The workflow does contain an SSH deploy step (`docker compose pull && up -d --force-recreate` against + the `DEPLOY_HOST` secret), but it is an **opt-in hook that is inert on this repository**: it exits 0 + early unless both `DEPLOY_HOST` and `DEPLOY_SSH_KEY` are set, and neither is. Self-hosters who prefer + a push deploy to a polling agent can set them. ## changelog.yml -Runs on merged PRs: +Runs on every PR to `main` or `develop` as a **verify-only check** (it never writes to the repo): + +- Confirms the PR adds an entry under the `## [Unreleased]` section of `CHANGELOG.md`. +- **Exempt** PR types (no entry required): titles prefixed `deps:`, `docs:`, `style:`, `chore:`, `ci:`, `test:`, or `build:`. +- **Escape hatch:** apply the `skip-changelog` label for a legitimate exception (re-runs automatically when the label is added). +- Fails with a clear message if a user-facing PR is missing its `[Unreleased]` entry, so it's caught **before** merge. -- Extracts the PR title and categorizes using conventional commit prefixes (`feat`, `fix`, `refactor`, `docs`, etc.) -- Inserts the entry into the `[Unreleased]` section of `CHANGELOG.md` -- Commits the update automatically +> Maintain `CHANGELOG.md` manually in each PR using the [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format — add your entry under `## [Unreleased]` (e.g. beneath `### Added` / `### Fixed`). ## release-changelog.yml diff --git a/docs/development/testing.md b/docs/development/testing.md index b9f09c70..f136da59 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -12,7 +12,7 @@ Uses Jest with `jest-preset-angular`. Tests cover: - Services (`user-geofence.service.spec.ts`, `admin-geofence.service.spec.ts`, `profile.service.spec.ts`) - Components (`region-selector.component.spec.ts`, `geofence-submissions.component.spec.ts`) - Dialogs (`geofence-name-dialog.component.spec.ts`, `geofence-approval-dialog.component.spec.ts`, `active-hours-editor-dialog.component.spec.ts`) -- Utilities (`geo.utils.spec.ts`, `active-hours.utils.spec.ts`) +- Utilities (`geo.utils.spec.ts`, `active-hours.models.spec.ts`) - Active hours (`active-hours-chip.component.spec.ts`, `location-warning.component.spec.ts`) - Pipes - Pokemon availability (`pokemon-availability.service.spec.ts`) @@ -29,9 +29,9 @@ Uses xUnit with Moq. Tests cover: - Alarm services (`MonsterServiceTests`, `RaidServiceTests`, `EggServiceTests`, `QuestServiceTests`, `InvasionServiceTests`, `LureServiceTests`, `NestServiceTests`, `GymServiceTests`) -- these mock `IPoracleTrackingProxy` - Proxy classes (`PoracleTrackingProxyTests`, `PoracleHumanProxyTests`) -- verify HTTP request construction, URL encoding, response unwrapping - Human/profile services (`HumanServiceTests`, `ProfileServiceTests`) -- mock `IPoracleHumanProxy` for single-user ops, `IHumanRepository` for admin bulk ops -- Active hours validation (`ActiveHoursValidationTests`) -- 17 tests for server-side active hours validation rules -- Other services (`UserGeofenceServiceTests`, `CleaningServiceTests`, `DashboardServiceTests`, `SiteSettingServiceTests`, `WebhookDelegateServiceTests`, `SettingsMigrationServiceTests`, `QuickPickServiceSecurityTests`, `PokemonAvailabilityServiceTests`) -- AutoMapper mappings (non-alarm entities) +- Active hours validation (`ActiveHoursValidationTests`) -- server-side active hours validation rules +- Other services (`UserGeofenceServiceTests`, `DiscordNotificationServiceTests`, `GeoMathTests`, `CleaningServiceTests`, `DashboardServiceTests`, `SiteSettingServiceTests`, `WebhookDelegateServiceTests`, `SettingsMigrationServiceTests`, `QuickPickServiceSecurityTests`, `PokemonAvailabilityServiceTests`) +- Mapping extensions (`MappingExtensionTests`) -- alarm DTO `To*()` / `ApplyUpdate()` and entity `ToModel()` / `ToEntity()` / `ApplyTo()` !!! info "Alarm service tests mock IPoracleTrackingProxy" Since alarm services no longer use repositories, their tests mock `IPoracleTrackingProxy` instead of `IRepository`. The mock returns `JsonElement` values matching PoracleNG's snake_case JSON format. @@ -39,6 +39,32 @@ Uses xUnit with Moq. Tests cover: !!! info "Human/profile tests mock IPoracleHumanProxy" `HumanServiceTests` and `ProfileServiceTests` mock `IPoracleHumanProxy` for single-user operations (get, create, exists, location, areas, profile switch, active hours). Admin bulk operations still mock `IHumanRepository`. `LocationControllerTests` and `AreaControllerTests` verify proxy calls with no direct DB interaction. `ProfileControllerTests` and `ProfileServiceTests` include extended coverage for active hours CRUD and validation. +## Auditing fixes for the defects they introduce + +Roughly one in five defects found in this project's audit sweeps was caused by an *earlier fix in the +same sweep*. They cluster into two shapes: a constraint added without enumerating who legitimately +depended on the loose rule, and a fix applied to one member of a set of ten while nine siblings are left +alone. + +`.claude/commands/regression-lens.md` is a Claude Code slash command (`/regression-lens`) that audits +recent merges asking only *what did these fixes break, and which siblings did they miss*. Run it after a +batch of fixes, scoping each pass to the previous pass's changes, until a pass reports nothing. When it +was first used it converged 8 → 5 → 2 → 1 → 0; stopping after one pass would have left five defects live, +including a profile-create path that answered 400 while leaving an orphan profile behind. + +Two habits that came out of it are worth applying by hand, with or without the tool: + +- **Give every guard a legitimate-case-still-passes test**, not just a refusal test. Check what real data + looks like before tightening a rule — an invasion grunt-type allowlist would have refused `blanche` and + `npc 0`, both of which exist in production. +- **Revert the fix and confirm the new test goes red.** A test written alongside a fix encodes that fix's + own assumptions and passes either way. One spec in this repo was asserting a broken request shape, so + the suite was defending the bug rather than catching it. + +The full rationale is in the "Fixing Defects Without Causing Them" section of `CLAUDE.md`. + ## CI -Both test suites run automatically on push/PR to `main` via GitHub Actions. See [CI/CD](ci-cd.md) for workflow details. +Both test suites run automatically on pushes and pull requests for **both** `main` and `develop`, and +against the merge queue. Since pull requests target `develop`, a workflow filtered to `main` alone would +mean PRs merged with no checks at all. See [CI/CD](ci-cd.md) for workflow details. diff --git a/docs/features/alarms.md b/docs/features/alarms.md index 48c318a0..4e1abf70 100644 --- a/docs/features/alarms.md +++ b/docs/features/alarms.md @@ -9,14 +9,14 @@ All alarm CRUD operations are proxied through the PoracleNG REST API. PoracleNG | Type | Description | |---|---| | **Pokemon** | Filter by species, IV, CP, level, PVP rank, gender, size | -| **Raids** | Filter by raid boss, tier, move, evolution, EX eligibility, specific gym, RSVP changes | -| **Eggs** | Filter by egg tier, EX eligibility, specific gym, RSVP changes | -| **Quests** | Filter by reward type and Pokemon | +| **Raids** | Filter by raid boss, level, move, evolution, EX eligibility, specific gym, RSVP notification mode. See [Raid level selector](#raid-level-selector). | +| **Eggs** | Filter by egg level, EX eligibility, specific gym, RSVP notification mode. See [Raid level selector](#raid-level-selector). | +| **Quests** | Filter by reward — Pokemon encounter, item, mega energy, candy or stardust — with an optional minimum amount. See [Quest alarm filters](#quest-alarm-filters). | | **Invasions** | Filter by grunt type and shadow Pokemon | | **Lures** | Filter by lure type | | **Nests** | Filter by nesting Pokemon species | | **Gyms** | Filter by gym team changes, battle activity, specific gym | -| **Fort Changes** | Filter by fort type (pokestop/gym), change types (name, location, image, removal, new) | +| **Fort Changes** | Filter by fort type (pokestop/gym), change types (name, location, image, description, removal, new) | | **Max Battles** | Filter by battle level (1-5 Dynamax, 7/8 Gigantamax), specific Pokemon, Gigantamax-only toggle | ## Creating alarms @@ -26,10 +26,73 @@ Each alarm type has a dedicated page accessible from the sidebar navigation. The 1. Click the **+** (add) button 2. Select the Pokemon/raid/quest target using the selector dialog 3. Configure filter options (IV range, CP range, level, etc.) -4. Set a **distance** — how far from your location to receive alerts (in meters) +4. On the **Delivery** tab, answer "Where should this alert reach you?" — see [Where an alert reaches you](#where-an-alert-reaches-you) 5. Optionally select a **template** for notification formatting 6. Save the alarm +## Where an alert reaches you + +Every alarm answers the same question, and the **Delivery** tab of every add and edit dialog asks it outright: *Where should this alert reach you?* Three options, one radio group. + +![Delivery scope picker in the Add Pokemon dialog](../screenshots/scope-picker.png) + +| Option | What it means | +|---|---| +| **Anywhere in my areas** | The alarm inherits whatever areas the active profile subscribes to. This is the default and the behaviour every alarm had before per-alarm scope existed. | +| **Near a point** | A radius around one fixed point. **Measured from** picks the point: your pin, or any [saved place](#saved-places). **Add a place** in the same select opens the map picker without losing the alarm you are editing. | +| **Only in specific areas** | A list of areas for this alarm alone. It *replaces* the profile's area list rather than narrowing it, and geofences you drew yourself are offered alongside the admin areas. | + +The three are exclusive rather than combinable, because PoracleNG refuses every mixture of them: a place with areas, areas with a radius, and a place without a radius are all rejected upstream. Modelling the choice as a radio group means those states can't be typed in the first place. + +Under the hood the picker writes three fields. "Anywhere in my areas" clears both overrides and sends `distance = 0`. "Near a point" sends the radius in metres, plus `override_location_label` when a saved place is chosen and nothing when the pin is. "Only in specific areas" sends `override_areas` and forces `distance` to 0. Both overrides are always sent explicitly, including empty, because a null means "leave what's stored" on the write path. Without that you could set an override and never take it off. + +If you choose "Near a point" without ever having set a pin, the picker says so and offers a **Set your pin** button in place, rather than saving an alarm that measures from 0,0. + +!!! warning "Needs PoracleNG 5.1.0" + `override_location_label` and `override_areas` arrive in PoracleNG 5.1.0. On an older server the columns don't exist, so the scope picker saves without complaint and changes nothing. PoracleWeb logs an error at startup and reports the detected version on **Admin → Settings**. + +!!! note "Your own geofences work here" + PoracleWeb serves user-drawn geofences with `userSelectable: false` to keep them out of the bot's `!area` picker, and PoracleNG rejects those names when they arrive in `override_areas`. PoracleWeb sends only the names PoracleNG will accept and writes the rest into the alarm row directly, so a geofence you drew can scope one alarm whether or not your profile subscribes to it. See [Custom geofences](custom-geofences/key-concepts.md). + +### Saved places + +A place is a named point ("home", "work", "the gym") that an alarm can measure from instead of your profile pin. They live on the **Areas & Places** page, below the pin and the area map. (`/places` still resolves; it redirects to `/areas`.) + +![Places section on the Areas & Places page](../screenshots/places-section.png) + +Add one with **Add a place**: drop the marker, then name it. Names are yours to choose and are what an alarm's `override_location_label` refers to. Places are user-scoped, not profile-scoped. + +Deleting a place that alarms still point at is refused. `DELETE /api/location/places/{label}` answers 409 with a `referencingRules` list, and the UI names the alarms so you know what to repoint first. + +The API is three endpoints on `LocationController`, all gated by `disable_location`: + +| Endpoint | Purpose | +|---|---| +| `GET /api/location/places` | Every place plus the profile pin, as `{ default, named }`. `default` is null when the user has never set a pin. | +| `POST /api/location/places` | Saves a place. A label PoracleNG refuses comes back as a 400 the dialog shows against the field. | +| `DELETE /api/location/places/{label}` | Deletes a place, or 409s with the alarms still using it. | + +### Changing scope from a card + +Most alarm cards carry a scope chip reading the alarm's answer back to you — "Anywhere I get alerts" when the profile has no areas selected, otherwise "Anywhere in my areas", "Within 2 km of Home", "Only in Terrigal, Erina". Clicking it opens the same picker in a small dialog, so one alarm's scope can be changed without opening its edit dialog. Pokemon, gym, invasion, lure, nest and fort-change cards have the chip; raid, quest and max-battle cards do not, so those are changed from their edit dialog. + +![Scope chip on an alarm card](../screenshots/where-chip.png) + +## Default delivery scope (Alert Defaults) + +By default, every new alarm opens pre-set to **Areas** (geofence-based — the alarm sends `distance = 0`). If you usually track by radius, you can change that default so new alarms open on **Distance** with a radius you choose, instead of switching the location mode and re-typing a distance on every add. + +Open the **user menu** (your avatar, top-right) and select **Alert Defaults**: + +- **Default mode** — choose **Areas** or **Distance** for new alarms. +- **Default distance** — when Distance is the default, the radius (0.1–100 km) used to pre-fill new alarms. A live delivery preview shows what the choice covers. +- **Measured from** — also Distance-only: the pin, or a [saved place](#saved-places) new alarms should measure from. Switching the default back to Areas clears it, since a place without a radius is a scope PoracleNG refuses. + +The preference is **per-browser** (stored in `localStorage` under `poracle-default-alert-mode` / `poracle-default-alert-distance-km` / `poracle-default-alert-place`, the same pattern as the theme and language settings) and is read by the `AlertDefaultsService`. It seeds the scope picker in **every add-alarm dialog** and in the **[Quick Pick](#quick-picks) apply dialog**. + +!!! note "Applies to new alarms only" + Alert Defaults only changes what the add/apply dialogs open with. Existing alarms are untouched, and you can still set a different scope on any individual alarm before saving it. Because the preference lives in the browser, it does not sync across devices. + ## Pokemon Availability When a [Golbat scanner](../configuration/reference.md#golbat-api) is configured, the Pokemon selector shows which species are currently spawning in the wild. This helps users create alarms for Pokemon that are actually available to encounter. @@ -61,13 +124,13 @@ The availability UI is **automatically hidden** when Golbat is not configured. N ## Alarm cards -![Pokemon alarm list with filter pills](../screenshots/pokemon.png) +![Pokemon alarm list with filter pills and scope chips](../screenshots/pokemon.png) Alarms are displayed as a card grid. Each card shows: - Pokemon sprite or raid/quest icon -- **Filter pills** — Quick-glance badges showing active filters (IV, CP, Level, PVP, Gender, Size) -- Distance setting +- **Filter pills** — Quick-glance badges showing active filters (IV, CP, Level, PVP, Gender, Size, minimum time left) +- **Scope chip** — where the alert reaches you, in words: "Anywhere I get alerts", "Anywhere in my areas", "Within 2 km of Home", "Only in Terrigal, Erina". Click it to [change the scope from the card](#changing-scope-from-a-card) - Template name - **Targeted gym name** — Gym, Raid, and Egg alarm cards display the name of the targeted gym when a specific gym is selected (via the gym picker) - Edit/delete actions @@ -136,9 +199,41 @@ When a user selects a specific size, both `size` and `max_size` are set to the s The default maximum level is **55** (not 40 or 50), matching Poracle's support for shadow/purified/best-buddy boosted levels. +### Minimum time left + +Under **More Filters**, **Minimum Time Left** skips spawns that will despawn before you could reach them. The field is a select rather than a free number, offering Any, 1, 2, 5, 10, 15 and 20 minutes. PoracleNG stores seconds (`min_time`), and a free field invited two silent failures: typing `5` meaning minutes asks for five seconds, and a value longer than a spawn lives mutes the alarm with no error. A value set from the bot that isn't one of the presets is kept and offered in the list rather than being overwritten on the next save. + +When set, the card shows a "5 min left" pill alongside the IV and CP pills. + +### PVP mega evolution + +On the **PVP** tab, once a league is selected, **Mega evolution** chooses which form the rank applies to: Base, Mega, Mega X or Mega Y (`pvp_ranking_evolution` 0–3). Megas are ranked separately from their base forms, so a Mega rule will not match a base-form spawn. Pick Base unless you specifically want mega rankings. + +The chosen form appears as a suffix on the card's PVP badge ("Great League · Mega X"). + +!!! warning "Needs PoracleNG 5.1.0" + `pvp_ranking_evolution` arrives in PoracleNG 5.1.0. Below that the column doesn't exist and the toggle group saves without effect. + +## Raid level selector + +The raid and egg pickers share the `` chip component. The vocabulary follows the [WatWowMap masterfile](https://github.com/WatWowMap/Masterfile-Generator/blob/main/master-latest-poracle-v2.json) — the same source PoracleNG uses for in-DM notification text — so the names you see in the picker match what users receive in their alerts. + +**Raid picker.** Multi-select. Primary chip row shows the seven most common types: `1 Star`, `2 Star`, `3 Star`, `4 Star`, `Legendary` (level 5), `Mega` (level 6), `Mega Legendary` (level 7). A `Any` chip selects the wildcard sentinel (level 9000) that matches every raid level. A **More raid types…** overflow menu surfaces the other 12 canonical types: `Ultra Beast` (8), `Elite` (9), `Primal` (10), `1–5 Shadow` (11–15), `4–5 Super Mega` (16–17), `Coordinated 1–2` (18–19). + +**Egg picker.** Multi-select. Only the five Star tiers (1–5) are surfaced — Pokémon GO has no Mega/Shadow/Primal eggs. + +**The "By Boss" tab has no level picker.** PoracleNG forces `level` to the wildcard 9000 for any raid +alarm carrying a specific `pokemon_id` (`trackingRaid.go`), so a level chosen alongside a boss could never +survive the request. The tab used to show a picker whose value was always discarded; it was removed in +v2.14.0. Track a boss to be alerted at whatever level it appears. + +**`+ Add`.** Both pickers expose an inline numeric input for any positive integer not in the canonical list. Useful for forward compatibility — if Niantic introduces a new raid type (`raid_20`) before PoracleWeb.NET ships an update, you can already alarm on it. Typed values are **ephemeral** to the dialog session: close the dialog (or refresh the page) and the chip is gone. Saved alarms at custom levels re-seed the chip when you open the edit dialog. + +The canonical list is served by the API at `GET /api/masterdata/raid-levels` (cached server-side; baked-in fallback if the masterfile fetch fails). Card titles like "All Mega Legendary Raids" compose by combining the modifier ("Mega Legendary") with the localized "Raids" suffix from `RAIDS.ALL_LEVEL_RAIDS`, so card text reads naturally without the doubled word that an unaltered masterfile string would produce. + ## Raid alarm filters -Raid alarms support these fields beyond the basic tier/boss selection: +Raid alarms support these fields beyond the basic level/boss selection: | Field | Default | Description | |---|---|---| @@ -147,7 +242,7 @@ Raid alarms support these fields beyond the basic tier/boss selection: | `evolution` | `9000` (any) | Filter by evolution type (e.g., Mega, Primal) | | `exclusive` | `false` | EX/exclusive raid flag | | `gymId` | `null` (all gyms) | Track a specific gym by ID (set via gym picker) | -| `rsvpChanges` | `false` | Receive RSVP change notifications | +| `rsvpChanges` | `0` (matches only) | RSVP notification mode: `0` matches only, `1` matches + RSVP updates, `2` RSVP updates only. Selectable as a three-option toggle group in the raid add/edit dialog; shown as an "RSVP" / "RSVP only" status badge on raid cards (beside the auto-delete tag) when non-default. Selecting mode `1` or `2` also sets PoracleNG's edit-in-place bit (`clean` bit 2) so RSVP count changes edit the existing alert rather than sending a new message. Mode `2` requires the upstream scanner to emit RSVP webhooks — selecting it in deployments without one will silence the alarm. | ## Egg alarm filters @@ -158,7 +253,27 @@ Egg alarms support: | `team` | `4` (any team) | Gym team controlling the egg | | `exclusive` | `false` | EX/exclusive egg flag | | `gymId` | `null` (all gyms) | Track a specific gym by ID (set via gym picker) | -| `rsvpChanges` | `false` | Receive RSVP change notifications | +| `rsvpChanges` | `0` (matches only) | RSVP notification mode: `0` matches only, `1` matches + RSVP updates, `2` RSVP updates only. Selectable as a three-option toggle group in the egg add/edit dialog; shown as an "RSVP" / "RSVP only" status badge on egg cards (beside the auto-delete tag) when non-default. Selecting mode `1` or `2` also sets PoracleNG's edit-in-place bit (`clean` bit 2) so RSVP count changes edit the existing alert rather than sending a new message. Mode `2` requires the upstream scanner to emit RSVP webhooks — selecting it in deployments without one will silence the alarm. | + +## Quest alarm filters + +![Add Quest dialog with the five reward tabs](../screenshots/quests-add-dialog.png) + +A quest alarm matches one reward. Which of the five reward tabs you use decides the `reward_type` PoracleNG stores, and where the number you type ends up: + +| Tab | `reward_type` | What you pick | Minimum field | +|---|---|---|---| +| Pokemon | `7` | Species the quest rewards (`reward` = pokemon id) | — | +| Items | `2` | The item (`reward` = item id) | `amount` | +| Mega Energy | `12` | Species whose energy is rewarded | `amount` | +| Candy | `4` | Species whose candy is rewarded | `amount` | +| Stardust | `3` | Nothing; the amount is the whole rule | `reward` | + +**Minimum Amount** is the fewest of the reward the quest has to give; `0` means any. It only applies where a reward comes in a quantity: items, candy and mega energy. A Pokemon encounter has nothing to count. + +Stardust is the exception worth knowing about. PoracleNG reads the stardust floor from `reward` rather than `amount`, so the Stardust tab has a single **Minimum Stardust** field that writes there, and `amount` stays `0` on a stardust alarm. + +Quest cards render the amount ahead of the reward name — "3× Rare Candy" — but only when it is above one; an amount of 1 shows the reward name on its own. Stardust cards read "25000 Stardust", from `reward`. ## Gym alarm filters @@ -172,15 +287,15 @@ Gym alarms support: ## Fort change alarm filters -Fort change alarms track changes to pokestops and gyms as points of interest (not activity at them). This includes name changes, location changes, image updates, removals, and new POI additions. +Fort change alarms track changes to pokestops and gyms as points of interest (not activity at them). This includes name changes, location changes, image updates, description changes, removals, and new POI additions. -![Fort change alarm page](../screenshots/fort-changes.png) +![Add Fort Change dialog showing the six change types](../screenshots/fort-changes-add-dialog.png) | Field | Default | Description | |---|---|---| | `fort_type` | `"everything"` | Fort type to track: `pokestop`, `gym`, or `everything` | | `include_empty` | `0` (false) | Include forts with no name | -| `change_types` | `[]` (all) | JSON array of change types to monitor: `name`, `location`, `image_url`, `removal`, `new` | +| `change_types` | `[]` (all) | JSON array of change types to monitor: `name`, `location`, `image_url`, `description`, `removal`, `new` | Fort change alarms are proxied through PoracleNG using tracking type `"fort"`. The API endpoints follow the standard alarm CRUD pattern at `/api/fort-changes`. @@ -256,9 +371,33 @@ The **gym picker** is a shared component (`app-gym-picker`) that allows users to Invasion alarms filter by grunt type. The `grunt_type` value is **automatically lowercased** on create because Poracle uses case-sensitive matching for grunt types. +## Delivery & message modes + +Every alarm carries a `clean` field that PoracleNG reads as a **bitmask** controlling how the notification is delivered. PoracleWeb surfaces the bits the bot actually acts on as per-alarm toggles in the add/edit dialogs (and shows them as status badges on the alarm cards): + +| Mode (`clean` bit) | Applies to | What it does | +|---|---|---| +| **Auto-delete** (bit 1) | all alarm types except Fort Changes | Deletes the Discord notification after the event expires (e.g. a Pokemon despawns or a raid ends). Toggle per-alarm in the dialog, or in bulk from the **Cleaning** page. PoracleNG has no `clean` column for fort changes, so the setting does not exist for that type. | +| **Edit message in place** (bit 2) | Lures; Raids/Eggs (via RSVP mode) | Updates the existing Discord message when the event changes instead of sending a new one. For lures, enable the **"Edit message in place"** toggle in the lure dialog; for raids/eggs it is set automatically when you choose an RSVP mode (see the `rsvpChanges` rows above). | +| **Daily summary** (bit 4) | Quests | Collects matching quests into a single summary message instead of one notification each. Enable the **"Daily summary"** toggle in the quest dialog. Requires a configured summary schedule on the bot. | + +The modes combine (a quest can be both auto-delete and daily-summary, for example). PoracleWeb **preserves any bits set elsewhere** — if you configured a delivery mode via the bot's `!command` interface that isn't surfaced in the web UI, editing the alarm in the browser will not wipe it. + +### RSVP updates (raids & eggs) + +Raid and egg alarms add a third delivery setting on top of auto-delete and edit-in-place: an **RSVP notification mode**, stored in the `rsvpChanges` field (see the `rsvpChanges` rows under the raid and egg filter tables above). Choose it from the three-option toggle group in the raid/egg add/edit dialog: + +- **Matches only** (`0`, the default) — standard raid/egg alerts only. You get one notification when a raid or egg matches, and nothing further. +- **Matches + RSVP updates** (`1`) — the same initial match alert, plus a re-notification whenever the RSVP count changes (trainers signing up to attend). +- **RSVP updates only** (`2`) — skips the initial match alert entirely and notifies you only when RSVP counts change. + +Picking mode `1` or `2` also turns on PoracleNG's edit-in-place behavior (`clean` bit 2), so RSVP count changes **edit the existing Discord alert in place** rather than sending a fresh message each time — your DMs stay to a single, updating notification per raid. When a non-default mode is set, the alarm card shows an **"RSVP"** (mode `1`) or **"RSVP only"** (mode `2`) status pill beside the auto-delete tag. + +> **Scanner caveat:** RSVP updates only arrive if the upstream scanner emits RSVP webhooks. In a deployment without one, mode `2` ("RSVP updates only") suppresses the initial match but never receives RSVP events — the alarm goes completely silent. Use mode `2` only if you know your scanner reports RSVPs. + ## Default values -Comprehensive table of all monster (Pokemon) alarm defaults, matching the PHP PoracleWeb.NET defaults: +All monster (Pokemon) alarm defaults: | Field | Default | Description | |---|---|---| @@ -278,6 +417,8 @@ Comprehensive table of all monster (Pokemon) alarm defaults, matching the PHP Po | `max_sta` | `15` | Maximum stamina IV | | `pvp_ranking_best` | `0` | Best PVP ranking position | | `pvp_ranking_worst` | `4096` | Worst PVP ranking position | +| `pvp_ranking_evolution` | `0` | PVP form to rank against (0 = base, 1 = mega, 2 = Mega X, 3 = Mega Y) | +| `min_time` | `0` | Minimum seconds of despawn time left (0 = no filter) | | `gender` | `0` | Gender filter (0 = any) | | `size` | `-1` | Size filter (-1 = no filter / all sizes) | | `max_size` | `5` | Maximum size upper bound | @@ -327,7 +468,7 @@ Every alarm card includes a **test button** (send/paper plane icon) that trigger ### Supported alarm types -Test alerts are available for all alarm types: +Test alerts are available for eight of the ten alarm types: - Pokemon - Raid @@ -337,8 +478,9 @@ Test alerts are available for all alarm types: - Lure - Nest - Gym -- Fort Change -- Max Battle + +**Not** Fort Change or Max Battle — `TestAlertController` rejects them, and neither module renders a test +button. Those two have no mock payload builder, so there is nothing to send. ### Rate limiting @@ -350,20 +492,22 @@ Test alerts are rate limited to prevent abuse: ## Weather Display -The dashboard shows the current in-game weather conditions at the user's saved location. +The dashboard shows the current in-game weather conditions at the user's pin. ![Weather section on the dashboard](../screenshots/dashboard-weather.png) ### Features -- **Current weather** — Displays the active in-game weather type at the user's saved coordinates +- **Current weather** — Displays the active in-game weather type at the user's pin - **Last update timestamp** — Shows when the weather data was last refreshed - **Area weather** — Weather conditions displayed for each of the user's selected areas - **Automatic updates** — Weather data refreshes in the background -!!! note "Location required" - The weather display requires a saved location to function. Users who have not set their location will not see weather information on the dashboard. Set a location via the Location page or the onboarding wizard. +!!! note "Pin required" + The weather display requires a saved pin to function. Users who have not set one will not see weather information on the dashboard. Set a pin on the **Areas & Places** page or through the onboarding wizard. ## Quick Picks Admins can define **Quick Pick** templates — pre-configured alarm sets that users can apply with one click. Useful for onboarding new users or sharing recommended configurations. + +When applying a Quick Pick, the apply dialog's **Delivery** tab holds the same [scope picker](#where-an-alert-reaches-you) the add dialogs use, seeded from your [Alert Defaults](#default-delivery-scope-alert-defaults). Whatever you choose there — areas, a saved place, a radius, or a specific set of areas — applies to every alarm the pick creates. diff --git a/docs/features/custom-geofences.md b/docs/features/custom-geofences.md deleted file mode 100644 index e7d7f0b2..00000000 --- a/docs/features/custom-geofences.md +++ /dev/null @@ -1,234 +0,0 @@ -# Custom Geofences - -Users can draw custom polygon geofences on the "My Geofences" page for precise notification zones (e.g., park boundaries) instead of distance-from-center circles. - -## How it works - -PoracleWeb.NET acts as the **single geofence source** for PoracleJS. Instead of PoracleJS connecting to Koji directly, PoracleWeb.NET fetches admin geofences from Koji, resolves group names from the Koji parent chain, merges them with user-drawn geofences from its own database, and serves everything via one endpoint. No custom code is needed in PoracleJS or Koji — standard upstream versions work. - -1. User draws a polygon on the map, saved to the PoracleWeb.NET database -2. PoracleWeb.NET serves a **unified geofence feed** via `GET /api/geofence-feed` — admin geofences from Koji (cached 5 minutes) plus user geofences from the local DB -3. PoracleJS loads **all** geofences from a single PoracleWeb.NET URL (no direct Koji connection needed) -4. User geofences have `displayInMatches: false` — names are hidden from all DMs for privacy -5. Admin geofences have `displayInMatches: true` and `group` populated from Koji parent hierarchy -6. Users can submit geofences for admin review, which creates a Discord forum post with a static map -7. Admins approve, and the geofence is promoted to Koji as a public area visible to all users -8. If Koji is unreachable, user geofences are still served (graceful degradation) -9. If PoracleWeb.NET itself is down, PoracleJS falls back to its built-in `.cache/` directory - -## Component diagram - -```mermaid -graph LR - KojiServer[(Koji Server
Public areas)] -->|admin geofences
cached 5 min| PoracleWeb - subgraph PoracleWeb - Feed[GeofenceFeedController
GET /api/geofence-feed
Unified proxy] - DB[(poracle_web DB
user geofences)] - end - PoracleWeb -->|single URL
admin + user geofences| Poracle[PoracleJS
geofence.path] - Poracle -.->|failover| Cache[PoracleJS .cache/] -``` - -## Detailed internal flow - -```mermaid -graph TB - subgraph PoracleWeb - UI1[My Geofences Page
Draw / Name / Submit] - UI2[Geofence Mgmt
Approve / Reject / Delete] - Feed[GeofenceFeedController
GET /api/geofence-feed
Unified proxy] - Svc[UserGeofenceService
Create / Delete / Submit / Approve] - Koji[KojiService
Fetch admin geofences + approve] - Discord[DiscordNotificationService
Forum posts + maps] - end - - DB[(poracle_web DB
user_geofences)] - KojiServer[(Koji Server
Public areas)] - DiscordForum[(Discord Forum
Threads + Tags)] - Poracle[PoracleJS
Single URL to PoracleWeb] - - UI1 --> Svc - UI2 --> Svc - Svc --> DB - Svc --> Koji - Svc --> Discord - Feed --> DB - Feed --> Koji - Koji --> KojiServer - Discord --> DiscordForum - Feed -.->|admin + user geofences| Poracle -``` - -## Geofence lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Create : User draws polygon - Create --> Active : Save to DB + add to area + reload Poracle - - Active --> Active : Alerts work via feed endpoint - Active --> Submitted : User clicks Submit for Review - - Submitted --> PendingReview : Discord forum post created with map - PendingReview --> PendingReview : Still works privately - - PendingReview --> Approved : Admin approves - PendingReview --> Rejected : Admin rejects - - Approved --> [*] : Push to Koji as public area\nLock Discord thread - Rejected --> Active : Stays private with review notes\nLock Discord thread - - note right of Active : Private — only owner\ngets alerts.\nName hidden from\nall DMs. - note right of Approved : Public — all users\ncan select it on\nthe Areas page. -``` - -## Admin geofence management - -Admins can view and manage all user-created geofences from the **User Geofences** page in the Admin sidebar (`/admin/geofence-submissions`). - -### View modes - -The page supports three view modes, toggled via the toolbar: - -- **Card view** (default) — Map thumbnail cards grouped by region in collapsible expansion panels. Each card shows the geofence polygon, owner with avatar, status chip, metadata, and action buttons. Map thumbnails are lazy-loaded via `IntersectionObserver` and preserved across view switches. -- **List view** — Compact table grouped by region in collapsible expansion panels. Columns: Name, Status, Owner (with avatar), Region, Points, Created, Actions. -- **Table view** — Flat ungrouped table showing all geofences with sortable columns. Columns: Name, Status, Owner, Region, Points, Created, Submitted, Reviewed By, Actions. Click column headers to sort ascending/descending. - -### Features - -- **Region grouping** — Card and list views group geofences by their `groupName` (region). Each group has a collapsible `mat-expansion-panel` with the region name and a geofence count badge. Regions are sorted alphabetically, with "No Region" last. -- **Sortable columns** — Table view supports sorting by name, status, owner, region, points, created, and submitted date. Click a column header to sort; click again to reverse direction. Sorting also applies to the card and list views. -- **Owner display names and avatars** — Resolves Discord/Telegram usernames from the Poracle `humans` table instead of showing raw user IDs. Circular avatars (24px) are displayed next to owner names. Fallback: generic person icon when no avatar is available. -- **Reviewer display names and avatars** — The `reviewedBy` field is resolved to the reviewer's Discord username and avatar via the same batch human lookup. Reviewer avatars (16px) appear in card metadata and the table's Reviewed By column. -- **Map thumbnails** — Each geofence card shows a non-interactive Leaflet map preview with the polygon rendered in its status color. Thumbnails are lazy-loaded via `IntersectionObserver` for performance. -- **Detail dialog** — Click a card's map thumbnail or View button to open an interactive Leaflet map dialog with: - - Full summary panel (name, owner, group, status, point count, area in km²/m², dates, review notes) - - Interactive pan/zoom map with the polygon auto-fitted to bounds - - Reference geofences from Poracle areas shown as dashed colored outlines (same palette as the Areas page) with name tooltips on hover -- **Point count and area** — Each geofence shows its vertex count and computed area (m² for areas under 1 km², km² otherwise) using the spherical excess formula -- **Status filtering** — Filter tabs for All, Pending, Active, Approved, and Rejected with counts. Filters apply across all view modes. -- **Skeleton loading** — Animated skeleton cards with map placeholders during data fetch - -### Owner and reviewer resolution - -Owner and reviewer names are resolved via a single batch lookup against the Poracle `humans` table. Distinct owner IDs and reviewer IDs are merged and fetched in one pass for efficiency. Avatars are served from `AvatarCacheService` with Discord CDN default fallback. The `UserGeofence` model exposes `ownerName`, `ownerAvatarUrl`, `reviewedByName`, and `reviewedByAvatarUrl` as enriched (non-mapped) properties set by `UserGeofenceService.GetAllWithDetailsAsync()` and `AdminGeofenceController.GetAll`. - -## Geofence statuses - -| Status | Description | -|---|---| -| `active` | Private, user-only. Alerts work via the feed endpoint. | -| `pending_review` | Submitted for admin review. Discord forum post created. Still works privately. | -| `approved` | Promoted to Koji as a public area. Visible to all users. | -| `rejected` | Remains private with review notes. User can continue using it. | - -## Limits - -- Maximum **10** custom geofences per user -- Polygons limited to **500** points - -## Naming rules - -- Geofence names (`kojiName` field) are always **lowercase** because Poracle does case-sensitive area matching -- Names are auto-generated from the user-provided display name (lowercased) -- Collisions are resolved by appending a numeric suffix - -## GeoJSON Import & Export - -Custom geofences can be exported and imported using the standard [GeoJSON](https://geojson.org/) format, making it easy to work with external GIS tools or migrate geofences between systems. - -### Export - -1. Click the **download/export** button on the My Geofences page -2. Select which geofences to include in the export -3. The file is exported as a standard GeoJSON `FeatureCollection` -4. Each geofence becomes a `Feature` with `Polygon` geometry -5. Feature properties include `name`, `region`, and `status` - -![GeoJSON export dialog](../screenshots/geofences-export-dialog.png) - -The exported file is compatible with any GIS tool that supports GeoJSON, including [geojson.io](https://geojson.io), QGIS, Google Earth, and others. - -### Import - -1. Click the **upload/import** button on the My Geofences page -2. Paste GeoJSON text directly or upload a `.geojson` file -3. Each `Polygon` in the `FeatureCollection` creates a new geofence -4. Review and rename each geofence before saving -5. Region auto-detection applies to imported polygons (same as hand-drawn geofences) -6. Names are auto-generated from Feature `properties` (e.g., `name` or `title`) or fall back to the polygon index -7. Imported geofences count toward the **10-geofence-per-user limit** - -![GeoJSON import dialog](../screenshots/geofences-import-dialog.png) - -!!! tip "Use cases" - - **Migrating from other systems** — Export geofences from another Pokemon GO tool or mapping platform and import them into PoracleWeb.NET - - **Drawing in desktop GIS tools** — Use QGIS or geojson.io for precise polygon editing, then import the result - - **Sharing boundaries between users** — One user exports their geofences and another imports them - -## Caching - -- Admin geofences from Koji are cached in memory for **5 minutes** (`IMemoryCache`) -- Cache is invalidated when a geofence is approved/promoted to Koji -- User geofences are served directly from the database (no caching) - -## Failover - -| Failure | Behavior | -|---|---| -| Koji unreachable | Feed endpoint logs the error, still serves user geofences from DB | -| PoracleWeb.NET down | PoracleJS falls back to its built-in `.cache/` directory | - -## Setup - -### 1. Create the PoracleWeb.NET database - -A separate MySQL/MariaDB database for app-owned data: - -```sql -CREATE DATABASE poracle_web; -``` - -The `user_geofences` table is created automatically on first run. - -### 2. Configure the Koji connection - -Set the following in your environment or `appsettings.json`: - -- `Koji:ApiAddress` — Koji server URL (e.g., `http://localhost:8080`) -- `Koji:BearerToken` — Koji API bearer token -- `Koji:ProjectId` — Koji project ID for promoted geofences -- `Koji:ProjectName` — Koji project name, used to fetch from `/geofence/poracle/{name}` - -### 3. Point PoracleJS to PoracleWeb.NET - -Set `geofence.path` in PoracleJS config to a single PoracleWeb.NET URL: - -```json -"geofence": { - "path": "http://poracleweb-host:8082/api/geofence-feed" -} -``` - -Remove `kojiOptions.bearerToken` from the PoracleJS geofence config if present (it is harmless if left, but no longer needed). - -### 4. Remove group_map.json - -Remove `group_map.json` from PoracleJS if it exists — group names are now resolved automatically from the Koji parent chain by PoracleWeb. - -### 5. Restart PoracleJS - -```bash -pm2 restart all -``` - -### 6. Discord forum channel (optional) - -For geofence submission discussions: - -1. Set `Discord:GeofenceForumChannelId` to your forum channel ID -2. Give the bot **View Channel**, **Send Messages in Threads**, and **Manage Threads** permissions -3. Forum tags (Pending/Approved/Rejected) are auto-created if the bot has **Manage Channels** permission, or create them manually - -!!! tip "PoracleJS failover" - PoracleJS's built-in `.cache/` directory automatically caches geofence data. If PoracleWeb.NET is temporarily unavailable, PoracleJS falls back to its last cached copy. diff --git a/docs/features/custom-geofences/admin-operations.md b/docs/features/custom-geofences/admin-operations.md new file mode 100644 index 00000000..ce3f93e0 --- /dev/null +++ b/docs/features/custom-geofences/admin-operations.md @@ -0,0 +1,99 @@ +# Admin Operations + +This page is the day-to-day admin reference: where to review submissions, how to approve/reject/delete, the optional Discord forum integration, and the limits that protect your deployment. + +## The Geofence Submissions screen + +Admins get a **Geofence Submissions** screen (under the admin area). It lists every user geofence with its status, owner, region, point count, and timestamps. Three view modes: + +| View | Best for | +|---|---| +| **Card** | Visual review — each card shows a small map thumbnail, grouped by region. | +| **List** | A compact, region-grouped table. | +| **Table** | A flat, sortable table with every column (sort by name, status, owner, region, points, dates). | + +Geofences with no region land in a **"No Region"** group, so nothing is hidden just because it lacks a region. Use the **status filter tabs** to focus — most of the time you'll filter to **pending_review** to see what's waiting on you. + +## Reviewing a submission + +Open a submission to see its shape on a map, who submitted it, and how many points it has. Then choose: + +- **Approve** — promotes it to a public Koji area. You can set a cleaner public name and (optionally) a region. See the full effect in [Private geofences & promotion](private-and-promotion.md#step-3a-approve-the-actual-promotion). +- **Reject** — declines the request with a short reason. The geofence stays private for its owner. + +```mermaid +flowchart TD + PR[pending_review] --> AP[Approve] + PR --> RJ[Reject] + AP --> A[approved — PUBLIC] + RJ --> R[rejected — PRIVATE + note] +``` + +### Choosing a region at approval time + +If your Koji project has regions, the approval dialog shows a region picker, pre-filled with whatever region the submission already had. You can: + +- **Keep it** — leave it as-is. +- **Change it** — file the area under a different region. +- **Clear it** — promote it as an ungrouped public area. + +If your project has **no regions**, the picker simply doesn't appear, and the area is promoted ungrouped. You can always organize it in Koji afterward. + +## Deleting geofences + +Admins can delete any geofence from the admin screen: + +- If it's a **private** geofence, it's removed from PoracleWeb.NET and from every profile that had it switched on. +- If it's an **approved (public)** geofence, PoracleWeb.NET also removes it from the Koji project so it stops being a selectable public area. + +!!! warning "Project removal vs. full deletion" + Removing a public geofence from the *project* stops it being selectable, but the geofence row may still exist in Koji's own database. To scrub it completely, delete it in the **Koji UI**. See [Troubleshooting](troubleshooting.md#removing-a-geofence-from-koji-completely). + +## Optional: Discord forum integration + +You can have PoracleWeb.NET open a **Discord forum thread** for each submission, so your admin team can discuss and track decisions in Discord. It's entirely optional — everything works without it. + +| Config | What it does | +|---|---| +| `Discord:GeofenceForumChannelId` | The Discord **forum channel** where submission threads are created. Leave unset to disable. | +| `Site:PublicUrl` | Optional. The public URL of your site, no trailing slash. Makes the review card's title a link straight to the Geofence Submissions screen. The link is omitted when unset. | + +### The review card + +**On submit**, a thread is created titled after the geofence, tagged **Pending**, with a card built to be decided on without leaving Discord: + +| Field | Why it's there | +|---|---| +| **Size** | Area in km² plus a plain-language band (`a block or two`, `neighbourhood`, `district`, `city-sized`, `very large`). This is the main gate — approving publishes the area to everyone, so a neighbourhood is fine and a whole metro usually isn't. The map alone can't tell you: a static map auto-zooms to fit, so a park and a county look identical. | +| **Region** | The auto-detected Koji parent region, or `Not detected`. | +| **Publishes as** | The lowercase name the area would take in the shared public list. This is what approval actually publishes, so `zz my house` and `Bowie - Melford` are worth telling apart before you click. | +| **Location** | Centroid coordinates with a maps link. Matters most when the region wasn't detected. | +| **Already covered by** | Only shown when the area's centre falls inside an existing public area, naming it, so duplicates are visible without opening a map. It's a centre-point test, not a true overlap measurement — a partial overlap won't trigger it. | + +The submitter appears in the card's author line, with a clickable mention in the message above it. The map is uploaded to Discord as an attachment rather than linked, so it doesn't expire when the tile server drops its cached copy. + +The colour bar tracks state: **amber** while pending, **green** approved, **red** rejected. + +**On approve/reject**, PoracleWeb.NET rewrites the original card in place — new colour, updated footer, the published name, and on rejection the reason — then posts the outcome as a reply, retags the thread **Approved**/**Rejected**, and **locks and archives** it. Rewriting the opening post means anyone opening the thread later sees the outcome immediately instead of scrolling for it. + +If Discord is unreachable or the channel isn't set, the submission still works — the geofence still moves to `pending_review`/`approved`/`rejected`. The forum post is a convenience, never a blocker. The same applies to the individual pieces: a failed map download falls back to linking it, a failed card rewrite still posts the outcome reply, and a Koji outage just omits the "Already covered by" line. + +!!! note "Bot token and tags" + PoracleWeb.NET uses the Discord bot token from your PoracleNG server's Discord bot configuration. The forum tags (`Geofence - Pending` / `Approved` / `Rejected`) are created automatically the first time they're needed. If you rename those tags in Discord, restart PoracleWeb.NET so it re-reads them. + +## Limits that protect your deployment + +These are built in to keep things sane — you don't configure them, but it helps to know them when a user asks why something was blocked: + +| Limit | Value | Why | +|---|---|---| +| Geofences per user | **10** | Stops any one user flooding the system. | +| Points per polygon | **3 – 500** | A polygon needs at least 3 points; 500 caps absurdly detailed shapes. | +| Name | 1–50 characters, letters/numbers/spaces and `- ' . ( ) &` | Keeps names clean and bot-safe. | +| GeoJSON import | ≤ 5 MB, ≤ 50 shapes per file | Bulk-import guardrails. | + +Duplicate names are handled automatically — if a user picks a name that's taken, PoracleWeb.NET appends a number (`downtown 2`, `downtown 3`, …). + +## Turning the whole feature off + +If you don't want user-drawn geofences at all, there's an admin site setting (`disable_user_geofences`) that hides the whole feature — the user *My Geofences* page, the admin review queue, and the create/submit/import endpoints. **Existing** geofences keep working; this just freezes new ones. Toggle it from the admin **Settings** page. diff --git a/docs/features/custom-geofences/index.md b/docs/features/custom-geofences/index.md new file mode 100644 index 00000000..b0a29ee2 --- /dev/null +++ b/docs/features/custom-geofences/index.md @@ -0,0 +1,57 @@ +# Custom Geofences + +This section is for **operators deploying and running PoracleWeb.NET.NET** — the person who configures the server, connects it to Koji, and approves user submissions. It explains how custom geofences work, how they connect to Koji, and how to set everything up so your users can draw their own notification zones. + +You do not need to read the code to use this guide. Where something is a value you set, it is called out. + +## The 30-second version + +Your users can draw their own polygons on a map ("**custom geofences**") to get Pokémon GO notifications only inside those shapes. By default each drawn geofence is **private** — it works only for the user who drew it. If a user thinks their area is useful to everyone, they can submit it; an **admin** reviews it and can **promote** it into a public area that everyone can pick. Public areas live in **Koji**; private ones live inside PoracleWeb.NET. + +```mermaid +flowchart TD + A[A user draws a shape] --> B[It is PRIVATE — only theirs
works immediately] + B -->|optional: user submits for review| C{An admin reviews it} + C -->|Approve| D[Becomes a PUBLIC area
everyone can pick it, in Koji] + C -->|Reject| E[Stays private,
with a note back to the user] +``` + +## How PoracleNG gets its geofences (and why not straight from Koji) + +Your bot does **not** read geofences from Koji. It reads them from **one PoracleWeb.NET URL** — `/api/geofence-feed` — which serves a single combined list: the public areas (from Koji) **plus** the private user-drawn areas (from PoracleWeb.NET's own database). + +```mermaid +flowchart LR + Koji[(Koji
public areas)] -->|cached 5 min| Feed + DB[(PoracleWeb.NET DB
private user geofences)] --> Feed + Feed["PoracleWeb.NET
/api/geofence-feed"] -->|single URL| Bot[PoracleNG] +``` + +Why it's set up this way: + +- **One source, not two.** The bot needs a single geofence source. PoracleWeb.NET does the Koji round-trip for you and merges in the private areas, so a stock PoracleNG install works with one config line — no custom code in the bot or in Koji. +- **Privacy.** Private user geofences must stay hidden from the bot's `!area` picker and from notification DMs. PoracleWeb.NET serves them with the right "hidden" flags. Pushing them into Koji wouldn't reliably hide them (Koji's hide-from-matches property isn't honored by every notification formatter), so PoracleWeb.NET keeps them in its own database and serves them itself. +- **Resilience.** If Koji is briefly unreachable, PoracleWeb.NET still serves the private user geofences and the last-known public ones, so notifications keep flowing. The bot also keeps its own local cache as a further safety net. + +The full breakdown is in [Troubleshooting → How the combined feed works](troubleshooting.md#how-the-combined-feed-works-background). + +## What's in this section + +| Page | Read this if you want to… | +|---|---| +| [Key concepts](key-concepts.md) | Understand the difference between an **area**, a **geofence**, and a **region** (start here). | +| [Koji & regions](koji-and-regions.md) | Connect PoracleWeb.NET to Koji and **set up regions**. Includes the geofence ↔ region diagram. | +| [Private geofences & promotion](private-and-promotion.md) | Understand how a geofence stays **private**, and the step-by-step flow to **promote** one to a public area. | +| [Admin operations](admin-operations.md) | Review, approve, reject, and delete geofences from the admin screen. | +| [Troubleshooting](troubleshooting.md) | Fix common problems: empty region dropdown, geofences not showing up, Koji errors, deleting from Koji. | + +## Three words to learn first + +| Term | In one sentence | +|---|---| +| **Geofence** | A named shape (polygon) drawn on the map. | +| **Area** | A name on a user's "notify me here" list — turning a geofence on. | +| **Region** | A folder in Koji that groups public geofences together (e.g. a state or city). | + +!!! tip "If you remember nothing else" + A geofence is a **shape**, an area is a **subscription** to that shape, and a region is a **folder** for public shapes. The [Key concepts](key-concepts.md) page expands on this. diff --git a/docs/features/custom-geofences/key-concepts.md b/docs/features/custom-geofences/key-concepts.md new file mode 100644 index 00000000..371ccba6 --- /dev/null +++ b/docs/features/custom-geofences/key-concepts.md @@ -0,0 +1,78 @@ +# Key Concepts — Areas, Geofences & Regions + +Three words get used constantly and they are easy to mix up. This page pins them down in plain language. Everything else in this section builds on it. + +## The mailing-list analogy + +Think of notifications like a set of mailing lists: + +- A **geofence** is a *shape on the map with a name*. It says **where** something happens — "anything inside this polygon." +- An **area** is **subscribing** to that shape. The shape does nothing until a user adds its name to their personal "notify me here" list. The area is the subscription; the geofence is the shape behind it. +- A **region** is a *folder* that groups public shapes together so they're easier to find — "all the geofences in Colorado." Regions live only in Koji and are set up by you, the operator. + +## The three concepts side by side + +| | **Geofence** | **Area** | **Region** | +|---|---|---|---| +| Plain meaning | A named shape on the map | A name on a user's "notify me" list | A folder that groups public shapes | +| Answers | *Where?* | *Am I subscribed?* | *Which group does it belong to?* | +| Who creates it | A user (draws it) or you (in Koji) | A user (toggles it on) | You, the operator (in Koji) | +| Where it's stored | Private: inside PoracleWeb.NET. Public: in Koji. | In the user's profile | In Koji | +| Example | A polygon named `downtown` | `downtown` is on your list | `Colorado` contains `downtown`, `boulder`, … | + +## Two kinds of geofence + +There are exactly two kinds, and the whole system is about the relationship between them: + +```mermaid +flowchart LR + subgraph P [PRIVATE user geofence] + direction TB + P1[Drawn by one user] + P2[Only that user sees it] + P3[Stored inside PoracleWeb.NET] + P4[Hidden from the bot] + end + subgraph A [PUBLIC admin geofence] + direction TB + A1[Lives in Koji] + A2[Everyone can pick it] + A3[Shows in the bot area picker] + A4[Grouped under a region] + end + P -->|promote| A +``` + +A private geofence can **stay private forever** — most do. Promotion is optional and is covered in [Private geofences & promotion](private-and-promotion.md). + +## The one rule that ties it together + +!!! abstract "The rule" + A geofence only sends notifications to a user when that geofence's **name is on that user's area list**. + +Drawing a shape isn't enough on its own — the name has to be "switched on." When a user draws a geofence, PoracleWeb.NET switches it on for them automatically. They can later toggle it off (and back on) per profile without deleting it. + +```mermaid +flowchart LR + G["Geofence
name: downtown
shape: polygon"] --> L["User's area list
[downtown, work]"] + L --> R["Notifications fire
inside the shape"] +``` + +If the name is **not** on the list, the shape is dormant — it exists, but it's silent. + +### The exception: one alarm, one geofence + +The rule above is the default, not the only path. An individual alarm can name areas of its own through its **Where** control ("Only in specific areas"), and geofences you drew yourself are offered there alongside the admin areas. An alarm scoped that way fires inside those areas whether or not the active profile subscribes to them, and ignores the profile's area list entirely. + +This works because PoracleNG never checks `userSelectable` when it matches a spawn against an alarm's own areas — only when it validates an area list on the way in. PoracleWeb writes the name past that check. See [Where an alert reaches you](../alarms.md#where-an-alert-reaches-you). + +## Profiles: on for one, off for another + +PoracleWeb.NET users can have multiple **profiles** (e.g. "Home", "Work"). A geofence is owned by the **user**, but the on/off switch is **per profile**. So the same `downtown` shape can be **on** for the Home profile and **off** for the Work profile. Drawing it once is enough; the user flips it per profile with a toggle. + +## A note on capitalization (it matters) + +PoracleNG matches area names **exactly, including case**. PoracleWeb.NET stores every geofence name in **lowercase** to avoid surprises — `Downtown` and `downtown` are *not* the same to PoracleNG, and a mismatch means no notifications, silently. + +!!! warning "If you edit the database by hand" + You don't normally need to do anything — PoracleWeb.NET handles lowercasing. But if you ever edit area names directly in `humans.area`, `profiles.area`, or a geofence name, keep them **lowercase**. diff --git a/docs/features/custom-geofences/koji-and-regions.md b/docs/features/custom-geofences/koji-and-regions.md new file mode 100644 index 00000000..3d09b15b --- /dev/null +++ b/docs/features/custom-geofences/koji-and-regions.md @@ -0,0 +1,150 @@ +# Koji & Regions + +This page covers two operator jobs: **connecting PoracleWeb.NET to Koji**, and **setting up regions** so the region picker works for your users. The region picker is the part most operators get tripped up on, so the relationship between geofences and regions is laid out carefully with diagrams. + +## What Koji is doing here + +[Koji](https://github.com/TurtIeSocks/Koji) is the home for your **public** geofences — the admin-managed areas everyone can subscribe to. PoracleWeb.NET talks to Koji to: + +1. **Read** the list of public areas (so users can pick them, and the bot can match on them). +2. **Read** your regions (the folders that group public areas). +3. **Write** a new public area when an admin approves a user submission. + +Private user geofences never touch Koji — they live inside PoracleWeb.NET until (and unless) an admin promotes them. + +!!! info "PoracleNG never talks to Koji directly" + PoracleWeb.NET is the only thing that talks to Koji. PoracleWeb.NET merges Koji's public areas with the private user areas and serves them as **one combined feed**. PoracleNG reads that single URL. See [Troubleshooting → How the combined feed works](troubleshooting.md#how-the-combined-feed-works-background). + +## Connecting PoracleWeb.NET to Koji + +Set these in your `.env` file: + +| `.env` variable | What it is | Example | +|---|---|---| +| `KOJI_API_ADDRESS` | URL of your Koji server | `http://koji-host:8080` | +| `KOJI_BEARER_TOKEN` | Koji API token | `your-koji-token` | +| `KOJI_PROJECT_ID` | The numeric Koji **project** PoracleWeb.NET works in | `1` | +| `KOJI_PROJECT_NAME` | That project's name (used for the area feed) | `MyProject` | + +!!! warning "The token is read at startup" + `KOJI_BEARER_TOKEN` is read **once when PoracleWeb.NET starts**. If you change it, **restart PoracleWeb.NET** for it to take effect. + +Then point your **PoracleNG** bot at PoracleWeb.NET's combined feed (a single URL, not Koji) via its geofence-source setting: + +```jsonc +// PoracleNG bot — geofence source +"geofence": { + "path": "http://poracleweb:8082/api/geofence-feed" +} +``` + +That's the whole connection. If Koji is briefly down, PoracleWeb.NET keeps serving the private user geofences and the last-known public ones, so notifications don't stop dead. + +## How geofences and regions relate in Koji + +This is the key mental model. + +!!! abstract "A region is not a special object" + Koji only has geofences. A geofence becomes a **region** simply by having **other geofences nested underneath it** (children that point to it as their *parent*). + +```mermaid +flowchart TD + Proj[Your Koji project] + Proj --> CO[colorado
★ REGION — has children] + Proj --> CA[california
★ REGION — has children] + Proj --> KC[kansas-city
not a region — no children] + + CO --> DEN[denver] + CO --> BOU[boulder] + CA --> SF[san-francisco] + CA --> LA[los-angeles] + + style CO fill:#1e88e5,color:#fff + style CA fill:#1e88e5,color:#fff + style KC fill:#bbb,color:#000 +``` + +So: + +- **A region = a parent geofence** (a geofence that other geofences are nested under). +- **The region itself is hidden** from the area picker — you don't subscribe to `colorado`, you subscribe to `denver`, *which is grouped under* Colorado. +- A geofence with **no children is not a region** — it's just a plain selectable area (`kansas-city` above). + +Here's how PoracleWeb.NET decides what counts as a region: + +```mermaid +flowchart TD + Start[For each geofence in Koji] --> Q{Does any OTHER geofence
list it as its parent?} + Q -->|Yes| R[It's a REGION
used as a folder] + Q -->|No| N[Not a region
just a plain area] +``` + +## Setting up regions (the operator how-to) + +If your users open the "draw a geofence" dialog and the region dropdown is empty (or only shows "All"), it's because **your Koji project has no nesting** — every geofence is flat, with no parents. Here's how to create regions: + +1. **Create a parent geofence in Koji** for each region you want — for example a polygon for the whole state of `colorado`, or a metro area like `denver-metro`. This is just a normal Koji geofence; nothing special. +2. **Nest your public area geofences under it.** In Koji, open each child geofence (e.g. `denver`, `boulder`) and set its **parent** to the region geofence (`colorado`). That parent link is the only thing that makes a region. +3. **Done.** Reload the geofence page in PoracleWeb.NET (the list refreshes within 5 minutes, or immediately after the next approval). Each parent that now has at least one child shows up as a region, using the parent geofence's display name as the folder label. + +```mermaid +flowchart LR + subgraph S1 [Step 1: make a parent] + A1[colorado
no children yet,
not a region] + end + subgraph S2 [Step 2: nest children under it] + B1[colorado — now a REGION] + B1 --> B2[denver] + B1 --> B3[boulder] + end + S1 --> S2 +``` + +### Do you even need regions? + +**No — regions are optional.** They are a convenience for *grouping* public areas and for *auto-suggesting* a folder when a user draws a shape. If your Koji project is flat and you don't want regions: + +- Users can still draw private geofences and use them — the region picker simply hides itself when there are no regions (this fixes the empty-dropdown problem; see [Troubleshooting](troubleshooting.md#the-region-dropdown-is-empty-or-only-shows-all)). +- When an admin promotes a geofence, they can leave the region unset, and it becomes an ungrouped public area. You can always organize it into a region later in Koji. + +## Koji geofence properties (and how they're used) + +When PoracleWeb.NET promotes a geofence to a public area, it writes a set of **properties** onto the Koji geofence. If you create or edit geofences **directly in Koji**, these are the same properties that matter — and getting `userSelectable` / `displayInMatches` wrong is the usual cause of a private area leaking into the bot, or a public area never showing up. + +There are two groups. The `__`-prefixed keys are **Koji structural directives** (they set Koji's own built-in fields). The rest are **custom properties** that Koji stores on the geofence and passes through to the bot feed. + +### Structural directives (the `__` keys) + +| Property | Value PoracleWeb.NET sends | What it does | +|---|---|---| +| `__name` | the lowercase area name | Koji's internal geofence key — this is the name that ends up in users' area lists and that the bot matches on. Must be **lowercase**. | +| `__mode` | `unset` | Koji's geofence "mode" marker. PoracleWeb.NET doesn't use modes, so it leaves this unset. | +| `__projects` | `[ ]` | Which Koji **project(s)** the geofence belongs to. A geofence must be in your project to appear in the feed. Sending an **empty** list (`[]`) removes it from the project — that's how "remove from project" works. | +| `__parent` | the region's geofence **id**, or `null` | Nests this geofence under a region (see [the region relationship](#how-geofences-and-regions-relate-in-koji)). Must be a real geofence id or `null` — never `0`. | + +!!! danger "`__parent` must be `null`, not `0`, for no region" + Koji looks up `__parent` as a real geofence id. Sending `0` makes Koji reject the save with `[GEOFENCE]: Does not exist` (while still writing the row — a half-broken state). PoracleWeb.NET sends `null` for a region-less geofence so this works cleanly. If you set parents by hand in Koji, leave it empty rather than `0`. + +### Custom properties (passed through to the bot feed) + +| Property | Value PoracleWeb.NET sends | What it does | +|---|---|---| +| `name` | the user-facing display name | The friendly label shown in PoracleWeb.NET's region/area lists. Read back from Koji to label regions. | +| `group` | the region/category label | The folder a public area is shown under in the bot's area picker. For admin geofences this is resolved from the parent chain. | +| `parent` | the same region/category label | A duplicate of `group` that some PoracleNG format serializers read instead of `group`. (This is the *custom* `parent` property — a text label — and is separate from the structural `__parent` id above.) | +| `userSelectable` | `true` when public, `false` when private | **The visibility switch.** `true` = the area appears in the bot's `!area` picker. `false` = hidden. PoracleNG also refuses to let a non-admin subscribe to a `userSelectable=false` area through its `setAreas` call, which is why private user geofences are managed entirely inside PoracleWeb.NET. | +| `displayInMatches` | `true` when public, `false` when private | Whether the **area name appears in notification DM text**. `false` keeps private geofence names out of messages. | + +For a **private** user geofence, PoracleWeb.NET never writes any of this to Koji — it serves the geofence from its own feed with `userSelectable=false` and `displayInMatches=false`. For an **approved (public)** geofence, both flags are set to `true` and the geofence is written into Koji as a normal public area. + +### What PoracleWeb.NET reads back from Koji + +| Koji endpoint | Properties read | Used for | +|---|---|---| +| `/api/v1/geofence/reference` | `id`, `name` (internal), `parent` (numeric id) | Listing every geofence and deriving which ones are **regions** (a geofence referenced as another's parent). | +| `/api/v1/geofence/area/{name}?rt=feature` | `properties.name`, the polygon geometry | The region's **display name** and its outline (used for region auto-detection). | +| `/api/v1/geofence/poracle/{project}` | name, polygon path, group | The public-area list merged into the combined feed. | + +## What auto-detection does + +When a user draws a shape, PoracleWeb.NET tries to **guess the region** by checking which region's outline the drawn shape falls inside (using the center point of the drawing). If it finds a match, it pre-fills the region for the user. This is purely a convenience — the user can change or clear it, and it only works if your regions have outlines (which they do, since they're real Koji geofences). diff --git a/docs/features/custom-geofences/private-and-promotion.md b/docs/features/custom-geofences/private-and-promotion.md new file mode 100644 index 00000000..237491f2 --- /dev/null +++ b/docs/features/custom-geofences/private-and-promotion.md @@ -0,0 +1,128 @@ +# Private Geofences & Promotion + +This is the heart of the feature. A user-drawn geofence has two possible lives: it can **stay private forever**, or it can be **promoted** into a public area that everyone can use. This page explains both, with the full step-by-step flow. + +## A geofence is private by default + +When a user draws a shape and names it, it becomes a **private geofence** straight away: + +- It works **immediately** — notifications start firing inside the shape for that user. +- **Only that user** can see or use it. Other users have no idea it exists. +- Its name is **hidden** from the PoracleNG bot — it won't appear in the bot's `!area` picker, and the area name won't show in notification messages. +- It's stored **inside PoracleWeb.NET**, not in Koji. + +## Staying private is a complete, valid choice + +!!! success "Most geofences stay private — and that's fine" + There is no requirement to submit or promote anything. A private geofence is fully functional on its own. A user can draw several personal areas (up to the per-user limit) and never involve an admin at all. + +A user might keep a geofence private because: + +- It's personal — their neighborhood, commute, or a specific park loop. +- It's only useful to them, so there's no reason to clutter everyone's public list. +- They simply don't want to share it. + +Nothing expires and nothing nags them. Private is the default **and** the destination for the large majority of geofences. + +## When promotion makes sense + +Promotion turns a private geofence into a **public admin geofence** that *everyone* can subscribe to. It makes sense when a user-drawn area is genuinely useful to the whole community — a popular park, a downtown core, a well-known raid hotspot. + +Promotion is a **two-party** action: + +- The **user** asks for it ("submit for review"). +- An **admin** decides ("approve" or "reject"). + +A user cannot promote their own geofence, and an admin doesn't promote things out of nowhere — it always starts with a user submission. + +## The promotion flow, end to end + +```mermaid +stateDiagram-v2 + [*] --> active: user draws and names a shape + active --> pending_review: user clicks
Submit for review + pending_review --> approved: admin clicks Approve
(optionally picks a region) + pending_review --> rejected: admin clicks Reject
(leaves a reason) + + active: active (PRIVATE) + pending_review: pending_review (awaiting admin) + approved: approved (PUBLIC — pushed to Koji) + rejected: rejected (still PRIVATE, with a note) + + note right of active + Stays private forever + if the user never submits + end note + note right of rejected + Keeps working privately + for its owner + end note +``` + +### Step 1 — User draws and (optionally) submits + +The user draws the shape, names it, and it's private. If they want it public, they click **Submit for review**. The geofence moves to **pending_review** and (if you've configured a Discord forum — see [Admin operations](admin-operations.md#optional-discord-forum-integration)) a forum thread opens so your team can discuss it. + +The geofence keeps working for the user the whole time it's under review — submitting doesn't take it away from them. + +### Step 2 — Admin reviews + +You (the admin) see the submission in the admin **Geofence Submissions** screen. You can look at the shape on a map, see who submitted it, and decide. See [Admin operations](admin-operations.md) for the screen details. + +### Step 3a — Approve (the actual promotion) + +When you click **Approve**: + +- You can give it a cleaner public name if you want (the "promoted name"). +- You can assign it to a **region** so it's filed in the right folder — or leave it unset if you don't use regions. +- PoracleWeb.NET **pushes the geofence into Koji** as a public area. + +At that moment the geofence flips from private to public: + +| | Before (private) | After (approved / public) | +|---|---|---| +| Lives in | PoracleWeb.NET | **Koji** | +| Who can use it | only the owner | **everyone** | +| In the bot's `!area` picker | hidden | **visible** | +| Name shown in notification DMs | hidden | **shown** | +| Region / grouping | none | the region you chose (if any) | +| Status | `active` | `approved` | + +```mermaid +flowchart LR + B["BEFORE — PRIVATE
owner only · hidden from bot
in PoracleWeb.NET · status active"] + A["AFTER — PUBLIC in Koji
everyone can pick · visible in area picker
name shows in DMs · status approved"] + B -->|admin approves| A +``` + +### Step 3b — Reject (stays private) + +If the area isn't a good fit for everyone, you click **Reject** and leave a short reason. The geofence: + +- Moves to **rejected** status (it stays in the geofence feed and keeps alerting — rejection means "not public", not "switched off"), +- **Stays private** and keeps working for its owner, +- Carries your note so the user understands why. + +!!! note "Rejection is not deletion" + The user loses nothing except the public listing they asked for. The geofence keeps working for them privately. + +## Quick reference: the four statuses + +| Status | What it means | Public? | Renameable? | +|---|---|---|---| +| `active` | Normal private geofence (the default after drawing). | No | Yes | +| `pending_review` | The user has submitted it; waiting on an admin. | No (still private to the owner) | No | +| `approved` | An admin promoted it; it's now a public Koji area. | **Yes** | No | +| `rejected` | An admin declined the request; it stays private with a note, and keeps alerting. | No | Yes | + +!!! info "Why approved and pending geofences can't be renamed" + Once a geofence is approved, Koji owns it under its promoted name and every subscriber's area list + holds *that* name. Renaming locally would rewrite the subscription to a name neither Koji nor the + geofence feed serves, silently unsubscribing the owner from a live public area. A submission under + review is frozen for a simpler reason: the admin is looking at the name as submitted. The rename + button is hidden for both, and the API refuses with a 400. Rejected geofences are the owner's again, + so they can be renamed and resubmitted. + +## Removing a public area later + +If you later decide a promoted geofence shouldn't be public, an admin can delete it from the admin screen — PoracleWeb.NET removes it from the project so it stops being a selectable public area. (Fully scrubbing a geofence out of Koji's database is done in the Koji UI; see [Troubleshooting](troubleshooting.md#removing-a-geofence-from-koji-completely).) diff --git a/docs/features/custom-geofences/troubleshooting.md b/docs/features/custom-geofences/troubleshooting.md new file mode 100644 index 00000000..75bf9eb2 --- /dev/null +++ b/docs/features/custom-geofences/troubleshooting.md @@ -0,0 +1,89 @@ +# Troubleshooting + +Common problems operators hit, what causes them, and how to fix them. Each entry is symptom → cause → fix. + +## The region dropdown is empty (or only shows "All") + +**Symptom:** when a user draws a geofence, the region picker has nothing useful in it, and (in older builds) the Save button stays greyed out. + +**Cause:** your Koji project is **flat** — no geofence is nested under another, so there are no regions to show. Regions are derived purely from parent/child nesting in Koji (see [Koji & regions](koji-and-regions.md#how-geofences-and-regions-relate-in-koji)). + +**Fix — pick one:** + +- **You want regions:** create parent geofences in Koji and nest your public areas under them. Step-by-step in [Setting up regions](koji-and-regions.md#setting-up-regions-the-operator-how-to). +- **You don't want regions:** nothing to do. Current builds make the region **optional** — the picker hides itself when there are no regions, and users can save a geofence without one. + +## A user's private geofence isn't sending notifications + +Work down this list: + +1. **Is it switched on for the right profile?** A geofence is on/off **per profile**. If the user switched profiles, it may be off on the new one. Have them check the toggle on the Geofences page for the active profile. +2. **Is PoracleNG pointed at PoracleWeb.NET's feed?** The bot's geofence-source setting must be the combined feed URL (`http://poracleweb:8082/api/geofence-feed`), **not** Koji. If it points at Koji, private geofences will be missing entirely. +3. **Did PoracleNG reload its geofences?** PoracleWeb.NET tells PoracleNG to reload after changes, but if the bot was down at that moment, trigger a reload (or it'll pick it up on its next refresh). +4. **Is the alarm pointed somewhere else?** An alarm with its own delivery scope ignores the profile area list. If its scope chip reads "Within N km of ..." or "Only in ..." and the geofence isn't in that list, the geofence has nothing to do with whether that alarm fires. Open the chip on the alarm card to check or change it. +5. **Polygon too small or odd?** A shape needs at least 3 points and must be a real area. Degenerate shapes are dropped from the feed. + +## Public (approved) geofences aren't showing up + +1. **Wait up to 5 minutes.** PoracleWeb.NET caches the Koji public list for 5 minutes. An approval clears that cache immediately, but a change made **directly in the Koji UI** won't be picked up until the cache expires. +2. **Check the Koji connection.** Wrong `KOJI_API_ADDRESS`, a bad `KOJI_BEARER_TOKEN`, or the wrong `KOJI_PROJECT_NAME` means PoracleWeb.NET can't read the public list. Remember the token is read **at startup** — restart after changing it. +3. **Is it actually in the project?** A geofence must belong to your `KOJI_PROJECT_ID` to appear. Parent/region geofences are intentionally excluded (they're folders, not selectable areas). + +## Koji is down — what happens? + +PoracleWeb.NET **degrades gracefully** rather than failing: + +```mermaid +flowchart TD + K[Koji unreachable] --> F[PoracleWeb.NET feed still serves:
• all private user geofences from its own DB
• last-cached public areas] + F --> N[Notifications keep working;
new public-area changes wait until Koji is back] +``` + +PoracleNG also keeps its own local cache as a second safety net. + +!!! warning "You can't approve while Koji is down" + Approving a submission writes to Koji, so approvals will error until Koji is reachable again. Everything else keeps working. + +## Approval fails with a Koji error + +The usual causes are an unreachable Koji or an auth problem (token/project). One specific gotcha worth knowing: + +!!! info "The `__parent: 0` gotcha" + Koji treats a geofence's parent as a real geofence ID. Sending a parent of `0` makes Koji reject the save with `[GEOFENCE]: Does not exist`. PoracleWeb.NET handles this for you — a geofence with **no** region is sent with a true "no parent" value, not `0` — so region-less approvals work. If you see this exact error from a custom integration, that's the cause. + +## A geofence name shows in the bot when it shouldn't (or vice-versa) + +Two flags control visibility, and PoracleWeb.NET sets them for you: + +| | Private user geofence | Public (approved) geofence | +|---|---|---| +| Appears in the bot's `!area` picker | No | Yes | +| Name shown in notification DMs | No | Yes | + +If a **private** geofence's name is leaking into the bot picker or DMs, something is serving it as public — check that PoracleNG reads PoracleWeb.NET's feed (not Koji directly), and that the geofence wasn't accidentally promoted. + +## Capitalization / name-match issues + +PoracleNG matches area names **case-sensitively**. PoracleWeb.NET always stores names in **lowercase**, so this normally just works. If you've hand-edited `humans.area`, `profiles.area`, or a geofence name in the database, make sure everything is lowercase — a single capital letter means a silent mismatch and no notifications. + +## Removing a geofence from Koji completely + +Deleting an approved geofence in PoracleWeb.NET removes it from the **project** (so it's no longer selectable), but the geofence row can still exist in Koji's database. To delete it **completely**, do it in the **Koji UI**. (PoracleWeb.NET intentionally does not hard-delete Koji geofences via the API.) This also applies to any stray test geofences — clean them up in the Koji UI. + +## How the combined feed works (background) + +So you understand why the bot only needs one URL: PoracleWeb.NET exposes **`/api/geofence-feed`**, which merges two sources into one list for PoracleNG. + +```mermaid +flowchart LR + Koji[(Koji
public areas)] -->|cached 5 min| Feed + DB[(PoracleWeb.NET DB
private user geofences)] --> Feed + Feed["/api/geofence-feed
combined list"] -->|single URL| PJS[PoracleNG] +``` + +- **Public** entries come from Koji, marked visible/selectable. +- **Private** entries come from PoracleWeb.NET's database, marked hidden/non-selectable (so the bot ignores them in pickers and DMs). +- Region/parent geofences are filtered out (they're folders, not areas). + +!!! warning "Keep the feed on a private network" + The feed endpoint is open (no login) so PoracleNG can read it on your internal network. Don't expose it to the internet. diff --git a/docs/features/internationalization.md b/docs/features/internationalization.md index cb25e30e..e57719cf 100644 --- a/docs/features/internationalization.md +++ b/docs/features/internationalization.md @@ -1,6 +1,6 @@ # Internationalization (i18n) -PoracleWeb.NET supports 11 UI languages, matching the language support from the original PoracleWeb.NET PHP. Users can switch the interface language at any time without reloading the page. +PoracleWeb.NET supports 11 UI languages. Users can switch the interface language at any time without reloading the page. ## Supported Languages @@ -25,16 +25,34 @@ PoracleWeb.NET supports 11 UI languages, matching the language support from the The UI translation system uses [ngx-translate](https://github.com/ngx-translate/core) for runtime language switching: - **Translation files** are stored in `ClientApp/src/assets/i18n/{code}.json` as flat namespaced JSON -- **Language detection** — on first visit, the browser's preferred language is auto-detected -- **Persistence** — the selected language is stored in `localStorage('poracle-ui-language')` - **Instant switching** — changing language updates all visible text immediately, no page reload needed -### Language Selector +The language a visitor lands on is decided in this order, first match winning: -Users access the language selector from the **user menu** (top-right toolbar) → **Language** submenu. Each language shows its flag emoji and native name. The currently active language is indicated with a check mark. +1. **A language they chose before**, from `localStorage('poracle-ui-language')`. +2. **A browser language this site ships.** `de-AT` matches `de`; `pt-BR` matches exactly before falling back to `pt`. +3. **Poracle's own `locale`**, read from its configuration. A German community running Poracle with `locale = "de"` therefore greets a first-time visitor in German rather than English, without configuring anything here. +4. **English.** -!!! note "Bot Language vs UI Language" - The **UI language** (this feature) controls the web interface language. The **bot language** (set in Areas & Location → Language) controls what language Poracle sends DMs in (Pokemon names, move names, etc.). These are separate settings — a user can have a German UI with English Pokemon names, for example. +Only a deliberate choice is written to `localStorage`. A language picked automatically is left unwritten so it can be re-decided next visit — otherwise the first page load would be authoritative forever, and a visitor who arrived while Poracle was unreachable would stay on English no matter what the server reported afterwards. + +Poracle's locale has to clear the same two filters as any other option: this site must ship that language, and `allowed_languages` must permit it. Poracle carries translations for languages this UI does not have, and those simply do not qualify. + +### Language selectors + +There are two, and they sit next to each other in the **user menu** (top-right toolbar): + +- **Display language** changes this site's text and nothing else. Its submenu is hidden when an admin has restricted the selector to a single language. +- **Alert language** is what Poracle writes your DMs in: alert text, Pokemon names, move names. The authoritative copy lives on your Poracle account (`humans.language`), with a browser cache used only for the first render, so it follows you between devices and reconciles if the bot changes it. + +Note that Pokemon names, types and forms **in this site's own screens** follow the *display* language, not the alert language — see [Game data names](#game-data-names) below. Setting the display language to German gives you Bisasam in the species picker and Käfer on the type chips; the alert language decides what your DMs say. + +Each submenu opens with its own hint line ("Changes this site's text only." / "Used for alert text and Pokemon names.") and lists the languages as flag and native name, with a check mark against the active one. Both draw from the same list of 11. + +The two settings are independent. A German UI with English Pokemon names is a normal thing to want, and the menu now shows that they are separate rather than leaving it to a footnote. + +!!! note "This moved" + The alert language used to live on the Areas page. It is in the user menu as of the Areas and Places merge, alongside the display language it kept being confused with. ### Admin Configuration @@ -45,9 +63,11 @@ Admins can restrict which languages appear in the selector by setting the `allow | `allowed_languages` | *(empty)* | All 11 languages available | | `allowed_languages` | `en,de,fr` | Only English, German, and French shown | -English is always available regardless of the `allowed_languages` setting. +English is always available regardless of the `allowed_languages` setting. The restriction applies to the signed-out login page as well as to signed-in users. -Set this in **Admin → Settings** under the **Features** category. +![The Allowed UI Languages field, with a line beneath it reading "Default language for new users: en, taken from Poracle's own configuration."](../screenshots/admin-language-default.png) + +Set this in **Admin → Settings** under the **Features** category. Directly beneath it, the page reports Poracle's own configured locale as a read-only line — the default a new visitor lands on, per the order above. It is Poracle's to set, not this site's: it is read from Poracle's configuration on every load and cannot be edited or overridden here. ## Translation File Structure @@ -91,17 +111,34 @@ Each language file uses namespaced keys organized by feature area: | `GYMS` | Gym alarm management | | `FORT_CHANGES` | Fort change alarm management | | `MAX_BATTLES` | Max battle alarm management | -| `AREAS` | Areas & location page | +| `AREAS` | Areas & Places page | | `PROFILES` | Profile management | | `GEOFENCES` | Custom geofences | | `CLEANING` | Clean mode settings | | `QUICK_PICKS` | Quick pick alarm presets | -| `HELP` | Help page chrome (section titles, search) | +| `HELP` | Help page: section titles, search, and the guide body HTML | | `AUTH` | Login page | | `ADMIN` | Admin pages | | `ALARM` | Shared alarm dialog fields | | `DIALOG` | Shared dialog components | | `TEST_ALERT` | Test alert feedback | +| `WHERE` | Per-alarm delivery scope and saved places | +| `ALERT_DEFAULTS` | Alert Defaults dialog | +| `ALARM_INFO` | Shared alarm summary component | +| `ACTIVE_HOURS_CHIP` | Profile schedule pills | +| `LOCATION_WARNING` | Missing-coordinates warning banner | +| `ONBOARDING` | First-run wizard | +| `DELIVERY_PREVIEW` | Delivery preview map | +| `AREA_MAP` | Shared area map component | +| `REGION_SELECTOR` | Region picker for geofence submission | +| `GEOFENCE_DETAIL` | Geofence detail view | +| `GEOJSON_IMPORT` | GeoJSON import dialog | +| `GYM_PICKER` | Gym autocomplete | +| `POKEMON_SELECTOR` | Species picker | +| `TEMPLATE` / `TEMPLATE_SELECTOR` | Notification template picking and preview | +| `ADMIN_SETTINGS` | Admin settings page | +| `PAGINATOR` | Material paginator labels | +| `ERROR` | Error page and interceptor messages | | `COMMON` | Common labels (Save, Cancel, Delete, etc.) | ### Interpolation @@ -136,12 +173,32 @@ To improve or add translations: 5. Keep game proper nouns: Mystic, Valor, Instinct, Giovanni, Team Rocket, Dynamax, Gigantamax, PokéStop 6. Use informal forms (du/tu/tú/je) appropriate for a gaming community +### Game data names + +Pokemon names, their types and their form names are not in the translation files at all. They come from Poracle, which translates them from its own i18n bundle, and this site asks for them in **the display language**: + +``` +GET /api/masterdata/monsters?locale=de + 1_0 -> Bisasam, types: Gift, Pflanze + 12_0 -> Smettbo, types: Flug, Käfer +``` + +Switching the display language re-fetches them, so an open species picker updates in place. Searching works on the translated names too — typing `bi` finds Bisasam. + +Two things this does not cover: + +- **Move and item names** stay English. Poracle serves no translated equivalent for them, so they come from the [WatWowMap masterfile](https://github.com/WatWowMap/Masterfile-Generator) as before. +- **A Poracle that cannot answer** — an older build without the endpoint, or one that is unreachable — falls back to the same English masterfile, so the pickers keep working rather than emptying out. + +Poracle ships translations for `de`, `en`, `es`, `fr`, `it`, `ja`, `nb-no`, `pl`, `ru`, `sv` and `zh-cn`. Four of this site's languages — `nl`, `pt`, `pt-BR` and `da` — have no counterpart there, so game data names appear in English while the interface around them is translated. + ### What Is NOT Translated -- **Pokemon names, move names, form names** — these come from Poracle's master data, controlled by the bot language setting +- **Move names and item names** — see above - **Admin-configured values** — site title, logo, custom navigation links - **User-generated content** — profile names, geofence names, area names -- **Help guide body content** — section titles are translated, but detailed help content remains in English (contributions welcome) + +The help guide is translated, body and all: the `HELP.CONTENT_*` values carry the HTML for each section and every locale has its own. The gap runs the other way now, and it is small: 30 of the 36 `HELP.SECTION_*` headings are still English in Dutch, Polish and Portuguese. ## Architecture @@ -149,7 +206,7 @@ To improve or add translations: ClientApp/ src/ assets/i18n/ # Translation JSON files - en.json # English (baseline, ~500 keys) + en.json # English (baseline, ~1,700 keys) de.json # German fr.json # French ... @@ -163,7 +220,8 @@ The `I18nService`: - Wraps `@ngx-translate/core`'s `TranslateService` - Manages available languages (filtered by admin `allowed_languages` setting) -- Handles browser language detection on first visit +- Handles browser language detection on first visit, and falls back to Poracle's configured locale when the browser asks for a language this site does not ship +- Records *how* the active language was chosen, so a locale arriving from the server after bootstrap replaces a bare English fallback but never a stored or browser-matched choice - Provides `instant()` for synchronous translation in TypeScript code - Sets `document.documentElement.lang` for accessibility diff --git a/docs/features/profiles.md b/docs/features/profiles.md index 8d2915d2..6e0ef389 100644 --- a/docs/features/profiles.md +++ b/docs/features/profiles.md @@ -40,10 +40,14 @@ The **copy icon** on any profile creates an exact duplicate with all alarm filte - You are prompted for a new name (default: "Profile (Copy)") - All alarm filters from the source profile are copied to the new profile -- Area selections are **not** copied -- the new profile starts with a fresh area configuration +- Area selections, location and active hours **are** copied too -!!! note - After duplicating a profile, remember to configure areas for the new profile. Alarms will not trigger until at least one area is selected. +!!! note "Duplication copies the whole profile, geography included" + This changed in #503. `addProfile` upstream ignores area, latitude and longitude, so a copy used to + silently inherit whatever the *active* profile had — the right alarms over the wrong map, and a + location that also drives the active-hours timezone. The duplicate now carries the source profile's + geography, so it starts alerting immediately. Change the areas afterwards if that is not what you + wanted. ## Profile Export & Import @@ -116,7 +120,7 @@ Each profile card on the Profiles page shows its schedule status: A red warning banner appears on a profile card when that profile has active hours configured but its coordinates are set to 0,0 (no location). This indicates the schedule may trigger at incorrect times because PoracleNG will use UTC instead of the user's local timezone. -To fix this, set a location for the profile from the **Dashboard** or **Areas** page while that profile is active. +To fix this, set the profile's pin from the **Dashboard** or the **Areas & Places** page while that profile is active. ### Validation rules @@ -160,6 +164,6 @@ With this schedule, PoracleNG automatically cycles through the three profiles wi ## Weather Per Profile -The dashboard shows current weather conditions at your saved location. Since each profile can have a **different saved location**, weather information varies by profile. +The dashboard shows current weather conditions at your pin. Since each profile carries **its own pin**, weather information varies by profile. For example, a "Home" profile with a residential location and a "Work" profile with an office location will each display the weather relevant to their respective area, helping you understand which weather-boosted Pokemon to expect at each location. diff --git a/docs/features/quest-summary-schedules.md b/docs/features/quest-summary-schedules.md new file mode 100644 index 00000000..145461f3 --- /dev/null +++ b/docs/features/quest-summary-schedules.md @@ -0,0 +1,151 @@ +# Quest Summary Delivery + +Field Research quests rotate daily and can match in large numbers, so a busy quest filter +can flood your Discord DMs. **Quest summary delivery** collects matching quests into a single +digest and delivers it on a schedule you choose -- one tidy message per day instead of dozens +of individual alerts. + +The feature has two parts that work together: + +1. A per-alarm **Daily summary** toggle that marks which quest alarms should be *buffered* + instead of delivered immediately. +2. A per-user **delivery schedule** that decides *when* the buffered quests are sent. + +Both are required: the toggle says *which* quests to collect, and the schedule says *when* to +deliver them. + +!!! info "Requires PoracleNG support" + Quest summary delivery is provided by PoracleNG (the bot). The web UI only appears when the + connected PoracleNG instance has the feature enabled. If you do not see the **Quest summary + delivery** menu on the Quests page, see [For server operators](#for-server-operators) below. + +## How it works + +```mermaid +flowchart LR + A[Quest webhook] --> B{Matches a quest alarm
with Daily summary on?} + B -- no --> C[Delivered immediately] + B -- yes --> D[(Buffered)] + D --> E{Your delivery
schedule fires} + E -- scheduled time --> F[Grouped summary DM] + G[Send summary now] --> F +``` + +When a quest matches an alarm that has **Daily summary** turned on, PoracleNG holds the match in +a per-user buffer instead of sending it right away. The buffer is flushed -- rendered into one +grouped message and delivered -- when your delivery schedule fires, or when you press **Send +summary now**. Quest alarms *without* the toggle continue to deliver individually as usual. + +!!! note "The schedule is per-user, not per-profile" + Unlike [profile active hours](profiles.md#active-hours), which are configured per profile, a + quest summary schedule belongs to **you** and is shared across all of your profiles. You have + at most one quest summary schedule. + +## Step 1 -- Turn on Daily summary for a quest alarm + +Open a quest alarm's **add** or **edit** dialog and enable **Daily summary**. This marks the alarm +so its matches are buffered for the digest rather than sent one-by-one. The setting is remembered +even if you originally set it from the bot -- editing the alarm in the web UI will not clear it. + +!!! tip + Turn the toggle on only for the quest alarms you want grouped. You can mix and match: keep + high-priority quests (for example, a rare encounter reward) delivering immediately, and batch + the noisier reward types into the daily summary. + +## Step 2 -- Set your delivery schedule + +Open the **Quests** page, then the **⋮** (more) menu in the toolbar, and choose **Quest summary +delivery**. The dialog shows your current schedule and lets you edit, clear, or trigger it. + +### Editing the schedule + +Choose **Edit schedule** to open the schedule editor -- the same editor used for +[profile active hours](profiles.md#using-the-schedule-editor): + +1. **Select days** with the circular day buttons (**M T W T F S S**). Quick presets are available: + - **Weekdays** -- Monday through Friday + - **Weekends** -- Saturday and Sunday + - **Every day** -- all seven days +2. **Choose a time** with the hour and minute dropdowns. +3. Choose **Add** to create entries for all selected days at that time. +4. Repeat to add more delivery times, then **Save**. + +Saved delivery times appear as **amber pills** in the dialog (for example, "Mon-Fri 8:00 AM"), +grouped by day pattern. A short note beneath them explains what **Send summary now** does. + +!!! warning "Timezone is determined by your location" + PoracleNG decides when the schedule fires using your saved coordinates. If your active + profile has **0,0 coordinates** (no location set), PoracleNG falls back to **UTC** and the + summary will arrive at the wrong local time. A red warning appears in the dialog when a + schedule is set but no pin is saved -- set your pin on the **Dashboard** or the + **Areas & Places** page to fix it. + +### Validation rules + +The schedule uses the same structure and limits as profile active hours: + +| Rule | Constraint | +|---|---| +| Day | Must be 1-7 (Monday through Sunday) | +| Hour | Must be 0-23 | +| Minute | Must be 0-59 | +| Maximum entries | 28 (up to 4 delivery times per day across all 7 days) | + +## Send summary now + +The **Send summary now** button flushes and delivers whatever is currently buffered, immediately +-- handy for testing or for getting the digest early. It is the equivalent of the bot's +`!summary quest now` command. + +!!! note "Nothing buffered means nothing to send" + Send summary now only delivers quests that have already been **buffered** since your last + summary. If no matching quests have come in yet -- or you have just enabled the feature -- the + buffer is empty and nothing is sent. This is expected; quests are buffered as they match, so + give it time and try again, or wait for the schedule to fire. + +To prevent accidental double-delivery, the button has a short cooldown after each use. + +## Clearing the schedule + +Choose **Remove schedule** in the dialog to delete your delivery schedule. Quest alarms with +**Daily summary** still buffer, but without a schedule they fall back to PoracleNG's default +delivery timing. Removing the schedule does not change the per-alarm toggle. + +## When quest alarms are switched off + +An operator can disable quests, either on this site or in Poracle's own config. The summary schedule +goes with them: the dialog, its endpoints and the quest pages are all unavailable until quests are +switched back on. A schedule already saved is not deleted — it lies dormant and returns intact. + +## For server operators + +Quest summary delivery is gated by PoracleNG, not by PoracleWeb.NET. The web UI is hidden unless +the connected bot reports the feature as enabled. + +To enable it, set the following in your PoracleNG `config.toml` and restart the processor: + +```toml +[tracking] +quest_summary_enabled = true +# Optional: how long a buffered quest survives if the schedule never fires (default 24). +quest_summary_buffer_ttl_hours = 24 +``` + +!!! info "How PoracleWeb.NET detects the flag" + PoracleWeb.NET reads the effective value of `tracking.quest_summary_enabled` from PoracleNG's + `/api/config/values` endpoint (cached for five minutes) and exposes it to the SPA. When the + flag is on, the **Quest summary delivery** menu appears; when it is off or cannot be read, the + menu is hidden so users are not led into a feature that will not deliver. + +!!! warning "The processor API must be reachable" + PoracleWeb.NET talks to PoracleNG over its HTTP API. Make sure the processor binds an address + reachable from the PoracleWeb.NET host -- set `host = "0.0.0.0"` (or the LAN IP) under + `[processor]`, not the `127.0.0.1` default, when the two run on different machines or in + separate containers. + +## Troubleshooting + +See the dedicated entries in [Troubleshooting](../troubleshooting.md): + +- [Quest summary delivery menu is missing](../troubleshooting.md#quest-summary-delivery-menu-is-missing) +- [Send summary now delivers nothing](../troubleshooting.md#send-summary-now-delivers-nothing) diff --git a/docs/features/webhooks.md b/docs/features/webhooks.md new file mode 100644 index 00000000..db840d13 --- /dev/null +++ b/docs/features/webhooks.md @@ -0,0 +1,102 @@ +# Webhooks and Delegates + +A **webhook** here is a Poracle account whose id is a Discord webhook URL. It holds alarms exactly as +a person does — profiles, areas, a pin, filters — and Poracle posts its alerts to that URL instead of +into a DM. Communities use one per feed: a raids channel, a hundo channel, a nests channel. + +A **delegate** is a person allowed to manage one of those accounts without being an administrator of +this site. + +## Creating a webhook + +**Admin → Webhooks → Add Webhook**, with a display name and the Discord webhook URL. The URL is the +account id, so it must be unique; an existing one is refused rather than merged. If Poracle rejects +the account, the half-written record is removed, so a failure means nothing was created and a retry +is safe. + +The same page can pause, resume, block, delete the account's alarms, delete the account outright, and +open its alarms directly (see [Managing alarms](#managing-a-webhooks-alarms)). + +Deleting a webhook takes everything it owned with it, delegate grants included. Recreating the same +URL later starts clean rather than adopting the old grants. + +## Adding a delegate + +**Admin → Webhooks →** the group-add icon on the webhook's row. + +Both accounts have to exist first. The person must have registered with the Poracle bot and signed in +at least once, so there is an account to grant; a grant naming an account that does not exist is +refused rather than stored. + +Search for them and select. The grant is written immediately, and they can reach the webhook on their +next page load — no sign-out, no restart. + +The dialog shows three kinds of chip: + +| Chip | Source | Removable here | +|---|---|---| +| Locked, "global admin" | Poracle's `admins` list, or `PORACLE_ADMIN_IDS` | No | +| Locked, "config delegate" | Poracle's own config | No | +| Removable | This site's `webhook_delegates` table | Yes | + +The locked ones are shown so you can see who already has access, not so you can change it. They come +from somewhere this site does not write. + +## What a delegate can do + +**Settings → My Webhooks** lists the webhooks they manage. **Manage Alarms** switches the session to +that webhook: from there they see the site as it does, and can set up alarms, areas, profiles, its +pin, and send test alerts. A banner names the account being viewed, and the button on it returns them +to their own. + +A delegate **cannot** create or delete webhooks, grant delegation to anyone else, or reach any admin +page. Their access is exactly the webhooks granted to them and nothing more, re-checked on every +attempt rather than trusted from their sign-in. + +## Do you need to edit Poracle's config? + +**For managing the webhook on this site, no.** Alarm writes reach Poracle over a server-to-server +secret, and the authorisation happens here. A row in this site's own table is enough, which is what +the admin dialog writes. + +**For managing it through the Discord bot, yes.** That is `discord.webhook_admins` in Poracle's +config, and it needs a Poracle restart. This site cannot grant it. + +The two are independent, and someone who should have both needs both. This site accepts either as +proof for its own access: a delegate configured in Poracle gets **My Webhooks** here without anyone +adding a row. + +## Where delegation is resolved from + +Three sources, unioned on every check: + +1. **Poracle's `getAdministrationRoles`** — covers `discord.webhook_admins` and any Discord + guild-role-based delegation Poracle performs. +2. **This site's `webhook_delegates` table** — what the admin dialog writes. +3. **Administrators**, from `PORACLE_ADMIN_IDS` or Poracle's `admins` list, who can manage any + webhook and are never listed as delegates of a particular one. + +The answer is cached for a minute per person, so granting or revoking takes effect within about that +long rather than at their next sign-in. A lookup that cannot reach one of its sources is never +cached, and falls back to what the session already knew rather than dropping access mid-session. + +## Troubleshooting + +**They cannot see My Webhooks.** The nav item appears only for people who manage at least one +webhook. Check the grant exists on the webhook's delegates dialog, and that they signed in at least +once before being granted — a grant to an account that never existed is refused, so an absent chip +means it was never written. + +**They can see it in Discord but not here, or the reverse.** These are separate grants. The bot side +is Poracle's config; this side is the delegates dialog. Having one does not imply the other, although +a Poracle-side grant does also work here. + +**A revoked delegate still has access.** Give it a minute — the resolution is cached that long. +Beyond that, check whether they are a global admin or hold a Poracle-side grant, both of which this +site can display but not remove. + +## Related + +- [Site Settings](../configuration/site-settings.md) — the admin settings the webhook pages sit + alongside +- [Database](../architecture/database.md) — the `webhook_delegates` table diff --git a/docs/getting-started/development-setup.md b/docs/getting-started/development-setup.md index 60be188c..f6156ef9 100644 --- a/docs/getting-started/development-setup.md +++ b/docs/getting-started/development-setup.md @@ -6,10 +6,24 @@ git clone https://github.com/PGAN-Dev/PoracleWeb.NET.git cd PoracleWeb.NET +# Work from develop, not main -- see the note below +git checkout develop + # Install frontend dependencies (from root) ./scripts/dev.sh install ``` +!!! important "Branch from `develop`, and target it in pull requests" + A plain `git clone` lands you on `main`, which only moves when a release is published — deliberately, + so that self-hosters cloning the repo get released code. **Development happens on `develop`**: it + receives every merged pull request and publishes the `:beta` image. + + So branch from `develop` and open your pull request against `develop`. A PR against `main` will be + asked to retarget. See [Branches](../development/ci-cd.md#branches). + + CI runs on both branches, so a PR to either gets the full backend, frontend, lint and changelog + checks. + Or manually: `cd Applications/Pgan.PoracleWebNet.App/ClientApp && npm install` ## 2. Configure secrets @@ -99,7 +113,17 @@ Or manually: `cd Applications/Pgan.PoracleWebNet.App/ClientApp && npm install` # or: cd Applications/Pgan.PoracleWebNet.App/ClientApp && npm start ``` - Starts on **http://localhost:4200**. The Angular dev server proxies API requests to the .NET backend. + Starts on **http://localhost:4200**. The dev server proxies `/api/*` and `/auth/*` to the API on `http://localhost:5048` via `Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json` (`changeOrigin: false` so the original `Host` header is preserved — this matters for OAuth callback URIs, which Discord matches by literal string against your registered redirect URI). + + The Angular environment uses an empty `apiUrl` in dev (`environment.development.ts`), so all HTTP calls are same-origin from the browser's view. This makes the dev server behave identically to the production single-port deployment that serves the Angular build out of the API's `wwwroot`. + + To use a different dev port (e.g. to match an existing Discord OAuth registration on `http://localhost:8082`): + + ```bash + npx ng serve --proxy-config proxy.conf.json --port 8082 + ``` + + The dev server port must be present in your Discord application's **Redirects** list for OAuth login to work, because the callback is built from the incoming `Host` header. Register `http://localhost:4200/api/auth/discord/callback`; see [Discord OAuth](discord-oauth.md). Open **http://localhost:4200** in your browser. diff --git a/docs/getting-started/discord-oauth.md b/docs/getting-started/discord-oauth.md index fa1d7eb5..d16d85ac 100644 --- a/docs/getting-started/discord-oauth.md +++ b/docs/getting-started/discord-oauth.md @@ -11,9 +11,21 @@ PoracleWeb.NET uses Discord OAuth2 for user authentication. This page walks thro | Environment | Redirect URI | |---|---| | Production / Docker | `http://your-domain:8082/api/auth/discord/callback` | - | Development | `http://localhost:5048/api/auth/discord/callback` | - - The redirect URI must point to the **API server** (not the Angular dev server). In production, both are served from the same origin. In development, the API runs on port 5048. + | Development | `http://localhost:4200/api/auth/discord/callback` | + + The redirect URI must match the origin **the browser is on**, because `AuthController` builds the + callback from the incoming request. In production the API and the SPA share an origin, so this + is simply your domain. In development the browser is on the Angular dev server (4200) and + `proxy.conf.json` sets `changeOrigin: false`, so the `Host` stays `localhost:4200` — register that, + not the API's 5048. If you serve the dev app on another port, register that port instead. + + !!! tip "Behind a reverse proxy, set `PUBLIC_URL`" + Deriving the callback from the request goes wrong when TLS is terminated in front of the app: + it sees plain HTTP and builds an `http://` callback that Discord rejects as unregistered. + Setting `PUBLIC_URL=https://poracle.example.com` in `.env` pins the callback to exactly what + you registered here, whatever the request looks like. Declaring the proxy with + `PROXY_KNOWN_PROXIES` / `PROXY_KNOWN_NETWORKS` fixes the same thing at source and additionally + keeps rate limits per-user — see [Behind a reverse proxy](standalone-setup.md#reverse-proxy-optional). 4. Copy the **Client ID** and **Client Secret** diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 1dd47788..b55c3092 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -10,10 +10,17 @@ This is the recommended way to run PoracleWeb.NET in production. ```bash git clone https://github.com/PGAN-Dev/PoracleWeb.NET.git cd PoracleWeb.NET + +# Check out the newest release. Without this you are on whatever the default +# branch resolves to; a tag pins you to a released version. +git checkout "$(git describe --tags --abbrev=0)" ``` Or download and extract a release. Everything below runs from this root directory. +!!! warning "`develop` is the beta channel" + `develop` receives every merged pull request and is published as the `:beta` Docker image. `main` only moves when a release is published. It is where changes soak before a release, so it can contain work that has never been in a released version. Build from a release tag unless you specifically want to test unreleased changes — and if you do, prefer the `:beta` image over building from source so you get the exact artifact that was tested. + ## 2. Create your `.env` and `docker-compose.yml` Both files ship as `.example` templates so your local copies aren't clobbered by upstream updates. All user configuration lives in `.env`; `docker-compose.yml` rarely needs changes because it loads settings from `.env` via `env_file`. @@ -58,6 +65,7 @@ JWT_SECRET=generate-a-long-random-secret-key-at-least-32-chars # Set the OAuth2 redirect URI to: http://your-server:8082/api/auth/discord/callback DISCORD_CLIENT_ID=your_discord_client_id DISCORD_CLIENT_SECRET=your_discord_client_secret +# Optional: only needed for avatar caching and the geofence review forum posts. DISCORD_BOT_TOKEN=your_discord_bot_token # Poracle API — your running PoracleNG instance @@ -71,11 +79,14 @@ PORACLE_ADMIN_IDS=your_discord_user_id # Comma-separated Discord user IDs CORS_ORIGIN=http://localhost:8082 ``` +!!! warning "PoracleNG 5.1.0 or newer" + Point `PORACLE_API_ADDRESS` at PoracleNG 5.1.0 or later. Older servers have no column for per-alarm delivery scope, the PVP mega evolution filter or the minimum time-left filter, so those three controls save without an error and change nothing. PoracleWeb logs an error at startup and shows the detected version on **Admin → Settings**. + ### Optional settings ```env # Poracle config directory — mount for DTS template previews -PORACLE_CONFIG_DIR=/path/to/PoracleJS/config +PORACLE_CONFIG_DIR=/path/to/PoracleNG/config # Koji geofence API (required for custom geofences feature) KOJI_API_ADDRESS=http://host.docker.internal:8080 @@ -97,6 +108,30 @@ TELEGRAM_BOT_USERNAME= See the [Configuration Reference](../configuration/reference.md) for the full list of settings. +### Public URL and reverse proxies + +`PUBLIC_URL` is the origin people reach the instance on, and it pins the OAuth callback for both Discord and OIDC instead of letting the app guess one from each incoming request. Whatever you put here is what the provider must have registered. It is an origin only — no trailing path, no query — and an unusable value stops the app at startup rather than producing a callback the provider silently refuses. + +```env +PUBLIC_URL=https://alerts.example.com +``` + +Leave it unset if the container is exposed directly, or if people reach it on several hostnames and you want the callback to follow whichever one they used. It is also the base for the links PoracleWeb puts in geofence review threads, so set it if you use the Discord submission forum. + +If anything terminates TLS in front of the container — nginx, Caddy, Traefik, a Cloudflare Tunnel — also name it. `X-Forwarded-For` and `X-Forwarded-Proto` are believed only from declared addresses, because a header trusted from anyone lets a caller invent a new address per request and hand itself a fresh rate-limit allowance on the sign-in endpoints. + +```env +# One or both. Comma-separated. Use the address the proxy connects FROM. +PROXY_KNOWN_PROXIES=127.0.0.1 +PROXY_KNOWN_NETWORKS=172.18.0.0/16 +``` + +With neither set, the app falls back to the connection address. That is safe but wrong in two visible ways: every user behind the proxy shares one sign-in rate-limit bucket, and callback URLs are built from the scheme the app received — `http://` — which Discord and OIDC providers reject. `PUBLIC_URL` fixes the callback half on its own; only these two fix the rate-limit bucket. + +### Version checking + +The Versions card under **Admin → Settings** compares your PoracleWeb and PoracleNG versions against the latest releases. Opening that page makes two anonymous GETs, to `api.github.com` and `raw.githubusercontent.com`, with no identifiers sent. There is no scheduler behind it: the result is cached for six hours, so an instance whose admins never open the page never calls GitHub at all. If your egress policy blocks it, or you'd rather it didn't happen, switch on **Do not check for updates** (`disable_update_check`) under **Admin → Settings**. + ## 3. Create the PoracleWeb.NET database PoracleWeb.NET needs its own database (separate from your Poracle bot database). Tables are created automatically on first start — you just need to create the empty database: @@ -179,12 +214,16 @@ The app will now be available at `http://your-server:9090`. Remember to update y === "Built from source" ```bash - git pull + git fetch --tags + git checkout "$(git describe --tags --abbrev=0 origin/main)" ./scripts/docker.sh update ``` Or without the script: `docker build -t poracleweb.net:latest . && docker compose up -d --force-recreate` + !!! note "A plain `git pull` tracks the beta channel" + `git pull` moves you to the newest commit on your current branch. On `develop` that is the beta channel. Fetch tags and check the newest one out to stay on released code. + ## Common issues **Container won't start / exits immediately** @@ -200,4 +239,4 @@ The app will now be available at `http://your-server:9090`. Remember to update y : `PORACLE_API_ADDRESS` must be reachable from inside the container. If Poracle runs on the host, use `http://host.docker.internal:3030`. If it's on another machine, use that machine's IP. **Discord login fails** -: The redirect URI in your Discord app must match exactly: `http://your-server:PORT/api/auth/discord/callback`. Check both the port and the hostname/IP. +: The redirect URI in your Discord app must match exactly: `http://your-server:PORT/api/auth/discord/callback`. Check both the port and the hostname/IP. If you serve the site over HTTPS through a reverse proxy and Discord reports the callback as `http://`, set `PUBLIC_URL` — see [Public URL and reverse proxies](#public-url-and-reverse-proxies) above and [the troubleshooting entry](../troubleshooting.md#sign-in-fails-with-invalid-oauth2-redirect_uri). diff --git a/docs/getting-started/standalone-setup.md b/docs/getting-started/standalone-setup.md index e4e4f1e0..ae9fa11e 100644 --- a/docs/getting-started/standalone-setup.md +++ b/docs/getting-started/standalone-setup.md @@ -10,34 +10,43 @@ You'll configure everything in a single `.env` file at the project root and run |---|---|---| | **.NET 10 Runtime** | [dotnet.microsoft.com/download](https://dotnet.microsoft.com/download/dotnet/10.0) | Like installing Node.js or Go | | **MySQL / MariaDB** | Your existing Poracle database server | Same DB your Poracle bot uses | -| **PoracleNG** | Already running with REST API enabled | The bot this app talks to | +| **PoracleNG 5.1.0+** | Already running with REST API enabled | The bot this app talks to | | **Discord App** | [discord.com/developers](https://discord.com/developers/applications) | OAuth2 for user login | +!!! warning "Why the PoracleNG version matters" + Per-alarm delivery scope, the PVP mega evolution filter and the minimum time-left filter write columns that only exist from PoracleNG 5.1.0. On an older server those three controls save without an error and change nothing. PoracleWeb logs an error at startup when it detects one, and reports the version it found on **Admin → Settings**. + !!! tip "Runtime vs SDK" You only need the **ASP.NET Core Runtime** to run a pre-built release. The **.NET SDK** is only needed if you want to build from source. ## 1. Get the app -=== "Download a release (easiest)" +=== "Clone a release tag (recommended)" - Download the latest release from the [GitHub Releases](https://github.com/PGAN-Dev/PoracleWeb.NET/releases) page and extract it: + Releases are source-only — no prebuilt archives are attached, so clone the tag you want and build it: ```bash - # Linux/macOS - mkdir poracleweb && cd poracleweb - tar -xzf poracleweb.net-linux-x64.tar.gz - - # Windows (PowerShell) - Expand-Archive poracleweb.net-win-x64.zip -DestinationPath poracleweb + git clone https://github.com/PGAN-Dev/PoracleWeb.NET.git poracleweb cd poracleweb + + # Newest release tag. Or `git checkout vX.Y.Z` to pin a specific one. + git checkout "$(git describe --tags --abbrev=0)" ``` + Then build it with the commands in the next tab. Skip the checkout to stay on `main`, which always + points at the most recent release. If you would rather not build at all, the + [Docker image](../configuration/docker.md) is the prebuilt option. + === "Build from source" ```bash git clone https://github.com/PGAN-Dev/PoracleWeb.NET.git cd PoracleWeb.NET + # Check out the newest release. `develop` is the beta channel and carries + # merged-but-unreleased work. + git checkout "$(git describe --tags --abbrev=0)" + # Build everything with the convenience script ./scripts/dev.sh build @@ -185,7 +194,7 @@ journalctl -u poracleweb -f Place your `.env` file in `/opt/poracleweb/` (the `WorkingDirectory`) and the app will pick it up automatically. -### pm2 (if you're already using it for PoracleJS) +### pm2 ```bash cd /opt/poracleweb @@ -211,8 +220,11 @@ Place your `.env` file in `C:\poracleweb\` (the `AppDirectory`) and the app will # Health check curl http://localhost:8082/ -# Check the API -curl http://localhost:8082/api/pokemon/master-data +# Which build is running (anonymous) +curl http://localhost:8082/api/version + +# Master data the SPA loads on startup (anonymous) +curl http://localhost:8082/api/masterdata/pokemon ``` Open `http://your-host:8082` in a browser. You should see the login page. @@ -248,6 +260,24 @@ If you want to put PoracleWeb.NET behind nginx or Caddy (just like you might wit When using a reverse proxy, set `CORS_ORIGIN=https://poracle.example.com` in your `.env` (replacing any `http://localhost:...` value you already have) and update your Discord OAuth2 redirect URI to match the public URL. +You also have to tell the app which proxy to believe. `X-Forwarded-For` and `X-Forwarded-Proto` are only honoured from declared addresses, because a header believed from anyone lets a caller name a different address on each request and hand itself a fresh rate-limit allowance on the sign-in endpoints: + +```bash +# One or both. Comma-separated. Use the address the proxy connects FROM. +PROXY_KNOWN_PROXIES=127.0.0.1 +PROXY_KNOWN_NETWORKS=172.18.0.0/16 +``` + +Leave both unset and the app falls back to the connection address, which is safe but wrong in two visible ways: every user behind the proxy shares one rate-limit bucket, and OAuth callback URLs are built from the scheme the app *received* — `http://` — so Discord and OIDC providers reject the sign-in with an invalid `redirect_uri`. + +For the sign-in half of that you can skip the inference entirely and name the URL: + +```bash +PUBLIC_URL=https://poracle.example.com +``` + +`PUBLIC_URL` is the origin users type in, and the one you register with Discord or your OIDC provider. It must be an origin only — no trailing path — and an unusable value stops the app at startup rather than producing a callback the provider silently refuses. Set it if you have a single public address; leave it unset if people reach the instance on several hostnames and you want the callback to follow whichever one they used. + ## Troubleshooting **"Configuration 'ConnectionStrings:PoracleDb' is required"** diff --git a/docs/index.md b/docs/index.md index e2f172f4..e3b28f8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,7 +4,12 @@ template: home.html # PoracleWeb.NET -A web application for managing Pokemon GO notification alarms through the Poracle bot system. Compatible with both [PoracleJS](https://github.com/KartulUdus/PoracleJS) and [PoracleNG](https://github.com/jfberry/PoracleNG). Users authenticate via Discord OAuth2 or Telegram and configure personalized alert filters (Pokemon, Raids, Quests, Invasions, Lures, Nests, Gyms) through a browser-based UI. +A web application for managing Pokemon GO notification alarms through the [PoracleNG](https://github.com/jfberry/PoracleNG) bot. Users authenticate via Discord OAuth2 or Telegram and configure personalized alert filters (Pokemon, Raids, Quests, Invasions, Lures, Nests, Gyms) through a browser-based UI. + +!!! warning "PoracleNG is required" + All alarm management, profile handling, and user operations are proxied through PoracleNG's REST API. [PoracleJS](https://github.com/KartulUdus/PoracleJS) is not a tested or supported configuration — some operations that rely on PoracleNG-specific endpoints will not work. + + **PoracleNG 5.1.0 or newer is required.** Older servers have no column to store per-alarm delivery scope, the PVP mega evolution filter or the minimum time-left filter, so those three controls save without complaint and change nothing. PoracleWeb logs an error at startup when it finds an older server, and reports the version on Admin → Settings. ## Tech Stack @@ -22,9 +27,12 @@ A web application for managing Pokemon GO notification alarms through the Poracl - **Gym Picker** — Search and target specific gyms for team, raid, and egg alarms with photo thumbnails and area names - **Pokemon Availability** — See which species are currently spawning when creating alarms (requires Golbat scanner) - **Bulk Operations** — Multi-select alarms with bulk delete and bulk distance update +- **Alert Defaults** — Set where new alerts reach you by default: your areas, or a radius from your pin or a saved place +- **Per-Alarm Delivery Scope** — Aim an individual alert anywhere in your areas, near a place or a point on the map, or only in specific areas — including geofences you drew yourself +- **Saved Places** — Name the points your alerts measure from, so one alert can watch your workplace while the rest follow your pin - **Quick Picks** — Admin-defined alarm templates users can apply with one click -- **Area Management** — Interactive Leaflet map for selecting geofence areas -- **Custom Geofences** — Draw custom polygon geofences on a map, served to PoracleJS via a built-in feed endpoint. Submit for admin review to promote to public areas. +- **Areas & Places** — Interactive Leaflet map for choosing geofence areas, dropping your pin, and naming the places your alerts measure from +- **Custom Geofences** — Draw custom polygon geofences on a map, served to the Poracle bot via a built-in unified feed endpoint. Submit for admin review to promote to public areas. - **Geofence Admin Review** — Approve or reject user-submitted geofences with Discord forum integration - **Profile Switching** — Multiple alarm profiles per user - **Profile Active Hours** — Schedule automatic profile switching by day and time @@ -34,11 +42,13 @@ A web application for managing Pokemon GO notification alarms through the Poracl - **Responsive Design** — Full mobile support with fullscreen dialogs and collapsible sidebar - **Onboarding Wizard** — First-run setup guide for new users - **Keyboard Shortcuts** — ++question++ for help, ++bracket-left++ / ++bracket-right++ for sidebar collapse -- **11 UI Languages** — Full interface translation (English, French, German, Spanish, Dutch, Italian, Portuguese, Brazilian Portuguese, Polish, Danish, Swedish) plus 18 Pokemon name locales -- **Admin Panel** — User management, webhook configuration, site settings, geofence submission review -- **Test Alerts** — Send sample notifications from any alarm card to preview exactly what your alerts look like -- **Weather Display** — View current in-game weather at your location and across all tracked areas on the dashboard -- **Fort Change Tracking** — Get notified when pokestops or gyms are added, removed, renamed, or relocated +- **11 UI Languages** — Full interface translation (English, French, German, Spanish, Dutch, Italian, Portuguese, Brazilian Portuguese, Polish, Danish, Swedish). Pokemon names, types and forms follow the display language, translated by Poracle itself. What Poracle writes in your DMs is a separate choice, **Alert language**, in the user menu beside **Display language** +- **Single Sign-On** — Discord and Telegram login, plus any OIDC provider ([setup](configuration/external-sso.md)), with optional [silent refresh and single logout](configuration/oidc-refresh-tokens.md) +- **Admin Panel** — User management, webhook configuration, site settings, geofence submission review +- **[Webhooks & Delegates](features/webhooks.md)** — Channel feeds managed as their own accounts, with named people allowed to manage one without being made an administrator +- **Test Alerts** — Send a sample notification from an alarm card to preview exactly what your alerts look like (all types except Fort Changes and Max Battles) +- **Weather Display** — View current in-game weather at your pin and across all tracked areas on the dashboard +- **Fort Change Tracking** — Get notified when pokestops or gyms are added, removed, renamed, relocated, re-described, or given a new image - **Max Battle (Dynamax) Alarms** — Track Dynamax and Gigantamax battles at Power Spots by level or specific Pokemon - **GeoJSON Import/Export** — Import and export custom geofences in standard GeoJSON format - **Profile Backup & Restore** — Export profiles as JSON backups and import them, including full alarm filter restoration @@ -49,7 +59,7 @@ A web application for managing Pokemon GO notification alarms through the Poracl | Requirement | Version | Purpose | |---|---|---| | MySQL | 5.7+ or 8.0+ | Poracle database (existing Poracle installation) | -| Poracle | PoracleJS or PoracleNG | Running instance with REST API enabled. All alarm writes are proxied through the Poracle API. | +| Poracle | [PoracleNG](https://github.com/jfberry/PoracleNG) 5.1.0 or newer | Running instance with REST API enabled. All alarm, profile, and user operations are proxied through PoracleNG's REST API. PoracleJS is not a tested configuration. | | Discord App | — | OAuth2 application for user authentication | | Koji | — | Geofence management server (required for custom geofences feature) | | .NET SDK | 10.0 | Backend development (not needed for Docker) | @@ -92,7 +102,7 @@ A web application for managing Pokemon GO notification alarms through the Poracl How the unified geofence feed works - [:octicons-arrow-right-24: Custom Geofences](features/custom-geofences.md) + [:octicons-arrow-right-24: Custom Geofences](features/custom-geofences/index.md) diff --git a/docs/poracleng-enhancement-requests.md b/docs/poracleng-enhancement-requests.md index 6589a94b..85c7b8bc 100644 --- a/docs/poracleng-enhancement-requests.md +++ b/docs/poracleng-enhancement-requests.md @@ -1,6 +1,6 @@ # PoracleNG API Enhancement Requests -This document tracks PoracleNG API gaps that require workarounds in PoracleWeb. Each gap is referenced by inline `HACK`/`TODO` comments throughout the codebase. +This document tracks PoracleNG API gaps that require workarounds in PoracleWeb.NET. Each gap is referenced by inline `HACK`/`TODO` comments throughout the codebase. ## Background @@ -191,6 +191,41 @@ This crashes the **entire state reload**, freezing PoracleNG on stale data for a --- +### available_languages is enforced but not readable + +**Filed upstream:** [jfberry/PoracleNG#194](https://github.com/jfberry/PoracleNG/issues/194) + +`POST /api/humans/{id}/setLanguage` rejects any language absent from `general.available_languages` +with `400 "language is not available"` (`internal/api/humans.go`). Nothing exposes that list: +`/api/config/poracleWeb` does not carry it, and `/api/config/values` is driven by `configSchema`, +which does not declare it. + +**Consequence here:** the alert-language menu offers all 11 of this site's languages. On a Poracle +that restricts the list, choosing an unlisted one fails the write and the user sees a generic error +with no reason. Filtering that menu is blocked until the codes are readable. + +**Workaround:** none. The menu is unfiltered. + +### disabledHooks omits fort, and carries an inert pokestop + +**Filed upstream:** [jfberry/PoracleNG#195](https://github.com/jfberry/PoracleNG/issues/195) + +The `disabledHooks` array on `/api/config/poracleWeb` is built from ten flags. `disable_fort_update` +is enforced by the processor, the bot, and `!tracked`, but is not one of them — so a client reading +the array concludes fort changes are enabled when they are not. Meanwhile `pokestop` is in the array +and nothing in the processor reads it. + +**Consequence here:** [feature gating](configuration/site-settings.md) needs a second call to +`GET /api/config/values` purely to learn `general.disable_fort_update`, and `pokestop` is +deliberately mapped to nothing. Mapping it to lures, invasions and quests — the obvious reading, +since those arrive on the pokestop webhook — would disable three working types on a flag that does +nothing. + +**Workaround:** the second config call, degraded independently so a Poracle without that route keeps +the hook list already in hand. + +--- + ## Summary Table | Gap | Priority | Workaround in Use | Status | @@ -204,3 +239,36 @@ This crashes the **entire state reload**, freezing PoracleNG on stale data for a | Atomic profile switch | Low | Already in PoracleNG | **Adopted** | | Atomic area update | Low | Already in PoracleNG | **Adopted** | | NULL field defaults | Low | Handled by PoracleNG cleanRow() | Resolved by migration | +| available_languages not readable | Medium | None -- alert-language menu is unfiltered | [Filed upstream (#194)](https://github.com/jfberry/PoracleNG/issues/194) | +| disabledHooks omits fort | Low | Second call to /api/config/values | [Filed upstream (#195)](https://github.com/jfberry/PoracleNG/issues/195) | + +### Tracking create has no upsert path for natural-key types + +`lure` and `invasion` are the only tracking tables carrying a unique index over a natural key: + +``` +lure lure_tracking(id, profile_no, lure_id) +invasion invasion_tracking(id, profile_no, gender, grunt_type) +``` + +Every other type is unique on `PRIMARY(uid)` alone. + +`HandleCreateLure` / `HandleCreateInvasion` treat a row as "already present" only when **every** field matches. Changing a field *outside* the natural key — distance, template or clean on a lure — is therefore not recognised as an existing row, so the handler attempts an `INSERT` that collides with the unique index: + +``` +Tracking API: insert lure: Error 1062 (23000): +Duplicate entry '--' for key 'lure_tracking' +``` + +PoracleNG answers `500 {"message":"database error"}` and the edit is discarded. Reproduced against a dev instance: + +| Request | Result | +|---|---| +| create a lure with an untracked `lure_id` | `200 insert:1` | +| re-post the identical row | `200 alreadyPresent:1` | +| re-post with only `distance` changed | **`500 database error`** | +| `DELETE byUid` then re-post | `200 insert:1` | + +So the only way to edit these two types is to delete the row first, which is what PoracleWeb now does (`NaturalKeyTrackingUpdate`). The cost is that the `uid` rotates on every edit, which in turn orphans anything holding the old uid — quick-pick applied state tracks uids, for example. + +**Request:** make the create handler upsert when the natural key matches an existing row for the same `(id, profile_no)`, updating the non-key columns in place and returning `updates: 1` with the existing uid. That matches how the uid-only types already behave and would let PoracleWeb drop the delete-then-create workaround along with the uid churn it causes. diff --git a/docs/poracleng-v2-review.md b/docs/poracleng-v2-review.md new file mode 100644 index 00000000..a4803ad4 --- /dev/null +++ b/docs/poracleng-v2-review.md @@ -0,0 +1,154 @@ +# PoracleNG v2 API Review & PoracleWeb.NET Migration Plan + +*Lead architect synthesis of architecture, db-elimination, tracking-types, performance, security, testing, optimization, and source-verification (researcher) reviews of PoracleNG v2 ([issue #138](https://github.com/jfberry/PoracleNG/issues/138) / [PR #139](https://github.com/jfberry/PoracleNG/pull/139), "huma" framework).* + +> Companion to [`poracleng-enhancement-requests.md`](poracleng-enhancement-requests.md). That doc tracks v1-era workaround gaps; this doc evaluates whether v2 closes them and what we must still ask for. **PR #139 is OPEN — wire shapes are not yet frozen.** + +--- + +## Executive Summary + +PoracleNG v2 is a strong, well-shaped API surface that PoracleWeb.NET should adopt. The full human-scoped **snapshot** (`GET /api/v2/humans/{id}/tracking` → `{human, tracking, profiles, locations, summaries}`) collapses our dashboard/bootstrap reads into one round-trip, the **wrapper-free typed bodies** delete reams of `JsonElement` unwrap plumbing, and **strict typing + RFC 9457** errors are exactly the defense-in-depth that the March 31 NULL-template incident demanded. + +But the headline goal of this review — **eliminate all direct access to the Poracle DB** — is **NOT achievable on v2 as currently designed.** The researcher verified against PR #139 source that the three load-bearing direct-DB touchpoints are each blocked by a missing endpoint or an unbypassable filter: + +1. **Trusted setAreas (the `IUserAreaDualWriter` HACK) — NOT closed.** `v2_humans.go registerV2HumanSetAreas` mirrors v1 `HandleSetAreas`: for non-admins it skips every fence where `!f.UserSelectable`. Our user-drawn geofences are served `userSelectable=false`, so their names are still silently dropped. The new per-rule `override_areas` field does **not** rescue this — `validateOverrideFields` (tracking.go) checks each override against `GetAvailableAreas`, which for non-admins also excludes `userSelectable=false` fences (`bot/area_logic.go`), returning a 400. **This is the single most important blocker and it requires a new PoracleNG capability.** + +2. **Admin list-all-humans, batch name/avatar resolve, full-purge delete — NONE exist in v2.** All v2 human endpoints are single-`{id}`-scoped. These three keep `HumanRepository` (and therefore `PoracleContext` and the entire Poracle-DB `Data` dependency) alive. + +Net consequence: **without three explicit asks to jfberry, the keystone deletion (`PoracleContext` + the 10 alarm entities, ~800 LOC) cannot happen.** What we *can* delete unconditionally on v2 is the proxy unwrap helpers, `StripUidZero`, `PoracleJsonHelper`'s coercion machinery, and the dead `ProfileRepository` / dead `HumanRepository` methods — a real but smaller win (~600–700 LOC). + +**Recommended posture:** adopt v2 behind a `Poracle:ApiVersion` flag, migrate reads first (snapshot), then writes after a strict-payload audit, while sending jfberry the three High-priority asks below (trusted setAreas, admin list, batch resolve). Treat v2 wire shapes as **not yet frozen** — PR #139 is still OPEN. + +> **Source-verified against branch `huma-api-migration` (PR #139):** the `userSelectable` filter in `registerV2HumanSetAreas` (`v2_humans.go:518-522`), the `override_areas` → `GetAvailableAreas` gate (`tracking.go:266,322`), the absence of any admin list/delete-human endpoint (full `RegisterV2Humans` set), the `active_hours` `day` 0-6/Sun=0 schema with no cross-midnight (`v2_profiles.go:43,71`), and `blocked_alerts` as a read-only field on the human GET. **Correction vs our older gap-tracker:** `monsters.go` now already does `COALESCE(template, '') AS template` on this branch — the template crash vector is closed; only `ping` is still selected raw (see ask #4). + +--- + +## What v2 Gets Right (for us) + +- **Full snapshot in one call.** `GET /api/v2/humans/{id}/tracking` (+ `?all_profiles=true`) folds human, areas, profiles, locations, all tracking, and summaries into one typed response — replaces our `/auth/me` + `/api/dashboard` + `/api/areas` + `/api/profiles` + `/api/locations` fan-out. Confirmed to cover `DashboardService`'s count needs. +- **No wrappers.** Typed bodies directly — deletes the `{human:{...}}` / `{profile:[...]}` / `{type:[...]}` / `{status:ok}` unwrap branches in `PoracleHumanProxy`/`PoracleTrackingProxy` and the `DeserializeHuman`/`DeserializeProfiles` hand-parsers. +- **Strict types, no coercion.** Game-master ids as ints, fixed categories as string enums, flags as bools. Lets us drop the defensive `JsonValueKind` switches (and the IDE0072 suppressions) and the `PropertyNameCaseInsensitive` tolerance. +- **`clean` split into independent booleans (`clean`/`edit`/`summary`).** Genuinely better than the 3-bit bitmask we compose client-side — we want it, provided bot-set bits survive partial writes. +- **POST-array create keeps the `{created,updated,unchanged}` diff** — matches today's `TrackingCreateResult` flow; we depend on the created UID round-tripping for the optimistic UI. +- **RFC 9457 problem+json** — a structured error contract we can map to typed exceptions, directly addressing the class of failure that motivated the proxy migration. +- **No `uid:0` sentinel** — explicit POST(create)/PUT(replace)/DELETE removes the `StripUidZero` magic. +- **Saved-locations editor** (`PUT /{label}`) and **typed `active_hours`** are net-new capabilities we can surface. + +--- + +## Direct-DB Elimination Scorecard + +| # | Touchpoint (file) | What it does | v2 status | What's needed to eliminate | +|---|---|---|---|---| +| 1 | `UserAreaDualWriter` + 6 `UserGeofenceService` callsites + `AreaController.UpdateAreas` merge-back | Direct dual-write of `humans.area` + `profiles.area` because setAreas strips `userSelectable=false` names; forces manual `ReloadGeofencesSafeAsync` | **NO** (verified: v2 setAreas mirrors v1; `override_areas` blocked by same filter) | **Trusted setAreas variant** (secret-gated, bypasses `userSelectable`, per-profile + all-profiles, triggers internal reloadState) — ask #1 | +| 2 | `HumanRepository.GetAllAsync` → `AdminController.GetAllUsers` | Admin user-list enumerates whole humans table | **NO** | `GET /api/v2/humans` paginated admin list — ask #2 | +| 3 | `HumanRepository.GetByIdsAsync` → `UserGeofenceService.GetAllWithDetailsAsync` | Batch-resolve owner/reviewer names for admin geofence submissions UI | **NO** | Batch human resolve endpoint — ask #3 | +| 4 | `HumanRepository.DeleteUserAsync` → `AdminController.DeleteUser` | Full account purge | **NO** | `DELETE /api/v2/humans/{id}` cascade — ask #4 | +| 5 | `UserGeofenceService` non-active-profile cleanup (`RemoveAreaFromAllProfilesAsync`, spans every `profiles.area`) | Remove deleted geofence from all profiles | **NO** | Trusted setAreas with `all_profiles` targeting — folded into ask #1 | +| 6 | `HumanRepository.GetByIdAndProfileAsync` (UserGeofenceService submission display name, :292) | Read human name for Discord forum post | **PARTIAL** | `GET /api/v2/humans/{id}` covers the read; swap now (independent of trusted-areas) | +| 7 | `LocationController.UpdateLanguage` → `HumanService.UpdateAsync` → `HumanRepository.UpdateAsync` | Generic direct-DB human update for language | **YES** | `POST /api/v2/humans/{id}/language` exists — swap now | +| 8 | `ProfileRepository` (all methods) + `ProfileService.Create/Update/Delete` | Dead code — controllers already use proxy | **YES** | Delete outright; v2 profile endpoints exist | +| 9 | `HumanRepository.GetByIdAsync`/`ExistsAsync`/`CreateAsync` | Dead — service uses proxy | **YES** | Delete dead methods | +| 10 | ~~`HumanRepository.DeleteAllAlarmsByUserAsync`~~ | Dead — service loops via proxy | **DONE** | Deleted in #707 (its `ExecuteDeleteAsync` calls could not run on MariaDB anyway); one-shot purge endpoint is still a nice-to-have (ask #6) | +| 11 | `DashboardService.GetAllTrackingAsync` (proxy, not direct DB) | Fetches full payloads to count | **YES** | Snapshot/counts — perf win, not DB elimination | + +**Verdict:** rows 6–11 close on v2 (some are pure dead-code deletion). Rows 1–5 — the entire reason `PoracleContext` still exists — require **four new PoracleNG capabilities.** Until those land, `HumanRepository` shrinks to ~3 admin methods that still pin `PoracleContext`, and `UserAreaDualWriter` stays in full. + +--- + +## Answering the maintainer's 6 open questions + +- **Q1 (collection scoping):** Keep the **human-scoped sub-resource** `/api/v2/humans/{id}/tracking[/{type}][/{uid}]?profile={n}`. PoracleWeb is always single-human-scoped; a flat `?user=&profile=` collection would force `user` into every query and lose the ownership boundary. We do **not** need a `/users/{id}/` alias. +- **Q2 (create response):** **Keep `{created,updated,unchanged}`** — we depend on the created UID round-tripping. **Enhancement:** return the *full resulting rule objects* in each bucket (with applied defaults), not just UIDs, so we can hydrate cards without a follow-up GET. +- **Q3 (int/enum split):** **Correct as-is.** Keep `team`/`gender`/`rsvp_changes`/`fort_type` as string enums, game ids as ints, flags as bools. The `clean`→3-booleans split is good; confirm all three are independently settable and bot-set bits survive partial writes. +- **Q4 (invasion two-axis + incident):** Two-axis fits our UI (we already carry `typeId`). **Caveat:** moving from `grunt_type` *string* to integer `type_id`/`grunt_id` requires a **published id dictionary** so our Angular table doesn't drift. Confirm `incident.display_type` is a *different* dictionary than invasion `type_id`, and that read-back returns the same axis we wrote. +- **Q5 (discrete action endpoints + typed active_hours):** Discrete endpoints suit us. Typed `active_hours` is good — but **day numbering flips from our 1-7 Mon-Sun to v2's 0-6 Sun=0**, and v2 forbids cross-midnight ranges. Document `day=0=Sunday` and `step`/`end_hours`/`end_mins` semantics authoritatively. +- **Q6 (v1 dependencies missing in v2):** Yes — admin list-all-humans, admin full-purge delete, and batch human resolve. These are hard blockers to a DB-free PoracleWeb. + +--- + +## New Capabilities to Build (unblocked by v2) + +- **`incident` alarm type** — genuinely new (facade over invasion, `display_type` int). Full four-layer wiring per CLAUDE.md (model + `IncidentCreate/Update`, controller with `[RequireFeatureEnabled]`, `DisableFeatureKeys` entry, Angular module/route/guard/nav). **Do not under-scope as "another invasion"** — `display_type` is a different dictionary. Effort: L. +- **`fort` type** — we already have `FortChange` model/UI; reconcile `include_empty` default (v2 = TRUE, ours = 0) and convert int flags to bools. Effort: S–M. +- **`maxbattle`** — model/UI exist; add `gmax` bool + `move` int coverage. Effort: S. +- **`pokemon.pvp_ranking_evolution`** (int 0/2/3) — additive field on `MonsterCreate/Update`. Effort: S. +- **Saved-locations editor** — v2 `PUT /{id}/locations/{label}` enables in-place edit of saved locations. Effort: S. +- **`blocked_alerts`** — read-only per-user authz signal (derived from Discord roles). Consume from the snapshot to hide/disable alarm-type nav + add-dialogs per user (distinct from global `disable_*` gates). Map `monster`→`pokemon`. Effort: M. + +--- + +## Risks & Gotchas + +1. **`override_areas` is a trap.** Per-*rule*, not the human/profile area subscription list, and source-verified to be gated by the same `userSelectable` filter. Adopting it expecting a filter-bypass would silently reintroduce the geofence-persistence regression. **Do not delete `UserAreaDualWriter` until a confirmed trusted human-level areas op exists.** +2. **PUT full-replace footgun.** v2 PUT resets omitted fields to defaults — incompatible with our `ApplyUpdate` null-skip merge. A naive partial PUT silently zeroes IV/CP/PvP/template — echoing the NULL-template incident. Route single-field edits through POST-array-diff or send the complete object. +3. **`active_hours` day off-by-one.** 1-7 Mon-Sun → 0-6 Sun=0 is a silent, high-blast-radius corruption. v2 also bans cross-midnight ranges. Needs a translation shim **and dedicated round-trip tests** (the existing suite tests string coercion `'09'`/`'00'` and 1-7 numbering — these *invert* under v2 and must be rewritten, not find-replaced). +4. **Strict 422 rejection.** Unknown fields and wrong types hard-fail. Our snake_case proxy currently sends ints for enums and string-coerced hours in places — a full payload audit is mandatory before flipping writes. +5. **Snapshot payload bloat.** For power users (500+ alarms) the snapshot is hundreds of KB. Do **not** use it for the lightweight badge path — use a counts projection/selective includes, keep `include_descriptions` OFF except on the Profiles-overview page, add client-side dedupe + ETag/304 if offered. +6. **RFC 9457 reflected input.** `errors[].value`/`detail` echo submitted input — sanitize at the proxy boundary before surfacing to the SPA. +7. **Trust model unchanged.** `X-Poracle-Secret` = full impersonation of any human id. If admin list/delete/resolve are added but reachable without the secret (e.g. via public `/docs`), they become mass-enumeration/deletion vulns. **Verify secret-gating before adopting.** +8. **PR #139 is OPEN.** Wire shapes may shift; pin a vendored `openapi.json` as a golden contract fixture and treat shapes as not-yet-frozen. +9. **`monsters.go` COALESCE — `template` fixed on the v2 branch, `ping` still raw.** Verified on `huma-api-migration`: `COALESCE(template, '') AS template, clean, ping,` — the template DoS vector (one NULL row crashing state reload for everyone) is closed there. `ping` remains raw; if nullable it's the same crash class. Confirm the template fix is in the release line we actually deploy (our older gap-tracker still lists it as live), and COALESCE `ping` for parity. + +--- + +## Appendix A — Prioritized API Change Requests to PoracleNG (feedback for issue #138) + +The three **High** asks (trusted setAreas, admin list, batch resolve) are the gating set for full DB elimination. The `monsters.go` item is now **Medium** — `template` is already COALESCE'd on the v2 branch (verified), leaving only a `ping` parity nit. + +| # | Priority | Ask | Proposed shape | +|---|---|---|---| +| 1 | **High** | **Trusted setAreas variant (bypass userSelectable filter) — keystone blocker.** Only thing that lets us delete `IUserAreaDualWriter` + 6 callsites + the merge-back + manual reloads. The filter is a browser-hack defense, meaningless against a caller holding `X-Poracle-Secret`. | `POST /api/v2/humans/{id}/areas?trusted=true&profile={n\|all}` body `{areas:[...], mode:add\|remove\|replace}`; secret-gated; runs `reloadState` internally. **Alternative (most defensible): per-fence `ownedBy:humanId`** so the intersection admits owned fences regardless of `userSelectable`. | +| 2 | **High** | **Admin list-all-humans (paginated).** Keeps `HumanRepository.GetAllAsync` → `PoracleContext` alive. | `GET /api/v2/humans?limit=&offset=&search=&community=` → `{humans:[{id,name,type,enabled,admin_disable,current_profile_no,language,last_checked,disabled_date,notes}], total}`. Secret-gated. Must include `last_checked`/`disabled_date` (admin grid shows them). | +| 3 | **High** | **Batch human display-name/avatar resolution.** `GetByIdsAsync` resolves N owner+reviewer ids in one query for the admin submissions UI; per-id fan-out is a perf regression. | `GET /api/v2/humans?ids=a,b,c` → `[{id,name,type,avatar?}]` or `POST /api/v2/humans/resolve {ids:[...]}`. Minimal projection. Secret-gated. | +| 4 | Medium | **`monsters.go` COALESCE parity — `template` already fixed on `huma-api-migration`, `ping` still raw.** Verified: line 97 now reads `COALESCE(template, '') AS template, clean, ping,`, so the original template crash vector (the incident that motivated our proxy migration) is **closed** there. But `ping` is still selected raw — if nullable, that's the same `converting NULL to string` crash class. | Confirm `ping` is `NOT NULL` in schema, or `COALESCE(ping,'') AS ping` for parity with the other tracking files. Also confirm the template fix is in the release line we deploy, not only the v2 branch. | +| 5 | Medium | **Admin delete-human (full purge).** Last admin write with no v2 mapping. | `DELETE /api/v2/humans/{id}` → `{deleted:{human,profiles,tracking}}`; cascades all profiles/tracking/locations/roles. Secret-gated, idempotent. | +| 6 | Medium | **Return full rule objects in `{created,updated,unchanged}`.** Eliminates our post-create re-fetch. | Each bucket returns full rule (incl. uid + applied defaults). Confirm POST-array upsert preserves omitted fields (vs PUT full-replace). | +| 7 | Medium | **Publish integer dictionaries** (invasion `type_id`/`grunt_id`, incident `display_type`). Our hardcoded Angular tables must match exactly. | In `openapi.json` or `/api/v2/dictionaries`. Confirm read-back returns the same axis written; clarify whether invasion `type_id` == incident `display_type` dictionary. | +| 8 | Medium | **Document sentinel→null mapping + `active_hours` day convention.** `distance=0`='use areas' is meaningful, not absent. | Annotate each field's null-meaning/omit-default in OpenAPI; flag `distance:0`. Document `day=0-6 (0=Sunday)`, `step`/`end_*`, no-wrap. Echo-on-read. | +| 9 | Medium | **Bulk field-update (PATCH) for distance/clean.** Replaces O(N) fetch-modify-POST; PUT full-replace makes per-uid updates more dangerous. | `PATCH /api/v2/humans/{id}/tracking/{type}?profile=N` body `{uids?:[...]\|all:true, distance?, clean?, edit?, summary?}` → `{updated:[uids]}`. | +| 10 | Medium | **Surface `blocked_alerts` in the snapshot + confirm reject semantics.** Per-user authz we should respect. | Include in `GET …/tracking`; document `monster=pokemon`/`specificgym`/`specificstation`; confirm POST of a blocked type returns 403/422 (not silent-unchanged). | +| 11 | Medium | **Versioned `/api/v2/openapi.json` as a golden contract fixture.** Our mocked tests can't catch wire drift (the NULL-template blind spot). | semver/etag, published per release, so we vendor it and add a build-time contract test. | +| 12 | Low | **Confirm profile-DELETE cascade + single-call all-types purge.** | Document `DELETE …/profiles/{n}` cascades tracking, reassigns `current_profile_no`, refreshes `humans.area`, rejects last profile (422). Add `DELETE …/tracking?all_profiles=true`. | +| 13 | Low | **Sanitize/document RFC 9457 error echo.** `errors[].value`/`detail` can reflect attacker input. | Document they contain only caller-submitted values; optional `X-Poracle-Verbose-Errors:false`. | + +--- + +## Appendix B — PoracleWeb.NET Migration Sequence + +**Phase 0 — Unconditional, no API dependency (do now):** + +| Step | Detail | Effort | +|---|---|---| +| 0a | Swap `LocationController.UpdateLanguage` to proxy `SetLanguageAsync` (→ `POST /api/v2/humans/{id}/language`). Removes the last live `HumanRepository.UpdateAsync` caller. | S | +| 0b | Swap `UserGeofenceService:292` display-name read to proxy `GetHumanAsync`. | S | +| 0c | Delete dead `ProfileRepository`/`IProfileRepository` + `ProfileService` CRUD; dead `HumanRepository` methods (`GetByIdAsync`/`ExistsAsync`/`CreateAsync`; `DeleteAllAlarmsByUserAsync` already gone in #707) + `EnsureNotNullDefaults`. Update test mocks. Keep only `GetAllAsync`/`GetByIdsAsync`/`DeleteUserAsync` until v2 admin endpoints land. | M | + +**Phase 1 — v2 read path (behind `Poracle:ApiVersion` flag):** + +| Step | Detail | Effort | +|---|---|---| +| 1a | Collapse `IPoracleTrackingProxy` + `IPoracleHumanProxy` into one `IPoracleV2Client`. Point reads at `/api/v2`, deserialize typed bodies, delete all unwrap branches + hand-parsers. Keep v1 as runtime fallback. | L | +| 1b | RFC 9457 → typed `PoracleApiException`; handle 400→422; **sanitize `errors[].value`/`detail`** before surfacing to SPA. | M | +| 1c | Add `GetSnapshotAsync`; re-point `DashboardService`, `AreaController.GetSelectedAreas`, profile/location reads, `/auth/me` resync at the cached snapshot. `include_descriptions` OFF; counts projection for the badge path. | M | + +**Phase 2 — v2 write path (after strict-payload audit):** + +| Step | Detail | Effort | +|---|---|---| +| 2a | Strict-payload audit across all create/update builders: correct JSON types, no unknown fields, int↔enum translation, sentinel↔null mapping (**preserve `distance=0`=use-areas**). | L | +| 2b | Keep POST-array upsert; **never adopt PUT full-replace for partial edits**. Verify `CleanFlags` bit preservation on the 3-boolean path. Drop `StripUidZero`. | M | +| 2c | `active_hours` 1-7↔0-6 shim + typed-schema validator rewrite; update Angular day picker + utilities; drop string coercion. **Rewrite (don't find-replace) the active_hours tests.** | L | +| 2d | Vendor `openapi.json` contract test; re-point proxy/service tests at `/api/v2`; shared typed-fixture builder to replace ~150 snake_case `JsonElement` fixtures. | L | + +**Phase 3 — gated on API asks landing:** + +| Step | Detail | Effort | +|---|---|---| +| 3a | **GATED on trusted setAreas:** replace all 6 `UserAreaDualWriter` callsites + merge-back, delete the writer + interface + tests + manual reloads. Until then, keep the HACK + add a regression-lock test proving v2 setAreas still strips `userSelectable=false`. | L | +| 3b | **GATED on admin endpoints:** re-point `AdminController.GetAllUsers`/`DeleteUser`/`GetAllWithDetailsAsync` at proxy; delete `HumanRepository`, then **`PoracleContext`, the 10 Poracle-DB entities, the connection string, and the Human/Profile half of `EntityMappingExtensions`.** Keep `PoracleWebContext` untouched. | L | + +**Phase 4 — new capabilities:** `incident` (four-layer, distinct dictionary), invasion two-axis, `pvp_ranking_evolution`, `fort.include_empty` reconcile, saved-locations editor, `blocked_alerts` consumption. Effort: L. + +**Phase 5 — gated on bulk PATCH:** replace fetch-modify-POST across 8 alarm services + `CleaningService` with one PATCH; reconcile `poracleng-enhancement-requests.md` with v2. Effort: M. diff --git a/docs/screenshots/admin-forced-by-poracle.png b/docs/screenshots/admin-forced-by-poracle.png new file mode 100644 index 00000000..7ebb93f5 Binary files /dev/null and b/docs/screenshots/admin-forced-by-poracle.png differ diff --git a/docs/screenshots/admin-language-default.png b/docs/screenshots/admin-language-default.png new file mode 100644 index 00000000..ea14fbf4 Binary files /dev/null and b/docs/screenshots/admin-language-default.png differ diff --git a/docs/screenshots/admin-settings-versions.png b/docs/screenshots/admin-settings-versions.png new file mode 100644 index 00000000..90f95478 Binary files /dev/null and b/docs/screenshots/admin-settings-versions.png differ diff --git a/docs/screenshots/areas-bottom.png b/docs/screenshots/areas-bottom.png index d7895967..9aef048e 100644 Binary files a/docs/screenshots/areas-bottom.png and b/docs/screenshots/areas-bottom.png differ diff --git a/docs/screenshots/areas.png b/docs/screenshots/areas.png index d7895967..596eabaa 100644 Binary files a/docs/screenshots/areas.png and b/docs/screenshots/areas.png differ diff --git a/docs/screenshots/cleaning.png b/docs/screenshots/cleaning.png index 5af34e62..06cc776d 100644 Binary files a/docs/screenshots/cleaning.png and b/docs/screenshots/cleaning.png differ diff --git a/docs/screenshots/dark-mode.png b/docs/screenshots/dark-mode.png index 28bd58c5..62e14f24 100644 Binary files a/docs/screenshots/dark-mode.png and b/docs/screenshots/dark-mode.png differ diff --git a/docs/screenshots/dashboard-bottom.png b/docs/screenshots/dashboard-bottom.png index de948016..5d4f0f63 100644 Binary files a/docs/screenshots/dashboard-bottom.png and b/docs/screenshots/dashboard-bottom.png differ diff --git a/docs/screenshots/dashboard-weather.png b/docs/screenshots/dashboard-weather.png index f71452c2..24a063ed 100644 Binary files a/docs/screenshots/dashboard-weather.png and b/docs/screenshots/dashboard-weather.png differ diff --git a/docs/screenshots/dashboard.png b/docs/screenshots/dashboard.png index 6360b00d..e08e6759 100644 Binary files a/docs/screenshots/dashboard.png and b/docs/screenshots/dashboard.png differ diff --git a/docs/screenshots/fort-changes-add-dialog.png b/docs/screenshots/fort-changes-add-dialog.png index 50266171..30fc051e 100644 Binary files a/docs/screenshots/fort-changes-add-dialog.png and b/docs/screenshots/fort-changes-add-dialog.png differ diff --git a/docs/screenshots/fort-changes.png b/docs/screenshots/fort-changes.png index 2700a5b1..a3bfce59 100644 Binary files a/docs/screenshots/fort-changes.png and b/docs/screenshots/fort-changes.png differ diff --git a/docs/screenshots/geofences-export-dialog.png b/docs/screenshots/geofences-export-dialog.png index 98d96e33..60c5290e 100644 Binary files a/docs/screenshots/geofences-export-dialog.png and b/docs/screenshots/geofences-export-dialog.png differ diff --git a/docs/screenshots/geofences-import-dialog.png b/docs/screenshots/geofences-import-dialog.png index e3c883ea..4aff8cd7 100644 Binary files a/docs/screenshots/geofences-import-dialog.png and b/docs/screenshots/geofences-import-dialog.png differ diff --git a/docs/screenshots/geofences.png b/docs/screenshots/geofences.png index eb8786cf..57b335fc 100644 Binary files a/docs/screenshots/geofences.png and b/docs/screenshots/geofences.png differ diff --git a/docs/screenshots/gyms-add-dialog.png b/docs/screenshots/gyms-add-dialog.png index 55ff2823..cd9a236b 100644 Binary files a/docs/screenshots/gyms-add-dialog.png and b/docs/screenshots/gyms-add-dialog.png differ diff --git a/docs/screenshots/gyms.png b/docs/screenshots/gyms.png index 2aa9ec98..de5f9ef9 100644 Binary files a/docs/screenshots/gyms.png and b/docs/screenshots/gyms.png differ diff --git a/docs/screenshots/help-page.png b/docs/screenshots/help-page.png index 36a28598..b810fcd3 100644 Binary files a/docs/screenshots/help-page.png and b/docs/screenshots/help-page.png differ diff --git a/docs/screenshots/invasions-add-dialog.png b/docs/screenshots/invasions-add-dialog.png index 57201233..032059f2 100644 Binary files a/docs/screenshots/invasions-add-dialog.png and b/docs/screenshots/invasions-add-dialog.png differ diff --git a/docs/screenshots/invasions.png b/docs/screenshots/invasions.png index 85faee81..8c00c44f 100644 Binary files a/docs/screenshots/invasions.png and b/docs/screenshots/invasions.png differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png index 89f71897..7f1696c9 100644 Binary files a/docs/screenshots/login.png and b/docs/screenshots/login.png differ diff --git a/docs/screenshots/lures-add-dialog.png b/docs/screenshots/lures-add-dialog.png index cf070963..a8097cc6 100644 Binary files a/docs/screenshots/lures-add-dialog.png and b/docs/screenshots/lures-add-dialog.png differ diff --git a/docs/screenshots/lures.png b/docs/screenshots/lures.png index d3aad798..d3adbc67 100644 Binary files a/docs/screenshots/lures.png and b/docs/screenshots/lures.png differ diff --git a/docs/screenshots/max-battles-add-dialog.png b/docs/screenshots/max-battles-add-dialog.png index d5f57a5c..c8911d26 100644 Binary files a/docs/screenshots/max-battles-add-dialog.png and b/docs/screenshots/max-battles-add-dialog.png differ diff --git a/docs/screenshots/max-battles.png b/docs/screenshots/max-battles.png index 20f037cc..0782276a 100644 Binary files a/docs/screenshots/max-battles.png and b/docs/screenshots/max-battles.png differ diff --git a/docs/screenshots/mobile-areas.png b/docs/screenshots/mobile-areas.png index e86c2b3c..ffd7cba6 100644 Binary files a/docs/screenshots/mobile-areas.png and b/docs/screenshots/mobile-areas.png differ diff --git a/docs/screenshots/mobile-dashboard.png b/docs/screenshots/mobile-dashboard.png index 00f81ff4..2e1cf117 100644 Binary files a/docs/screenshots/mobile-dashboard.png and b/docs/screenshots/mobile-dashboard.png differ diff --git a/docs/screenshots/mobile-geofences.png b/docs/screenshots/mobile-geofences.png index def925a8..c14865a8 100644 Binary files a/docs/screenshots/mobile-geofences.png and b/docs/screenshots/mobile-geofences.png differ diff --git a/docs/screenshots/mobile-pokemon.png b/docs/screenshots/mobile-pokemon.png index c63a8283..655afeb2 100644 Binary files a/docs/screenshots/mobile-pokemon.png and b/docs/screenshots/mobile-pokemon.png differ diff --git a/docs/screenshots/mobile-profiles.png b/docs/screenshots/mobile-profiles.png index cbed52f6..bb5027cf 100644 Binary files a/docs/screenshots/mobile-profiles.png and b/docs/screenshots/mobile-profiles.png differ diff --git a/docs/screenshots/mobile-sidenav.png b/docs/screenshots/mobile-sidenav.png index a3dd0019..e39343c7 100644 Binary files a/docs/screenshots/mobile-sidenav.png and b/docs/screenshots/mobile-sidenav.png differ diff --git a/docs/screenshots/my-webhooks.png b/docs/screenshots/my-webhooks.png index b5b374ba..483dc69e 100644 Binary files a/docs/screenshots/my-webhooks.png and b/docs/screenshots/my-webhooks.png differ diff --git a/docs/screenshots/nests-add-dialog.png b/docs/screenshots/nests-add-dialog.png index 24f2e801..e2b98b12 100644 Binary files a/docs/screenshots/nests-add-dialog.png and b/docs/screenshots/nests-add-dialog.png differ diff --git a/docs/screenshots/nests.png b/docs/screenshots/nests.png index 321502ba..a513fb8f 100644 Binary files a/docs/screenshots/nests.png and b/docs/screenshots/nests.png differ diff --git a/docs/screenshots/places-section.png b/docs/screenshots/places-section.png new file mode 100644 index 00000000..aa3f76d9 Binary files /dev/null and b/docs/screenshots/places-section.png differ diff --git a/docs/screenshots/pokemon-add-dialog.png b/docs/screenshots/pokemon-add-dialog.png index 660e8e9f..5ba78908 100644 Binary files a/docs/screenshots/pokemon-add-dialog.png and b/docs/screenshots/pokemon-add-dialog.png differ diff --git a/docs/screenshots/pokemon.png b/docs/screenshots/pokemon.png index f6cacab5..caab7a47 100644 Binary files a/docs/screenshots/pokemon.png and b/docs/screenshots/pokemon.png differ diff --git a/docs/screenshots/profiles.png b/docs/screenshots/profiles.png index 678e8bf7..ca5ada91 100644 Binary files a/docs/screenshots/profiles.png and b/docs/screenshots/profiles.png differ diff --git a/docs/screenshots/quests-add-dialog.png b/docs/screenshots/quests-add-dialog.png index 3a0d2491..7823e522 100644 Binary files a/docs/screenshots/quests-add-dialog.png and b/docs/screenshots/quests-add-dialog.png differ diff --git a/docs/screenshots/quests.png b/docs/screenshots/quests.png index e732da94..06e8e876 100644 Binary files a/docs/screenshots/quests.png and b/docs/screenshots/quests.png differ diff --git a/docs/screenshots/quick-picks.png b/docs/screenshots/quick-picks.png index 83d8adf2..3f12eb08 100644 Binary files a/docs/screenshots/quick-picks.png and b/docs/screenshots/quick-picks.png differ diff --git a/docs/screenshots/raids-add-dialog.png b/docs/screenshots/raids-add-dialog.png index 6bbc51d6..997b4de6 100644 Binary files a/docs/screenshots/raids-add-dialog.png and b/docs/screenshots/raids-add-dialog.png differ diff --git a/docs/screenshots/raids.png b/docs/screenshots/raids.png index c67c4407..42393c66 100644 Binary files a/docs/screenshots/raids.png and b/docs/screenshots/raids.png differ diff --git a/docs/screenshots/scope-picker.png b/docs/screenshots/scope-picker.png new file mode 100644 index 00000000..975fda9d Binary files /dev/null and b/docs/screenshots/scope-picker.png differ diff --git a/docs/screenshots/toolbar-theme.png b/docs/screenshots/toolbar-theme.png index 8e9d5dde..7186a22d 100644 Binary files a/docs/screenshots/toolbar-theme.png and b/docs/screenshots/toolbar-theme.png differ diff --git a/docs/screenshots/user-menu.png b/docs/screenshots/user-menu.png index 5402648d..398fc529 100644 Binary files a/docs/screenshots/user-menu.png and b/docs/screenshots/user-menu.png differ diff --git a/docs/screenshots/where-chip.png b/docs/screenshots/where-chip.png new file mode 100644 index 00000000..62e59a2c Binary files /dev/null and b/docs/screenshots/where-chip.png differ diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fc907914..86f1b483 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,5 +1,36 @@ # Troubleshooting +## Sign-in fails with "Invalid OAuth2 redirect_uri" + +**Problem**: Clicking sign in sends you to Discord (or your OIDC provider) and it refuses with an invalid `redirect_uri`. Inspecting the URL shows the callback as `http://your-host/api/auth/discord/callback` even though the site is served over HTTPS. + +**Solution**: You are behind a reverse proxy that PoracleWeb.NET has not been told to trust, so its `X-Forwarded-Proto: https` is discarded and the callback URL is built from the plain HTTP request the app actually received. The app logs a warning naming this when it happens — check the container logs to confirm. + +The direct fix is to state the URL rather than let the app infer it: + +```env +PUBLIC_URL=https://poracle.example.com +``` + +Also declare the proxy, which fixes the same problem at the source and additionally stops everyone behind it sharing a single rate-limit bucket: + +```env +PROXY_KNOWN_NETWORKS=172.18.0.0/16,10.0.0.0/8 +# or, for a single address: +# PROXY_KNOWN_PROXIES=127.0.0.1 +``` + +Use the address the proxy connects **from**, as the container sees it. Then recreate the container: `docker compose up -d --force-recreate`. + +Two things to check if it still comes back as `http://`: + +- Your `docker-compose.yml` must actually pass the variable through. The shipped example uses `env_file: - .env`, which covers everything; a hand-edited file with an explicit `environment:` list needs `PROXY_KNOWN_PROXIES` and `PROXY_KNOWN_NETWORKS` added to it. Confirm with `docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PROXY`. +- Your proxy must send `X-Forwarded-Proto`. Nginx needs `proxy_set_header X-Forwarded-Proto $scheme;` explicitly; Caddy and Cloudflare Tunnel send it by default. + +The same misconfiguration also puts every user behind the proxy into a single rate-limit bucket. See [Behind a reverse proxy](getting-started/standalone-setup.md#reverse-proxy-optional). + +--- + ## Container exits on startup: `Configuration 'Cors:AllowedOrigins' is required` **Problem**: The container crash-loops on start. Logs show `System.InvalidOperationException: Configuration 'Cors:AllowedOrigins' is required in non-development environments.` @@ -212,12 +243,12 @@ UPDATE gym SET team = 4 WHERE team = 0; ## Monster filter defaults (size, max_level, etc.) — legacy -!!! note "Legacy issue" - This was caused by direct database writes with incorrect C# model defaults. New alarms created through the PoracleNG API proxy have correct defaults. The SQL queries below help diagnose alarms created before the migration. +!!! note "Legacy issue (PoracleJS only)" + This was caused by direct database writes with incorrect C# model defaults in early versions of PoracleWeb.NET. New alarms created through the PoracleNG API proxy have correct defaults applied by PoracleNG itself. The SQL queries below help diagnose alarms created before the migration, on PoracleJS installations. -**Problem**: New monster alarms created via the web UI may silently filter out pokemon if model defaults don't match PoracleJS expectations. For example, `max_size=0` causes all pokemon with size data to be rejected, and `size=0` instead of `size=-1` shows incorrectly in the old PHP UI as "-XXL". +**Problem**: On PoracleJS, monster alarms created by old versions of PoracleWeb.NET may silently filter out pokemon if model defaults don't match PoracleJS expectations. For example, `max_size=0` causes all pokemon with size data to be rejected, and `size=0` instead of `size=-1` shows incorrectly as "-XXL". This does not apply to PoracleNG, which applies its own defaults on every write. -**Solution**: All Create model defaults are aligned with the PHP PoracleWeb.NET `include/defaults.php`. Key values: +**Solution**: All Create model defaults are aligned with the values Poracle itself expects. Key values: - `size=-1` means "no size filter" (not `0`) - `max_size=5` means "up to XXL" @@ -231,7 +262,7 @@ If users report missing alerts, check the `monsters` table for rows where max fi -- Find alarms with broken size filter (rejects all pokemon with size data) SELECT * FROM monsters WHERE max_size = 0; --- Find alarms with incorrect "no size filter" value (shows as "-XXL" in PHP UI) +-- Find alarms with incorrect "no size filter" value (shows as "-XXL") SELECT * FROM monsters WHERE size = 0; ``` @@ -241,7 +272,7 @@ SELECT * FROM monsters WHERE size = 0; **Problem**: Auto-profile switches happen hours earlier or later than the configured active hours schedule. -**Solution**: The profile has `0,0` coordinates, so PoracleNG's scheduler falls back to UTC instead of the user's local timezone. Set a location on the affected profile via the Dashboard or Areas page. The Profiles page shows a red location warning banner on profiles that have no coordinates set. +**Solution**: The profile has `0,0` coordinates, so PoracleNG's scheduler falls back to UTC instead of the user's local timezone. Set the pin on the affected profile via the Dashboard or the Areas & Places page. The Profiles page shows a red location warning banner on profiles that have no coordinates set. --- @@ -295,3 +326,231 @@ docker exec poracleweb.net printenv | grep -i golbat # Check app logs for Golbat activity docker logs poracleweb.net 2>&1 | grep -i golbat ``` + +--- + +## Quest summary delivery menu is missing + +**Problem**: The **Quest summary delivery** item does not appear in the Quests page **⋮** menu. + +**Solution**: The menu is shown only when the connected PoracleNG instance reports quest summaries as enabled. PoracleWeb.NET reads the effective `tracking.quest_summary_enabled` flag from PoracleNG's `/api/config/values` endpoint (cached for five minutes). If the menu is missing: + +1. **Enable the feature on the bot**: set `quest_summary_enabled = true` under `[tracking]` in PoracleNG's `config.toml` and restart the processor. +2. **Make sure the processor API is reachable**: PoracleWeb.NET must be able to reach PoracleNG over HTTP. If they run on different machines or in separate containers, set `host = "0.0.0.0"` (or the LAN IP) under `[processor]` in PoracleNG's config — the `127.0.0.1` default refuses off-box connections. +3. **Wait out the cache / hard refresh**: the capability is cached for five minutes; reload the Quests page (Ctrl+Shift+R) after enabling. + +!!! note + A transient `503` from PoracleNG's summary endpoints is treated as a temporary backend fault, **not** as "feature off." The feature flag is read from the config endpoint, not inferred from a 503. + +--- + +## Send summary now delivers nothing + +**Problem**: Pressing **Send summary now** succeeds but no summary DM arrives. + +**Solution**: Send summary now flushes only the quests PoracleNG has **buffered** since your last summary. An empty buffer delivers nothing — which is expected, not an error. To buffer quests: + +1. **Enable Daily summary on at least one quest alarm** (the per-alarm toggle in the quest add/edit dialog). Only alarms with this toggle are buffered; the rest deliver immediately. +2. **Confirm the feature is enabled on the bot** (`tracking.quest_summary_enabled = true`) — when it is off, PoracleNG's matcher does not buffer at all, so the buffer stays empty. +3. **Give it time**: quests are buffered as they match. Right after enabling the feature, or after a summary fires, the buffer starts empty and fills as matching quests come in. PoracleNG's status log shows the current count (`Summary: N buffered`). + +--- + +## External SSO / OIDC login + +The issues below cover the generic external OIDC/OAuth2 login provider. For the full settings reference, see [External SSO](configuration/external-sso.md); for the silent-refresh feature, see [OIDC Refresh Tokens](configuration/oidc-refresh-tokens.md). + +### "External login failed" / 405 on the token or userinfo call + +**Problem**: The OIDC login starts, the user authenticates at the provider, but the callback redirects to `/login#error=oidc_token_exchange_failed` (or `oidc_userinfo_failed`). The provider's logs show a `405 Method Not Allowed` on the token or userinfo request. + +**Solution**: Some providers are *split-host* — the browser-facing authorize endpoint lives on one host (the frontend/login host) while the token and userinfo endpoints live on a separate API host. Pointing `OIDC_TOKEN_URL` / `OIDC_USERINFO_URL` at the frontend host hits a static site with no POST handler, which returns `405`. + +Set each endpoint to its correct host: + +```env +OIDC_AUTHORIZATION_URL=https://login.provider.example/oauth2/authorize +OIDC_TOKEN_URL=https://api.provider.example/oauth2/token +OIDC_USERINFO_URL=https://api.provider.example/oauth2/userinfo +``` + +Only `OIDC_AUTHORIZATION_URL` belongs on the frontend host; `OIDC_TOKEN_URL` and `OIDC_USERINFO_URL` go to the API host. + +--- + +### redirect_uri mismatch / invalid redirect + +**Problem**: The provider rejects the login with an "invalid redirect URI" or "redirect_uri mismatch" error before the user ever reaches PoracleWeb.NET's callback. + +**Solution**: PoracleWeb.NET builds the callback URL as `{scheme}://{Host}/api/auth/oidc/callback` from the **incoming request Host header**, and that exact URL must be registered at the IdP. In local development the Angular dev-server proxy preserves `Host = localhost:4201`, so the callback becomes `:4201`, not the API's `:5048`. Register every host that can originate the request as an allowed redirect URI at the provider: + +```text +http://localhost:5048/api/auth/oidc/callback +http://localhost:4201/api/auth/oidc/callback +https://poracle.example.com/api/auth/oidc/callback +``` + +Include your real production host alongside the two local-dev URIs. + +--- + +### 404 after OIDC login in local dev (standalone `ng serve`) + +**Problem**: Running the Angular dev server standalone, OIDC login completes at the provider but the browser lands on a 404 instead of the dashboard. + +**Solution**: The callback issues a `302` to the Angular client route `/auth/oidc/callback#token=…`. The committed `proxy.conf.json` proxies `/auth` to the API, so the dev server forwards that client route to the API (which has no such route) → `404`. Run the dev server with an `/api`-only proxy so Angular serves `/auth/*` itself: + +```json +{ + "/api": { "target": "http://localhost:5048", "secure": false } +} +``` + +A ready-made `proxy.local.json` is provided for this; start the dev server with `ng serve --proxy-config proxy.local.json`. + +!!! note "Only affects standalone `ng serve`" + When the API serves the built SPA (Docker, production), there is no separate dev-server proxy and Angular's router handles `/auth/oidc/callback` directly — this issue does not occur. + +--- + +### `#error=user_not_registered` at the callback + +**Problem**: Login succeeds at the provider but the browser returns to `/login#error=user_not_registered`. + +**Solution**: The value carried by the configured identity claim has no matching row in the Poracle `human` table — the SSO user has no Poracle account. PoracleWeb.NET reads `OIDC_IDENTITY_CLAIM` (default `discord_id`, falling back to the standard `sub` claim) and looks that value up as the Poracle human id. To fix: + +1. Ensure the claim carries the user's Poracle id — a linked Discord or Telegram id, not an internal SSO/email id. +2. Confirm a matching user actually exists in Poracle (they must have registered with the bot). + +!!! note "PogoAlerts users must link Discord" + At PogoAlerts the user must have Discord linked to their account so the provider emits the `discord_id` claim. Without a linked Discord, no `discord_id` is sent and the lookup fails. + +--- + +### Silent refresh not happening / no refresh token issued + +**Problem**: Sessions still expire at the full JWT lifetime instead of refreshing silently. The app logs *"OIDC refresh tokens are enabled but the provider returned no refresh token (offline_access not granted?); falling back to a standard session."* + +**Solution**: Standards-compliant providers only issue a refresh token when the `offline_access` scope is requested and granted. If `OIDC_OFFLINE_ACCESS_SCOPE` is blanked (or the provider declined to grant it), the token response carries no refresh token, and PoracleWeb.NET gracefully falls back to a normal full-lifetime session — no error is shown to the user. + +1. Set `OIDC_USE_REFRESH_TOKENS=true`. +2. Ensure the provider actually issues refresh tokens: leave `OIDC_OFFLINE_ACCESS_SCOPE=offline_access` (the default) so the scope is requested, or use the provider's own mechanism (e.g. Google's `?access_type=offline`). + +See [OIDC Refresh Tokens](configuration/oidc-refresh-tokens.md) for the full setup. + +--- + +### Token endpoint returns 400/401 invalid_client + +**Problem**: The token exchange fails with the provider returning `400`/`401` and an `invalid_client` error, so the callback redirects to `/login#error=oidc_token_exchange_failed`. + +**Solution**: `OIDC_TOKEN_AUTH_METHOD` must match how the provider expects client credentials presented: + +- `client_secret_post` — credentials sent in the request **body**. +- `client_secret_basic` — credentials sent in the HTTP **Basic** `Authorization` header. + +Match the provider's expectation: Keycloak and Okta default to `client_secret_basic`; Auth0, Azure, and PogoAlerts use `client_secret_post`. + +--- + +### Locked out after switching to SSO (provider down / misconfigured) + +**Problem**: An admin set `enable_oidc` to SSO mode, the login page now auto-redirects to the provider, and the provider is down or misconfigured — nobody can sign in to fix it. + +**Solution**: This is the break-glass scenario. Set the env flag and restart: + +```env +AUTH_FORCE_LOCAL=true +``` + +This forces the local login page regardless of the `enable_oidc` mode, so an admin can sign in and disable or repair the OIDC configuration. Once fixed, remove the flag and restart. + +!!! note "Admins can always reach local login" + Even when `enable_oidc` is off (or a provider is broken), admins can always reach the local login page — the `enable_oidc` gate is enforced *after* authentication so an admin is never locked out of re-enabling or fixing the setting. + +--- + +### "Sign out everywhere" 404s / single logout doesn't end the provider session + +**Problem**: The "Sign out everywhere" option is missing, returns a 404, or signs the user out of PoracleWeb.NET but leaves the provider session active (so the next login skips re-authentication). + +**Solution**: RP-initiated single logout requires all three of: + +1. **`OIDC_END_SESSION_URL` configured** — without it, logout falls back to a plain local sign-out. +2. **`enable_oidc_slo` not set to `false`** — this admin runtime toggle defaults to on once the end-session URL is wired; an explicit `false` disables single logout. +3. **The `post_logout_redirect_uri` registered at the IdP** — PoracleWeb.NET sends `{origin}/login?loggedout=1`. If that URL isn't in the provider's allow-list, the provider rejects the logout redirect. + +Configure the end-session URL, leave `enable_oidc_slo` unset (or `true`), and register the post-logout redirect URI at the IdP. + +--- + +## Pokemon names and types stay English with the site set to another language + +**Symptom**: The interface is translated, but the species picker still lists Bulbasaur, Blastoise and Butterfree, and the type chips read Bug, Dark, Dragon. + +**Cause**: Those names come from Poracle, which translates them from its own bundle. If it cannot serve them, the site falls back to the English WatWowMap masterfile rather than showing nothing. + +**Checks, in order**: + +1. **Poracle is too old for the endpoint.** `GET /api/masterdata/monsters?locale=de` on your Poracle should answer with a map of `"{pokemonId}_{formId}"` entries. A 404 means the fallback is doing its job and only an upgrade will change it. + + ```bash + curl -s -H "X-Poracle-Secret: $SECRET" "http://poracle-host:3030/api/masterdata/monsters?locale=de" | head -c 200 + ``` + +2. **Poracle has no translation for that language.** It ships `de`, `en`, `es`, `fr`, `it`, `ja`, `nb-no`, `pl`, `ru`, `sv` and `zh-cn`. For `nl`, `pt`, `pt-BR` and `da` it returns English, and there is nothing to configure — the interface around the names is still translated. + +3. **Poracle's game-data locale files are missing.** If names come back as their own keys (`poke_25` rather than `Pikachu`), Poracle's resource download has failed. The site ignores such values and keeps the English name, so the symptom is English names rather than visible keys. Check Poracle's startup log for its resource fetch. + +4. **Move and item names are meant to be English.** Poracle serves no translated equivalent, so those pickers are unaffected by the display language. + +**Note**: this follows the **display** language, not the alert language. The alert language decides what your DMs say. See [Internationalization](features/internationalization.md). + +--- + +## An alarm type has disappeared and its admin toggle will not switch on + +**Symptom**: A type is gone from the sidebar and the dashboard, its page redirects away, and on **Admin > Settings** its toggle is off, greyed out and refuses to move. + +**Cause**: Poracle has that type disabled in its own config. Its processor drops the webhook and its bot refuses the command, so alarms of that type could never fire — this site honours that rather than offering a feature the server will not deliver. Rules users already had are not deleted; they lie dormant and come back if the type is switched on again. + +**Fix**: change it in Poracle's `config.toml`, not here. The flags are `disable_pokemon`, `disable_raid`, `disable_quest`, `disable_invasion`, `disable_lure`, `disable_nest`, `disable_gym`, `disable_max_battle` and `disable_fort_update`. Restart Poracle afterwards; this site re-reads them within five minutes, or immediately on restart. + +**Diagnostic**: + +```bash +# What Poracle reports as disabled +curl -s -H "X-Poracle-Secret: $SECRET" http://poracle-host:3030/api/config/poracleWeb | jq .disabledHooks + +# What this site resolved that into (any signed-in user) +curl -s -H "Authorization: Bearer $JWT" https://your-site/api/settings/upstream-disabled +``` + +An empty array on the first call, with the type still locked, means the local `disable_*` site setting is the one doing it — that one is yours to change on **Admin > Settings**. The page names which side it came from, so you should not have to run either command to find out. + +--- + +## A delegate cannot see "My Webhooks" + +**Symptom**: You granted someone a webhook on **Admin > Webhooks**, and they report no *My Webhooks* +item in the sidebar. + +**Checks, in order**: + +1. **Did the grant land?** Reopen the webhook's delegates dialog. A removable chip with their name + means it exists. A grant naming an account that does not exist is refused rather than stored, so an + absent chip usually means they had never signed in when you added them — they must register with + the Poracle bot and sign in once before they can be granted. + +2. **Give it a minute.** Delegation is resolved live but cached for about a minute per person. + +3. **Are they an administrator?** Admins can manage any webhook and are never listed as a delegate of + a particular one, so they never see the item — it is for delegates only. + +4. **Are you thinking of the bot?** `discord.webhook_admins` in Poracle's config grants access through + the Discord bot, not here. The reverse also holds: a row added here grants nothing to the bot. A + Poracle-side grant *does* work here, but not the other way round. + +**Note**: before v2.17 the item came from a claim stamped into the sign-in token, so a new delegate +waited up to 24 hours or had to sign out and back in. If you are running an older build, that is the +explanation — see [Webhooks & Delegates](features/webhooks.md). diff --git a/mkdocs.yml b/mkdocs.yml index c5b775d0..cc3e0568 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -79,6 +79,8 @@ nav: - Configuration: - Reference: configuration/reference.md - Site Settings: configuration/site-settings.md + - External SSO (OIDC): configuration/external-sso.md + - OIDC Refresh Tokens: configuration/oidc-refresh-tokens.md - Docker Compose: configuration/docker.md - Architecture: - Overview: architecture/overview.md @@ -89,7 +91,15 @@ nav: - Features: - Alarm Management: features/alarms.md - Profiles: features/profiles.md - - Custom Geofences: features/custom-geofences.md + - Quest Summary Delivery: features/quest-summary-schedules.md + - Webhooks & Delegates: features/webhooks.md + - Custom Geofences: + - Overview: features/custom-geofences/index.md + - Key Concepts: features/custom-geofences/key-concepts.md + - Koji & Regions: features/custom-geofences/koji-and-regions.md + - Private Geofences & Promotion: features/custom-geofences/private-and-promotion.md + - Admin Operations: features/custom-geofences/admin-operations.md + - Troubleshooting: features/custom-geofences/troubleshooting.md - Internationalization (i18n): features/internationalization.md - Development: - Testing: development/testing.md