From 577aa525edd05da488404308bd03b979fc511903 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 22 May 2026 09:55:22 -0400 Subject: [PATCH 01/59] ci: pin npm 11 to fix Dependabot frontend `npm ci` failures (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI installs Node 22 which ships npm 10.9.7. That version's `npm ci` strictly requires the nested `chokidar@4.0.3` / `readdirp@4.1.2` entries that `@angular-devkit/*` packages declare as optional peer deps. Dependabot regenerates `package-lock.json` with a newer npm that prunes those entries, producing lockfiles npm 10.9.7 rejects with EUSAGE — blocking #248, #250, #256, #261, #262. Aligning CI to npm 11 matches Dependabot's resolution so the post-rebase lockfile is accepted. --- .github/workflows/ci.yml | 7 +++++++ CHANGELOG.md | 3 +++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8a54e48..c61936f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,13 @@ jobs: 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/CHANGELOG.md b/CHANGELOG.md index 304c582c..2977e8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **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. From 91c892f714b0fa6303d1e2e8531a7a0cd6599685 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 09:57:20 -0400 Subject: [PATCH 02/59] deps: Bump the microsoft group with 11 updates (#268) Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.5 to 10.0.8 Bumps Microsoft.AspNetCore.Mvc.Testing from 10.0.5 to 10.0.8 Bumps Microsoft.AspNetCore.OpenApi from 10.0.5 to 10.0.8 Bumps Microsoft.EntityFrameworkCore from 10.0.5 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.Design from 10.0.5 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.5 to 10.0.8 Bumps Microsoft.Extensions.Caching.Memory from 10.0.5 to 10.0.8 Bumps Microsoft.Extensions.Configuration.Abstractions from 10.0.5 to 10.0.8 Bumps Microsoft.Extensions.Http from 10.0.5 to 10.0.8 Bumps Microsoft.Extensions.Logging.Abstractions from 10.0.5 to 10.0.8 Bumps Microsoft.NET.Test.Sdk from 18.4.0 to 18.5.1 --- updated-dependencies: - dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.AspNetCore.Mvc.Testing dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.AspNetCore.OpenApi dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.EntityFrameworkCore dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.EntityFrameworkCore dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.EntityFrameworkCore.Design dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.EntityFrameworkCore.InMemory dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.Extensions.Caching.Memory dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.Extensions.Logging.Abstractions dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.Extensions.Configuration.Abstractions dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.Extensions.Http dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: microsoft - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.5.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: microsoft ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj | 6 +++--- .../Pgan.PoracleWebNet.Core.Services.csproj | 8 ++++---- .../Pgan.PoracleWebNet.Data.Scanner.csproj | 4 ++-- .../Pgan.PoracleWebNet.Data.csproj | 4 ++-- .../Pgan.PoracleWebNet.Tests.csproj | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj b/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj index ebc4f244..6b4960b5 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj +++ b/Applications/Pgan.PoracleWebNet.Api/Pgan.PoracleWebNet.Api.csproj @@ -11,9 +11,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive 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..dde944da 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/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj b/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj index aeb789c7..4f6ab6a8 100644 --- a/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj +++ b/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj b/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj index 62f11b09..3cf69bf3 100644 --- a/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj +++ b/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj @@ -7,8 +7,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj index 3ae3ea8e..d9d684d7 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj +++ b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj @@ -9,10 +9,10 @@ - - + + - + From 364638a88a94321619670a750925fcf860214b3b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 10:07:57 -0400 Subject: [PATCH 03/59] deps: bump the angular group across 1 directory with 13 updates (#250) Bumps the angular group with 13 updates in the /Applications/Pgan.PoracleWebNet.App/ClientApp directory: | Package | From | To | | --- | --- | --- | | [@angular/animations](https://github.com/angular/angular/tree/HEAD/packages/animations) | `21.2.8` | `21.2.14` | | [@angular/cdk](https://github.com/angular/components) | `21.2.6` | `21.2.12` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.8` | `21.2.14` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.8` | `21.2.14` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.8` | `21.2.14` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.8` | `21.2.14` | | [@angular/material](https://github.com/angular/components) | `21.2.6` | `21.2.12` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.8` | `21.2.14` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.8` | `21.2.14` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.7` | `21.2.12` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.7` | `21.2.12` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.8` | `21.2.14` | | [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `21.2.8` | `21.2.14` | Updates `@angular/animations` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/animations) Updates `@angular/cdk` from 21.2.6 to 21.2.12 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.6...v21.2.12) Updates `@angular/common` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/common) Updates `@angular/compiler` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/compiler) Updates `@angular/core` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/core) Updates `@angular/forms` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/forms) Updates `@angular/material` from 21.2.6 to 21.2.12 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.6...v21.2.12) Updates `@angular/platform-browser` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/platform-browser) Updates `@angular/router` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/router) Updates `@angular/build` from 21.2.7 to 21.2.12 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.7...v21.2.12) Updates `@angular/cli` from 21.2.7 to 21.2.12 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.7...v21.2.12) Updates `@angular/compiler-cli` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/compiler-cli) Updates `@angular/platform-browser-dynamic` from 21.2.8 to 21.2.14 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.14/packages/platform-browser-dynamic) --- updated-dependencies: - dependency-name: "@angular/animations" dependency-version: 21.2.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/build" dependency-version: 21.2.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cdk" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/material" dependency-version: 21.2.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser-dynamic" dependency-version: 21.2.10 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ClientApp/package-lock.json | 354 ++++++------------ .../ClientApp/package.json | 26 +- 2 files changed, 122 insertions(+), 258 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 6184b7f6..34fc46ac 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -8,15 +8,15 @@ "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", + "@angular/animations": "^21.2.14", + "@angular/cdk": "^21.2.12", + "@angular/common": "^21.2.14", + "@angular/compiler": "^21.2.14", + "@angular/core": "^21.2.14", + "@angular/forms": "^21.2.14", + "@angular/material": "^21.2.12", + "@angular/platform-browser": "^21.2.14", + "@angular/router": "^21.2.14", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -33,10 +33,10 @@ "@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", + "@angular/build": "^21.2.12", + "@angular/cli": "^21.2.12", + "@angular/compiler-cli": "^21.2.14", + "@angular/platform-browser-dynamic": "^21.2.14", "@jest/globals": "^30.3.0", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.56.0", @@ -334,40 +334,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 +355,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.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.12.tgz", + "integrity": "sha512-nXms0jVWwHOJK+z6vHvhw7HYFBelxh2gEnkij0OQMABXZN5hoUlTD0DDP1lYR7hQNi8Yb2Ar0UN9ihyUFVM5Kg==", "dev": true, "license": "MIT", "dependencies": { @@ -463,40 +429,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 +492,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 +599,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 +650,9 @@ } }, "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.14", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.14.tgz", + "integrity": "sha512-9WLnsJE0xqtd1rVtHMvsAUxFy3OdPks4bdmUIqyw23X/je7ytUALAGWNadffcZBwRpa1A6TUnLr9X4+Draz3kw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -797,18 +661,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.8" + "@angular/core": "21.2.14" } }, "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.12", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.12.tgz", + "integrity": "sha512-zYfo21RuldDWXnshuPfWYtmh5ltlO9+XFHpNObdIInQTFxKD6grLNVNOblFFpi+oIIm4Km+CGSXvBHs/aH0ufA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.7", + "@angular-devkit/architect": "0.2102.12", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -851,7 +715,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.12", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -901,13 +765,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.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.12.tgz", + "integrity": "sha512-w9FSMHYeeHkk0kRSAOCvNqEVyOHqpC1SUf3iN7tDnXBOA0dtc6JYvJU7O4joiwf7wMPZDK8LKc/6eu8/Tx87Fw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.12", "rxjs": "7.8.2" }, "bin": { @@ -943,9 +807,9 @@ } }, "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==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.12.tgz", + "integrity": "sha512-wB4FLlAdYzQp5htHVKn+fXlNxkFSNw89jPfsJKc15UiadCay6GdzYASLyLqtbk6D4Jz1pBHUpI2ib3mjkCcwxg==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -959,19 +823,19 @@ } }, "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==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.12.tgz", + "integrity": "sha512-oLEL1C1fI39b1eQo5f2cyQhQfE+QMv7dm8z2MmxbP7YR7jAdQPVfGU8CXECR5g7mrYi9WgvIRKB+9Oeq2aH6Jw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.7", - "@angular-devkit/core": "21.2.7", - "@angular-devkit/schematics": "21.2.7", + "@angular-devkit/architect": "0.2102.12", + "@angular-devkit/core": "21.2.12", + "@angular-devkit/schematics": "21.2.12", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.7", + "@schematics/angular": "21.2.12", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -994,13 +858,13 @@ } }, "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==", + "version": "0.2102.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.12.tgz", + "integrity": "sha512-w9FSMHYeeHkk0kRSAOCvNqEVyOHqpC1SUf3iN7tDnXBOA0dtc6JYvJU7O4joiwf7wMPZDK8LKc/6eu8/Tx87Fw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.12", "rxjs": "7.8.2" }, "bin": { @@ -1013,13 +877,13 @@ } }, "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==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.12.tgz", + "integrity": "sha512-29xe6C9nwHejV9zBcu0js7NmzLWuCFzBGBTmL6eD4JN1NcxEZ/nO1JuaGINjPjzb/UDXPZIqEwHbnFNcGS5v1A==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.12", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -1160,9 +1024,9 @@ } }, "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==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { @@ -1193,9 +1057,9 @@ } }, "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==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.14.tgz", + "integrity": "sha512-J6K7cE7uKOKmg4+sxLeGfsmaYDjP5l1XCiMMI0WPT0t68uxLk8g3MzV5Trqfb6ZnRxWcfp9c4c+XxAvMBB7ymA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1204,14 +1068,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.8", + "@angular/core": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.8", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.8.tgz", - "integrity": "sha512-Il9KlT6qX8rWmun5jY6wMLx56bCQZpOVIFEyHM4ai2wmxvbqyxgRFKDs4iMRNn1h04Tgupl6cKSqP9lecIvH6w==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.14.tgz", + "integrity": "sha512-8mqgwRYfn2Z1vg/5YVt60dDBattnZL45nNJd2vTMwAiDTzhWhgKgRWKOeVL0aj2JqHeHiwuIlrLnz46acJMulQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1221,9 +1085,9 @@ } }, "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==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.14.tgz", + "integrity": "sha512-h+WQfPKFxaDfDhMqUUdOQ1TsDMccav8kLFERmKTRfD4MNOczSMpOMyeXJHCL0Rq4I8WDQvaBJGMG7DXRDefSog==", "dev": true, "license": "MIT", "dependencies": { @@ -1244,7 +1108,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.8", + "@angular/compiler": "21.2.14", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -1254,9 +1118,9 @@ } }, "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==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.14.tgz", + "integrity": "sha512-Z1Ivjh7L2lT//8LA7vQ3tj7Rg6wl2XRA5kPSAukgn8u0Yu0XxG8NE8KG0Eypb3v9CEcbwATwpgnxzbJFZ8TFcw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1265,7 +1129,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.8", + "@angular/compiler": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -1279,9 +1143,9 @@ } }, "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==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.14.tgz", + "integrity": "sha512-HQYIybyMt0CrI31rW6vXbiDsSM2DDtTcOVeT/nWDRNCoqBrREDg8rVsm2Y+fUMsiQVJNa6dCXPwvYhjzJ4r7ug==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1291,22 +1155,22 @@ "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", + "@angular/common": "21.2.14", + "@angular/core": "21.2.14", + "@angular/platform-browser": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0" } }, "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==", + "version": "21.2.12", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.12.tgz", + "integrity": "sha512-u4q6m6+UY0RPp9nAM4YXr9GdvEsD+tU3c8EWaOOeD2LNbPQmDPW2X5Uyx++MI1H6BrFhTPp05wQkD23752wl5w==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": "21.2.6", + "@angular/cdk": "21.2.12", "@angular/common": "^21.0.0 || ^22.0.0", "@angular/core": "^21.0.0 || ^22.0.0", "@angular/forms": "^21.0.0 || ^22.0.0", @@ -1315,9 +1179,9 @@ } }, "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==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.14.tgz", + "integrity": "sha512-34tBwxh86yN2YifBDhCesm6N+nn9WcbuXjRwfo0mTme15OZ/zt56yw7v1mcK3UFLegIIALtsIgpXXrPWWQoKkA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1326,9 +1190,9 @@ "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" + "@angular/animations": "21.2.14", + "@angular/common": "21.2.14", + "@angular/core": "21.2.14" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1337,9 +1201,9 @@ } }, "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==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.14.tgz", + "integrity": "sha512-m5U4zX8JFnxTAIGpsBXIAyefSmYqdORY/OfHC0aMmZovuFCbXXIYqYRQDBB7+YVNpSDSHllCrKEZFu/CC6dq3g==", "dev": true, "license": "MIT", "dependencies": { @@ -1349,16 +1213,16 @@ "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" + "@angular/common": "21.2.14", + "@angular/compiler": "21.2.14", + "@angular/core": "21.2.14", + "@angular/platform-browser": "21.2.14" } }, "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==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz", + "integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1367,9 +1231,9 @@ "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", + "@angular/common": "21.2.14", + "@angular/core": "21.2.14", + "@angular/platform-browser": "21.2.14", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -5688,14 +5552,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.12", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.12.tgz", + "integrity": "sha512-eHoAbxd6Kdw9YIQeZO/6lBXTmKKi10t4WTujY8CM5v4qv1zoJu9yiwVeQp9y3e7/Sybz5Ec3m4FmQ0Tw8iVDiA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", - "@angular-devkit/schematics": "21.2.7", + "@angular-devkit/core": "21.2.12", + "@angular-devkit/schematics": "21.2.12", "jsonc-parser": "3.3.1" }, "engines": { @@ -5705,13 +5569,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.12", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.12.tgz", + "integrity": "sha512-29xe6C9nwHejV9zBcu0js7NmzLWuCFzBGBTmL6eD4JN1NcxEZ/nO1JuaGINjPjzb/UDXPZIqEwHbnFNcGS5v1A==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.7", + "@angular-devkit/core": "21.2.12", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -5839,9 +5703,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.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { @@ -16016,9 +15880,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": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index fd65c83a..28c46e80 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -15,15 +15,15 @@ "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", + "@angular/animations": "^21.2.14", + "@angular/cdk": "^21.2.12", + "@angular/common": "^21.2.14", + "@angular/compiler": "^21.2.14", + "@angular/core": "^21.2.14", + "@angular/forms": "^21.2.14", + "@angular/material": "^21.2.12", + "@angular/platform-browser": "^21.2.14", + "@angular/router": "^21.2.14", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -40,10 +40,10 @@ "@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", + "@angular/build": "^21.2.12", + "@angular/cli": "^21.2.12", + "@angular/compiler-cli": "^21.2.14", + "@angular/platform-browser-dynamic": "^21.2.14", "@jest/globals": "^30.3.0", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.56.0", From 9a023c00017eae949d8f45c8bea843e7436d413e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 14:12:21 +0000 Subject: [PATCH 04/59] deps: bump @jest/globals (#262) Bumps [@jest/globals](https://github.com/jestjs/jest/tree/HEAD/packages/jest-globals) from 30.3.0 to 30.4.1. - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.4.1/packages/jest-globals) --- updated-dependencies: - dependency-name: "@jest/globals" dependency-version: 30.4.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ClientApp/package-lock.json | 455 +++++++++++++++++- .../ClientApp/package.json | 2 +- 2 files changed, 445 insertions(+), 12 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 34fc46ac..2d3ed232 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -37,7 +37,7 @@ "@angular/cli": "^21.2.12", "@angular/compiler-cli": "^21.2.14", "@angular/platform-browser-dynamic": "^21.2.14", - "@jest/globals": "^30.3.0", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", @@ -3465,21 +3465,422 @@ } }, "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/globals/node_modules/@jest/diff-sequences": { + "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": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/environment": { + "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.4.1", + "@jest/types": "30.4.1", + "@types/node": "*", + "jest-mock": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/expect": { + "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.4.1", + "jest-snapshot": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/expect-utils": { + "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": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/fake-timers": { + "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.4.1", + "@sinonjs/fake-timers": "^15.4.0", + "@types/node": "*", + "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" + } + }, + "node_modules/@jest/globals/node_modules/@jest/pattern": { + "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.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/schemas": { + "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": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/snapshot-utils": { + "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.4.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/transform": { + "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.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.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" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/@jest/types": { + "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.4.0", + "@jest/schemas": "30.4.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/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/@jest/globals/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/@jest/globals/node_modules/expect": { + "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.4.1", + "@jest/get-type": "30.1.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" + } + }, + "node_modules/@jest/globals/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", + "dependencies": { + "@jest/diff-sequences": "30.4.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.4.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/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": { + "@jest/types": "30.4.1", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "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" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/@jest/globals/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": { + "@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/globals/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": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.4.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "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/globals/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/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/globals/node_modules/jest-regex-util": { + "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": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/jest-snapshot": { + "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": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.4.1", + "@jest/get-type": "30.1.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.4.1", + "graceful-fs": "^4.2.11", + "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" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/@jest/globals/node_modules/jest-util": { + "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.4.1", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/jest-worker": { + "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.4.1", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals/node_modules/pretty-format": { + "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.4.1", + "ansi-styles": "^5.2.0", + "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" + } + }, + "node_modules/@jest/globals/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/@jest/pattern": { "version": "30.0.1", "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", @@ -5833,9 +6234,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": { @@ -12108,6 +12509,22 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-runtime/node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "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" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/jest-snapshot": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", @@ -14838,6 +15255,22 @@ "dev": true, "license": "MIT" }, + "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", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index 28c46e80..5a6ed00e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -44,7 +44,7 @@ "@angular/cli": "^21.2.12", "@angular/compiler-cli": "^21.2.14", "@angular/platform-browser-dynamic": "^21.2.14", - "@jest/globals": "^30.3.0", + "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", From 7dbf2df9729e258a66e7b8d4b214881c75e5a58f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 14:16:44 +0000 Subject: [PATCH 05/59] deps: bump jsdom in /Applications/Pgan.PoracleWebNet.App/ClientApp (#256) Bumps [jsdom](https://github.com/jsdom/jsdom) from 28.1.0 to 29.1.1. - [Release notes](https://github.com/jsdom/jsdom/releases) - [Commits](https://github.com/jsdom/jsdom/compare/v28.1.0...v29.1.1) --- updated-dependencies: - dependency-name: jsdom dependency-version: 29.1.1 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ClientApp/package-lock.json | 157 +++++++++--------- .../ClientApp/package.json | 2 +- 2 files changed, 75 insertions(+), 84 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 2d3ed232..4ea75ca7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -53,20 +53,13 @@ "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "jest-preset-angular": "^16.1.1", - "jsdom": "^28.0.0", + "jsdom": "^29.1.1", "prettier": "^3.8.1", "prettier-eslint": "^16.4.0", "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", @@ -1238,14 +1231,15 @@ } }, "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==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" }, @@ -1254,27 +1248,30 @@ } }, "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==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@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" + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "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/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -1891,9 +1888,9 @@ } }, "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.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", "dev": true, "funding": [ { @@ -1915,9 +1912,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.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", "dev": true, "funding": [ { @@ -1932,7 +1929,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" + "@csstools/css-calc": "^3.2.1" }, "engines": { "node": ">=20.19.0" @@ -1966,9 +1963,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.4", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", + "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", "dev": true, "funding": [ { @@ -8464,32 +8461,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", @@ -12691,36 +12662,36 @@ } }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "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.3.5", + "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.1", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -12731,6 +12702,26 @@ } } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -14461,12 +14452,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" @@ -14514,12 +14505,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" diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index 5a6ed00e..2b3fcaec 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -60,7 +60,7 @@ "jest": "^30.3.0", "jest-environment-jsdom": "^30.3.0", "jest-preset-angular": "^16.1.1", - "jsdom": "^28.0.0", + "jsdom": "^29.1.1", "prettier": "^3.8.1", "prettier-eslint": "^16.4.0", "ts-node": "^10.9.2", From 3de29f64c61d712a6831b5b9dca97c4f98347fdc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 14:20:56 +0000 Subject: [PATCH 06/59] deps: bump the jest group across 1 directory with 3 updates (#261) Bumps the jest group with 3 updates in the /Applications/Pgan.PoracleWebNet.App/ClientApp directory: [jest](https://github.com/jestjs/jest/tree/HEAD/packages/jest), [jest-environment-jsdom](https://github.com/jestjs/jest/tree/HEAD/packages/jest-environment-jsdom) and [jest-preset-angular](https://github.com/thymikee/jest-preset-angular). Updates `jest` from 30.3.0 to 30.4.2 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.4.2/packages/jest) Updates `jest-environment-jsdom` from 30.3.0 to 30.4.1 - [Release notes](https://github.com/jestjs/jest/releases) - [Changelog](https://github.com/jestjs/jest/blob/main/CHANGELOG.md) - [Commits](https://github.com/jestjs/jest/commits/v30.4.1/packages/jest-environment-jsdom) Updates `jest-preset-angular` from 16.1.4 to 16.1.5 - [Release notes](https://github.com/thymikee/jest-preset-angular/releases) - [Changelog](https://github.com/thymikee/jest-preset-angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/thymikee/jest-preset-angular/compare/v16.1.4...v16.1.5) --- updated-dependencies: - dependency-name: jest dependency-version: 30.4.2 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: jest - dependency-name: jest-environment-jsdom dependency-version: 30.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: jest - dependency-name: jest-preset-angular dependency-version: 16.1.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: jest ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ClientApp/package-lock.json | 1078 +++++------------ .../ClientApp/package.json | 6 +- 2 files changed, 335 insertions(+), 749 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 4ea75ca7..3819905e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -50,9 +50,9 @@ "eslint-plugin-prettier": "^5.5.0", "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", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-preset-angular": "^16.1.5", "jsdom": "^29.1.1", "prettier": "^3.8.1", "prettier-eslint": "^16.4.0", @@ -3288,17 +3288,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": { @@ -3306,38 +3306,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": { @@ -3353,131 +3354,6 @@ } }, "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", - "@types/node": "*", - "jest-mock": "30.3.0" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.3.0", - "@jest/fake-timers": "30.3.0", - "@jest/types": "30.3.0", - "@types/jsdom": "^21.1.7", - "@types/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "30.3.0", - "jest-snapshot": "30.3.0" - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.3.0", - "@sinonjs/fake-timers": "^15.0.0", - "@types/node": "*", - "jest-message-util": "30.3.0", - "jest-mock": "30.3.0", - "jest-util": "30.3.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "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.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/globals/node_modules/@jest/diff-sequences": { "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==", @@ -3487,7 +3363,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/@jest/environment": { + "node_modules/@jest/environment": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", @@ -3503,407 +3379,131 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/@jest/expect": { - "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.4.1", - "jest-snapshot": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/node_modules/@jest/expect-utils": { - "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": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/node_modules/@jest/fake-timers": { - "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.4.1", - "@sinonjs/fake-timers": "^15.4.0", - "@types/node": "*", - "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" - } - }, - "node_modules/@jest/globals/node_modules/@jest/pattern": { - "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.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/node_modules/@jest/schemas": { - "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": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/node_modules/@jest/snapshot-utils": { - "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.4.1", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/node_modules/@jest/transform": { + "node_modules/@jest/environment-jsdom-abstract": { "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", - "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", + "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": { - "@babel/core": "^7.27.4", + "@jest/environment": "30.4.1", + "@jest/fake-timers": "30.4.1", "@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.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" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/node_modules/@jest/types": { - "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.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", + "@types/jsdom": "^21.1.7", "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/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/@jest/globals/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/@jest/globals/node_modules/expect": { - "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.4.1", - "@jest/get-type": "30.1.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" - } - }, - "node_modules/@jest/globals/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", - "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/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": { - "@jest/types": "30.4.1", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "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" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/@jest/globals/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": { - "@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/globals/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": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "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/globals/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/types": "30.4.1", - "@types/node": "*", - "jest-util": "30.4.1" + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals/node_modules/jest-regex-util": { - "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": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/@jest/globals/node_modules/jest-snapshot": { + "node_modules/@jest/expect": { "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", - "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", + "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.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.4.1", - "graceful-fs": "^4.2.11", - "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" + "jest-snapshot": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/jest-util": { + "node_modules/@jest/expect-utils": { "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", + "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": { - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" + "@jest/get-type": "30.1.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals/node_modules/jest-worker": { + "node_modules/@jest/fake-timers": { "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==", + "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.4.1", + "@sinonjs/fake-timers": "^15.4.0", "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.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" } }, - "node_modules/@jest/globals/node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "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" } }, - "node_modules/@jest/globals/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "node_modules/@jest/globals": { + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", + "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", "dev": true, "license": "MIT", "dependencies": { - "has-flag": "^4.0.0" + "@jest/environment": "30.4.1", + "@jest/expect": "30.4.1", + "@jest/types": "30.4.1", + "jest-mock": "30.4.1" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" + "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", @@ -3916,9 +3516,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" @@ -3936,9 +3536,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": { @@ -3949,13 +3549,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" @@ -3980,14 +3580,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" }, @@ -3996,15 +3596,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": { @@ -4012,23 +3612,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" @@ -4045,14 +3645,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": "*", @@ -7470,16 +7070,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" @@ -7512,9 +7112,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": { @@ -7552,13 +7152,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": { @@ -9751,18 +9351,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" @@ -11556,16 +11156,16 @@ } }, "node_modules/jest": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", - "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "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.3.0", - "@jest/types": "30.3.0", + "@jest/core": "30.4.2", + "@jest/types": "30.4.1", "import-local": "^3.2.0", - "jest-cli": "30.3.0" + "jest-cli": "30.4.2" }, "bin": { "jest": "bin/jest.js" @@ -11583,14 +11183,14 @@ } }, "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==", + "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.3.0", + "jest-util": "30.4.1", "p-limit": "^3.1.0" }, "engines": { @@ -11598,29 +11198,29 @@ } }, "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==", + "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": { - "@jest/environment": "30.3.0", - "@jest/expect": "30.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@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.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", + "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.3.0", + "pretty-format": "30.4.1", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -11630,21 +11230,21 @@ } }, "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==", + "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.3.0", - "@jest/test-result": "30.3.0", - "@jest/types": "30.3.0", + "@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.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0", + "jest-config": "30.4.2", + "jest-util": "30.4.1", + "jest-validate": "30.4.1", "yargs": "^17.7.2" }, "bin": { @@ -11747,33 +11347,33 @@ } }, "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==", + "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": { "@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", + "@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.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", + "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.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -11798,25 +11398,25 @@ } }, "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==", + "version": "30.4.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", + "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.3.0", + "@jest/diff-sequences": "30.4.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==", + "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", "dependencies": { @@ -11827,31 +11427,31 @@ } }, "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==", + "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": { "@jest/get-type": "30.1.0", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "chalk": "^4.1.2", - "jest-util": "30.3.0", - "pretty-format": "30.3.0" + "jest-util": "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-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==", + "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": { - "@jest/environment": "30.3.0", - "@jest/environment-jsdom-abstract": "30.3.0", + "@jest/environment": "30.4.1", + "@jest/environment-jsdom-abstract": "30.4.1", "jsdom": "^26.1.0" }, "engines": { @@ -12190,39 +11790,39 @@ } }, "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==", + "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": { - "@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/node": "*", - "jest-mock": "30.3.0", - "jest-util": "30.3.0", - "jest-validate": "30.3.0" + "jest-mock": "30.4.1", + "jest-util": "30.4.1", + "jest-validate": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "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==", + "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": { - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@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", + "jest-regex-util": "30.4.0", + "jest-util": "30.4.1", + "jest-worker": "30.4.1", "picomatch": "^4.0.3", "walker": "^1.0.8" }, @@ -12234,49 +11834,50 @@ } }, "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==", + "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/get-type": "30.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" } }, "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==", + "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": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.3.0", - "pretty-format": "30.3.0" + "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-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==", + "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": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.3.0", + "@jest/types": "30.4.1", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", + "jest-util": "30.4.1", "picomatch": "^4.0.3", - "pretty-format": "30.3.0", + "pretty-format": "30.4.1", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -12285,15 +11886,15 @@ } }, "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==", + "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/types": "30.3.0", + "@jest/types": "30.4.1", "@types/node": "*", - "jest-util": "30.3.0" + "jest-util": "30.4.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -12318,9 +11919,9 @@ } }, "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==", + "version": "16.1.5", + "resolved": "https://registry.npmjs.org/jest-preset-angular/-/jest-preset-angular-16.1.5.tgz", + "integrity": "sha512-4YNjA8O02TAQisr3JozsyFGQ4Dkc3FQyGebjpRZfXhiMo32arYW1bXZ5KXCjSe4OoXZrFV5T5nrV7SXr4NmBuA==", "dev": true, "license": "MIT", "dependencies": { @@ -12348,9 +11949,9 @@ } }, "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": { @@ -12358,18 +11959,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" }, @@ -12378,46 +11979,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" }, @@ -12447,32 +12048,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" }, @@ -12480,26 +12081,10 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/@jest/globals": { - "version": "30.3.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", - "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", - "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" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, "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": { @@ -12508,20 +12093,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" }, @@ -12530,13 +12115,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", @@ -12548,18 +12133,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" @@ -12579,19 +12164,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": { @@ -12599,15 +12184,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" }, @@ -15084,15 +14669,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" diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index 2b3fcaec..5334e0fa 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -57,9 +57,9 @@ "eslint-plugin-prettier": "^5.5.0", "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", + "jest": "^30.4.2", + "jest-environment-jsdom": "^30.4.1", + "jest-preset-angular": "^16.1.5", "jsdom": "^29.1.1", "prettier": "^3.8.1", "prettier-eslint": "^16.4.0", From 657b52e267d93abab8a343ca82894e9ee3202347 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 10:41:47 -0400 Subject: [PATCH 07/59] deps: bump the eslint group across 1 directory with 5 updates (#248) Bumps the eslint group with 3 updates in the /Applications/Pgan.PoracleWebNet.App/ClientApp directory: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [eslint-plugin-perfectionist](https://github.com/azat-io/eslint-plugin-perfectionist) and [prettier](https://github.com/prettier/prettier). Updates `@typescript-eslint/eslint-plugin` from 8.58.1 to 8.59.4 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.4/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.58.1 to 8.59.4 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.4/packages/parser) Updates `@typescript-eslint/utils` from 8.58.1 to 8.59.4 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.59.4/packages/utils) Updates `eslint-plugin-perfectionist` from 5.8.0 to 5.9.0 - [Release notes](https://github.com/azat-io/eslint-plugin-perfectionist/releases) - [Changelog](https://github.com/azat-io/eslint-plugin-perfectionist/blob/main/changelog.md) - [Commits](https://github.com/azat-io/eslint-plugin-perfectionist/compare/v5.8.0...v5.9.0) Updates `prettier` from 3.8.2 to 3.8.3 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.58.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint - dependency-name: "@typescript-eslint/parser" dependency-version: 8.58.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint - dependency-name: "@typescript-eslint/utils" dependency-version: 8.58.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint - dependency-name: eslint-plugin-perfectionist dependency-version: 5.9.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: eslint - dependency-name: prettier dependency-version: 3.8.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: HokiePokeDad --- .../ClientApp/package-lock.json | 134 +++++++++--------- .../ClientApp/package.json | 6 +- 2 files changed, 70 insertions(+), 70 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 3819905e..4217f60e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -39,14 +39,14 @@ "@angular/platform-browser-dynamic": "^21.2.14", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.59.4", "@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-plugin-import": "^2.32.0", - "eslint-plugin-perfectionist": "^5.8.0", + "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-prettier": "^5.5.0", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", @@ -54,7 +54,7 @@ "jest-environment-jsdom": "^30.4.1", "jest-preset-angular": "^16.1.5", "jsdom": "^29.1.1", - "prettier": "^3.8.1", + "prettier": "^3.8.3", "prettier-eslint": "^16.4.0", "ts-node": "^10.9.2", "typescript": "~5.9.2" @@ -6110,17 +6110,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", + "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", "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.59.4", + "@typescript-eslint/type-utils": "8.59.4", + "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6133,22 +6133,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.59.4", "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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", + "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", "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.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "engines": { @@ -6164,14 +6164,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", + "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.59.4", + "@typescript-eslint/types": "^8.59.4", "debug": "^4.4.3" }, "engines": { @@ -6186,14 +6186,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", + "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6204,9 +6204,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", + "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", "dev": true, "license": "MIT", "engines": { @@ -6221,15 +6221,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", + "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", "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.59.4", + "@typescript-eslint/typescript-estree": "8.59.4", + "@typescript-eslint/utils": "8.59.4", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6246,9 +6246,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", + "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", "dev": true, "license": "MIT", "engines": { @@ -6260,16 +6260,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", + "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", "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.59.4", + "@typescript-eslint/tsconfig-utils": "8.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6288,9 +6288,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.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "dev": true, "license": "ISC", "bin": { @@ -6301,16 +6301,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", + "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", "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.59.4", + "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/typescript-estree": "8.59.4" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6325,13 +6325,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.59.4", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", + "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.59.4", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -9003,13 +9003,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.9.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.9.0.tgz", + "integrity": "sha512-8TWzg02zmnBdZwCkWLi8jhzqXI+fE7Z/RwV8SL6xD45tJ8Bp3wGuYL2XtQgfe/Wd0eBqOUX+s6ey73IyszvKTA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.58.0", + "@typescript-eslint/utils": "^8.58.2", "natural-orderby": "^5.0.0" }, "engines": { @@ -14399,9 +14399,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.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", "bin": { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index 5334e0fa..adea2899 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -46,14 +46,14 @@ "@angular/platform-browser-dynamic": "^21.2.14", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^8.59.4", "@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-plugin-import": "^2.32.0", - "eslint-plugin-perfectionist": "^5.8.0", + "eslint-plugin-perfectionist": "^5.9.0", "eslint-plugin-prettier": "^5.5.0", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", @@ -61,7 +61,7 @@ "jest-environment-jsdom": "^30.4.1", "jest-preset-angular": "^16.1.5", "jsdom": "^29.1.1", - "prettier": "^3.8.1", + "prettier": "^3.8.3", "prettier-eslint": "^16.4.0", "ts-node": "^10.9.2", "typescript": "~5.9.2" From 7022ab877e4389f3730732fb49492ce50dc0b2db Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 22 May 2026 11:13:48 -0400 Subject: [PATCH 08/59] ci: fix Dependabot auto-merge workflow never firing on PRs (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auto-merge-deps workflow listed both `pull_request_target` and `push:` as triggers. In practice GitHub fired it only on push events — the last 100+ runs were all `push`, zero were `pull_request_target` — even though the sibling `pr-labeler.yml` (only `pull_request_target`) fires correctly. The job-level `if: github.event_name == 'pull_request_target'` then skipped every step on those push runs, recording each as failure with 0 successful steps. Two changes: - Drop the `push:` trigger so only pull_request_target events run. - Drop the job-level gate; gate each step instead and add a sentinel first step so non-Dependabot PRs still record as success rather than 0-step failure. #231 attempted this with job-level if assuming "all-skipped = success", but GitHub treats 0-job runs as failure regardless. --- .github/workflows/auto-merge-deps.yml | 20 ++++++++++++-------- CHANGELOG.md | 1 + 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-merge-deps.yml b/.github/workflows/auto-merge-deps.yml index eabad42e..d4db158f 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,13 +15,15 @@ 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]' diff --git a/CHANGELOG.md b/CHANGELOG.md index 2977e8ea..78442bf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **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 From 1163f75ddac542d709dd265f0d727d0d2f35c443 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 22 May 2026 11:27:45 -0400 Subject: [PATCH 09/59] ci: fix unquoted colon in auto-merge-deps workflow body string (#276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The approval body string contained Auto-approved: — an unquoted colon inside an unquoted YAML scalar. PyYAML rejects this with `mapping values are not allowed here`, and GitHub Actions appears to silently fail to register the workflow's `pull_request_target` trigger as a result (the workflow only ever fires on push events, the friendly name from `name:` never resolves in the API). Sibling `pr-labeler.yml` has no such ambiguity and fires correctly. Dropping the colon from the body resolves both symptoms without needing to nest YAML quoting. --- .github/workflows/auto-merge-deps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-merge-deps.yml b/.github/workflows/auto-merge-deps.yml index d4db158f..8649e3c5 100644 --- a/.github/workflows/auto-merge-deps.yml +++ b/.github/workflows/auto-merge-deps.yml @@ -53,7 +53,7 @@ jobs: 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 }} From 637d938b19f98b004d9712047f885ef674f2f206 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 15:33:16 +0000 Subject: [PATCH 10/59] deps: Bump coverlet.collector from 8.0.1 to 10.0.1 (#270) --- updated-dependencies: - dependency-name: coverlet.collector dependency-version: 10.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: test - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.5.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: test ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj index d9d684d7..5360b567 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj +++ b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj @@ -8,7 +8,7 @@ - + From 9f14e6c5ed381adcaec44f7a168351fe4fbb6ec7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 11:49:28 -0400 Subject: [PATCH 11/59] deps: Bump Microsoft.EntityFrameworkCore and 3 others (#266) Bumps Microsoft.EntityFrameworkCore from 10.0.5 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.Design from 10.0.5 to 10.0.8 Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.5 to 10.0.8 Bumps MySql.EntityFrameworkCore from 10.0.1 to 10.0.7 --- updated-dependencies: - dependency-name: Microsoft.EntityFrameworkCore dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ef-core - dependency-name: Microsoft.EntityFrameworkCore.Design dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ef-core - dependency-name: MySql.EntityFrameworkCore dependency-version: 10.0.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ef-core - dependency-name: Microsoft.EntityFrameworkCore.InMemory dependency-version: 10.0.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: ef-core ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> --- .../Pgan.PoracleWebNet.Data.Scanner.csproj | 2 +- Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 4f6ab6a8..71bd7bc4 100644 --- a/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj +++ b/Data/Pgan.PoracleWebNet.Data.Scanner/Pgan.PoracleWebNet.Data.Scanner.csproj @@ -9,7 +9,7 @@ - + diff --git a/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj b/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj index 3cf69bf3..e22e036f 100644 --- a/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj +++ b/Data/Pgan.PoracleWebNet.Data/Pgan.PoracleWebNet.Data.csproj @@ -12,7 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 8c6287fb97cb9e0a06becd36e1ecf27fa3408828 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 22 May 2026 12:35:56 -0400 Subject: [PATCH 12/59] chore: ignore Playwright MCP artifacts at repo root (#277) Loose .png screenshots from Playwright MCP sessions and the `.playwright-mcp/` output directory keep showing up as untracked at the repo root. None of them belong in the tree (tracked PNGs all live under `Applications/Pgan.PoracleWebNet.App/ClientApp/public/assets/`). Add a root-only `/*.png` rule plus `.playwright-mcp/` so `git status` stays clean. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 9ce5b7db..b9a77d75 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,10 @@ 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 From 1f5c4b76a47ecedaa3f9e6294cd7872ba980d260 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 22 May 2026 17:25:39 -0400 Subject: [PATCH 13/59] fix(scanner): use `|` as LIKE escape char to stop MariaDB syntax error (#260) (#279) LikeEscape (added in #232) used `\` as the SQL LIKE escape character, and ScannerService.SearchGymsAsync passed `\` to EF.Functions.Like via `"\\"`. MariaDB's default mode (`NO_BACKSLASH_ESCAPES=OFF`) also treats `\` as a string-literal escape, so an escaped `\` in the pattern (which LikeEscape itself produces for user-supplied backslashes) left the SQL string literal unbalanced and broke gym search with `near ''\')`. Switch the escape character to `|`, which has no special meaning in MariaDB string literals. The LIKE pattern can no longer interact with quote escaping no matter what the user types. Added a `LikeEscape.EscapeChar` constant so callers stay in sync. Tests updated to match the new escape sequences. Reported by @prof-miles0 in #260. --- CHANGELOG.md | 1 + Core/Pgan.PoracleWebNet.Core.Services/LikeEscape.cs | 11 ++++++++--- .../ScannerService.cs | 2 +- .../Controllers/ScannerControllerTests.cs | 11 ++++++----- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78442bf7..d71ecc74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **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. - **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. 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/ScannerService.cs b/Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs index d009bf1d..d27905ee 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/ScannerService.cs @@ -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/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); From 019deb8a26ba2ee53bad1440d203ca3cf691f27d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:29:21 +0000 Subject: [PATCH 14/59] deps: bump the angular group (#286) Bumps the angular group in /Applications/Pgan.PoracleWebNet.App/ClientApp with 13 updates: | Package | From | To | | --- | --- | --- | | [@angular/animations](https://github.com/angular/angular/tree/HEAD/packages/animations) | `21.2.14` | `21.2.15` | | [@angular/cdk](https://github.com/angular/components) | `21.2.12` | `21.2.13` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.14` | `21.2.15` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.14` | `21.2.15` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.14` | `21.2.15` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.14` | `21.2.15` | | [@angular/material](https://github.com/angular/components) | `21.2.12` | `21.2.13` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.14` | `21.2.15` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.14` | `21.2.15` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.12` | `21.2.13` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.12` | `21.2.13` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.14` | `21.2.15` | | [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `21.2.14` | `21.2.15` | Updates `@angular/animations` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/animations) Updates `@angular/cdk` from 21.2.12 to 21.2.13 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.12...v21.2.13) Updates `@angular/common` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/common) Updates `@angular/compiler` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/compiler) Updates `@angular/core` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/core) Updates `@angular/forms` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/forms) Updates `@angular/material` from 21.2.12 to 21.2.13 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.12...v21.2.13) Updates `@angular/platform-browser` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/platform-browser) Updates `@angular/router` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/router) Updates `@angular/build` from 21.2.12 to 21.2.13 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.12...v21.2.13) Updates `@angular/cli` from 21.2.12 to 21.2.13 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.12...v21.2.13) Updates `@angular/compiler-cli` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/compiler-cli) Updates `@angular/platform-browser-dynamic` from 21.2.14 to 21.2.15 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.15/packages/platform-browser-dynamic) --- updated-dependencies: - dependency-name: "@angular/animations" dependency-version: 21.2.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cdk" dependency-version: 21.2.13 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/material" dependency-version: 21.2.13 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/build" dependency-version: 21.2.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser-dynamic" dependency-version: 21.2.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ClientApp/package-lock.json | 200 +++++++++--------- .../ClientApp/package.json | 26 +-- 2 files changed, 113 insertions(+), 113 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index 4217f60e..d1db2391 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -8,15 +8,15 @@ "name": "client-app", "version": "0.0.0", "dependencies": { - "@angular/animations": "^21.2.14", - "@angular/cdk": "^21.2.12", - "@angular/common": "^21.2.14", - "@angular/compiler": "^21.2.14", - "@angular/core": "^21.2.14", - "@angular/forms": "^21.2.14", - "@angular/material": "^21.2.12", - "@angular/platform-browser": "^21.2.14", - "@angular/router": "^21.2.14", + "@angular/animations": "^21.2.15", + "@angular/cdk": "^21.2.13", + "@angular/common": "^21.2.15", + "@angular/compiler": "^21.2.15", + "@angular/core": "^21.2.15", + "@angular/forms": "^21.2.15", + "@angular/material": "^21.2.13", + "@angular/platform-browser": "^21.2.15", + "@angular/router": "^21.2.15", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -33,10 +33,10 @@ "@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.12", - "@angular/cli": "^21.2.12", - "@angular/compiler-cli": "^21.2.14", - "@angular/platform-browser-dynamic": "^21.2.14", + "@angular/build": "^21.2.13", + "@angular/cli": "^21.2.13", + "@angular/compiler-cli": "^21.2.15", + "@angular/platform-browser-dynamic": "^21.2.15", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.59.4", @@ -348,9 +348,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.12.tgz", - "integrity": "sha512-nXms0jVWwHOJK+z6vHvhw7HYFBelxh2gEnkij0OQMABXZN5hoUlTD0DDP1lYR7hQNi8Yb2Ar0UN9ihyUFVM5Kg==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.13.tgz", + "integrity": "sha512-9jLaHcUr6BumIY9nCsBib1q62p259nf++gd2igYJ7mLm1w/0wEacsZ1cC8wCGEe6vx8a+DrD+EVCQ6zivePG2A==", "dev": true, "license": "MIT", "dependencies": { @@ -643,9 +643,9 @@ } }, "node_modules/@angular/animations": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.14.tgz", - "integrity": "sha512-9WLnsJE0xqtd1rVtHMvsAUxFy3OdPks4bdmUIqyw23X/je7ytUALAGWNadffcZBwRpa1A6TUnLr9X4+Draz3kw==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.15.tgz", + "integrity": "sha512-Z8AsLTwc++Fcu0fJnclAF9zMfumAd5KXrwtSdyECqLpqd+lEmmsOpeOl6P7loqdDz99KYh/8UF4eJxdMvnsaKw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -654,18 +654,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.14" + "@angular/core": "21.2.15" } }, "node_modules/@angular/build": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.12.tgz", - "integrity": "sha512-zYfo21RuldDWXnshuPfWYtmh5ltlO9+XFHpNObdIInQTFxKD6grLNVNOblFFpi+oIIm4Km+CGSXvBHs/aH0ufA==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.13.tgz", + "integrity": "sha512-Y9TDAaTQ+E5LScCKA/hPZmns/7Mpu6J2BiPj2cETA1xNjvgRpeb5Mh32KuhZb20NSFLvjpdnLuBTTtbym7hevw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.12", + "@angular-devkit/architect": "0.2102.13", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -708,7 +708,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.12", + "@angular/ssr": "^21.2.13", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -758,13 +758,13 @@ } }, "node_modules/@angular/build/node_modules/@angular-devkit/architect": { - "version": "0.2102.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.12.tgz", - "integrity": "sha512-w9FSMHYeeHkk0kRSAOCvNqEVyOHqpC1SUf3iN7tDnXBOA0dtc6JYvJU7O4joiwf7wMPZDK8LKc/6eu8/Tx87Fw==", + "version": "0.2102.13", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.13.tgz", + "integrity": "sha512-fheyi0gPx6b7tT+WQ+ePlzdGqKjPLUK72wg5Z9pkVtQ5+VN/8yB9mlRlmoivngd2FeNG9wMeNynWZGYycnOWVw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.12", + "@angular-devkit/core": "21.2.13", "rxjs": "7.8.2" }, "bin": { @@ -800,9 +800,9 @@ } }, "node_modules/@angular/cdk": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.12.tgz", - "integrity": "sha512-wB4FLlAdYzQp5htHVKn+fXlNxkFSNw89jPfsJKc15UiadCay6GdzYASLyLqtbk6D4Jz1pBHUpI2ib3mjkCcwxg==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.13.tgz", + "integrity": "sha512-nQGGJ6Efqi8n0qhT/PllsaIIY+vz+TL7/tpR7F2QKiqzS/9l4m7ea0vvS6fSMGrjEbqbkzTHbjLDsIg6X2hK+w==", "license": "MIT", "dependencies": { "parse5": "^8.0.0", @@ -816,19 +816,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.12.tgz", - "integrity": "sha512-oLEL1C1fI39b1eQo5f2cyQhQfE+QMv7dm8z2MmxbP7YR7jAdQPVfGU8CXECR5g7mrYi9WgvIRKB+9Oeq2aH6Jw==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.13.tgz", + "integrity": "sha512-j1kOV/f0og/3xCwG7Y8RyPd6V7uYfX2NuvXbvN1mzgxLLN2mu6CTsvPg5l/9Pu9SJI3KOPRgDxWyuP3k8KuzMg==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.12", - "@angular-devkit/core": "21.2.12", - "@angular-devkit/schematics": "21.2.12", + "@angular-devkit/architect": "0.2102.13", + "@angular-devkit/core": "21.2.13", + "@angular-devkit/schematics": "21.2.13", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.12", + "@schematics/angular": "21.2.13", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -851,13 +851,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { - "version": "0.2102.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.12.tgz", - "integrity": "sha512-w9FSMHYeeHkk0kRSAOCvNqEVyOHqpC1SUf3iN7tDnXBOA0dtc6JYvJU7O4joiwf7wMPZDK8LKc/6eu8/Tx87Fw==", + "version": "0.2102.13", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.13.tgz", + "integrity": "sha512-fheyi0gPx6b7tT+WQ+ePlzdGqKjPLUK72wg5Z9pkVtQ5+VN/8yB9mlRlmoivngd2FeNG9wMeNynWZGYycnOWVw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.12", + "@angular-devkit/core": "21.2.13", "rxjs": "7.8.2" }, "bin": { @@ -870,13 +870,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.12.tgz", - "integrity": "sha512-29xe6C9nwHejV9zBcu0js7NmzLWuCFzBGBTmL6eD4JN1NcxEZ/nO1JuaGINjPjzb/UDXPZIqEwHbnFNcGS5v1A==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.13.tgz", + "integrity": "sha512-gifpOcMNiAy49lQmQKhzpxoSfS3qJQSEdJSF5m7RVFkAcmllfcCD76GPN4dhho3wdAnbZ3qr54LtDqrGY4xNjw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.12", + "@angular-devkit/core": "21.2.13", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -1050,9 +1050,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.14.tgz", - "integrity": "sha512-J6K7cE7uKOKmg4+sxLeGfsmaYDjP5l1XCiMMI0WPT0t68uxLk8g3MzV5Trqfb6ZnRxWcfp9c4c+XxAvMBB7ymA==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.15.tgz", + "integrity": "sha512-PHbICQe4YCXnax2FcmKUpiffs8XPW9A0KlZF35qgJoQyBMBZx5F8c8geCh25jxtq77n3eBTmOa/WIAdSqiitkQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1061,14 +1061,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.14", + "@angular/core": "21.2.15", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.14.tgz", - "integrity": "sha512-8mqgwRYfn2Z1vg/5YVt60dDBattnZL45nNJd2vTMwAiDTzhWhgKgRWKOeVL0aj2JqHeHiwuIlrLnz46acJMulQ==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.15.tgz", + "integrity": "sha512-nwpNb+NbVUNzR3cck0QXbU/oFK7BpmXOXVnN/w7+P4+TsFUYeTtO1Ojbc15jkqe6mSM0lBvGlcoztVblHQkqcw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1078,9 +1078,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.14.tgz", - "integrity": "sha512-h+WQfPKFxaDfDhMqUUdOQ1TsDMccav8kLFERmKTRfD4MNOczSMpOMyeXJHCL0Rq4I8WDQvaBJGMG7DXRDefSog==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.15.tgz", + "integrity": "sha512-/MU7OA9d/e9P5SthR+N6JJObBmzcGsgNQaeQ2YfSUnU0lCRVQweTWwxLFDbfU6UX8MZFWB6pdI57zod8r5kXUw==", "dev": true, "license": "MIT", "dependencies": { @@ -1101,7 +1101,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.14", + "@angular/compiler": "21.2.15", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -1111,9 +1111,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.14.tgz", - "integrity": "sha512-Z1Ivjh7L2lT//8LA7vQ3tj7Rg6wl2XRA5kPSAukgn8u0Yu0XxG8NE8KG0Eypb3v9CEcbwATwpgnxzbJFZ8TFcw==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.15.tgz", + "integrity": "sha512-J5JsUnNtQURdeA7EA3DoCsMBizW3l01gfqM326Al72Ou3woFWmRb5P3LOXpIOzAeMQhO6Z5tW+B1t+4qmoq7uw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1122,7 +1122,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.14", + "@angular/compiler": "21.2.15", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -1136,9 +1136,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.14.tgz", - "integrity": "sha512-HQYIybyMt0CrI31rW6vXbiDsSM2DDtTcOVeT/nWDRNCoqBrREDg8rVsm2Y+fUMsiQVJNa6dCXPwvYhjzJ4r7ug==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.15.tgz", + "integrity": "sha512-swGUHgbBrPNvODPR9qBP6+vT2EHiyW361iEgS3HpTmvDhF/kD4l8NE0vh3P5N0DnEtGh4umOCKfQ1w6hPJ7lqA==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1148,22 +1148,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.14", - "@angular/core": "21.2.14", - "@angular/platform-browser": "21.2.14", + "@angular/common": "21.2.15", + "@angular/core": "21.2.15", + "@angular/platform-browser": "21.2.15", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/material": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.12.tgz", - "integrity": "sha512-u4q6m6+UY0RPp9nAM4YXr9GdvEsD+tU3c8EWaOOeD2LNbPQmDPW2X5Uyx++MI1H6BrFhTPp05wQkD23752wl5w==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.13.tgz", + "integrity": "sha512-6gWFb9LNh4cRIvkdocktej6MUVuGa9HQvap+j9gbZOtiveD7ER+FByUPlLlypreRebF29G2MRZeshKSdmv4NbA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": "21.2.12", + "@angular/cdk": "21.2.13", "@angular/common": "^21.0.0 || ^22.0.0", "@angular/core": "^21.0.0 || ^22.0.0", "@angular/forms": "^21.0.0 || ^22.0.0", @@ -1172,9 +1172,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.14.tgz", - "integrity": "sha512-34tBwxh86yN2YifBDhCesm6N+nn9WcbuXjRwfo0mTme15OZ/zt56yw7v1mcK3UFLegIIALtsIgpXXrPWWQoKkA==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.15.tgz", + "integrity": "sha512-O4ZHVV/rxkK1AuiD9M3UssL/HkoQvBcZy2+U421IMNibclGhwH9aRwc/0ZlQ7zpseS9+KPZ23FebvN4/92IbPg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1183,9 +1183,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.14", - "@angular/common": "21.2.14", - "@angular/core": "21.2.14" + "@angular/animations": "21.2.15", + "@angular/common": "21.2.15", + "@angular/core": "21.2.15" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1194,9 +1194,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.14.tgz", - "integrity": "sha512-m5U4zX8JFnxTAIGpsBXIAyefSmYqdORY/OfHC0aMmZovuFCbXXIYqYRQDBB7+YVNpSDSHllCrKEZFu/CC6dq3g==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.15.tgz", + "integrity": "sha512-3xvlWLZlsWjPyJFGatOOsod/f5AFjmSUDoOXo0zsr2ckHc4TxbDTnkLULhRSWv6m68fKOdQb8Si8rI15gC5yqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1206,16 +1206,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.14", - "@angular/compiler": "21.2.14", - "@angular/core": "21.2.14", - "@angular/platform-browser": "21.2.14" + "@angular/common": "21.2.15", + "@angular/compiler": "21.2.15", + "@angular/core": "21.2.15", + "@angular/platform-browser": "21.2.15" } }, "node_modules/@angular/router": { - "version": "21.2.14", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.14.tgz", - "integrity": "sha512-Yo3LdgcqkfMu2/Ycl8o/4QjCBqZhtA+a7B8JVdW5cWdrpFTxKCOrzm+YRUMuIFmH5nzSv9oGnUuz64uk1+7r5Q==", + "version": "21.2.15", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.15.tgz", + "integrity": "sha512-Cej4hYkmaTB6wXn1xQPlr4O1wHgUD0WLv//Oue1IssKqL8vkzic5f5x/H/bxtxxGlSnc+i6uIUF/lvjdGoWk/A==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1224,9 +1224,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.14", - "@angular/core": "21.2.14", - "@angular/platform-browser": "21.2.14", + "@angular/common": "21.2.15", + "@angular/core": "21.2.15", + "@angular/platform-browser": "21.2.15", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -5550,14 +5550,14 @@ "license": "MIT" }, "node_modules/@schematics/angular": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.12.tgz", - "integrity": "sha512-eHoAbxd6Kdw9YIQeZO/6lBXTmKKi10t4WTujY8CM5v4qv1zoJu9yiwVeQp9y3e7/Sybz5Ec3m4FmQ0Tw8iVDiA==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.13.tgz", + "integrity": "sha512-e5guslSLKbb3PJ6gUuVqM+V9xgn68cJkG1IyBohho34shbpOeoWW2eYdWQQjxvn0KUdgEhYSRBluBamCHngaUA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.12", - "@angular-devkit/schematics": "21.2.12", + "@angular-devkit/core": "21.2.13", + "@angular-devkit/schematics": "21.2.13", "jsonc-parser": "3.3.1" }, "engines": { @@ -5567,13 +5567,13 @@ } }, "node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": { - "version": "21.2.12", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.12.tgz", - "integrity": "sha512-29xe6C9nwHejV9zBcu0js7NmzLWuCFzBGBTmL6eD4JN1NcxEZ/nO1JuaGINjPjzb/UDXPZIqEwHbnFNcGS5v1A==", + "version": "21.2.13", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.13.tgz", + "integrity": "sha512-gifpOcMNiAy49lQmQKhzpxoSfS3qJQSEdJSF5m7RVFkAcmllfcCD76GPN4dhho3wdAnbZ3qr54LtDqrGY4xNjw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.12", + "@angular-devkit/core": "21.2.13", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index adea2899..374a83c0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -15,15 +15,15 @@ "private": true, "packageManager": "npm@11.5.2", "dependencies": { - "@angular/animations": "^21.2.14", - "@angular/cdk": "^21.2.12", - "@angular/common": "^21.2.14", - "@angular/compiler": "^21.2.14", - "@angular/core": "^21.2.14", - "@angular/forms": "^21.2.14", - "@angular/material": "^21.2.12", - "@angular/platform-browser": "^21.2.14", - "@angular/router": "^21.2.14", + "@angular/animations": "^21.2.15", + "@angular/cdk": "^21.2.13", + "@angular/common": "^21.2.15", + "@angular/compiler": "^21.2.15", + "@angular/core": "^21.2.15", + "@angular/forms": "^21.2.15", + "@angular/material": "^21.2.13", + "@angular/platform-browser": "^21.2.15", + "@angular/router": "^21.2.15", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -40,10 +40,10 @@ "@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.12", - "@angular/cli": "^21.2.12", - "@angular/compiler-cli": "^21.2.14", - "@angular/platform-browser-dynamic": "^21.2.14", + "@angular/build": "^21.2.13", + "@angular/cli": "^21.2.13", + "@angular/compiler-cli": "^21.2.15", + "@angular/platform-browser-dynamic": "^21.2.15", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.59.4", From ec70bde6fdf073701a842b24295cce41529e1067 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Tue, 2 Jun 2026 20:03:37 -0400 Subject: [PATCH 15/59] feat(raids): redesign level selector with named tiers + custom palette (#259) (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(raids): redesign level selector with named tiers + custom palette (#259) The raid/egg add dialog had three level-pickers (raid checkboxes, egg checkboxes, boss-level dropdown) all driven by a hardcoded `levels = [1, 2, 3, 4, 5, 6]` array, even though PoracleNG accepts any positive integer. Users with Elite Raids (level 7) or custom server schemes had to configure those via the bot's `!command` interface; the UI silently locked them out. Replace all three sites with a new `` shared component — a Material 3 chip listbox in three sections: - STANDARD: T1-T5 - SPECIAL: Mega (6), Elite (7), plus "Any" (9000) when showAny=true - CUSTOM: any user-added integer, persisted per-user in localStorage Power users add a custom level via an inline "+ Add level" affordance that transforms into a numeric input. The chip then persists across dialog opens (one-click selection on subsequent alarms) and seeds itself from saved alarm data on open, so editing an existing level-42 alarm renders the chip pre-selected rather than orphaned. Single source of truth for label resolution lives in `core/models/raid-level.models.ts`. `resolveLevel(value)` maps any integer to the right LevelOption — adopted by raid-list cards too, so the dialog and the cards now speak the same vocabulary ("Elite", not "Level 7" vs "7" on different surfaces). Edge cases: - 0 / negatives / non-integers in custom input -> inline validation error - 9000 in custom input -> snaps to the "Any" chip (no duplicate) - duplicate of built-in -> flashes existing chip + selects, no new entry - 20-entry LRU cap on the localStorage palette i18n: new `RAIDS.LEVEL.*` keys added in all 11 locales with English fallbacks for non-en (translation volunteers can localize later, per discussion #211). Bonus correctness: the boss tab used to default level=0 ("any") but PoracleNG's canonical wildcard sentinel is 9000. New alarms now use 9000; old alarms with 0 continue to work and edit fine. 37 new unit tests across the model, store, pipe, and component. Closes #259, reported by @prof-miles0. * fix(raids): address #259 review — compact layout, per-type palette, unblock save Follow-up on d58752e (the initial #259 redesign), addressing visual, correctness, and backend-validation issues found during testing. UI / UX - Collapse the three-section (Standard/Special/Custom) chip layout into one wrapping row per picker. Categories are encoded in chip content (T1 / "Mega · 6" / "42 ⊗") rather than container labels; cuts dialog height roughly in half. - Replace the heavy mat-form-field "+ Add custom level" with a chip-sized inline numeric input. Enter commits, Esc cancels, blur commits. Help text only renders when the input is open or there's a validation error. - Override Material 3 selected-chip font-weight so the selected state actually pops; bind `hideSingleSelectionIndicator` to `!multiple` so multi-select chips get a leading checkmark. - Cap card star icons to levels 1-7 (was 1-100) — alarms at level 23 no longer render 23 stars in the card. Per-type palette - Adding a custom level on the raid picker was leaking it into the egg and boss pickers. `CustomLevelStore` now keys palettes by `paletteKey` ("raid" / "egg" / "boss"), each persisted to its own localStorage slot. Required `paletteKey` input on ``. Any chip surfaced where PoracleNG actually honors it - Raid + boss pickers show the `Any` chip (PoracleNG treats level=9000 as the wildcard sentinel — see trackingRaid.go). - Egg picker deliberately omits Any: PoracleNG's trackingEgg.go only validates level >= 1 with no wildcard semantic, so an "Any egg" alarm at 9000 would simply never fire. Server-side fix that was blocking custom-level alarms - `[Range(0, 10)]` on `RaidCreate.Level`, `RaidUpdate.Level`, `EggCreate.Level`, `EggUpdate.Level` was rejecting custom integers (8+) and the new Any=9000 sentinel with 400 Bad Request before they could reach PoracleNG. Relaxed to `[Range(0, int.MaxValue)]` matching PoracleNG's actual range. Label vocabulary consistency - Edit dialog (raid/egg) now uses the same `resolveLevel` resolver as the cards via the new `LevelLabelPipe` — an alarm at level 7 reads "Elite" on the card AND in the edit dialog (was "Level 7" in the dialog before). Egg image alt-text in the card list now uses the pipe too. Error UX - Removing a custom chip (`⊗`) opens a 3-second snackbar with Undo — accidental click is recoverable; intentional removal still wipes the palette entry. State-machine clarity - `addInputOpen: signal(boolean)` replaced with an explicit `addMode: signal<'closed' | 'open'>` and named `isAddClosed()` / `isAddOpen()` getters in the template — removes the `!` negation pattern that prior renders sometimes appeared to misread. Tests - Updated `custom-level-store.service.spec.ts` for the keyed API. - Updated `level-selector.component.spec.ts` to set `paletteKey` per test and assert per-key isolation. - 697/697 frontend tests pass, 1063/1063 backend tests pass. i18n - Added `RAIDS.LEVEL.REMOVED` and `COMMON.UNDO` keys in all 11 locales (English placeholder text for non-en — translation volunteers per discussion #211). * fix(raids): align level selector with WatWowMap masterfile (19 named levels) Builds on the v2 review-pass (2c2f0aa). Follow-up driven by the issue reporter pointing to the canonical Pokémon GO raid level vocabulary in the WatWowMap masterfile — there are 19 named raid types (1-Star through Coordinated 2), not 7, and the prior UI labeled level 7 as "Elite" when the masterfile says it's "Mega Legendary". Backend - `GET /api/masterdata/raid-levels` returns the canonical list with per-level integer, category, and singular/plural English names. - New `IRaidLevelService` / `RaidLevelService` returns a baked-in snapshot of the masterfile. A TODO documents how to swap the implementation for a live fetch from raw.githubusercontent.com/WatWowMap/Masterfile-Generator without changing the wire contract. - 6 new unit tests cover the service + controller endpoint. Frontend - `RaidLevelService` (Angular) calls the new API on first dialog use, caches the result in a signal. Falls back to `KNOWN_LEVELS` baked-in constants when the network fails or before resolve. - `raid-level.models.ts` rewritten around 19 canonical levels keyed by `RAIDS.LEVEL.RAID_1` through `RAID_19` (plus `_PLURAL` variants). Removed the bogus `T1`-`T5` / `MEGA` / `ELITE` keys. - `LevelSelectorComponent` simplified to a `pickerType` input (`'raid' | 'egg' | 'boss'`). Inputs: • raid → primary chips 1-7, overflow menu for 8-19, Any chip, +Add • egg → star tiers (1-5) only, +Add (no overflow, no Any) • boss → single-select with the same primary + overflow as raid "More raid types…" overlay menu (mat-menu) surfaces the 12 less common levels without crowding the chip row. - i18n: 19 singular + 19 plural keys in all 11 locales, with English placeholders for the 10 non-en locales (volunteers per #211). - Card star icons now render only for the literal 1-5 "N Star Raid" tier (was 1-7, producing ~23 stars for custom-level alarms). - Label vocabulary consistency: alarm at level 7 now reads "Mega Legendary Raid" on the card and in the edit dialog (was "Elite" on the card, "Level 7" in the edit dialog). Forward compatibility - Any positive integer remains addable via the `+ Add` chip. When the WatWowMap masterfile adds raid_20+ in the future, the backend service can pick it up automatically (once the live-fetch path is wired); existing custom alarms at that level continue to work. Tests: 711/711 frontend, 1069/1069 backend. Lint + prettier clean. * fix(raids): shorten labels, ephemeral palette, review fixes Follow-up on 12be778 (v3 masterfile alignment). User feedback + internal PR review revealed several issues; this commit addresses them. User feedback - Custom levels typed via `+ Add` were persisting across modal close AND page refresh because the per-type palette was backed by localStorage. Deleted CustomLevelStore entirely; LevelSelector now tracks the palette in a local `customPalette` signal that lives for the component lifetime only. Refresh or close-and-reopen wipes typed-but-not-saved chips. Existing alarms still seed the palette through the `[value]` input. - Chip labels were too long ("Mega Legendary Raid"), and the same string caused "All Mega Legendary Raid Raids" double-Raid in card titles. Dropped the "Raid" suffix from the 19 RAID_N keys in all 11 locales — chips now read "Mega Legendary", "Legendary", "1 Star", etc. The card-title template (`RAIDS.ALL_LEVEL_RAIDS = "All {{level}} Raids"`) supplies the noun once; result reads natural English. Also dropped the unused `pluralKey` from `LevelOption` and the `_PLURAL` i18n keys (the shortened `RAID_N` strings work in both card and chip contexts). Backend `RaidLevelInfo.Name` is now the modifier form ("Mega Legendary") while `NamePlural` retains the full "Mega Legendary Raids" for any future standalone use. Review fixes (MUST FIX) - Snackbar undo subscription in `LevelSelector.removeCustom` now pipes through `takeUntilDestroyed(this.destroyRef)` so closing the dialog mid-toast can't fire the callback against a destroyed component. - `raid-list.getRaidLevelName` was bypassing the live `RaidLevelService.byValue()` and using the baked-in `KNOWN_LEVELS` constant — cards would drift from the dialog if the API ever extended the canonical list. Cards now consult the service first, fall back to the baked-in resolver. `raid-list.ngOnInit` primes the cache so the list page doesn't depend on a dialog open. - `LevelLabelPipe` now detects ngx-translate's "key not found" pass-through (translated string === key) and falls back to "Level {n}" instead of leaking "RAIDS.LEVEL.RAID_20" into the UI. Graceful degradation for future masterfile additions before locales catch up. Review fixes (SHOULD FIX) - `raid-edit-dialog.formatLevel` deleted — the dialog now injects `LevelLabelPipe` and calls `.transform()`, eliminating the duplicate label-resolution logic. Single source of truth. Analyzers - xUnit2032 in `MasterDataControllerRaidLevelsTests`: switched `Assert.IsAssignableFrom` to `Assert.IsType(..., exactMatch: false)`. - CA1707 in `RaidLevelServiceTests` and the new controller test: test method names renamed to PascalCase to match the project's preferred style and silence the analyzer. Dev workflow - New `proxy.conf.json` forwards `/api/*` and `/auth/*` from the Angular dev server to the API. `environment.development.ts` apiUrl set to `''` so all HTTP calls are same-origin from the browser's view — works identically for `ng serve` + proxy and for the production single-port deployment. OAuth flows survive the proxy because Host is preserved. Tests: 700/700 frontend (+1 fallback test), 1069/1069 backend. Lint + prettier + dotnet format (scoped) all clean. * docs: cover the level-selector redesign + ng-serve proxy workflow - features/alarms.md: replace the generic "tier" wording on Raids/Eggs rows with pointers to a new "Raid level selector" subsection that documents the chip layout, the 19 masterfile-defined raid types, primary vs overflow split per pickerType, the ephemeral custom-add affordance, and the wildcard sentinel. - architecture/backend.md: add a "Raid level service" section documenting IRaidLevelService, GET /api/masterdata/raid-levels, the baked-in fallback + live-fetch upgrade path, and the [Range] relax that lets PoracleNG-accepted custom integers pass validation. Also register the singleton in the service lifetimes table. - architecture/frontend.md: document LevelSelectorComponent + the Angular RaidLevelService consumer (signal cache, baked-in fallback, LevelLabelPipe missing-key fallback), and the ephemeral palette behavior. - getting-started/development-setup.md: the "proxies API requests" claim is now accurate thanks to the committed proxy.conf.json — describe it explicitly (changeOrigin: false to preserve Host for OAuth callbacks), note the empty apiUrl + same-origin dev flow, and document the --port override for matching a non-default Discord OAuth redirect URI. --- .../ServiceCollectionExtensions.cs | 1 + .../Controllers/MasterDataController.cs | 161 ++++++----- .../ClientApp/proxy.conf.json | 14 + .../app/core/models/raid-level.models.spec.ts | 114 ++++++++ .../src/app/core/models/raid-level.models.ts | 106 ++++++++ .../core/services/raid-level.service.spec.ts | 82 ++++++ .../app/core/services/raid-level.service.ts | 98 +++++++ .../raids/raid-add-dialog.component.html | 27 +- .../raids/raid-add-dialog.component.ts | 34 ++- .../raids/raid-edit-dialog.component.html | 2 +- .../raids/raid-edit-dialog.component.ts | 8 +- .../modules/raids/raid-list.component.html | 2 +- .../app/modules/raids/raid-list.component.ts | 29 +- .../level-selector.component.html | 102 +++++++ .../level-selector.component.scss | 189 +++++++++++++ .../level-selector.component.spec.ts | 198 ++++++++++++++ .../level-selector.component.ts | 255 ++++++++++++++++++ .../app/shared/pipes/level-label.pipe.spec.ts | 57 ++++ .../src/app/shared/pipes/level-label.pipe.ts | 40 +++ .../ClientApp/src/assets/i18n/da.json | 43 ++- .../ClientApp/src/assets/i18n/de.json | 43 ++- .../ClientApp/src/assets/i18n/en.json | 43 ++- .../ClientApp/src/assets/i18n/es.json | 43 ++- .../ClientApp/src/assets/i18n/fr.json | 43 ++- .../ClientApp/src/assets/i18n/it.json | 43 ++- .../ClientApp/src/assets/i18n/nl.json | 43 ++- .../ClientApp/src/assets/i18n/pl.json | 43 ++- .../ClientApp/src/assets/i18n/pt-BR.json | 43 ++- .../ClientApp/src/assets/i18n/pt.json | 43 ++- .../ClientApp/src/assets/i18n/sv.json | 43 ++- .../environments/environment.development.ts | 6 +- CHANGELOG.md | 1 + .../Services/IRaidLevelService.cs | 18 ++ .../EggCreate.cs | 3 +- .../EggUpdate.cs | 3 +- .../RaidCreate.cs | 6 +- .../RaidLevelInfo.cs | 25 ++ .../RaidUpdate.cs | 4 +- .../RaidLevelService.cs | 64 +++++ .../MasterDataControllerRaidLevelsTests.cs | 54 ++++ .../Controllers/MasterDataControllerTests.cs | 222 +++++++-------- .../Services/RaidLevelServiceTests.cs | 78 ++++++ docs/architecture/backend.md | 7 + docs/architecture/frontend.md | 8 + docs/features/alarms.md | 20 +- docs/getting-started/development-setup.md | 12 +- 46 files changed, 2269 insertions(+), 254 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts create mode 100644 Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/RaidLevelInfo.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index be23019d..b255baf2 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -77,6 +77,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs index 641e5315..a527210e 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs @@ -1,72 +1,89 @@ -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 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] + [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); + } + + [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.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/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/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/modules/raids/raid-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html index a8bf2586..5dd85d08 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 @@ -39,22 +39,10 @@

{{ 'RAIDS.SPECIFIC_GYM' | 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 }} - - } -
+
@@ -66,15 +54,8 @@

{{ 'RAIDS.EGG_LEVELS' | translate }}

{{ '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 }} - } - - +

{{ 'RAIDS.RAID_LEVEL_LABEL' | translate }}

+ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts index bb67d0c5..718b19a2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts @@ -1,7 +1,6 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; @@ -16,12 +15,14 @@ import { TranslateModule } from '@ngx-translate/core'; import { forkJoin } from 'rxjs'; import { RaidCreate, EggCreate } from '../../core/models'; +import { ANY_LEVEL_VALUE } from '../../core/models/raid-level.models'; import { AuthService } from '../../core/services/auth.service'; import { EggService } from '../../core/services/egg.service'; import { I18nService } from '../../core/services/i18n.service'; import { RaidService } from '../../core/services/raid.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; +import { LevelSelectorComponent } from '../../shared/components/level-selector/level-selector.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; @@ -36,7 +37,6 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatSlideToggleModule, MatIconModule, MatTabsModule, - MatCheckboxModule, MatRadioModule, MatSnackBarModule, MatProgressSpinnerModule, @@ -45,6 +45,7 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele TemplateSelectorComponent, DeliveryPreviewComponent, GymPickerComponent, + LevelSelectorComponent, ], selector: 'app-raid-add-dialog', standalone: true, @@ -57,9 +58,11 @@ export class RaidAddDialogComponent { private readonly i18n = inject(I18nService); private readonly raidService = inject(RaidService); private readonly snackBar = inject(MatSnackBar); - bossForm = this.fb.group({ - level: [0], - }); + + /** Single-select Boss-tab level; defaults to PoracleNG's "any" sentinel (9000). */ + bossLevel = signal(ANY_LEVEL_VALUE); + /** Stable array reference for the level selector input — prevents per-tick re-binding. */ + bossLevelArray = computed(() => [this.bossLevel()]); commonForm = this.fb.group({ clean: [false], @@ -71,14 +74,12 @@ export class RaidAddDialogComponent { }); readonly dialogRef = inject(MatDialogRef); - readonly isWebhook = inject(AuthService).isImpersonating(); - levels = [1, 2, 3, 4, 5, 6]; saving = signal(false); selectedEggLevels = signal([]); selectedGymId = signal(null); - selectedPokemonIds = signal([]); + selectedPokemonIds = signal([]); selectedRaidLevels = signal([]); tabIndex = 0; @@ -90,6 +91,11 @@ export class RaidAddDialogComponent { return this.selectedPokemonIds().length > 0; } + /** Boss tab is single-select; the selector emits an array of length 0 or 1. */ + onBossLevelChange(values: number[]): void { + this.bossLevel.set(values[0] ?? ANY_LEVEL_VALUE); + } + onDistanceModeChange(): void { if (this.commonForm.controls.distanceMode.value === 'areas') { this.commonForm.controls.distanceKm.setValue(0); @@ -148,7 +154,7 @@ export class RaidAddDialogComponent { } } else { // By Boss - const bossLevel = this.bossForm.controls.level.value ?? 0; + const bossLevel = this.bossLevel(); for (const pokemonId of this.selectedPokemonIds()) { const raid: RaidCreate = { clean: common.clean ? 1 : 0, @@ -182,12 +188,4 @@ export class RaidAddDialogComponent { }, }); } - - 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..7f0682d0 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 }}
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts index 199153c0..1fe76f5a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts @@ -21,6 +21,7 @@ import { RaidService } from '../../core/services/raid.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; export interface RaidEditDialogData { item: Raid | Egg; @@ -28,6 +29,7 @@ export interface RaidEditDialogData { } @Component({ + providers: [LevelLabelPipe], imports: [ ReactiveFormsModule, MatDialogModule, @@ -44,6 +46,7 @@ export interface RaidEditDialogData { TemplateSelectorComponent, DeliveryPreviewComponent, GymPickerComponent, + LevelLabelPipe, ], selector: 'app-raid-edit-dialog', standalone: true, @@ -55,6 +58,7 @@ export class RaidEditDialogComponent { private readonly fb = inject(FormBuilder); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); + private readonly levelLabelPipe = inject(LevelLabelPipe); private readonly raidService = inject(RaidService); private readonly snackBar = inject(MatSnackBar); readonly data = inject(MAT_DIALOG_DATA); @@ -86,13 +90,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'); + return this.levelLabelPipe.transform(raid.level) + ' ' + this.i18n.instant('RAIDS.RAID_SUFFIX'); } onDistanceModeChange(): void { 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..413eddd7 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 @@ -188,7 +188,7 @@

{{ 'RAIDS.EMPTY_RAIDS_TITLE' | translate }}

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..e714f9a5 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 @@ -14,16 +14,19 @@ 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 { 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 { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -38,6 +41,7 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTabsModule, TranslateModule, AlarmInfoComponent, + LevelLabelPipe, ], selector: 'app-raid-list', standalone: true, @@ -51,6 +55,7 @@ export class RaidListComponent implements OnInit { 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); @@ -246,7 +251,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 +268,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 { @@ -322,6 +334,7 @@ export class RaidListComponent implements OnInit { ngOnInit(): void { this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); + this.raidLevelService.load(); this.loadData(); } 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..5d8ca14c --- /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 { TranslateModule, 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, TranslateModule], + 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/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/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index a9a70822..2e5eaf1a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -401,7 +401,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": "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-alarmer", @@ -1296,6 +1336,7 @@ "EDIT": "Rediger", "ADD": "Tilføj", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bekræft", "DELETE_ALL": "Slet alle", "CLOSE": "Luk", 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..3c070780 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -401,7 +401,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": "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-Alarme", @@ -1296,6 +1336,7 @@ "EDIT": "Bearbeiten", "ADD": "Hinzufügen", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bestätigen", "DELETE_ALL": "Alle löschen", "CLOSE": "Schließen", 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..e133235a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -401,7 +401,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", @@ -1296,6 +1336,7 @@ "EDIT": "Edit", "ADD": "Add", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirm", "DELETE_ALL": "Delete All", "CLOSE": "Close", 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..f9642ca1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -401,7 +401,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": "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": "Alarmas de Misión", @@ -1296,6 +1336,7 @@ "EDIT": "Editar", "ADD": "Añadir", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmar", "DELETE_ALL": "Eliminar todo", "CLOSE": "Cerrar", 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..0412c755 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -401,7 +401,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": "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": "Alarmes Quête", @@ -1296,6 +1336,7 @@ "EDIT": "Modifier", "ADD": "Ajouter", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmer", "DELETE_ALL": "Tout supprimer", "CLOSE": "Fermer", 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..d2a5dfbe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -401,7 +401,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": "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": "Allarmi Missioni", @@ -1296,6 +1336,7 @@ "EDIT": "Modifica", "ADD": "Aggiungi", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Conferma", "DELETE_ALL": "Elimina Tutto", "CLOSE": "Chiudi", 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..e8461ea4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -401,7 +401,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": "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 Alarmen", @@ -1296,6 +1336,7 @@ "EDIT": "Bewerken", "ADD": "Toevoegen", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bevestigen", "DELETE_ALL": "Alles Verwijderen", "CLOSE": "Sluiten", 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..cb546515 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -401,7 +401,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": "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": "Alarmy zadań", @@ -1296,6 +1336,7 @@ "EDIT": "Edytuj", "ADD": "Dodaj", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Potwierdź", "DELETE_ALL": "Usuń wszystko", "CLOSE": "Zamknij", 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..27332b53 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 @@ -401,7 +401,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": "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": "Alarmes de Quest", @@ -1296,6 +1336,7 @@ "EDIT": "Editar", "ADD": "Adicionar", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmar", "DELETE_ALL": "Excluir Tudo", "CLOSE": "Fechar", 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..b25f6b0c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -401,7 +401,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": "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": "Alarmes de Missões", @@ -1296,6 +1336,7 @@ "EDIT": "Editar", "ADD": "Adicionar", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmar", "DELETE_ALL": "Eliminar Tudo", "CLOSE": "Fechar", 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..7c2e03be 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -401,7 +401,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": "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-larm", @@ -1296,6 +1336,7 @@ "EDIT": "Redigera", "ADD": "Lägg till", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bekräfta", "DELETE_ALL": "Radera alla", "CLOSE": "Stäng", 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/CHANGELOG.md b/CHANGELOG.md index d71ecc74..e22414ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **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. 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.Models/EggCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs index 61527641..7aef59ff 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs @@ -19,7 +19,8 @@ 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs index 2356e53f..e60d21b7 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs @@ -22,7 +22,8 @@ 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs index b2a05a83..cc4e5f74 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; 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..6a1db197 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; 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/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..7acd3da0 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs @@ -1,109 +1,113 @@ -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()); + } +} 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/docs/architecture/backend.md b/docs/architecture/backend.md index 0550dc9c..5b82a2b3 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -57,6 +57,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. @@ -127,6 +133,7 @@ Geofence polygons come from the Poracle API (via the unified feed), not the data |---|---|---| | Most services | **Scoped** | Per-request | | `MasterDataService` | **Singleton** | Cached game data | +| `RaidLevelService` | **Singleton** | Stateless canonical-list provider; future live masterfile fetch will cache here | !!! 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. diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index abcd1e2b..89a1a927 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -115,6 +115,14 @@ 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`. +### 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/features/alarms.md b/docs/features/alarms.md index 48c318a0..2f9aa664 100644 --- a/docs/features/alarms.md +++ b/docs/features/alarms.md @@ -9,8 +9,8 @@ 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 | +| **Raids** | Filter by raid boss, level, move, evolution, EX eligibility, specific gym, RSVP changes. See [Raid level selector](#raid-level-selector). | +| **Eggs** | Filter by egg level, EX eligibility, specific gym, RSVP changes. See [Raid level selector](#raid-level-selector). | | **Quests** | Filter by reward type and Pokemon | | **Invasions** | Filter by grunt type and shadow Pokemon | | **Lures** | Filter by lure type | @@ -136,9 +136,23 @@ 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. +## Raid level selector + +Raid, egg, and raid-boss-level 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. + +**Boss-level picker.** Single-select. Same primary/overflow layout as the raid picker, used in the "By Boss" tab when a specific Pokémon is selected but the user still wants to scope to certain raid levels. + +**`+ Add`.** All three 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 | |---|---|---| diff --git a/docs/getting-started/development-setup.md b/docs/getting-started/development-setup.md index 60be188c..af0bc765 100644 --- a/docs/getting-started/development-setup.md +++ b/docs/getting-started/development-setup.md @@ -99,7 +99,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. The default `4200` matches `Discord:FrontendUrl` in `appsettings.json`. Open **http://localhost:4200** in your browser. From 37f0f9cc52cb620a7e0702b627aeb3d1ab173335 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Tue, 2 Jun 2026 20:06:59 -0400 Subject: [PATCH 16/59] chore: gitignore API runtime artifacts (data/, wwwroot/) (#289) The standalone `dotnet run` DataProtection fallback writes keys to Applications/Pgan.PoracleWebNet.Api/data/ (Program.cs uses ./data when DATA_DIR is unset), and the published Angular bundle is copied into the API host at Applications/Pgan.PoracleWebNet.Api/wwwroot/. Both are regenerated build/runtime output and were showing up as untracked. The existing Data/dataprotection-keys/ rule only covered the Data project path, not the API host path. Added both so a local build/run leaves a clean working tree. --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index b9a77d75..9a3b707b 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ 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/ From f17994f7ebd682de422df48bd5d4e1676a41985e Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Tue, 2 Jun 2026 20:28:39 -0400 Subject: [PATCH 17/59] feat(pokemon): add PvP level cap selector to alarm dialogs (#237) (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Poracle's `pvp_ranking_cap` field was never surfaced by PoracleWeb.NET, so every PvP alarm defaulted to matching all caps (L50 + L51) server-side. New users got flooded with L51 noise even when admins configured `tracking.defaultUserTrackingLevelCap = 50` in Poracle. This wires `pvp_ranking_cap` end-to-end and mirrors the PoracleWeb PHP passthrough pattern — no new admin setting; the cap list and default come from Poracle's existing `/api/config/poracleWeb` response (`pvpCaps`, `defaultPvpCap`). Backend: - `Monster` / `MonsterCreate` / `MonsterUpdate` / `MonsterEntity`: new `PvpRankingCap` field (0 = all caps). - `AlarmMappingExtensions.ToMonster` + `ApplyUpdate`: mapping + null-skip. - `PoracleConfig.PvpCaps` (list) and `DefaultPvpCap` (int), parsed in `PoracleApiProxy.GetConfigAsync`. Accepts number or string caps (PoracleJS is inconsistent on this). - `QuickPickService.SafeMonsterFilterKeys`: allow `pvpRankingCap` so quick-pick authors can pin a cap per definition. Frontend: - `Monster.pvpRankingCap`, `PoracleServerConfig` interface. - New `PoracleConfigService` caches `/api/config` behind a signal. - `pokemon-add-dialog`: `mat-button-toggle-group` under the PvP league field with `All` / `L{cap}` options. Pre-fills from `defaultPvpCap`. Italic "Default · from Poracle config" hint disappears the moment the user changes the selection. Hidden entirely when Poracle offers only one cap. - `pokemon-edit-dialog`: same toggle group; shows stored cap on load. Tests (+1066 backend, +658 frontend): - Mapping tests cover `PvpRankingCap` in `ToMonster`, null-skip `ApplyUpdate`, and explicit-overwrite cases. - `ConfigControllerTests` verify `PvpCaps` + `DefaultPvpCap` flow through the controller unmodified. - `monster.service.spec.ts` fixtures updated with the new field. --- .../ClientApp/src/app/core/models/index.ts | 22 ++++++++ .../app/core/services/monster.service.spec.ts | 2 + .../core/services/poracle-config.service.ts | 53 +++++++++++++++++++ .../pokemon/pokemon-add-dialog.component.html | 16 ++++++ .../pokemon/pokemon-add-dialog.component.scss | 22 ++++++++ .../pokemon/pokemon-add-dialog.component.ts | 30 ++++++++++- .../pokemon-edit-dialog.component.html | 11 ++++ .../pokemon-edit-dialog.component.scss | 16 ++++++ .../pokemon/pokemon-edit-dialog.component.ts | 18 ++++++- .../ClientApp/src/assets/i18n/en.json | 4 ++ CHANGELOG.md | 3 ++ .../AlarmMappingExtensions.cs | 2 + .../Pgan.PoracleWebNet.Core.Models/Monster.cs | 4 ++ .../MonsterCreate.cs | 6 +++ .../MonsterUpdate.cs | 6 +++ .../PoracleConfig.cs | 16 ++++++ .../PoracleApiProxy.cs | 27 ++++++++++ .../QuickPickService.cs | 2 +- .../Entities/MonsterEntity.cs | 6 +++ .../Controllers/ConfigControllerTests.cs | 19 +++++++ .../Mappings/PoracleMappingProfileTests.cs | 26 +++++++++ 21 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/poracle-config.service.ts 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..6653eb29 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 @@ -28,6 +28,7 @@ export interface Monster { pokemonId: number; profileNo: number; pvpRankingBest: number; + pvpRankingCap: number; pvpRankingLeague: number; pvpRankingMinCp: number; pvpRankingWorst: number; @@ -331,6 +332,27 @@ export interface PoracleConfig { pokemon: Record; } +/** + * Server-side Poracle config surfaced via GET /api/config. + * Mirrors the .NET PoracleConfig model. + */ +export interface PoracleServerConfig { + defaultPvpCap: number; + defaultTemplateName: string; + everythingFlagPermissions: string; + locale: string; + maxDistance: number; + poracleVersion: string; + providerURL: string; + pvpCaps: number[]; + pvpFilterGreatMinCp: number; + pvpFilterLittleMinCp: number; + pvpFilterMaxRank: number; + pvpFilterUltraMinCp: number; + pvpLittleLeagueAllowed: boolean; + staticKey: string; +} + export interface AreaDefinition { description?: string; group: string; 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..77d2ccd8 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, 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..d26646e1 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/poracle-config.service.ts @@ -0,0 +1,53 @@ +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 = { + providerURL: '', + defaultPvpCap: 0, + defaultTemplateName: 'default', + everythingFlagPermissions: '', + locale: 'en', + maxDistance: 10726000, + poracleVersion: 'unknown', + pvpCaps: [], + pvpFilterGreatMinCp: 0, + pvpFilterLittleMinCp: 0, + pvpFilterMaxRank: 100, + pvpFilterUltraMinCp: 0, + pvpLittleLeagueAllowed: true, + staticKey: '', +}; + +@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/modules/pokemon/pokemon-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.html index fff2045a..43e91364 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 @@ -217,6 +217,22 @@

{{ '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_BEST_RANK' | translate }} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.scss index 9989f804..f2e4d594 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.scss @@ -93,6 +93,28 @@ mat-expansion-panel { color: var(--text-secondary, rgba(0, 0, 0, 0.54)); line-height: 1.5; } +.pvp-cap-fieldset { + border: none; + padding: 0; + margin: 0 0 12px; +} +.pvp-cap-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; +} +.pvp-cap-toggle { + display: inline-flex; +} +.pvp-cap-default-hint { + margin: 6px 0 0; + font-size: 12px; + color: var(--text-secondary, rgba(0, 0, 0, 0.54)); + line-height: 1.4; +} .btn-spinner { display: inline-block; margin-right: 8px; 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..c97bf946 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'; @@ -20,6 +21,7 @@ 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 { PoracleConfigService } from '../../core/services/poracle-config.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; @@ -29,6 +31,7 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele ReactiveFormsModule, MatDialogModule, MatButtonModule, + MatButtonToggleModule, MatFormFieldModule, MatInputModule, MatSelectModule, @@ -49,11 +52,12 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele styleUrl: './pokemon-add-dialog.component.scss', templateUrl: './pokemon-add-dialog.component.html', }) -export class PokemonAddDialogComponent { +export class PokemonAddDialogComponent implements OnInit { 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,6 +66,9 @@ 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({ @@ -95,8 +102,12 @@ export class PokemonAddDialogComponent { 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], pvpRankingLeague: [0], pvpRankingMinCp: [0], pvpRankingWorst: [100], @@ -104,10 +115,24 @@ export class PokemonAddDialogComponent { saving = signal(false); + /** 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; } + 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); + }); + } + onDistanceModeChange(): void { if (this.notifForm.controls.distanceMode.value === 'areas') { this.notifForm.controls.distanceKm.setValue(0); @@ -152,6 +177,7 @@ export class PokemonAddDialogComponent { ping: notif.ping || null, pokemonId, pvpRankingBest: pvp.pvpRankingLeague ? (pvp.pvpRankingBest ?? 1) : 0, + pvpRankingCap: pvp.pvpRankingLeague ? (pvp.pvpRankingCap ?? 0) : 0, pvpRankingLeague: pvp.pvpRankingLeague ?? 0, pvpRankingMinCp: pvp.pvpRankingLeague ? (pvp.pvpRankingMinCp ?? 0) : 0, pvpRankingWorst: pvp.pvpRankingLeague ? (pvp.pvpRankingWorst ?? 100) : 4096, 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..7aaf1f1e 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 @@ -193,6 +193,17 @@

{{ '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_BEST_RANK' | translate }} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.scss index fd84601c..cb5c4404 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-edit-dialog.component.scss @@ -88,6 +88,22 @@ mat-expansion-panel { color: var(--text-secondary, rgba(0, 0, 0, 0.54)); font-weight: normal; } +.pvp-cap-fieldset { + border: none; + padding: 0; + margin: 0 0 12px; +} +.pvp-cap-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; +} +.pvp-cap-toggle { + display: inline-flex; +} @media (max-width: 599px) { mat-dialog-content { min-width: 0; 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..469bcbd9 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'; @@ -19,6 +20,7 @@ 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 { PoracleConfigService } from '../../core/services/poracle-config.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; @@ -27,6 +29,7 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele ReactiveFormsModule, MatDialogModule, MatButtonModule, + MatButtonToggleModule, MatFormFieldModule, MatInputModule, MatSelectModule, @@ -45,12 +48,13 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele 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(() => { @@ -81,6 +85,7 @@ export class PokemonEditDialogComponent { minWeight: [this.data.minWeight], ping: [this.data.ping ?? ''], pvpRankingBest: [this.data.pvpRankingBest], + pvpRankingCap: [this.data.pvpRankingCap ?? 0], pvpRankingLeague: [this.data.pvpRankingLeague], pvpRankingMinCp: [this.data.pvpRankingMinCp], pvpRankingWorst: [this.data.pvpRankingWorst], @@ -93,12 +98,20 @@ 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); + readonly showCapPicker = computed(() => this.pvpCaps().length > 1); + getPokemonImage(): string { return this.iconService.getPokemonUrl(this.data.pokemonId, this.data.form); } + ngOnInit(): void { + this.poracleConfig.load().subscribe(); + } + onDistanceModeChange(): void { if (this.form.controls.distanceMode.value === 'areas') { this.form.controls.distanceKm.setValue(0); @@ -146,6 +159,7 @@ export class PokemonEditDialogComponent { minWeight: values.minWeight ?? 0, ping: values.ping || null, pvpRankingBest: values.pvpRankingLeague ? (values.pvpRankingBest ?? 1) : 0, + pvpRankingCap: values.pvpRankingLeague ? (values.pvpRankingCap ?? 0) : 0, pvpRankingLeague: values.pvpRankingLeague ?? 0, pvpRankingMinCp: values.pvpRankingLeague ? (values.pvpRankingMinCp ?? 0) : 0, pvpRankingWorst: values.pvpRankingLeague ? (values.pvpRankingWorst ?? 100) : 4096, 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 e133235a..9516a889 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -255,6 +255,10 @@ "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_UPDATED": "Pokemon alarm updated", "SNACK_DELETED": "Pokemon alarm deleted", diff --git a/CHANGELOG.md b/CHANGELOG.md index e22414ee..4d0c5ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **PvP level cap selector on new Pokemon alarms** ([#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 - **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. diff --git a/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs b/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs index e224e6b1..eef704f6 100644 --- a/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs +++ b/Core/Pgan.PoracleWebNet.Core.Mappings/AlarmMappingExtensions.cs @@ -29,6 +29,7 @@ public static class AlarmMappingExtensions PvpRankingBest = src.PvpRankingBest, PvpRankingMinCp = src.PvpRankingMinCp, PvpRankingLeague = src.PvpRankingLeague, + PvpRankingCap = src.PvpRankingCap, Form = src.Form, Size = src.Size, MaxSize = src.MaxSize, @@ -59,6 +60,7 @@ 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.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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs b/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs index e7f7dec8..09b43aaa 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/Monster.cs @@ -67,6 +67,10 @@ public int PvpRankingLeague { get; set; } + public int PvpRankingCap + { + get; set; + } public int Form { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs index 23e28df9..b49ae025 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs @@ -106,6 +106,12 @@ public int PvpRankingLeague get; set; } + [Range(0, 55)] + public int PvpRankingCap + { + get; set; + } + [Range(0, int.MaxValue)] public int Form { diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs index f6da1c7b..0cea8bf2 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs @@ -124,6 +124,12 @@ public int? PvpRankingLeague get; set; } + [Range(0, 55)] + public int? PvpRankingCap + { + get; set; + } + [Range(0, int.MaxValue)] public int? Form { diff --git a/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs b/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs index 5cbc373a..7dc6b774 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/PoracleConfig.cs @@ -34,6 +34,22 @@ 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 diff --git a/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs index cfb85ba5..429ffd04 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 diff --git a/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs b/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs index 5a4f6b90..ded66066 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/QuickPickService.cs @@ -48,7 +48,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", }; 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/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs index fb22acd6..e6eb0140 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/ConfigControllerTests.cs @@ -68,6 +68,25 @@ public async Task GetConfigReturnsOkWhenAvailable() 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() { diff --git a/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs index 1e86f84d..de346e77 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.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); @@ -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] From 169b5610420866984e7831e89438b666c7333bbf Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Tue, 2 Jun 2026 23:49:57 -0400 Subject: [PATCH 18/59] feat(raids): RSVP notification mode for raid and egg alarms (#233) (#291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(raids): RSVP notification mode for raid and egg alarms (#233) Surface the existing `rsvpChanges` field as a three-option mode toggle (Matches only / Matches + RSVP / RSVP only) in the raid & egg add/edit dialogs, with a matching pill on alarm cards. The field, mapping, and dialog form binding already exist on main; this adds the missing UI control and the third mode value. - New self-contained shared components: rsvp-toggle (FormControl input) and rsvp-pill (numeric value input). - Widen [Range(0, 1)] -> [Range(0, 2)] on RsvpChanges in RaidCreate, RaidUpdate, EggCreate, EggUpdate so mode 2 isn't rejected with HTTP 400. - Wire rsvp-toggle into both dialogs (raid + egg) bound to the rsvpChanges form control; rsvp-pill onto raid/egg cards. - RSVP i18n keys added to all 11 locales (incl. new pl/sv/da). - RsvpRangeValidationTests covering the 0..2 range and null updates. Salvaged from #235, dropping that branch's raid-dialog section-refactor which conflicted with the #280 level-selector restructure already on main. * feat(raids): set edit-in-place bit when RSVP updates are enabled PoracleNG's `clean` field is a bitmask (bit 1 = auto-delete, bit 2 = edit-in-place). Raid/egg RSVP updates are PoracleNG's first edit-tracking consumer: with the edit bit set, RSVP count changes edit the existing alert in place; without it, each change sends a brand-new message. Verified against PoracleNG main (processor/internal/dts/renderer.go): the per-user editKey is gated on db.IsEdit(user.Clean), so RSVP mode alone is not enough — the edit bit must accompany it. - raid/egg add + edit dialogs: OR in `clean` bit 2 when rsvpChanges >= 1. - edit dialog: read the auto-delete toggle from `clean & 1` (was `clean === 1`) so it round-trips correctly once bit 2 can be set. - raid-list: new isAutoDelete() helper masks bit 1 for the card auto-delete badge (Angular templates can't express bitwise &). - CHANGELOG + docs note the edit-in-place coupling. * style(raids): align RSVP toggle + pill with the app design system The RSVP components were copied from a pre-#280 branch and used a bespoke look (stacked icon+label+description button rows, hardcoded indigo pill) that didn't match the current Material 3 language. rsvp-toggle: rebuilt to mirror the #237 PvP-cap pattern — a
with an uppercase section header, a plain full-width segmented mat-button-toggle-group (no icons/no inline descriptions), and a single hint below that shows the selected mode's description (so the "RSVP only silences without a scanner" caveat stays visible). Uses --text-muted / --text-secondary tokens instead of raw opacity. rsvp-pill: now a .clean-tag-style status badge themed with --mat-sys-primary / --mat-sys-on-primary (was hardcoded #3f51b5). Moved out of the stat grid into card-top-actions so it sits beside the auto-delete tag as a sibling status indicator. Specs updated for the new structure; docs reworded (themed badge, not "indigo pill"). prettier/lint/jest(712)/ng build all green. * chore: keep Dockerfile.local out of the PR (local test artifact) * style(raids): square off the RSVP toggle ends (--mat-button-toggle-shape) M3 defaults the segmented button group to a pill radius; override the shape token to 4px so the end segments read as a crisp Material control rather than a rounded pill. * fix(raids): stop RSVP toggle overflow + vertically center labels - fieldset defaulted to min-inline-size: auto and overflowed its container (right edge clipped); force min-inline-size: 0 / width 100% / border-box. - flex-center the button-toggle label content so single-line options align with the wrapped 'Matches + RSVP updates' segment. * fix(raids): RSVP toggle overflow (drop fieldset, border-box) + drop dead i18n key - Replace the
/ with a plain block + border-box group so the segmented control fits the dialog width (right edge was clipped); the toggle group keeps its aria-label for the accessible name. - Remove the now-unused RSVP_HINT key from all 11 locales (the restyle replaced the static hint with the per-mode description). * fix(raids): widen Clean [Range] to bitmask 0-7 so RSVP edit bit saves The RSVP edit-in-place coupling sets clean bit 2 (clean = 2 or 3), but Clean was still [Range(0, 1)] on RaidCreate/RaidUpdate/EggCreate/EggUpdate -> creating/editing an RSVP alarm failed model validation with HTTP 400 ('failed to create alarm'). clean is a PoracleNG bitmask (auto-delete | edit-in-place | summary); widened to [Range(0, 7)]. Added validation tests. * style(raids): hide RSVP toggle selection checkmark for more label room Single-select group, so the M3 checkmark only ate ~24px and pushed labels to wrap. hideSingleSelectionIndicator drops it; the accent fill + bold already signal the selected mode. --- .gitignore | 3 + .../raids/raid-add-dialog.component.html | 2 + .../raids/raid-add-dialog.component.ts | 18 ++-- .../raids/raid-edit-dialog.component.html | 2 + .../raids/raid-edit-dialog.component.ts | 16 ++- .../modules/raids/raid-list.component.html | 6 +- .../app/modules/raids/raid-list.component.ts | 7 ++ .../rsvp-pill/rsvp-pill.component.html | 3 + .../rsvp-pill/rsvp-pill.component.scss | 19 ++++ .../rsvp-pill/rsvp-pill.component.spec.ts | 61 +++++++++++ .../rsvp-pill/rsvp-pill.component.ts | 25 +++++ .../rsvp-toggle/rsvp-toggle.component.html | 13 +++ .../rsvp-toggle/rsvp-toggle.component.scss | 55 ++++++++++ .../rsvp-toggle/rsvp-toggle.component.spec.ts | 83 ++++++++++++++ .../rsvp-toggle/rsvp-toggle.component.ts | 28 +++++ .../ClientApp/src/assets/i18n/da.json | 9 ++ .../ClientApp/src/assets/i18n/de.json | 9 ++ .../ClientApp/src/assets/i18n/en.json | 9 ++ .../ClientApp/src/assets/i18n/es.json | 9 ++ .../ClientApp/src/assets/i18n/fr.json | 9 ++ .../ClientApp/src/assets/i18n/it.json | 9 ++ .../ClientApp/src/assets/i18n/nl.json | 9 ++ .../ClientApp/src/assets/i18n/pl.json | 9 ++ .../ClientApp/src/assets/i18n/pt-BR.json | 9 ++ .../ClientApp/src/assets/i18n/pt.json | 9 ++ .../ClientApp/src/assets/i18n/sv.json | 9 ++ CHANGELOG.md | 2 +- .../EggCreate.cs | 5 +- .../EggUpdate.cs | 5 +- .../RaidCreate.cs | 5 +- .../RaidUpdate.cs | 5 +- .../Validation/RsvpRangeValidationTests.cs | 101 ++++++++++++++++++ docs/features/alarms.md | 8 +- 33 files changed, 545 insertions(+), 26 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-pill/rsvp-pill.component.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/rsvp-toggle/rsvp-toggle.component.ts create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Validation/RsvpRangeValidationTests.cs diff --git a/.gitignore b/.gitignore index 9a3b707b..1db3376b 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ 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 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 5dd85d08..672b491f 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,6 +34,8 @@

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.GYM_PICKER_HINT' | translate }}

+ + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts index 718b19a2..0edd5898 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts @@ -24,6 +24,7 @@ import { DeliveryPreviewComponent } from '../../shared/components/delivery-previ import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; import { LevelSelectorComponent } from '../../shared/components/level-selector/level-selector.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; +import { RsvpToggleComponent } from '../../shared/components/rsvp-toggle/rsvp-toggle.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; @Component({ @@ -46,6 +47,7 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele DeliveryPreviewComponent, GymPickerComponent, LevelSelectorComponent, + RsvpToggleComponent, ], selector: 'app-raid-add-dialog', standalone: true, @@ -69,6 +71,7 @@ export class RaidAddDialogComponent { distanceKm: [1], distanceMode: ['areas' as 'areas' | 'distance'], ping: [''], + rsvpChanges: [0], team: [4], template: [''], }); @@ -115,6 +118,9 @@ export class RaidAddDialogComponent { this.saving.set(true); const common = this.commonForm.getRawValue(); const distanceMeters = common.distanceMode === 'areas' ? 0 : Math.round((common.distanceKm ?? 1) * 1000); + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place. + // RSVP modes (1/2) need the edit bit so count changes edit the alert instead of re-sending. + const clean = (common.clean ? 1 : 0) | ((common.rsvpChanges ?? 0) >= 1 ? 2 : 0); const creates: ReturnType[] = []; @@ -122,7 +128,7 @@ export class RaidAddDialogComponent { // By Level for (const level of this.selectedRaidLevels()) { const raid: RaidCreate = { - clean: common.clean ? 1 : 0, + clean, distance: distanceMeters, evolution: 9000, exclusive: 0, @@ -132,7 +138,7 @@ export class RaidAddDialogComponent { move: 9000, ping: common.ping || null, pokemonId: 9000, - rsvpChanges: 0, + rsvpChanges: common.rsvpChanges ?? 0, team: common.team ?? 4, template: common.template || null, }; @@ -140,13 +146,13 @@ export class RaidAddDialogComponent { } for (const level of this.selectedEggLevels()) { const egg: EggCreate = { - clean: common.clean ? 1 : 0, + clean, distance: distanceMeters, exclusive: 0, gymId: this.selectedGymId() || null, level, ping: common.ping || null, - rsvpChanges: 0, + rsvpChanges: common.rsvpChanges ?? 0, team: common.team ?? 4, template: common.template || null, }; @@ -157,7 +163,7 @@ export class RaidAddDialogComponent { const bossLevel = this.bossLevel(); for (const pokemonId of this.selectedPokemonIds()) { const raid: RaidCreate = { - clean: common.clean ? 1 : 0, + clean, distance: distanceMeters, evolution: 9000, exclusive: 0, @@ -167,7 +173,7 @@ export class RaidAddDialogComponent { move: 9000, ping: common.ping || null, pokemonId, - rsvpChanges: 0, + rsvpChanges: common.rsvpChanges ?? 0, team: common.team ?? 4, template: common.template || null, }; 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 7f0682d0..a1c55d4a 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 @@ -40,6 +40,8 @@

{{ getTitle() }}

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.GYM_PICKER_HINT' | translate }}

+ +
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts index 1fe76f5a..0ae3d54f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts @@ -20,6 +20,7 @@ import { IconService } from '../../core/services/icon.service'; import { RaidService } from '../../core/services/raid.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; +import { RsvpToggleComponent } from '../../shared/components/rsvp-toggle/rsvp-toggle.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; import { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; @@ -46,6 +47,7 @@ export interface RaidEditDialogData { TemplateSelectorComponent, DeliveryPreviewComponent, GymPickerComponent, + RsvpToggleComponent, LevelLabelPipe, ], selector: 'app-raid-edit-dialog', @@ -64,10 +66,11 @@ export class RaidEditDialogComponent { readonly data = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.item.clean === 1], + clean: [(this.data.item.clean & 1) !== 0], 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 ?? ''], + rsvpChanges: [this.data.item.rsvpChanges], team: [this.data.item.team], template: [this.data.item.template ?? ''], }); @@ -117,11 +120,14 @@ export class RaidEditDialogComponent { this.saving.set(true); const values = this.form.getRawValue(); const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); + // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place. + // RSVP modes (1/2) need the edit bit so count changes edit the alert instead of re-sending. + const clean = (values.clean ? 1 : 0) | ((values.rsvpChanges ?? 0) >= 1 ? 2 : 0); if (this.data.type === 'raid') { const raid = this.data.item as Raid; const update: RaidUpdate = { - clean: values.clean ? 1 : 0, + clean, distance: distanceMeters, evolution: raid.evolution, exclusive: raid.exclusive, @@ -131,7 +137,7 @@ export class RaidEditDialogComponent { 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, }; @@ -148,13 +154,13 @@ export class RaidEditDialogComponent { } else { const egg = this.data.item as Egg; const update: EggUpdate = { - clean: values.clean ? 1 : 0, + clean, distance: distanceMeters, exclusive: egg.exclusive, gymId: this.selectedGymId() || null, level: egg.level, ping: values.ping || null, - rsvpChanges: egg.rsvpChanges, + rsvpChanges: values.rsvpChanges ?? 0, team: values.team ?? 4, template: values.template || null, }; 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 413eddd7..a9712014 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 @@ -102,9 +102,10 @@

{{ getRaidTitle(raid) }}

}
- @if (raid.clean === 1) { + @if (isAutoDelete(raid.clean)) { {{ 'RAIDS.CLEAN_TAG' | translate }} } +
@@ -207,9 +208,10 @@

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

}
- @if (egg.clean === 1) { + @if (isAutoDelete(egg.clean)) { {{ 'RAIDS.CLEAN_TAG' | translate }} } +
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 e714f9a5..365170d6 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 @@ -26,6 +26,7 @@ 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 { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; @Component({ @@ -41,6 +42,7 @@ import { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; MatTabsModule, TranslateModule, AlarmInfoComponent, + RsvpPillComponent, LevelLabelPipe, ], selector: 'app-raid-list', @@ -315,6 +317,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()]) 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..5b86897d --- /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 { TranslateModule } from '@ngx-translate/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslateModule], + 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..c014f3d7 --- /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 { TranslateModule } from '@ngx-translate/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ReactiveFormsModule, MatButtonToggleModule, TranslateModule], + 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/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index 2e5eaf1a..f36e6201 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Fælles indstillinger" }, "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}})", 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 3c070780..bdad4ee3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Allgemeine Einstellungen" }, "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}})", 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 9516a889..2b6153b0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -343,6 +343,15 @@ "COMMON_SETTINGS": "Common Settings" }, "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}})", 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 f9642ca1..e6bcbe8f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Ajustes comunes" }, "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}})", 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 0412c755..9f62d5e8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Paramètres communs" }, "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}})", 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 d2a5dfbe..cf636d07 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Impostazioni comuni" }, "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}})", 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 e8461ea4..402ecc92 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Gemeenschappelijke Instellingen" }, "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}})", 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 cb546515..cf56169e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Wspólne ustawienia" }, "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}})", 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 27332b53..2d6d0a24 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 @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Configurações comuns" }, "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}})", 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 b25f6b0c..d5d6ac58 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Definições Comuns" }, "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}})", 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 7c2e03be..756571cb 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -339,6 +339,15 @@ "COMMON_SETTINGS": "Gemensamma inställningar" }, "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}})", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d0c5ded..cd27b788 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **PvP level cap selector on new Pokemon alarms** ([#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. +- **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 - **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. diff --git a/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs index 7aef59ff..07c952c6 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs @@ -26,7 +26,8 @@ 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; @@ -50,7 +51,7 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int RsvpChanges { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs index e60d21b7..03680e0c 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs @@ -29,7 +29,8 @@ 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; @@ -53,7 +54,7 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int? RsvpChanges { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs index cc4e5f74..6f2deadf 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs @@ -41,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; @@ -71,7 +72,7 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int RsvpChanges { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs index 6a1db197..ac7c04a6 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs @@ -36,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; @@ -72,7 +73,7 @@ public string? GymId get; set; } - [Range(0, 1)] + [Range(0, 2)] public int? RsvpChanges { get; set; 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/features/alarms.md b/docs/features/alarms.md index 2f9aa664..7efed4f5 100644 --- a/docs/features/alarms.md +++ b/docs/features/alarms.md @@ -9,8 +9,8 @@ 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, level, move, evolution, EX eligibility, specific gym, RSVP changes. See [Raid level selector](#raid-level-selector). | -| **Eggs** | Filter by egg level, EX eligibility, specific gym, RSVP changes. See [Raid level selector](#raid-level-selector). | +| **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 type and Pokemon | | **Invasions** | Filter by grunt type and shadow Pokemon | | **Lures** | Filter by lure type | @@ -161,7 +161,7 @@ Raid alarms support these fields beyond the basic level/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 @@ -172,7 +172,7 @@ 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. | ## Gym alarm filters From 0e37037e9da18caccc3f28eb3cf91dc9b6585315 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Tue, 2 Jun 2026 23:53:11 -0400 Subject: [PATCH 19/59] fix(docker): pin npm 11 in the Dockerfile angular stage (#293) node:22-alpine bundles npm 10.9.x, which rejects the npm-11-generated package-lock.json with EUSAGE (pruned chokidar/readdirp optional peers), so docker build / compose up --build failed from source. CI already pins npm 11 for this; mirror it in the Dockerfile's angular-build stage. --- CHANGELOG.md | 1 + Dockerfile | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd27b788..3999cb60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 +- **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. diff --git a/Dockerfile b/Dockerfile index 82b82d6f..5771ec69 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/ ./ From a275f2b8021fdefa53042134071f15d1bedcc11a Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 00:50:01 -0400 Subject: [PATCH 20/59] fix(alarms): make the clean bitmask bit-aware across all alarm types (#292 PR1) (#294) PoracleNG reads `clean` as a 3-bit bitmask (1=auto-delete, 2=edit-in-place, 4=summary) but PoracleWeb treated it as a boolean for 8 of 10 alarm types, which (a) 400'd bot-set values >1 via [Range(0,1)] and (b) clobbered bits 2/4 on every web edit. This is the invisible correctness fix; the lure-edit and quest-summary user controls follow in PR2. - New CleanFlags helper (Core.Models) + clean-flags.ts twin with Preserve(existing, mask, changes) read-modify-write; unit tests for both. - Widen Clean [Range(0,1)] -> [Range(0,7)] on the 16 Monster/Quest/Invasion/ Lure/Nest/Gym/MaxBattle/FortChange Create+Update models (Raid/Egg already done). - CleaningService: preserve unknown bits on bulk-clean toggle; bit-aware AllClean. - All 10 alarm types' edit dialogs preserve unknown bits on save and read auto-delete via the bit; list cards + profile overview gate the badge via an isAutoDelete() method (Angular templates can't parse bitwise &). - Fix the raid/egg RSVP save dropping bit 4; fix the quick-pick apply clobber. - Tests: CleanFlags, clean-range validation (8 types), CleaningService bit preservation, mapping round-trip. Backend 1324 pass; frontend 743 pass. --- .../fort-change-add-dialog.component.ts | 3 +- .../fort-change-edit-dialog.component.ts | 5 +- .../fort-change-list.component.html | 2 +- .../fort-change-list.component.ts | 5 + .../modules/gyms/gym-add-dialog.component.ts | 3 +- .../modules/gyms/gym-edit-dialog.component.ts | 5 +- .../app/modules/gyms/gym-list.component.html | 2 +- .../app/modules/gyms/gym-list.component.ts | 5 + .../invasion-edit-dialog.component.ts | 5 +- .../invasions/invasion-list.component.html | 2 +- .../invasions/invasion-list.component.ts | 6 + .../lures/lure-edit-dialog.component.ts | 5 +- .../modules/lures/lure-list.component.html | 2 +- .../app/modules/lures/lure-list.component.ts | 5 + .../max-battle-add-dialog.component.ts | 5 +- .../max-battle-edit-dialog.component.ts | 5 +- .../max-battle-list.component.html | 2 +- .../max-battles/max-battle-list.component.ts | 5 + .../nests/nest-edit-dialog.component.ts | 5 +- .../modules/nests/nest-list.component.html | 2 +- .../app/modules/nests/nest-list.component.ts | 6 + .../pokemon/pokemon-edit-dialog.component.ts | 5 +- .../pokemon/pokemon-list.component.html | 2 +- .../modules/pokemon/pokemon-list.component.ts | 6 + .../profile-overview.component.html | 2 +- .../profile-overview.component.ts | 5 + .../quests/quest-edit-dialog.component.ts | 5 +- .../modules/quests/quest-list.component.html | 2 +- .../modules/quests/quest-list.component.ts | 5 + .../quick-pick-apply-dialog.component.ts | 8 +- .../raids/raid-add-dialog.component.ts | 6 +- .../raids/raid-edit-dialog.component.ts | 9 +- .../src/app/shared/utils/clean-flags.spec.ts | 80 ++++++++ .../src/app/shared/utils/clean-flags.ts | 47 +++++ CHANGELOG.md | 1 + .../CleanFlags.cs | 42 ++++ .../FortChangeCreate.cs | 3 +- .../FortChangeUpdate.cs | 3 +- .../GymCreate.cs | 3 +- .../GymUpdate.cs | 3 +- .../InvasionCreate.cs | 3 +- .../InvasionUpdate.cs | 3 +- .../LureCreate.cs | 3 +- .../LureUpdate.cs | 3 +- .../MaxBattleCreate.cs | 3 +- .../MaxBattleUpdate.cs | 3 +- .../MonsterCreate.cs | 3 +- .../MonsterUpdate.cs | 3 +- .../NestCreate.cs | 3 +- .../NestUpdate.cs | 3 +- .../QuestCreate.cs | 3 +- .../QuestUpdate.cs | 3 +- .../CleaningService.cs | 8 +- .../Mappings/PoracleMappingProfileTests.cs | 38 ++++ .../Models/CleanFlagsTests.cs | 70 +++++++ .../Services/CleaningServiceTests.cs | 126 ++++++++++++ .../Validation/CleanRangeValidationTests.cs | 185 ++++++++++++++++++ 57 files changed, 732 insertions(+), 53 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/clean-flags.ts create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/CleanFlags.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Models/CleanFlagsTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Validation/CleanRangeValidationTests.cs 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..7e78a221 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 @@ -18,6 +18,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { compose } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -86,7 +87,7 @@ export class FortChangeAddDialogComponent { this.fortChangeService .create({ changeTypes, - clean: v.clean ? 1 : 0, + clean: compose(!!v.clean, false, false), distance: dist, fortType: v.fortType, includeEmpty: v.includeEmpty ? 1 : 0, 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..0632602c 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 @@ -19,6 +19,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -56,7 +57,7 @@ export class FortChangeEditDialogComponent { 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], + clean: [isAutoDelete(this.data.clean)], 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'], @@ -88,7 +89,7 @@ export class FortChangeEditDialogComponent { this.fortChangeService .update(this.data.uid, { changeTypes, - clean: v.clean ? 1 : 0, + clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), distance: dist, fortType: v.fortType, includeEmpty: v.includeEmpty ? 1 : 0, 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..82ba5442 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 @@ -67,7 +67,7 @@

{{ formatFortType(item.fortType) }}

Fort Change Tracking
- @if (item.clean === 1) { + @if (isAutoDelete(item.clean)) { clean } @if (item.includeEmpty === 1) { 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..3b49e84d 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 @@ -185,6 +185,11 @@ export class FortChangeListComponent 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; + } + loadItems(): void { this.loading.set(true); this.fortChangeService diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-add-dialog.component.ts index 46a63904..2774e5cc 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-add-dialog.component.ts @@ -19,6 +19,7 @@ import { I18nService } from '../../core/services/i18n.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { compose } from '../../shared/utils/clean-flags'; interface TeamOption { color: string; @@ -94,7 +95,7 @@ export class GymAddDialogComponent { const creates = this.selectedTeamIds().map(team => this.gymService.create({ battleChanges: v.battleChanges ? 1 : 0, - clean: v.clean ? 1 : 0, + clean: compose(!!v.clean, false, false), distance: dist, gymId: this.selectedGymId() || null, ping: v.ping || null, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-edit-dialog.component.ts index 8d55d3b1..9ec465f2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-edit-dialog.component.ts @@ -18,6 +18,7 @@ import { I18nService } from '../../core/services/i18n.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -50,7 +51,7 @@ export class GymEditDialogComponent { readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ battleChanges: [this.data.battleChanges === 1], - clean: [this.data.clean === 1], + clean: [isAutoDelete(this.data.clean)], distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], ping: [this.data.ping ?? ''], @@ -93,7 +94,7 @@ export class GymEditDialogComponent { this.gymService .update(this.data.uid, { battleChanges: v.battleChanges ? 1 : 0, - clean: v.clean ? 1 : 0, + clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), distance: dist, gymId: this.selectedGymId() || null, ping: v.ping || null, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html index a6a4c1f6..40ace3a8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/gyms/gym-list.component.html @@ -72,7 +72,7 @@

{{ getTeamName(gym.team) }}

- @if (gym.clean === 1) { + @if (isAutoDelete(gym.clean)) { clean }
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..5e9fcc5c 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 @@ -191,6 +191,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 diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.ts index 89382fe7..23c14e67 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/invasions/invasion-edit-dialog.component.ts @@ -20,6 +20,7 @@ import { I18nService } from '../../core/services/i18n.service'; import { InvasionService } from '../../core/services/invasion.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -53,7 +54,7 @@ export class InvasionEditDialogComponent { readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.clean === 1], + clean: [isAutoDelete(this.data.clean)], distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], gender: [this.data.gender], @@ -109,7 +110,7 @@ export class InvasionEditDialogComponent { const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); this.invasionService .update(this.data.uid, { - clean: v.clean ? 1 : 0, + clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), distance: dist, // 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 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..40168a4d 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 @@ -89,7 +89,7 @@

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

}
- @if (invasion.clean === 1) { + @if (isAutoDelete(invasion.clean)) { {{ 'INVASIONS.CLEAN_TAG' | translate }} }
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..dc331264 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 @@ -28,6 +28,7 @@ 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 { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flags'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -194,6 +195,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); } 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..0ed2bece 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 @@ -17,6 +17,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -47,7 +48,7 @@ export class LureEditDialogComponent { readonly data = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.clean === 1], + clean: [isAutoDelete(this.data.clean)], distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], ping: [this.data.ping ?? ''], @@ -91,7 +92,7 @@ export class LureEditDialogComponent { const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); this.lureService .update(this.data.uid, { - clean: v.clean ? 1 : 0, + clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), distance: dist, lureId: this.data.lureId, ping: v.ping || null, 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..8995d5fa 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 @@ -62,7 +62,7 @@

{{ 'LURES.PAGE_TITLE' | translate }}

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

- @if (lure.clean === 1) { + @if (isAutoDelete(lure.clean)) { clean }
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..c403ad25 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 @@ -196,6 +196,11 @@ 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; + } + loadLures(): void { this.loading.set(true); this.lureService diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.ts index 45ee2c98..a97de532 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/max-battles/max-battle-add-dialog.component.ts @@ -23,6 +23,7 @@ import { ScannerService } from '../../core/services/scanner.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { compose } from '../../shared/utils/clean-flags'; /** Max Battle levels as defined in PoracleNG util.json */ interface MaxBattleLevel { @@ -135,7 +136,7 @@ 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, + clean: compose(!!common.clean, false, false), distance: distanceMeters, evolution: 9000, form: common.form ?? 0, @@ -153,7 +154,7 @@ 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, + clean: compose(!!common.clean, false, false), distance: distanceMeters, evolution: 9000, form: common.form ?? 0, 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..ace83c93 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 @@ -20,6 +20,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; export interface MaxBattleEditDialogData { item: MaxBattle; @@ -77,7 +78,7 @@ export class MaxBattleEditDialogComponent { readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.item.clean === 1], + clean: [isAutoDelete(this.data.item.clean)], distanceKm: [this.data.item.distance > 0 ? this.data.item.distance / 1000 : 1], distanceMode: [this.data.item.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], gmax: [this.data.item.gmax === 1], @@ -150,7 +151,7 @@ export class MaxBattleEditDialogComponent { const gmaxVal = this.isLevelBased ? (levelDef?.gmax ? 1 : 0) : values.gmax ? 1 : 0; const update: MaxBattleUpdate = { - clean: values.clean ? 1 : 0, + clean: preserve(item.clean, AUTO_DELETE, values.clean ? 1 : 0), distance: distanceMeters, evolution: 9000, form: item.form, 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..ef58fcf7 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 @@ -102,7 +102,7 @@

{{ getTitle(mb) }}

@if (isGmax(mb.level) || mb.gmax === 1) { {{ 'MAX_BATTLES.GMAX_TAG' | translate }} } - @if (mb.clean === 1) { + @if (isAutoDelete(mb.clean)) { clean } 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..b3f9f6ef 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 @@ -226,6 +226,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; } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.ts index a316a892..78290ff5 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/nests/nest-edit-dialog.component.ts @@ -19,6 +19,7 @@ import { MasterDataService } from '../../core/services/masterdata.service'; import { NestService } from '../../core/services/nest.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -51,7 +52,7 @@ export class NestEditDialogComponent { readonly data = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.clean === 1], + clean: [isAutoDelete(this.data.clean)], distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], minSpawnAvg: [this.data.minSpawnAvg], @@ -82,7 +83,7 @@ export class NestEditDialogComponent { const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); this.nestService .update(this.data.uid, { - clean: v.clean ? 1 : 0, + clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), distance: dist, minSpawnAvg: v.minSpawnAvg ?? 0, ping: v.ping || null, 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..c5024268 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 @@ -67,7 +67,7 @@

{{ getPokemonName(nest.pokemonId) }}

#{{ nest.pokemonId }}
- @if (nest.clean === 1) { + @if (isAutoDelete(nest.clean)) { clean }
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..6cefc731 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 @@ -22,6 +22,7 @@ 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 { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flags'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -166,6 +167,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 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 469bcbd9..b6b8041c 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 @@ -23,6 +23,7 @@ import { MonsterService } from '../../core/services/monster.service'; import { PoracleConfigService } from '../../core/services/poracle-config.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -65,7 +66,7 @@ export class PokemonEditDialogComponent implements OnInit { 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')], @@ -140,7 +141,7 @@ export class PokemonEditDialogComponent implements OnInit { const update: MonsterUpdate = { 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, form: values.form ?? 0, 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..96880f7f 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 @@ -158,7 +158,7 @@

{{ getPokemonName(monster.pokemonId) }}

}
- @if (monster.clean === 1) { + @if (isAutoDelete(monster.clean)) { {{ 'POKEMON.CLEAN_TAG' | translate }} }
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..c201cf29 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 @@ -27,6 +27,7 @@ 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 { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flags'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -343,6 +344,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 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..001785c9 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 @@ -644,6 +644,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/quests/quest-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts index c8db4992..0f5ad0b5 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 @@ -19,6 +19,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -55,7 +56,7 @@ export class QuestEditDialogComponent { readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [this.data.clean === 1], + clean: [isAutoDelete(this.data.clean)], distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], ping: [this.data.ping ?? ''], @@ -150,7 +151,7 @@ export class QuestEditDialogComponent { const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); const update: QuestUpdate = { - clean: values.clean ? 1 : 0, + clean: preserve(this.data.clean, AUTO_DELETE, values.clean ? 1 : 0), distance: distanceMeters, ping: values.ping || null, pokemonId: this.data.pokemonId, 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..5f078801 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 @@ -84,7 +84,7 @@

{{ getQuestTitle(quest) }}

- @if (quest.clean === 1) { + @if (isAutoDelete(quest.clean)) { {{ 'QUESTS.CLEAN_TAG' | translate }} }
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..e72e810d 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 @@ -245,6 +245,11 @@ 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; + } + loadQuests(): void { this.loading.set(true); this.questService 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..bdc39445 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 @@ -20,6 +20,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -97,8 +98,13 @@ export class QuickPickApplyDialogComponent { const delivery = this.deliveryForm.getRawValue(); const distanceMeters = delivery.distanceMode === 'areas' ? 0 : Math.round((delivery.distanceKm ?? 1) * 1000); + // 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, + clean: preserve(baseClean, AUTO_DELETE, delivery.clean ? 1 : 0), distance: distanceMeters, excludePokemonIds: this.showExclusions && this.excludeEnabled() ? this.excludedPokemonIds() : [], template: delivery.template || undefined, diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts index 0edd5898..903202fe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts @@ -26,6 +26,7 @@ import { LevelSelectorComponent } from '../../shared/components/level-selector/l import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; import { RsvpToggleComponent } from '../../shared/components/rsvp-toggle/rsvp-toggle.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { AUTO_DELETE, EDIT } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -118,9 +119,10 @@ export class RaidAddDialogComponent { this.saving.set(true); const common = this.commonForm.getRawValue(); const distanceMeters = common.distanceMode === 'areas' ? 0 : Math.round((common.distanceKm ?? 1) * 1000); - // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place. + // 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. - const clean = (common.clean ? 1 : 0) | ((common.rsvpChanges ?? 0) >= 1 ? 2 : 0); + // 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[] = []; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts index 0ae3d54f..7d967259 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts @@ -23,6 +23,7 @@ import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picke import { RsvpToggleComponent } from '../../shared/components/rsvp-toggle/rsvp-toggle.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; import { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; +import { AUTO_DELETE, EDIT, isAutoDelete } from '../../shared/utils/clean-flags'; export interface RaidEditDialogData { item: Raid | Egg; @@ -66,7 +67,7 @@ export class RaidEditDialogComponent { readonly data = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); form = this.fb.group({ - clean: [(this.data.item.clean & 1) !== 0], + clean: [isAutoDelete(this.data.item.clean)], 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 ?? ''], @@ -120,9 +121,11 @@ export class RaidEditDialogComponent { this.saving.set(true); const values = this.form.getRawValue(); const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); - // clean is a PoracleNG bitmask: bit 1 = auto-delete, bit 2 = edit-in-place. + // 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. - const clean = (values.clean ? 1 : 0) | ((values.rsvpChanges ?? 0) >= 1 ? 2 : 0); + // 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; 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/CHANGELOG.md b/CHANGELOG.md index 3999cb60..7faa01cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. 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/FortChangeCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeCreate.cs index f63979b4..c7b9780a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeCreate.cs @@ -37,7 +37,8 @@ public int IncludeEmpty FortChangeOptions.ChangeTypeNew)] public List ChangeTypes { 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs index fe8138cf..f81472f7 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/FortChangeUpdate.cs @@ -40,7 +40,8 @@ public List? ChangeTypes 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/GymCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/GymCreate.cs index 4a3b7a81..ccce8896 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/GymUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/GymUpdate.cs index cb96c8df..2cd1c9f9 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.cs index 13e974e8..034b2d26 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/InvasionCreate.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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/InvasionUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/InvasionUpdate.cs index 066a9490..ddf3c0b4 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/LureCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/LureCreate.cs index 3bb18280..52cae08a 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/LureUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/LureUpdate.cs index d3654064..6de7fce9 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleCreate.cs index 405bcf43..54b28038 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MaxBattleUpdate.cs index 3bc037fe..4680ff07 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs index b49ae025..457740ae 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MonsterCreate.cs @@ -130,7 +130,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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs index 0cea8bf2..c8e60589 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/MonsterUpdate.cs @@ -154,7 +154,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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/NestCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/NestCreate.cs index 406d91c5..61c3980e 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/NestUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/NestUpdate.cs index e36d1244..2784db72 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs index d57f59a4..232efee8 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/QuestCreate.cs @@ -34,7 +34,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; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs index 7d405c2c..4f4b09a1 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/QuestUpdate.cs @@ -34,7 +34,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; diff --git a/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs b/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs index e542672f..274eb06a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs @@ -95,7 +95,11 @@ 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)); } @@ -126,7 +130,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/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs index de346e77..87335af1 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs @@ -899,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] 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/Services/CleaningServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/CleaningServiceTests.cs index 39e98290..9654267f 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/CleaningServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/CleaningServiceTests.cs @@ -234,6 +234,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 +400,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); diff --git a/Tests/Pgan.PoracleWebNet.Tests/Validation/CleanRangeValidationTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Validation/CleanRangeValidationTests.cs new file mode 100644 index 00000000..1cedb3ef --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Validation/CleanRangeValidationTests.cs @@ -0,0 +1,185 @@ +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)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void FortChangeCreateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new FortChangeCreate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void FortChangeCreateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new FortChangeCreate(), 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)); + + [Theory] + [MemberData(nameof(AcceptedValues))] + public void FortChangeUpdateCleanAcceptsBitmaskRange(int value) => Assert.True(ValidateClean(new FortChangeUpdate(), value)); + + [Theory] + [MemberData(nameof(RejectedValues))] + public void FortChangeUpdateCleanRejectsOutOfRange(int value) => Assert.False(ValidateClean(new FortChangeUpdate(), 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)); + + [Fact] + public void FortChangeUpdateCleanAcceptsNull() => Assert.True(ValidateClean(new FortChangeUpdate(), null)); +} From e39bb357b8f0b2fee77fa92e51671760bd440172 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 09:20:45 -0400 Subject: [PATCH 21/59] feat(alarms): lure edit-in-place + quest daily-summary controls (#292 PR2) (#295) * feat(alarms): lure edit-in-place + quest daily-summary controls (#292 PR2) Surfaces the two remaining meaningful clean bits as user controls, building on PR1's CleanFlags helper and preservation fixes. - Lure add+edit dialogs: "Edit message in place" toggle (clean bit 2), default off, edit-dialog inits from isEdit() and save composes via preserve(clean, AUTO_DELETE|EDIT, ...) so the summary bit survives. - Quest add+edit dialogs: "Daily summary" toggle (clean bit 4), default off, same preserve pattern; hint notes it needs a summary schedule on the bot. - Card badges: lure edit (--mat-sys-secondary), quest summary (--mat-sys-tertiary), gated via methods, matching .clean-tag/.rsvp-tag. - i18n: LURES.EDIT_* / QUESTS.SUMMARY_* added + translated in all 11 locales. - Only lure/quest get controls (the only types whose PoracleNG processor reads the bit). New dialog specs cover init-from-bit + save-preserves-bits. Frontend: prettier/eslint/build clean, jest 781 pass. No backend change. * docs(#292): document clean delivery modes (alarms doc, in-app help, CLAUDE.md) - docs/features/alarms.md: new 'Delivery & message modes' section covering the clean bitmask (auto-delete / edit-in-place / daily-summary), which types support each, and that PoracleWeb preserves bot-set bits (gh-pages). - In-app Help (HELP.CONTENT_DELIVERY): appended an 'Edit in place & summaries' block describing the lure edit + quest daily-summary modes, translated in all 11 locales. - CLAUDE.md: dev note on the clean bitmask + CleanFlags helper + the Angular 'templates can't parse &' gotcha. * docs(rsvp): document RSVP notification mode (alarms doc + in-app help, 11 locales) Mirrors the #292 delivery-mode docs for RSVP (#233), authored by a tech-writer swarm: - docs/features/alarms.md: new '### RSVP updates (raids & eggs)' subsection under Delivery & message modes, explaining the 3 modes + edit-in-place coupling + the scanner caveat. - In-app Help (HELP.CONTENT_DELIVERY): appended an 'RSVP updates' block in all 11 locales, alongside the edit/summary block. Jest 781 pass; all 11 i18n files valid JSON. --- .../lures/lure-add-dialog.component.html | 2 + .../lures/lure-add-dialog.component.spec.ts | 100 ++++++++++++ .../lures/lure-add-dialog.component.ts | 14 +- .../lures/lure-edit-dialog.component.html | 3 + .../lures/lure-edit-dialog.component.spec.ts | 136 +++++++++++++++++ .../lures/lure-edit-dialog.component.ts | 7 +- .../modules/lures/lure-list.component.html | 3 + .../modules/lures/lure-list.component.scss | 15 ++ .../app/modules/lures/lure-list.component.ts | 5 + .../quests/quest-add-dialog.component.html | 3 + .../quests/quest-add-dialog.component.spec.ts | 114 ++++++++++++++ .../quests/quest-add-dialog.component.ts | 12 +- .../quests/quest-edit-dialog.component.html | 3 + .../quest-edit-dialog.component.spec.ts | 144 ++++++++++++++++++ .../quests/quest-edit-dialog.component.ts | 5 +- .../modules/quests/quest-list.component.html | 3 + .../modules/quests/quest-list.component.scss | 13 ++ .../modules/quests/quest-list.component.ts | 5 + .../ClientApp/src/assets/i18n/da.json | 12 +- .../ClientApp/src/assets/i18n/de.json | 12 +- .../ClientApp/src/assets/i18n/en.json | 12 +- .../ClientApp/src/assets/i18n/es.json | 12 +- .../ClientApp/src/assets/i18n/fr.json | 12 +- .../ClientApp/src/assets/i18n/it.json | 12 +- .../ClientApp/src/assets/i18n/nl.json | 12 +- .../ClientApp/src/assets/i18n/pl.json | 12 +- .../ClientApp/src/assets/i18n/pt-BR.json | 12 +- .../ClientApp/src/assets/i18n/pt.json | 12 +- .../ClientApp/src/assets/i18n/sv.json | 12 +- CHANGELOG.md | 1 + CLAUDE.md | 3 + docs/features/alarms.md | 24 +++ 32 files changed, 704 insertions(+), 43 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts 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..ff03540b 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 @@ -75,6 +75,8 @@

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

(valueChange)="form.controls.template.setValue($event)"> {{ '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.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.spec.ts new file mode 100644 index 00000000..b554b964 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-add-dialog.component.spec.ts @@ -0,0 +1,100 @@ +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 { TranslateModule } 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: [ + 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, TranslateModule.forRoot()], + }); + + 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..ec4fef58 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 @@ -18,6 +18,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { compose } from '../../shared/utils/clean-flags'; interface LureOption { color: string; @@ -53,7 +54,15 @@ export class LureAddDialogComponent { 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], + distanceKm: [1], + distanceMode: ['areas' as 'areas' | 'distance'], + editInPlace: [false], + ping: [''], + template: [''], + }); + readonly isWebhook = inject(AuthService).isImpersonating(); lureTypes: LureOption[] = [ { id: 501, name: 'Normal', color: '#FF9800' }, @@ -83,7 +92,8 @@ export class LureAddDialogComponent { const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); const creates = this.selectedLureIds().map(lureId => this.lureService.create({ - clean: v.clean ? 1 : 0, + // 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: dist, lureId, ping: v.ping || null, 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..89aacbe8 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 @@ -79,6 +79,9 @@

{{ '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.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.spec.ts new file mode 100644 index 00000000..4d437bdb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/lures/lure-edit-dialog.component.spec.ts @@ -0,0 +1,136 @@ +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 { TranslateModule } 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: [ + 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, TranslateModule.forRoot()], + }); + + 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 0ed2bece..96704342 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 @@ -17,7 +17,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; -import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; +import { AUTO_DELETE, compose, EDIT, isAutoDelete, isEdit, preserve } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -51,6 +51,7 @@ export class LureEditDialogComponent { clean: [isAutoDelete(this.data.clean)], distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], + editInPlace: [isEdit(this.data.clean)], ping: [this.data.ping ?? ''], template: [this.data.template ?? ''], }); @@ -92,7 +93,9 @@ export class LureEditDialogComponent { const dist = v.distanceMode === 'areas' ? 0 : Math.round((v.distanceKm ?? 1) * 1000); this.lureService .update(this.data.uid, { - clean: preserve(this.data.clean, AUTO_DELETE, v.clean ? 1 : 0), + // 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: dist, lureId: this.data.lureId, ping: v.ping || null, 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 8995d5fa..9133124b 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 @@ -65,6 +65,9 @@

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

@if (isAutoDelete(lure.clean)) { clean } + @if (isEdit(lure.clean)) { + {{ 'LURES.EDIT_BADGE' | translate }} + } 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 c403ad25..e301ddc2 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 @@ -201,6 +201,11 @@ export class LureListComponent implements OnInit { 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 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..3901661d 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 @@ -111,6 +111,9 @@

{{ 'ALARM.COMMON_SETTINGS' | translate }}

{{ 'ALARM.CLEAN_MODE' | translate }}

{{ 'ALARM.CLEAN_HINT_QUEST' | translate }}

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

{{ 'QUESTS.SUMMARY_HINT' | translate }}

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..af07899f --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts @@ -0,0 +1,114 @@ +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 { TranslateModule } 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: [ + 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, TranslateModule.forRoot()], + }); + + 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('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..f545e774 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 @@ -21,6 +21,7 @@ import { QuestService } from '../../core/services/quest.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { compose } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -61,6 +62,7 @@ export class QuestAddDialogComponent { distanceKm: [1], distanceMode: ['areas' as 'areas' | 'distance'], ping: [''], + summary: [false], template: [''], }); @@ -143,6 +145,8 @@ export class QuestAddDialogComponent { this.saving.set(true); const common = this.commonForm.getRawValue(); const distanceMeters = common.distanceMode === 'areas' ? 0 : Math.round((common.distanceKm ?? 1) * 1000); + // 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,7 +155,7 @@ export class QuestAddDialogComponent { for (const pokemonId of this.selectedPokemonIds()) { creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, + clean: cleanValue, distance: distanceMeters, ping: common.ping || null, pokemonId, @@ -166,7 +170,7 @@ export class QuestAddDialogComponent { case 1: creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, + clean: cleanValue, distance: distanceMeters, ping: common.ping || null, pokemonId: 0, @@ -181,7 +185,7 @@ export class QuestAddDialogComponent { for (const pokemonId of this.selectedMegaPokemonIds()) { creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, + clean: cleanValue, distance: distanceMeters, ping: common.ping || null, pokemonId, @@ -197,7 +201,7 @@ export class QuestAddDialogComponent { for (const pokemonId of this.selectedCandyPokemonIds()) { creates.push( this.questService.create({ - clean: common.clean ? 1 : 0, + clean: cleanValue, distance: distanceMeters, ping: common.ping || null, pokemonId, 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..e4c28234 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 @@ -84,6 +84,9 @@

{{ 'ALARM.MESSAGE_SETTINGS' | translate }}

{{ 'ALARM.CLEAN_MODE' | translate }}

{{ 'ALARM.CLEAN_HINT_QUEST' | translate }}

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

{{ 'QUESTS.SUMMARY_HINT' | translate }}

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..a9a9c872 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts @@ -0,0 +1,144 @@ +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 { TranslateModule } 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, + 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: [ + 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, TranslateModule.forRoot()], + }); + + 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); + }); + }); +}); 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 0f5ad0b5..3febcb66 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 @@ -19,7 +19,7 @@ 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 { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; -import { AUTO_DELETE, isAutoDelete, preserve } from '../../shared/utils/clean-flags'; +import { AUTO_DELETE, compose, isAutoDelete, isSummary, preserve, SUMMARY } from '../../shared/utils/clean-flags'; @Component({ imports: [ @@ -60,6 +60,7 @@ export class QuestEditDialogComponent { distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], distanceMode: [this.data.distance === 0 ? 'areas' : ('distance' as 'areas' | 'distance')], ping: [this.data.ping ?? ''], + summary: [isSummary(this.data.clean)], template: [this.data.template ?? ''], }); @@ -151,7 +152,7 @@ export class QuestEditDialogComponent { const distanceMeters = values.distanceMode === 'areas' ? 0 : Math.round((values.distanceKm ?? 1) * 1000); const update: QuestUpdate = { - clean: preserve(this.data.clean, AUTO_DELETE, values.clean ? 1 : 0), + clean: preserve(this.data.clean, AUTO_DELETE | SUMMARY, compose(!!values.clean, false, !!values.summary)), distance: distanceMeters, ping: values.ping || null, pokemonId: this.data.pokemonId, 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 5f078801..68db5d15 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 @@ -87,6 +87,9 @@

{{ getQuestTitle(quest) }}

@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 e72e810d..421f4d68 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 @@ -250,6 +250,11 @@ export class QuestListComponent implements OnInit { 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 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 f36e6201..2e97ab39 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Invasionsalarmer", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Rede-alarmer", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 bdad4ee3..3a20461b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Invasions-Alarme", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Nest-Alarme", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 2b6153b0..af530010 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -506,7 +506,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Invasion Alarms", @@ -614,7 +617,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Nest Alarms", @@ -1110,7 +1116,7 @@ "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_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.

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 (requires a summary schedule configured on the bot). 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_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.
", 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 e6bcbe8f..fa3d810d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Alarmas de Invasión", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Alarmas de Nido", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 9f62d5e8..55e152a1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -502,7 +502,10 @@ "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é" }, "INVASIONS": { "PAGE_TITLE": "Alarmes Invasion", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Alarmes Nid", @@ -1106,7 +1112,7 @@ "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_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).

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_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.
", 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 cf636d07..437b7222 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Allarmi Invasioni", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Allarmi Nidi", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 402ecc92..c7cf6749 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Invasie Alarmen", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Nest Alarmen", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 cf56169e..e3921a85 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Alarmy inwazji", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Alarmy gniazd", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 2d6d0a24..8be5cd66 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 @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Alarmes de Invasão", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Alarmes de Ninho", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 d5d6ac58..043ce803 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Alarmes de Invasões", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Alarmes de Ninhos", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", 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 756571cb..ce09e840 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -502,7 +502,10 @@ "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" }, "INVASIONS": { "PAGE_TITLE": "Invasionslarm", @@ -610,7 +613,10 @@ "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" }, "NESTS": { "PAGE_TITLE": "Nästlarm", @@ -1106,7 +1112,7 @@ "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_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.

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_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.
", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7faa01cf..1afd81ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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 diff --git a/CLAUDE.md b/CLAUDE.md index 67bff09e..eb147b02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -330,6 +330,9 @@ On first startup after upgrade, the `SettingsMigrationStartupService` automatica ### 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. + ### 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. diff --git a/docs/features/alarms.md b/docs/features/alarms.md index 7efed4f5..ee2ad53e 100644 --- a/docs/features/alarms.md +++ b/docs/features/alarms.md @@ -270,6 +270,30 @@ 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 | 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. | +| **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: From 5246907edbedf4516174fc7d0fae284f62ef0802 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:44:45 -0400 Subject: [PATCH 22/59] ci: bump peter-evans/create-pull-request from 7 to 8 (#281) Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7 to 8. - [Release notes](https://github.com/peter-evans/create-pull-request/releases) - [Commits](https://github.com/peter-evans/create-pull-request/compare/v7...v8) --- updated-dependencies: - dependency-name: peter-evans/create-pull-request dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index 5a2b9019..ae1c98a2 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -34,7 +34,7 @@ jobs: fi - name: Open PR with changelog update - uses: peter-evans/create-pull-request@v7 + uses: peter-evans/create-pull-request@v8 with: commit-message: "docs: cut changelog for ${{ github.event.release.tag_name }}" title: "docs: cut changelog for ${{ github.event.release.tag_name }}" From 5a3f78ca86418f64f1a5fac5a95de5aa3858e43f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:44:50 -0400 Subject: [PATCH 23/59] ci: bump marocchino/sticky-pull-request-comment from 2 to 3 (#282) Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 2 to 3. - [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases) - [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/v2...v3) --- updated-dependencies: - dependency-name: marocchino/sticky-pull-request-comment dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-preview.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 9422e5a1..495eb217 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -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: | From 0f7fc4e07d4a36172f0f6241e3ea4efc84c85b7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:44:54 -0400 Subject: [PATCH 24/59] ci: bump actions/upload-artifact from 4 to 7 (#283) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c61936f7..0807b1ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,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' From 3f12e72c4c1f474b23943dfb48f12ba348677a46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:45:02 -0400 Subject: [PATCH 25/59] ci: bump actions/github-script from 7 to 9 (#285) Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 9. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v9) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '9' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-prune.yml | 2 +- .github/workflows/pr-labeler.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-prune.yml b/.github/workflows/docker-prune.yml index c706a3c2..bfe5e2c0 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 }} 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; From a786ff07f7d0d22bbcdb030a590286d702c3be42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:45:06 -0400 Subject: [PATCH 26/59] deps: Bump the microsoft group with 1 update (#287) Bumps Microsoft.NET.Test.Sdk from 18.5.1 to 18.6.0 --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: microsoft ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj index 5360b567..39aab0d6 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj +++ b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj @@ -10,7 +10,7 @@ - + From 3f25d6aa45124b22fa5cb2180090a4ff0059466d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:45:11 -0400 Subject: [PATCH 27/59] deps: bump the eslint group (#288) Bumps the eslint group in /Applications/Pgan.PoracleWebNet.App/ClientApp with 5 updates: | Package | From | To | | --- | --- | --- | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.59.4` | `8.60.1` | | [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.59.4` | `8.60.1` | | [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) | `8.59.4` | `8.60.1` | | [eslint-import-resolver-typescript](https://github.com/import-js/eslint-import-resolver-typescript) | `4.4.4` | `4.4.5` | | [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) | `5.5.5` | `5.5.6` | Updates `@typescript-eslint/eslint-plugin` from 8.59.4 to 8.60.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.59.4 to 8.60.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/parser) Updates `@typescript-eslint/utils` from 8.59.4 to 8.60.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.1/packages/utils) Updates `eslint-import-resolver-typescript` from 4.4.4 to 4.4.5 - [Release notes](https://github.com/import-js/eslint-import-resolver-typescript/releases) - [Changelog](https://github.com/import-js/eslint-import-resolver-typescript/blob/master/CHANGELOG.md) - [Commits](https://github.com/import-js/eslint-import-resolver-typescript/compare/v4.4.4...v4.4.5) Updates `eslint-plugin-prettier` from 5.5.5 to 5.5.6 - [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases) - [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.5.5...v5.5.6) --- updated-dependencies: - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.60.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: eslint - dependency-name: "@typescript-eslint/parser" dependency-version: 8.60.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: eslint - dependency-name: "@typescript-eslint/utils" dependency-version: 8.60.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: eslint - dependency-name: eslint-import-resolver-typescript dependency-version: 4.4.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint - dependency-name: eslint-plugin-prettier dependency-version: 5.5.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: eslint ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ClientApp/package-lock.json | 144 +++++++++--------- .../ClientApp/package.json | 6 +- 2 files changed, 75 insertions(+), 75 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index d1db2391..cc400142 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -39,15 +39,15 @@ "@angular/platform-browser-dynamic": "^21.2.15", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/eslint-plugin": "^8.60.1", "@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.9.0", - "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", "jest": "^30.4.2", @@ -4952,13 +4952,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" @@ -6110,17 +6110,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.4.tgz", - "integrity": "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", + "integrity": "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/type-utils": "8.59.4", - "@typescript-eslint/utils": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/type-utils": "8.60.1", + "@typescript-eslint/utils": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -6133,22 +6133,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.4.tgz", - "integrity": "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.1.tgz", + "integrity": "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "engines": { @@ -6164,14 +6164,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.4.tgz", - "integrity": "sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.1.tgz", + "integrity": "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.4", - "@typescript-eslint/types": "^8.59.4", + "@typescript-eslint/tsconfig-utils": "^8.60.1", + "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "engines": { @@ -6186,14 +6186,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.4.tgz", - "integrity": "sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.1.tgz", + "integrity": "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4" + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6204,9 +6204,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.4.tgz", - "integrity": "sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.1.tgz", + "integrity": "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==", "dev": true, "license": "MIT", "engines": { @@ -6221,15 +6221,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.4.tgz", - "integrity": "sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.1.tgz", + "integrity": "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4", - "@typescript-eslint/utils": "8.59.4", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1", + "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -6246,9 +6246,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.4.tgz", - "integrity": "sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.1.tgz", + "integrity": "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==", "dev": true, "license": "MIT", "engines": { @@ -6260,16 +6260,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.4.tgz", - "integrity": "sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.1.tgz", + "integrity": "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.4", - "@typescript-eslint/tsconfig-utils": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/visitor-keys": "8.59.4", + "@typescript-eslint/project-service": "8.60.1", + "@typescript-eslint/tsconfig-utils": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -6301,16 +6301,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.4.tgz", - "integrity": "sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.1.tgz", + "integrity": "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.4", - "@typescript-eslint/types": "8.59.4", - "@typescript-eslint/typescript-estree": "8.59.4" + "@typescript-eslint/scope-manager": "8.60.1", + "@typescript-eslint/types": "8.60.1", + "@typescript-eslint/typescript-estree": "8.60.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6325,13 +6325,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.4", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.4.tgz", - "integrity": "sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==", + "version": "8.60.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.1.tgz", + "integrity": "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.4", + "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8842,9 +8842,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": { @@ -9020,14 +9020,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" @@ -16173,13 +16173,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" diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index 374a83c0..31af849c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -46,15 +46,15 @@ "@angular/platform-browser-dynamic": "^21.2.15", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", - "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/eslint-plugin": "^8.60.1", "@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.9.0", - "eslint-plugin-prettier": "^5.5.0", + "eslint-plugin-prettier": "^5.5.6", "eslint-plugin-sort-class-members": "^1.21.0", "eslint-plugin-unused-imports": "^4.4.0", "jest": "^30.4.2", From eb97dbdf0aacbee7937d6e73e511baaf6304f51b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 13:49:28 +0000 Subject: [PATCH 28/59] ci: bump actions/setup-node from 4 to 6 (#284) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0807b1ac..771ea3fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,7 @@ jobs: uses: actions/checkout@v6 - name: Setup Node.js 22 - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '22' cache: 'npm' From f49e5512a1e1c0ed1cf1c33eb2147c1c12965a9c Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 11:40:21 -0400 Subject: [PATCH 29/59] feat: show Discord server/category notes on admin user list (#265) (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the Poracle `notes` field on channel-type users in the admin user list so admins can disambiguate channels that share the same name across different servers. PoracleJS/PoracleNG can auto-fill notes with the Discord guild name and channel category; that column already existed on the humans table but was dropped at every layer. Reuses data PoracleNG already provides — no new database queries and no live Discord API calls: - Single-user reads come from the PoracleNG human JSON (HumanService .DeserializeHuman now reads `notes`). - The admin bulk list maps the `notes` column through the existing read (Human model + EntityMappingExtensions); projected by GET /api/admin/users and GET /api/admin/users/by-id. Frontend renders notes as a muted second line under the name with a tooltip, and the admin search box matches against it. A notesLabel() normalizer trims whitespace and strips a surrounding quote layer so PoracleJS/NG's quoted-empty `""` sentinel (and any JSON-quoted note) doesn't render a stray value. Tests: extend HumanEntity.ToModel mapping test and the admin.service spec mock. --- .../Controllers/AdminController.cs | 2 ++ .../ClientApp/src/app/core/models/index.ts | 2 ++ .../app/core/services/admin.service.spec.ts | 1 + .../modules/admin/admin-users.component.html | 8 ++++++- .../modules/admin/admin-users.component.scss | 13 ++++++++++++ .../modules/admin/admin-users.component.ts | 21 ++++++++++++++++++- CHANGELOG.md | 1 + .../EntityMappingExtensions.cs | 3 +++ Core/Pgan.PoracleWebNet.Core.Models/Human.cs | 10 +++++++++ .../HumanService.cs | 1 + .../Mappings/PoracleMappingProfileTests.cs | 2 ++ 11 files changed, 62 insertions(+), 2 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs index aaebf311..793fb216 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminController.cs @@ -46,6 +46,7 @@ public async Task GetAllUsers() h.DisabledDate, h.CurrentProfileNo, h.Language, + h.Notes, AvatarUrl = Services.AvatarCacheService.GetAvatarOrDefault(h.Id, h.Type) }); @@ -79,6 +80,7 @@ public async Task GetUser([FromQuery] string id) human.Area, human.Latitude, human.Longitude, + human.Notes, AvatarUrl = avatarUrl }); } 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 6653eb29..099f6be0 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 @@ -248,6 +248,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; } 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/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..6abf388e 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 @@ -72,7 +72,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 +221,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/CHANGELOG.md b/CHANGELOG.md index 1afd81ff..92982032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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. 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/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.Services/HumanService.cs b/Core/Pgan.PoracleWebNet.Core.Services/HumanService.cs index b8f1699c..fd04809a 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/HumanService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/HumanService.cs @@ -99,6 +99,7 @@ public async Task DeleteAllAlarmsByUserAsync(string userId) AdminDisable = json.GetIntProp("admin_disable"), CurrentProfileNo = json.GetIntProp("current_profile_no"), CommunityMembership = json.GetStringPropOrNull("community_membership"), + Notes = json.GetStringPropOrNull("notes"), }; private static JsonElement SerializeHumanForCreate(Human human) diff --git a/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs index 87335af1..8946bde1 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Mappings/PoracleMappingProfileTests.cs @@ -95,6 +95,7 @@ public void HumanEntity_ToModel_MapsAllFields() AdminDisable = 0, CurrentProfileNo = 2, CommunityMembership = "groupA", + Notes = "My Server / Alerts", }; var model = entity.ToModel(); @@ -111,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 ─────────────────────────────── From 06826ecd7777db7e4ed71c0efbb681b5943c4f6b Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 12:36:45 -0400 Subject: [PATCH 30/59] feat: add admin setting to disable user-submitted geofences (#297) (#299) * feat: add admin setting to disable user-submitted geofences (#297) From discussion #214: let operators turn off the custom/user-drawn geofence feature via a new `disable_user_geofences` site setting, reusing the existing `disable_*` feature-gate pattern. Backend - DisableFeatureKeys.UserGeofences constant. - Gate the "provide a geofence" actions on UserGeofenceController (create, submit-for-review, GeoJSON import) with [RequireFeatureEnabled], plus a defense-in-depth IFeatureGate.EnsureEnabledAsync guard in UserGeofenceService .CreateAsync (also covers import, which funnels through it) and .SubmitForReviewAsync. - Reads, delete, activate/deactivate and the admin review queue stay ungated so existing geofences keep working and are still served by /api/geofence-feed. - SettingsMigrationService CategoryMap + BooleanKeys carry the key. Frontend - Hide the My Geofences nav item (disableKey) and guard the /geofences route (disabledFeatureGuard) -> redirect to dashboard with the existing ERROR.FEATURE_DISABLED toast; the 403 interceptor handles direct API hits. - Admin settings toggle in the Features group. - ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_* label/description added and translated across all 11 locales. Tests: UserGeofenceService gate tests for CreateAsync and SubmitForReviewAsync; existing test setup updated for the new IFeatureGate dependency. * feat: hide admin "User Geofences" review queue when disabled Extend disable_user_geofences to also hide the admin-facing geofence review queue: add the disableKey to the /admin/geofence-submissions nav item, make the adminNavItems computed honour isFeatureDisabled (it previously ignored disableKey), and guard the route with disabledFeatureGuard. Enabling the toggle now hides the whole feature end to end. The admin review backend stays ungated so any pre-existing submissions aren't bricked and reappear if the feature is re-enabled. --- .../Controllers/UserGeofenceController.cs | 4 +++ .../ClientApp/src/app/app.routes.ts | 4 +-- .../ClientApp/src/app/app.ts | 14 ++++++-- .../modules/admin/admin-settings.component.ts | 6 ++++ .../ClientApp/src/assets/i18n/da.json | 2 ++ .../ClientApp/src/assets/i18n/de.json | 2 ++ .../ClientApp/src/assets/i18n/en.json | 2 ++ .../ClientApp/src/assets/i18n/es.json | 2 ++ .../ClientApp/src/assets/i18n/fr.json | 2 ++ .../ClientApp/src/assets/i18n/it.json | 2 ++ .../ClientApp/src/assets/i18n/nl.json | 2 ++ .../ClientApp/src/assets/i18n/pl.json | 2 ++ .../ClientApp/src/assets/i18n/pt-BR.json | 2 ++ .../ClientApp/src/assets/i18n/pt.json | 2 ++ .../ClientApp/src/assets/i18n/sv.json | 2 ++ CHANGELOG.md | 1 + .../DisableFeatureKeys.cs | 7 ++++ .../SettingsMigrationService.cs | 3 +- .../UserGeofenceService.cs | 8 +++++ .../Services/UserGeofenceServiceTests.cs | 34 +++++++++++++++++++ 20 files changed, 98 insertions(+), 5 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/UserGeofenceController.cs index a25ec969..3a95a34f 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; @@ -30,6 +31,7 @@ public async Task GetCustomGeofences() } [HttpPost("custom")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] public async Task CreateGeofence([FromBody] UserGeofenceCreate model) { try @@ -70,6 +72,7 @@ public async Task DeleteGeofence(int id) } [HttpPost("custom/{kojiName}/submit")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] public async Task SubmitForReview(string kojiName) { try @@ -171,6 +174,7 @@ public async Task ExportGeoJson() } [HttpPost("import/geojson")] + [RequireFeatureEnabled(DisableFeatureKeys.UserGeofences)] [EnableRateLimiting("geojson-import")] [RequestSizeLimit(5 * 1024 * 1024)] public async Task ImportGeoJson(IFormFile file) 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..76eee9ce 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.routes.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.routes.ts @@ -84,7 +84,7 @@ export const routes: Routes = [ path: 'areas', }, { - canActivate: [authGuard], + canActivate: [authGuard, disabledFeatureGuard('disable_user_geofences')], loadComponent: () => import('./modules/geofences/geofence-list.component').then(m => m.GeofenceListComponent), path: 'geofences', }, @@ -119,7 +119,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.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts index 1f1b5e92..6dbfb893 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts @@ -184,7 +184,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 +199,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,7 +210,9 @@ 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), + ), ); protected readonly alarmNavItems = computed(() => 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..30a3b815 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 @@ -192,6 +192,12 @@ const SETTING_GROUPS: SettingGroup[] = [ labelKey: 'ADMIN_SETTINGS.DISABLE_GEOMAP_SELECT_LABEL', type: 'boolean', }, + { + descriptionKey: 'ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_DESC', + key: 'disable_user_geofences', + labelKey: 'ADMIN_SETTINGS.DISABLE_USER_GEOFENCES_LABEL', + type: 'boolean', + }, { descriptionKey: 'ADMIN_SETTINGS.ENABLE_TEMPLATES_DESC', key: 'enable_templates', 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 2e97ab39..225633c0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Deaktivér brugerdefinerede geofences", + "DISABLE_USER_GEOFENCES_DESC": "Forhindrer brugere i at tegne, importere eller indsende deres egne geofences. Eksisterende geofences fungerer fortsat.", "ENABLE_TEMPLATES_LABEL": "Aktiver skabeloner", "ENABLE_TEMPLATES_DESC": "Tillad brugere at vælge skabeloner for notifikationsbeskeder.", "ALLOWED_LANGUAGES_LABEL": "Tilladte UI-sprog", 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 3a20461b..6af60999 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Eigene Geofences deaktivieren", + "DISABLE_USER_GEOFENCES_DESC": "Hindert Benutzer daran, eigene Geofences zu zeichnen, zu importieren oder einzureichen. Bestehende Geofences bleiben aktiv.", "ENABLE_TEMPLATES_LABEL": "Vorlagen aktivieren", "ENABLE_TEMPLATES_DESC": "Benutzern erlauben, Benachrichtigungsvorlagen auszuwählen.", "ALLOWED_LANGUAGES_LABEL": "Erlaubte UI-Sprachen", 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 af530010..ba8bad2e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -1501,6 +1501,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Disable Custom Geofences", + "DISABLE_USER_GEOFENCES_DESC": "Stop users from drawing, importing, or submitting their own geofences. Existing geofences keep working.", "ENABLE_TEMPLATES_LABEL": "Enable Templates", "ENABLE_TEMPLATES_DESC": "Allow users to choose notification message templates.", "ALLOWED_LANGUAGES_LABEL": "Allowed UI Languages", 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 fa3d810d..7086f22d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Desactivar geofences personalizadas", + "DISABLE_USER_GEOFENCES_DESC": "Impide que los usuarios dibujen, importen o envíen sus propias geofences. Las geofences existentes siguen funcionando.", "ENABLE_TEMPLATES_LABEL": "Activar plantillas", "ENABLE_TEMPLATES_DESC": "Permite a los usuarios elegir plantillas de mensaje de notificación.", "ALLOWED_LANGUAGES_LABEL": "Idiomas de interfaz permitidos", 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 55e152a1..438f3c40 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Désactiver les Geofences personnalisées", + "DISABLE_USER_GEOFENCES_DESC": "Empêche les utilisateurs de dessiner, importer ou soumettre leurs propres Geofences. Les Geofences existantes continuent de fonctionner.", "ENABLE_TEMPLATES_LABEL": "Activer les modèles", "ENABLE_TEMPLATES_DESC": "Autoriser les utilisateurs à choisir des modèles de messages de notification.", "ALLOWED_LANGUAGES_LABEL": "Langues d'interface autorisées", 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 437b7222..84a8b23e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Disabilita le geofence personalizzate", + "DISABLE_USER_GEOFENCES_DESC": "Impedisce agli utenti di disegnare, importare o inviare le proprie geofence. Le geofence esistenti continuano a funzionare.", "ENABLE_TEMPLATES_LABEL": "Abilita modelli", "ENABLE_TEMPLATES_DESC": "Consenti agli utenti di scegliere modelli di messaggio di notifica.", "ALLOWED_LANGUAGES_LABEL": "Lingue UI consentite", 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 c7cf6749..d8057978 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Eigen geofences uitschakelen", + "DISABLE_USER_GEOFENCES_DESC": "Voorkomt dat gebruikers eigen geofences tekenen, importeren of indienen. Bestaande geofences blijven werken.", "ENABLE_TEMPLATES_LABEL": "Templates inschakelen", "ENABLE_TEMPLATES_DESC": "Laat gebruikers meldingsberichttemplates kiezen.", "ALLOWED_LANGUAGES_LABEL": "Toegestane UI-talen", 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 e3921a85..80dbe688 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Wyłącz własne geofence", + "DISABLE_USER_GEOFENCES_DESC": "Uniemożliwia użytkownikom rysowanie, importowanie i zgłaszanie własnych geofence. Istniejące geofence nadal działają.", "ENABLE_TEMPLATES_LABEL": "Włącz szablony", "ENABLE_TEMPLATES_DESC": "Pozwól użytkownikom wybierać szablony wiadomości powiadomień.", "ALLOWED_LANGUAGES_LABEL": "Dozwolone języki UI", 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 8be5cd66..bfed8f7e 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 @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Desativar geofences personalizadas", + "DISABLE_USER_GEOFENCES_DESC": "Impede que os usuários desenhem, importem ou enviem suas próprias geofences. As geofences existentes continuam funcionando.", "ENABLE_TEMPLATES_LABEL": "Ativar modelos", "ENABLE_TEMPLATES_DESC": "Permitir que os usuários escolham modelos de mensagens de notificação.", "ALLOWED_LANGUAGES_LABEL": "Idiomas da UI permitidos", 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 043ce803..10b43bd5 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Desativar geofences personalizadas", + "DISABLE_USER_GEOFENCES_DESC": "Impede que os utilizadores desenhem, importem ou submetam as suas próprias geofences. As geofences existentes continuam a funcionar.", "ENABLE_TEMPLATES_LABEL": "Ativar modelos", "ENABLE_TEMPLATES_DESC": "Permitir que os utilizadores escolham modelos de mensagens de notificação.", "ALLOWED_LANGUAGES_LABEL": "Idiomas de UI permitidos", 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 ce09e840..2344a4ff 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -1503,6 +1503,8 @@ "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.", + "DISABLE_USER_GEOFENCES_LABEL": "Inaktivera egna geofences", + "DISABLE_USER_GEOFENCES_DESC": "Hindrar användare från att rita, importera eller skicka in egna geofences. Befintliga geofences fortsätter att fungera.", "ENABLE_TEMPLATES_LABEL": "Aktivera mallar", "ENABLE_TEMPLATES_DESC": "Låt användare välja mallar för notifieringsmeddelanden.", "ALLOWED_LANGUAGES_LABEL": "Tillåtna UI-språk", diff --git a/CHANGELOG.md b/CHANGELOG.md index 92982032..aae4a84a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **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. - **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. diff --git a/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs b/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs index 3980901d..32c859d8 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/DisableFeatureKeys.cs @@ -29,6 +29,13 @@ 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"; + /// /// Tracking-type string (as used in PoracleNG's /api/tracking/{type} URLs and /// ProfileOverviewService's alarm-type loop) → matching disable_* key. diff --git a/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs b/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs index ce5ba213..837ce7d1 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs @@ -66,6 +66,7 @@ public partial class SettingsMigrationService( ["disable_nominatim"] = "features", ["disable_geomap"] = "features", ["disable_geomap_select"] = "features", + ["disable_user_geofences"] = "features", ["enable_templates"] = "features", // admin @@ -117,7 +118,7 @@ public partial class SettingsMigrationService( "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_geomap", "disable_geomap_select", "disable_user_geofences", "enable_templates", "enable_roles", "enable_telegram", "enable_discord", "hide_header_logo", "site_is_https", "debug", }; diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs b/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs index a2b14979..7269f100 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs @@ -16,6 +16,7 @@ public partial class UserGeofenceService( IHumanRepository humanRepository, IUserAreaDualWriter areaWriter, IDiscordNotificationService discordNotificationService, + IFeatureGate featureGate, ILogger logger) : IUserGeofenceService { private const int MaxGeofencesPerUser = 10; @@ -27,6 +28,7 @@ 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 ILogger _logger = logger; public async Task> GetByUserAsync(string humanId) @@ -53,6 +55,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) @@ -260,6 +266,8 @@ 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."); diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs index 45cb2d50..8bb856dc 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs @@ -17,6 +17,7 @@ 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 Mock> _logger = new(); private readonly UserGeofenceService _sut; @@ -28,6 +29,7 @@ public class UserGeofenceServiceTests this._humanRepo.Object, this._areaWriter.Object, this._discordNotificationService.Object, + this._featureGate.Object, this._logger.Object); /// @@ -65,6 +67,38 @@ public async Task GetByUserAsyncReturnsGeofencesFromRepositoryWithPolygons() Assert.Equal(polygon[0][1], result[0].Polygon![0][1]); } + // --- Feature gate (#214 disable_user_geofences) --- + + [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] From 275110cbdd1d5a0fd6d9b673b4507ef312e31cbb Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 13:47:25 -0400 Subject: [PATCH 31/59] feat: configurable default delivery scope for new alerts (#298) (#301) New alarms always opened pre-set to Areas; radius users had to switch the location mode and re-type a distance on every add. Add a user-level default. - AlertDefaultsService persists mode + default distance (km) to localStorage, mirroring the theme/accent/language pattern; distance clamped 0.1-100 km. - New Alert Defaults dialog (user menu) reusing the distance-dialog idiom: Areas/Distance mode cards, km input, live delivery preview. - All 9 add-alarm dialogs (Pokemon, Raids/Eggs, Quests, Invasions, Lures, Nests, Gyms, Fort Changes, Max Battles) and the quick-pick apply dialog seed distanceMode/distanceKm from the service instead of hard-coded values. - ALERT_DEFAULTS.* + MENU.ALERT_DEFAULTS i18n keys across all 11 locales. - Service + dialog unit tests; CHANGELOG entry. - Docs: gh-pages site (features/alarms.md "Default delivery scope" section + Quick Picks note, index.md + README feature bullets), in-app Help (Delivery Settings section), and CLAUDE.md developer note. Tracks discussion #217. --- .../ClientApp/src/app/app.html | 4 + .../ClientApp/src/app/app.ts | 7 ++ .../services/alert-defaults.service.spec.ts | 57 +++++++++++++ .../core/services/alert-defaults.service.ts | 54 ++++++++++++ .../fort-change-add-dialog.component.ts | 6 +- .../modules/gyms/gym-add-dialog.component.ts | 6 +- .../invasion-add-dialog.component.ts | 6 +- .../lures/lure-add-dialog.component.ts | 6 +- .../max-battle-add-dialog.component.ts | 6 +- .../nests/nest-add-dialog.component.ts | 6 +- .../pokemon/pokemon-add-dialog.component.ts | 6 +- .../quests/quest-add-dialog.component.ts | 6 +- .../quick-pick-apply-dialog.component.ts | 6 +- .../raids/raid-add-dialog.component.ts | 6 +- .../alert-defaults-dialog.component.html | 49 +++++++++++ .../alert-defaults-dialog.component.scss | 83 +++++++++++++++++++ .../alert-defaults-dialog.component.spec.ts | 64 ++++++++++++++ .../alert-defaults-dialog.component.ts | 51 ++++++++++++ .../ClientApp/src/assets/i18n/da.json | 10 ++- .../ClientApp/src/assets/i18n/de.json | 10 ++- .../ClientApp/src/assets/i18n/en.json | 12 ++- .../ClientApp/src/assets/i18n/es.json | 10 ++- .../ClientApp/src/assets/i18n/fr.json | 10 ++- .../ClientApp/src/assets/i18n/it.json | 10 ++- .../ClientApp/src/assets/i18n/nl.json | 10 ++- .../ClientApp/src/assets/i18n/pl.json | 10 ++- .../ClientApp/src/assets/i18n/pt-BR.json | 10 ++- .../ClientApp/src/assets/i18n/pt.json | 10 ++- .../ClientApp/src/assets/i18n/sv.json | 10 ++- CHANGELOG.md | 1 + CLAUDE.md | 1 + README.md | 1 + docs/features/alarms.md | 16 ++++ docs/index.md | 1 + 34 files changed, 529 insertions(+), 32 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-defaults.service.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.ts diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html index 7ec28460..86384600 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html @@ -110,6 +110,10 @@ {{ 'MENU.CLEANING' | 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..467f9ee2 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.scss @@ -0,0 +1,83 @@ +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; +} 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..ec61d6c1 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.spec.ts @@ -0,0 +1,64 @@ +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); + }); + + it('clamps an out-of-range distance before persisting', () => { + const component = create(); + component.mode.set('distance'); + component.distanceKm = 99999; + component.save(); + + expect(localStorage.getItem('poracle-default-alert-distance-km')).toBe(String(component.maxKm)); + }); +}); 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..abef8b3c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/alert-defaults-dialog/alert-defaults-dialog.component.ts @@ -0,0 +1,51 @@ +import { Component, 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 { TranslateModule } from '@ngx-translate/core'; + +import { + AlertDefaultsService, + AlertLocationMode, + MAX_DEFAULT_DISTANCE_KM, + MIN_DEFAULT_DISTANCE_KM, +} from '../../../core/services/alert-defaults.service'; +import { DeliveryPreviewComponent } from '../delivery-preview/delivery-preview.component'; + +@Component({ + imports: [ + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + MatIconModule, + DeliveryPreviewComponent, + TranslateModule, + ], + selector: 'app-alert-defaults-dialog', + standalone: true, + styleUrl: './alert-defaults-dialog.component.scss', + templateUrl: './alert-defaults-dialog.component.html', +}) +export class AlertDefaultsDialogComponent { + 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()); + + save(): void { + this.alertDefaults.save(this.mode(), this.distanceKm); + this.dialogRef.close(true); + } +} 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 225633c0..eb83935d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -60,7 +60,8 @@ "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Standardindstillinger for advarsler" }, "SHORTCUTS": { "TITLE": "Tastaturgenveje", @@ -1619,5 +1620,12 @@ "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." } } 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 6af60999..b957f8f3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -60,7 +60,8 @@ "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Benachrichtigungs-Standards" }, "SHORTCUTS": { "TITLE": "Tastenkürzel", @@ -1619,5 +1620,12 @@ "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." } } 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 ba8bad2e..c8be3812 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -60,7 +60,8 @@ "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Alert Defaults" }, "SHORTCUTS": { "TITLE": "Keyboard Shortcuts", @@ -1116,7 +1117,7 @@ "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.

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 (requires a summary schedule configured on the bot). 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_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.

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, and set a default radius. 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 override the mode and distance on any individual alarm.

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.

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 (requires a summary schedule configured on the bot). 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_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.
", @@ -1623,5 +1624,12 @@ "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." } } 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 7086f22d..012fbcef 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -60,7 +60,8 @@ "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", @@ -1619,5 +1620,12 @@ "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." } } 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 438f3c40..990b96d8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -60,7 +60,8 @@ "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", @@ -1619,5 +1620,12 @@ "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." } } 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 84a8b23e..c14b1156 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -60,7 +60,8 @@ "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", @@ -1619,5 +1620,12 @@ "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." } } 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 d8057978..bdc58d81 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -60,7 +60,8 @@ "ACCENT_RAIDS": "Raids", "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", - "ACCENT_INSTINCT": "Instinct" + "ACCENT_INSTINCT": "Instinct", + "ALERT_DEFAULTS": "Standaardinstellingen meldingen" }, "SHORTCUTS": { "TITLE": "Sneltoetsen", @@ -1619,5 +1620,12 @@ "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." } } 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 80dbe688..e8c52fab 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -60,7 +60,8 @@ "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", @@ -1619,5 +1620,12 @@ "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." } } 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 bfed8f7e..c27c871a 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 @@ -60,7 +60,8 @@ "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", @@ -1619,5 +1620,12 @@ "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." } } 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 10b43bd5..82abaf0a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -60,7 +60,8 @@ "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", @@ -1619,5 +1620,12 @@ "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." } } 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 2344a4ff..950df0b0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -60,7 +60,8 @@ "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", @@ -1619,5 +1620,12 @@ "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." } } diff --git a/CHANGELOG.md b/CHANGELOG.md index aae4a84a..da681c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **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. diff --git a/CLAUDE.md b/CLAUDE.md index eb147b02..20bb5806 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -253,6 +253,7 @@ The `disable_mons` / `disable_raids` / `disable_quests` / `disable_invasions` / - `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. diff --git a/README.md b/README.md index ec0b3c85..dfb92d06 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ See the [Quick Start guide](https://pgan-dev.github.io/PoracleWeb.NET/getting-st - **Alarm Management** — Pokemon, Raids, Quests, Invasions, Lures, Nests, Gyms - **Gym Picker** — Search and target specific gyms for team change, raid, and egg alarms - **Bulk Operations** — Multi-select with bulk delete and distance update +- **Alert Defaults** — Choose whether new alerts default to Areas or a Distance radius, with a configurable default distance - **Custom Geofences** — Draw polygons, auto-served to PoracleJS via unified feed - **Geofence Admin Review** — Approve/reject with Discord forum integration - **Quick Picks** — One-click alarm templates diff --git a/docs/features/alarms.md b/docs/features/alarms.md index ee2ad53e..3bb82813 100644 --- a/docs/features/alarms.md +++ b/docs/features/alarms.md @@ -30,6 +30,20 @@ Each alarm type has a dedicated page accessible from the sidebar navigation. The 5. Optionally select a **template** for notification formatting 6. Save the alarm +## 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. + +The preference is **per-browser** (stored in `localStorage` under `poracle-default-alert-mode` / `poracle-default-alert-distance-km`, the same pattern as the theme and language settings) and is read by the `AlertDefaultsService`. It seeds the location-mode controls 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 override the mode and distance for 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. @@ -405,3 +419,5 @@ The dashboard shows the current in-game weather conditions at the user's saved l ## 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 (location mode and distance) is seeded from your [Alert Defaults](#default-delivery-scope-alert-defaults), so applied picks follow the same default as manually added alarms. You can still adjust the mode and distance for each apply before confirming. diff --git a/docs/index.md b/docs/index.md index e2f172f4..116a689e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -22,6 +22,7 @@ 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** — Choose whether new alerts default to Areas or a Distance radius, with a configurable default distance - **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. From ceda0baea4b677296c2fd52942df55034e89e69b Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 17:07:27 -0400 Subject: [PATCH 32/59] fix(quick-picks): correct stale distanceKm default-value assertion (#304) #301 changed the quick-pick apply dialog (and every add-dialog) to seed the delivery form's distanceKm from AlertDefaultsService.defaultDistanceKm() (default 1 km), so the radius is pre-filled if the user switches to Distance mode. The quick-pick-apply-dialog spec still asserted the pre-#301 default of 0, so it failed and turned the Frontend (Angular) CI red on main from the #301 merge onward. The component is correct and matches all sibling add-dialogs (in Areas mode apply() sends 0 m regardless of distanceKm); only the assertion was stale. Updated it to expect the AlertDefaultsService default (1) with a comment. Verified: full Jest suite green (792/792), prettier + eslint clean. --- .../quick-picks/quick-pick-apply-dialog.component.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.spec.ts index d2d2334f..0a5dc299 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quick-picks/quick-pick-apply-dialog.component.spec.ts @@ -79,7 +79,10 @@ 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); + // Since #301, distanceKm is pre-seeded from AlertDefaultsService.defaultDistanceKm() (default 1 km), + // matching every add-dialog, so the radius is ready if the user switches to Distance mode. In Areas + // mode it is irrelevant — apply() sends 0 m for 'areas' regardless of this value. + expect(form.distanceKm).toBe(1); expect(form.distanceMode).toBe('areas'); expect(form.template).toBe(''); }); From 418d4589fa54b89b28faa4fa8f238ab20e4510c2 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 17:13:07 -0400 Subject: [PATCH 33/59] feat(quests): quest summary delivery schedule UI (#300) (#303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(quests): quest summary delivery schedule management UI (#300) Implements the deferred summary_schedules management UI from #292 — the piece the per-alarm quest "Daily summary" clean-bit toggle (#295) depends on. Users can now view, edit, clear, and force-deliver ("Send summary now") their quest summary schedule, wired to PoracleNG's /api/summaries endpoints. Backend - IPoracleSummaryProxy / PoracleSummaryProxy mirroring PoracleHumanProxy: X-Poracle-Secret, Uri.EscapeDataString, raw-JSON active_hours pass-through, 404 -> null, 503 -> SummaryBackendUnavailableException (transient, not feature-off). Distinguishes 400/404/500 rather than collapsing them. - SummaryScheduleController: GET/GET{alertType}/PUT/DELETE/POST trigger, all derived from this.UserId (JWT) with NO {userId} route segment (IDOR-safe); [RequireFeatureEnabled(disable_quests)]; trigger rate-limited test-alert (5/60s), writes auth-read; quest-only alertType allow-set. - Capability from tracking.quest_summary_enabled via the config proxy, surfaced as questSummaryEnabled in auth/me (Golbat-style 200 boolean), IMemoryCache 5-min. Defaults true when the field is absent from a successful config, degrades to false on fault. - Extracted ProfileController.ValidateActiveHours into a shared ActiveHoursValidator; both controllers and the tests re-pointed. Frontend - SummaryScheduleService (CRUD + trigger, 404 -> null, capability signal). - SummaryScheduleDialogComponent reusing ActiveHoursEditorDialogComponent and LocationWarningComponent; staggered schedule pills, empty/loading states, accent-theme tokens, reduced-motion + ARIA; client cooldown/dedup on trigger. - Quests toolbar menu entry gated on capability; SUMMARY_DISABLED_HINT on the quest dialogs when off (clean-bit logic untouched). - New QUESTS.SUMMARY_SCHEDULE_* keys across all 11 locales. Tests: PoracleSummaryProxyTests, SummaryScheduleControllerTests, SummaryCapabilityServiceTests, ActiveHoursValidationTests (re-pointed), and summary-schedule service + dialog Jest specs. Verified locally: dotnet test 1382 pass; ng build (AOT) ok; jest feature suites pass; eslint + prettier clean. * docs(changelog): add quest summary delivery schedule UI entry (#300) * fix(quests): return 200 empty schedule (not 404) when no summary schedule exists Opening the Quest summary delivery dialog calls GET /api/summary-schedules/{alertType}, which returned 404 when the user had no schedule yet — a normal empty state. The SPA's global error interceptor toasts ERROR.NOT_FOUND on any non-silent 404, so users saw 'The requested resource was not found.' on first open. Return 200 with an empty schedule (active_hours = []), consistent with the list endpoint returning []. The dialog renders its empty state and no toast fires. * feat(quests): honest capability default + Send-now expectation copy (#300) (1) Default quest-summary capability to OFF when PoracleNG's tracking.quest_summary_enabled is absent, instead of ON. When the flag is unset PoracleNG's matcher never buffers quests, so showing the UI was a dead-end (schedule + per-alarm bit set, but nothing ever delivered). PoracleConfig.QuestSummaryEnabled now defaults false; the menu appears only when the bot has the feature explicitly enabled. PoracleWeb stays read-only on Poracle config — no toggle here. (2) "Send summary now" now sets expectations: it flushes only quest matches PoracleNG has buffered since the last summary, so an empty send isn't surprising. Added QUESTS.SUMMARY_SCHEDULE_SEND_NOW_HINT (all 11 locales), shown under the schedule pills and as the button tooltip. Verified: dotnet test 1382 pass; ng build (AOT) ok; jest + eslint + prettier clean. * fix(quests): read quest_summary_enabled from /api/config/values, not /api/config/poracleWeb The capability probe read tracking.quest_summary_enabled from GetConfigAsync() — which hits /api/config/poracleWeb, a PoracleWeb-specific view that does NOT include the [tracking] section. So the flag was never read. It happened to "work" only while the default was true-when-absent; once that flipped to off-when-absent, the menu could never appear even with the flag set on the bot. Add IPoracleApiProxy.GetQuestSummaryEnabledAsync() which reads the effective value from /api/config/values (values.tracking.quest_summary_enabled) — the config-editor endpoint that exposes the merged-with-defaults config. SummaryCapabilityService now uses it: true -> enabled, false/absent/fault -> hidden. Removed the dead parse in GetConfigAsync and the now-unused PoracleConfig.QuestSummaryEnabled property; rewrote SummaryCapabilityServiceTests against the new method. Verified: dotnet test 1381 pass. * style(quests): mobile refinements for the summary schedule dialog - openSummaryDialog now passes maxWidth: '95vw' (matches the reused active-hours editor) so the dialog uses the full phone width instead of Material's 80vw default. - At <=600px: action buttons get a 44px min-height (comfortable thumb targets), the "Send summary now" button goes full-width as a clear primary action, and the flex spacer is dropped so the secondary buttons pack cleanly. Verified: ng build (AOT) ok, jest + eslint + prettier clean. * docs: document quest summary delivery (MkDocs site + in-app help) gh-pages (MkDocs): - New features/quest-summary-schedules.md — user + operator guide with a mermaid buffer/delivery diagram, the two-part model (per-alarm Daily summary toggle + per-user delivery schedule), the schedule editor, Send-now semantics, the per-user (not per-profile) note, validation table, timezone/location warning, and a "For server operators" section (quest_summary_enabled + processor bind). - mkdocs.yml: add the page to the Features nav. - troubleshooting.md: "Quest summary delivery menu is missing" and "Send summary now delivers nothing" entries. In-app help: - help-sections.ts: new "Quest Summary Delivery" section (schedule_send / amber). - i18n: HELP.SECTION_QUEST_SUMMARY(+_SUB) and HELP.CONTENT_QUEST_SUMMARY added and fully translated for all 11 locales (da, de, en, es, fr, it, nl, pl, pt, pt-BR, sv), reusing the existing UI terminology (menu name, Edit schedule, Send now). - Updated the en Delivery help section's "Daily summary" line to point at the new schedule UI instead of "configure on the bot". Verified: jest (help + i18n parity) pass, ng build (AOT) ok, eslint + prettier clean. * refactor(quests): code-review cleanups for the summary-schedule feature - Remove the unused auth/me quest-summary capability "fast-path": nothing consumed UserInfo.questSummaryEnabled / auth.service.questSummaryEnabled — the UI gates on the dedicated GET /api/summary-schedules/capability endpoint. Drops a per-auth/me capability call (a hot path) and dead frontend state. Removed the property, AuthController dependency + Me() call, the TS field + computed, and the now-moot ISummaryCapabilityService args in the AuthController tests. - PoracleSummaryProxy now Encode()s the alertType path segment too (defense-in-depth consistency with userId), and the helper doc reflects it. - dotnet format applied to the changed files (whitespace + analyzer fixes: CA1861 array hoist in tests, redundant `public` removed from IPoracleApiProxy members). Verified: dotnet test 1381 pass, dotnet format 0 errors on changed files, ng build (AOT) ok, eslint + prettier clean, jest feature suites pass. --- .../ServiceCollectionExtensions.cs | 4 + .../Controllers/ProfileController.cs | 86 +---- .../Controllers/SummaryScheduleController.cs | 180 +++++++++ ...ummaryBackendUnavailableExceptionFilter.cs | 32 ++ .../Pgan.PoracleWebNet.Api/Program.cs | 6 +- .../services/summary-schedule.service.spec.ts | 204 +++++++++++ .../core/services/summary-schedule.service.ts | 85 +++++ .../src/app/modules/help/help-sections.ts | 8 + .../quests/quest-add-dialog.component.html | 3 + .../quests/quest-add-dialog.component.ts | 8 +- .../quests/quest-edit-dialog.component.html | 3 + .../quests/quest-edit-dialog.component.ts | 4 +- .../modules/quests/quest-list.component.html | 11 + .../modules/quests/quest-list.component.ts | 13 + .../summary-schedule-dialog.component.html | 66 ++++ .../summary-schedule-dialog.component.scss | 246 +++++++++++++ .../summary-schedule-dialog.component.spec.ts | 250 +++++++++++++ .../summary-schedule-dialog.component.ts | 158 ++++++++ .../ClientApp/src/assets/i18n/da.json | 18 +- .../ClientApp/src/assets/i18n/de.json | 18 +- .../ClientApp/src/assets/i18n/en.json | 20 +- .../ClientApp/src/assets/i18n/es.json | 18 +- .../ClientApp/src/assets/i18n/fr.json | 18 +- .../ClientApp/src/assets/i18n/it.json | 18 +- .../ClientApp/src/assets/i18n/nl.json | 18 +- .../ClientApp/src/assets/i18n/pl.json | 18 +- .../ClientApp/src/assets/i18n/pt-BR.json | 18 +- .../ClientApp/src/assets/i18n/pt.json | 18 +- .../ClientApp/src/assets/i18n/sv.json | 18 +- CHANGELOG.md | 1 + .../Services/IPoracleApiProxy.cs | 29 +- .../Services/IPoracleSummaryProxy.cs | 22 ++ .../Services/ISummaryCapabilityService.cs | 6 + .../ActiveHoursValidator.cs | 94 +++++ .../SummaryBackendUnavailableException.cs | 6 + .../SummarySchedule.cs | 18 + .../PoracleApiProxy.cs | 28 ++ .../PoracleSummaryProxy.cs | 117 ++++++ .../SummaryCapabilityService.cs | 47 +++ .../Controllers/AuthControllerMeTests.cs | 8 +- .../AuthControllerProvidersTests.cs | 2 +- .../SummaryScheduleControllerTests.cs | 344 +++++++++++++++++ .../Services/PoracleSummaryProxyTests.cs | 345 ++++++++++++++++++ .../Services/SummaryCapabilityServiceTests.cs | 77 ++++ .../Validation/ActiveHoursValidationTests.cs | 54 +-- docs/features/quest-summary-schedules.md | 145 ++++++++ docs/troubleshooting.md | 27 ++ mkdocs.yml | 1 + 48 files changed, 2791 insertions(+), 147 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs create mode 100644 Applications/Pgan.PoracleWebNet.Api/Filters/SummaryBackendUnavailableExceptionFilter.cs create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/summary-schedule.service.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.ts create mode 100644 Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleSummaryProxy.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Abstractions/Services/ISummaryCapabilityService.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/ActiveHoursValidator.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/SummaryBackendUnavailableException.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/SummarySchedule.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Services/PoracleSummaryProxy.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Services/SummaryCapabilityService.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Controllers/SummaryScheduleControllerTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/PoracleSummaryProxyTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/SummaryCapabilityServiceTests.cs create mode 100644 docs/features/quest-summary-schedules.md diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index b255baf2..4da22e94 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -82,6 +82,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -122,6 +123,9 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv // 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 => { diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs index ca231f56..1c34f23d 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/ProfileController.cs @@ -43,7 +43,7 @@ public async Task Create([FromBody] Profile profile) var maxNo = existing.Any() ? existing.Max(p => p.ProfileNo) : 0; profile.ProfileNo = maxNo + 1; - var (isValid, validationError) = ValidateActiveHours(profile.ActiveHours); + var (isValid, validationError) = ActiveHoursValidator.Validate(profile.ActiveHours); if (!isValid) { return this.BadRequest(validationError); @@ -74,7 +74,7 @@ public async Task Update(int profileNo, [FromBody] Profile profil return this.NotFound(); } - var (isValid, validationError) = ValidateActiveHours(profile.ActiveHours); + var (isValid, validationError) = ActiveHoursValidator.Validate(profile.ActiveHours); if (!isValid) { return this.BadRequest(validationError); @@ -178,88 +178,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/SummaryScheduleController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs new file mode 100644 index 00000000..992906ae --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SummaryScheduleController.cs @@ -0,0 +1,180 @@ +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; 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}" + }); + } + + 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}" + }); + } + + 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}" + }); + } + + 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}" + }); + } + + 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/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/Program.cs b/Applications/Pgan.PoracleWebNet.Api/Program.cs index 4c180ace..fe989898 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Program.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Program.cs @@ -137,7 +137,11 @@ // 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(); +}); // Add Poracle services (DbContext, repositories, services, settings) builder.Services.AddPoracleServices(builder.Configuration); 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/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/quests/quest-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html index 3901661d..ab86a291 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 @@ -114,6 +114,9 @@

{{ 'ALARM.COMMON_SETTINGS' | 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.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts index ea708e1f..13767388 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 @@ -19,6 +19,7 @@ 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 { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; @@ -58,7 +59,6 @@ export class QuestAddDialogComponent { private readonly questService = inject(QuestService); private readonly snackBar = inject(MatSnackBar); - commonForm = this.fb.group({ clean: [false], distanceKm: [this.alertDefaults.defaultDistanceKm()], @@ -69,8 +69,8 @@ export class QuestAddDialogComponent { }); readonly dialogRef = inject(MatDialogRef); - readonly iconService = inject(IconService); + readonly iconService = inject(IconService); readonly isWebhook = inject(AuthService).isImpersonating(); itemForm = this.fb.group({ @@ -79,13 +79,15 @@ export class QuestAddDialogComponent { /** Quest-relevant items (balls, berries, potions, revives, TMs, etc.) */ readonly questItems = signal<{ id: number; name: string }[]>([]); + saving = signal(false); selectedCandyPokemonIds = signal([]); - selectedMegaPokemonIds = signal([]); selectedPokemonIds = signal([]); + readonly summaryService = inject(SummaryScheduleService); + tabIndex = 0; constructor() { 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 e4c28234..4cd3e8bb 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 @@ -87,6 +87,9 @@

{{ 'ALARM.MESSAGE_SETTINGS' | 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.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts index 3febcb66..32f0f0a0 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 @@ -17,6 +17,7 @@ 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 { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; import { AUTO_DELETE, compose, isAutoDelete, isSummary, preserve, SUMMARY } from '../../shared/utils/clean-flags'; @@ -54,7 +55,6 @@ export class QuestEditDialogComponent { private readonly snackBar = inject(MatSnackBar); readonly data = inject(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); - form = this.fb.group({ clean: [isAutoDelete(this.data.clean)], distanceKm: [this.data.distance > 0 ? this.data.distance / 1000 : 1], @@ -68,6 +68,8 @@ export class QuestEditDialogComponent { saving = signal(false); + readonly summaryService = inject(SummaryScheduleService); + private get questPokemonId(): number { return this.data.pokemonId > 0 ? this.data.pokemonId : this.data.reward; } 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 68db5d15..e195ce31 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()) { + + + + + } 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 421f4d68..5f752885 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 @@ -13,11 +13,13 @@ 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 { 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'; @@ -60,6 +62,7 @@ export class QuestListComponent implements OnInit { readonly selectMode = signal(false); readonly skeletonCards = Array.from({ length: 6 }); + readonly summaryService = inject(SummaryScheduleService); readonly testAlertService = inject(TestAlertService); async bulkDelete(): Promise { @@ -272,6 +275,7 @@ export class QuestListComponent implements OnInit { } ngOnInit(): void { + this.summaryService.loadCapability(); this.masterData .loadData() .pipe(takeUntilDestroyed(this.destroyRef)) @@ -298,6 +302,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); 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..289fa3e6 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.spec.ts @@ -0,0 +1,250 @@ +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(); + } + + 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', () => { + 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..56e0a3e2 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/summary-schedule-dialog/summary-schedule-dialog.component.ts @@ -0,0 +1,158 @@ +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 { TranslateModule } from '@ngx-translate/core'; +import { finalize } 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, + TranslateModule, + ], + 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)); + + this.locationService.getLocation().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(), + 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/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index eb83935d..dfaf1064 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Invasionsalarmer", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 b957f8f3..b6d5cf12 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Invasions-Alarme", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 c8be3812..c904323d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -510,7 +510,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Invasion Alarms", @@ -1092,6 +1105,8 @@ "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_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", @@ -1117,7 +1132,8 @@ "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.

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, and set a default radius. 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 override the mode and distance on any individual alarm.

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.

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 (requires a summary schedule configured on the bot). 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_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.

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, and set a default radius. 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 override the mode and distance on any individual alarm.

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.

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.
", 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 012fbcef..b25d51c0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Alarmas de Invasión", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 990b96d8..b26e1994 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Alarmes Invasion", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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).

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.
", 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 c14b1156..8900014d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Allarmi Invasioni", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 bdc58d81..67831126 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Invasie Alarmen", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 e8c52fab..d079e53a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Alarmy inwazji", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 c27c871a..2d6a3025 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 @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Alarmes de Invasão", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 82abaf0a..08e54e0b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Alarmes de Invasões", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", 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 950df0b0..7b9df29b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -506,7 +506,20 @@ "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_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." }, "INVASIONS": { "PAGE_TITLE": "Invasionslarm", @@ -1088,6 +1101,8 @@ "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_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", @@ -1114,6 +1129,7 @@ "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.

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.
", diff --git a/CHANGELOG.md b/CHANGELOG.md index da681c32..a6ce9522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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. diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs index 37a4839e..dde9a710 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IPoracleApiProxy.cs @@ -4,18 +4,19 @@ 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(); + Task GetAreasAsync(string userId); + Task GetTemplatesAsync(); + Task GetAdminRolesAsync(string userId); + Task GetGruntsAsync(); + 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/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/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.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/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.Services/PoracleApiProxy.cs b/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs index 429ffd04..9b68d9e0 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/PoracleApiProxy.cs @@ -194,6 +194,34 @@ 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 async Task GetQuestSummaryEnabledAsync() + { + 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); + + // { "values": { "tracking": { "quest_summary_enabled": true, ... }, ... } } + if (doc.RootElement.TryGetProperty("values", out var values) + && values.TryGetProperty("tracking", out var tracking) + && tracking.TryGetProperty("quest_summary_enabled", out var qse) + && qse.ValueKind is JsonValueKind.True or JsonValueKind.False) + { + return qse.GetBoolean(); + } + + return null; + } + public async Task GetAreasAsync(string userId) { var request = this.CreateRequest(HttpMethod.Get, $"{this._apiAddress}/api/humans/{userId}"); 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/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/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs index fd9ec973..4573c761 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs @@ -40,7 +40,7 @@ public AuthControllerMeTests() } [Fact] - public async Task Me_ReturnsRefreshedToken_WhenProfileNoMismatch() + public async Task MeReturnsRefreshedTokenWhenProfileNoMismatch() { SetupUser(this._sut, profileNo: 2); this._humanService.Setup(s => s.GetByIdAsync("123456789")) @@ -56,7 +56,7 @@ public async Task Me_ReturnsRefreshedToken_WhenProfileNoMismatch() } [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 +72,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")) @@ -87,7 +87,7 @@ public async Task Me_UsesDbProfileNo_WhenHumanExists() } [Fact] - public async Task Me_FallsBackToJwtProfileNo_WhenHumanNotFound() + public async Task MeFallsBackToJwtProfileNoWhenHumanNotFound() { SetupUser(this._sut, profileNo: 2); this._humanService.Setup(s => s.GetByIdAsync("123456789")) diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs index 0102fc1b..a0a81d78 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs @@ -18,7 +18,7 @@ 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) => new( new Mock().Object, new Mock().Object, new Mock().Object, diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/SummaryScheduleControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SummaryScheduleControllerTests.cs new file mode 100644 index 00000000..4c3b1eea --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SummaryScheduleControllerTests.cs @@ -0,0 +1,344 @@ +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. + var attr = typeof(SummaryScheduleController).GetCustomAttribute(); + Assert.NotNull(attr); + } + + [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/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/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/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/docs/features/quest-summary-schedules.md b/docs/features/quest-summary-schedules.md new file mode 100644 index 00000000..e6904be8 --- /dev/null +++ b/docs/features/quest-summary-schedules.md @@ -0,0 +1,145 @@ +# 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 location is saved -- set a location on the **Dashboard** or **Areas** + 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. + +## 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/troubleshooting.md b/docs/troubleshooting.md index fc907914..8aa37855 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -295,3 +295,30 @@ 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`). diff --git a/mkdocs.yml b/mkdocs.yml index c5b775d0..be9dab1e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,6 +89,7 @@ nav: - Features: - Alarm Management: features/alarms.md - Profiles: features/profiles.md + - Quest Summary Delivery: features/quest-summary-schedules.md - Custom Geofences: features/custom-geofences.md - Internationalization (i18n): features/internationalization.md - Development: From a8c5f8a2e7ce2d8aaf42fb7d10aed375d63e0a98 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 17:19:40 -0400 Subject: [PATCH 34/59] style(editorconfig): preserve single-line statements so dotnet format matches the codebase (#305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.editorconfig` had `csharp_preserve_single_line_statements = false`, which makes `dotnet format` explode the codebase's deliberate compact one-liners. The clearest victim is `AlarmMappingExtensions.cs`, whose ~98 null-skip mappings (`if (src.X != null) dest.X = src.X.Value;`) `dotnet format` rewrites into ~480 lines — a 5x readability regression. The single-line style is used throughout the mapping/guard code, so the setting contradicted the actual code (and was never enforced — CI does not run `dotnet format`). Flipping it to `true` makes `dotnet format` PRESERVE existing single-line statements (it never collapses multi-line into single-line), so the tool aligns with the codebase and `dotnet format --verify` stops flagging these files — with zero code changes. (Sibling `csharp_preserve_single_line_blocks` is left as-is; this targets the reported single-line *statement* explosion.) --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ########################################## From 21fdd8e93fc9a187233bf521104a0c6beb9cc99c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:33:35 -0400 Subject: [PATCH 35/59] docs: cut changelog for v2.9.0 (#306) Co-authored-by: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ce9522..24697293 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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. @@ -559,7 +561,8 @@ 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 +[Unreleased]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.9.0...HEAD +[2.9.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.7.0...v2.9.0 [2.8.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.6.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 From bf272deba62e5d826cd0f4938812d74b1a983f36 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 17:39:55 -0400 Subject: [PATCH 36/59] fix(release): correct changelog compare links + the off-by-one in the cut workflow (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `release-changelog.yml` "Update comparison links" step used `grep ... | head -2 | tail -1`, which picks the *second*-newest version as the previous release — an off-by-one. Every cut release since has produced a wrong `[x.y.z]:` compare link (e.g. `[2.9.0]: compare/v2.7.0...v2.9.0` instead of `v2.8.0...v2.9.0`), and re-runs accumulated duplicate link-def lines. - Workflow: `head -2 | tail -1` -> `head -1`. The new version's link-def isn't added until the following sed, so the first existing version link is the immediately-preceding release. - CHANGELOG.md: regenerated the footer compare links so each points at its true predecessor tag (10 corrected), and removed 5 duplicate link-def lines. Only the footer link section changed — no changelog content touched. The three pre-tag entries (0.1.0–0.3.0) and the oldest tag (0.4.0) are left as-is. --- .github/workflows/release-changelog.yml | 5 +++-- CHANGELOG.md | 25 ++++++++++--------------- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index ae1c98a2..d7f7d32c 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -27,8 +27,9 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 24697293..a83fd3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -562,38 +562,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Docker deployment with Watchtower auto-updates [Unreleased]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.9.0...HEAD -[2.9.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.7.0...v2.9.0 -[2.8.0]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.6.0...v2.8.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 From e8293cfad769b7efceae3a1fd5931ce496dc2a86 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 18:16:27 -0400 Subject: [PATCH 37/59] fix(admin-settings): remove duplicate allowed_languages setting row (#308) (#311) The admin settings page rendered two rows that both wrote to the allowed_languages key -- "Allowed UI Languages" (Features group) and "Allowed Languages" (Administration group). The component keys its value map by the setting key, so the two collapsed onto one entry: editing one changed the other, and a save could silently clobber the value with an empty string. Remove the redundant Administration-group row and its now-unused ADMIN_ALLOWED_LANGUAGES_LABEL / ADMIN_ALLOWED_LANGUAGES_DESC keys across all 11 locales, keeping the single Features-group control whose description matches the actual behavior. --- .../src/app/modules/admin/admin-settings.component.ts | 6 ------ .../ClientApp/src/assets/i18n/da.json | 2 -- .../ClientApp/src/assets/i18n/de.json | 2 -- .../ClientApp/src/assets/i18n/en.json | 2 -- .../ClientApp/src/assets/i18n/es.json | 2 -- .../ClientApp/src/assets/i18n/fr.json | 2 -- .../ClientApp/src/assets/i18n/it.json | 2 -- .../ClientApp/src/assets/i18n/nl.json | 2 -- .../ClientApp/src/assets/i18n/pl.json | 2 -- .../ClientApp/src/assets/i18n/pt-BR.json | 2 -- .../ClientApp/src/assets/i18n/pt.json | 2 -- .../ClientApp/src/assets/i18n/sv.json | 2 -- CHANGELOG.md | 3 +++ 13 files changed, 3 insertions(+), 28 deletions(-) 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 30a3b815..1f175058 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 @@ -230,12 +230,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', - }, ], }, { 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 dfaf1064..aa339af9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -1530,8 +1530,6 @@ "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\").", "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", 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 b6d5cf12..f38bf40a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -1530,8 +1530,6 @@ "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“).", "REGISTER_COMMAND_LABEL": "Registrierungsbefehl", "REGISTER_COMMAND_DESC": "Poracle-Bot-Befehl, den Benutzer zur Registrierung ausführen (z. B. „$!register“).", "LOCATION_COMMAND_LABEL": "Standortbefehl", 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 c904323d..b7a902cf 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -1528,8 +1528,6 @@ "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\").", "REGISTER_COMMAND_LABEL": "Register Command", "REGISTER_COMMAND_DESC": "The Poracle bot command users run to register (e.g. \"$!register\").", "LOCATION_COMMAND_LABEL": "Location Command", 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 b25d51c0..25e40902 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -1530,8 +1530,6 @@ "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»).", "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", 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 b26e1994..a982f45b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -1530,8 +1530,6 @@ "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 »).", "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", 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 8900014d..63548015 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -1530,8 +1530,6 @@ "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\").", "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", 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 67831126..0d5f1200 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -1530,8 +1530,6 @@ "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\").", "REGISTER_COMMAND_LABEL": "Registratiecommando", "REGISTER_COMMAND_DESC": "Poracle-bot-commando dat gebruikers uitvoeren om zich te registreren (bijv. \"$!register\").", "LOCATION_COMMAND_LABEL": "Locatiecommando", 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 d079e53a..31eeeebe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -1530,8 +1530,6 @@ "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”).", "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", 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 2d6a3025..63568d34 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 @@ -1530,8 +1530,6 @@ "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\").", "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", 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 08e54e0b..03b11f58 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -1530,8 +1530,6 @@ "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\").", "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", 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 7b9df29b..b276e355 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -1530,8 +1530,6 @@ "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\").", "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", diff --git a/CHANGELOG.md b/CHANGELOG.md index a83fd3a2..674d1e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 From 1e296a9443febedb6b5141dc81291b8a2582e5ab Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Wed, 3 Jun 2026 18:37:47 -0400 Subject: [PATCH 38/59] feat(areas): add notification-language selector (#310) (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LanguageSelectorComponent — the only path from the web app to a user's Poracle DM language (human.Language, which controls alert text and Pokemon names) — was imported into app.ts but never rendered in any template, so it was unreachable dead code. The toolbar globe menu only calls i18n.use(), changing the Angular UI translation, not the bot's DM language. - Render the selector in a labelled "Notification language" section on the Areas & Location page, distinct from the toolbar display-language menu. - Reconcile its stale hardcoded 18-language list with I18nService.allLanguages (the 11 supported locales) so it can't drift again. - Seed the value from the persisted human.Language via a new GET /api/location/language endpoint (reconciles with bot-set changes) instead of trusting only localStorage; show success/failure feedback. - Remove the now-orphaned dead import and app-language-selector style from the app shell. - Add AREAS.NOTIFICATION_LANGUAGE / _DESC / SNACK_LANGUAGE_UPDATED / _FAILED i18n keys across all 11 locales. Whether localized Pokemon names actually render still depends on the Poracle server having that language's master data loaded — PoracleWeb's responsibility ends at writing human.Language. Service and component tests cover the new GET endpoint and the load/save/revert behavior. --- .../Controllers/LocationController.cs | 15 ++++ .../ClientApp/src/app/app.scss | 7 -- .../ClientApp/src/app/app.ts | 2 - .../core/services/location.service.spec.ts | 20 +++++ .../src/app/core/services/location.service.ts | 6 ++ .../modules/areas/area-list.component.html | 12 +++ .../modules/areas/area-list.component.scss | 43 ++++++++++ .../app/modules/areas/area-list.component.ts | 2 + .../language-selector.component.html | 5 +- .../language-selector.component.scss | 23 ++--- .../language-selector.component.spec.ts | 86 +++++++++++++++++++ .../language-selector.component.ts | 74 +++++++++------- .../ClientApp/src/assets/i18n/da.json | 4 + .../ClientApp/src/assets/i18n/de.json | 4 + .../ClientApp/src/assets/i18n/en.json | 4 + .../ClientApp/src/assets/i18n/es.json | 4 + .../ClientApp/src/assets/i18n/fr.json | 4 + .../ClientApp/src/assets/i18n/it.json | 4 + .../ClientApp/src/assets/i18n/nl.json | 4 + .../ClientApp/src/assets/i18n/pl.json | 4 + .../ClientApp/src/assets/i18n/pt-BR.json | 4 + .../ClientApp/src/assets/i18n/pt.json | 4 + .../ClientApp/src/assets/i18n/sv.json | 4 + CHANGELOG.md | 3 + 24 files changed, 284 insertions(+), 58 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.spec.ts diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs index 9e3ed4d0..4daad6a8 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs @@ -66,6 +66,21 @@ public async Task UpdateLocation([FromBody] LocationUpdateRequest }); } + [HttpGet("language")] + public async Task GetLanguage() + { + var human = await this._humanService.GetByIdAndProfileAsync(this.UserId, this.ProfileNo); + if (human == null) + { + return this.NotFound(); + } + + return this.Ok(new + { + language = human.Language + }); + } + [HttpPut("language")] public async Task UpdateLanguage([FromBody] LanguageUpdateRequest request) { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss index 6a76de04..f9bdac1a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.scss @@ -430,11 +430,4 @@ kbd { font-size: 16px; } } - - // Hide language selector on very small screens - @media (max-width: 480px) { - app-language-selector { - display: none; - } - } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts index ba21b070..373dcd49 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts @@ -19,7 +19,6 @@ import { DashboardService } from './core/services/dashboard.service'; import { I18nService } from './core/services/i18n.service'; import { SettingsService } from './core/services/settings.service'; import { AlertDefaultsDialogComponent } from './shared/components/alert-defaults-dialog/alert-defaults-dialog.component'; -import { LanguageSelectorComponent } from './shared/components/language-selector/language-selector.component'; interface NavItem { adminOnly?: boolean; @@ -49,7 +48,6 @@ interface NavItem { MatBadgeModule, MatTooltipModule, TranslateModule, - LanguageSelectorComponent, ], selector: 'app-root', styleUrl: './app.scss', 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/modules/areas/area-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.html index 9340a617..ad63a4e1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.html @@ -80,6 +80,18 @@

{{ 'AREAS.METHOD_LOCATION' | translate }}

{{ 'AREAS.METHOD_NOTE' | translate }}
+ +
+
+

+ translate + {{ 'AREAS.NOTIFICATION_LANGUAGE' | translate }} +

+

{{ 'AREAS.NOTIFICATION_LANGUAGE_DESC' | translate }}

+
+ +
+

map diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss index fccbe462..1e19d44d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.scss @@ -160,6 +160,49 @@ } } +.notification-language { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--border-color, rgba(0, 0, 0, 0.12)); + background: var(--surface-2, transparent); + + .notification-language-text { + flex: 1 1 240px; + + h3 { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 4px; + font-size: 15px; + font-weight: 500; + + mat-icon { + color: #ff9800; + font-size: 20px; + width: 20px; + height: 20px; + } + } + + p { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--text-secondary, rgba(0, 0, 0, 0.64)); + } + } + + app-language-selector { + flex: 0 1 280px; + } +} + .section-title { display: flex; align-items: center; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts index b67a4a80..b76900ce 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/areas/area-list.component.ts @@ -18,6 +18,7 @@ import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { LocationService } from '../../core/services/location.service'; import { AreaMapComponent } from '../../shared/components/area-map/area-map.component'; +import { LanguageSelectorComponent } from '../../shared/components/language-selector/language-selector.component'; import { LocationDialogComponent } from '../../shared/components/location-dialog/location-dialog.component'; import { RegionOption, RegionSelectorComponent } from '../../shared/components/region-selector/region-selector.component'; @@ -49,6 +50,7 @@ interface GroupInfo { MatSnackBarModule, TranslateModule, AreaMapComponent, + LanguageSelectorComponent, RegionSelectorComponent, ], selector: 'app-area-list', 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 index 948dec81..254983c1 100644 --- 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 @@ -1,8 +1,9 @@ - language + {{ 'AREAS.NOTIFICATION_LANGUAGE' | translate }} + translate @for (lang of languages; track lang.code) { - {{ lang.label }} + {{ lang.name }} } 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 index a66d9b59..18eff046 100644 --- 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 @@ -1,22 +1,9 @@ .language-field { - width: 110px; - margin: 0 4px; + width: 100%; + max-width: 280px; - ::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; + mat-icon[matPrefix] { + margin-right: 8px; + opacity: 0.7; } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.spec.ts new file mode 100644 index 00000000..c14b4419 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/language-selector/language-selector.component.spec.ts @@ -0,0 +1,86 @@ +import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of, throwError } from 'rxjs'; + +import { LanguageSelectorComponent } from './language-selector.component'; +import { I18nService } from '../../../core/services/i18n.service'; +import { LocationService } from '../../../core/services/location.service'; + +describe('LanguageSelectorComponent', () => { + let component: LanguageSelectorComponent; + let locationService: { getLanguage: jest.Mock; setLanguage: jest.Mock }; + let snackBar: { open: jest.Mock }; + + function setup(overrides: { language?: string | null; setLanguageResult?: 'ok' | 'error' } = {}) { + const language = overrides.language === undefined ? 'en' : overrides.language; + locationService = { + getLanguage: jest.fn(() => of({ language })), + setLanguage: jest.fn(() => (overrides.setLanguageResult === 'error' ? throwError(() => new Error('fail')) : of(undefined))), + }; + snackBar = { open: jest.fn() }; + localStorage.clear(); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: LocationService, useValue: locationService }, + { provide: MatSnackBar, useValue: snackBar }, + { + provide: I18nService, + useValue: { + allLanguages: [ + { name: 'English', code: 'en' }, + { name: 'Deutsch', code: 'de' }, + ], + instant: (key: string) => key, + }, + }, + ], + imports: [LanguageSelectorComponent, NoopAnimationsModule], + }).overrideComponent(LanguageSelectorComponent, { + // MatSnackBarModule provides MatSnackBar at the component injector, which shadows the + // module-level test provider — override at component scope so feedback is captured. + add: { providers: [{ provide: MatSnackBar, useValue: snackBar }] }, + }); + + const fixture = TestBed.createComponent(LanguageSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + } + + it('should create', () => { + setup(); + expect(component).toBeTruthy(); + }); + + it('seeds the selected language from the persisted human.Language', () => { + setup({ language: 'de' }); + expect(locationService.getLanguage).toHaveBeenCalledTimes(1); + expect(component['selectedLanguage']()).toBe('de'); + expect(localStorage.getItem('poracle-language')).toBe('de'); + }); + + it('ignores a backend language not in the supported set', () => { + setup({ language: 'xx' }); + expect(component['selectedLanguage']()).toBe('en'); + }); + + it('persists a language change and confirms via snackbar', () => { + setup(); + component.onLanguageChange('de'); + expect(locationService.setLanguage).toHaveBeenCalledWith('de'); + expect(localStorage.getItem('poracle-language')).toBe('de'); + expect(snackBar.open).toHaveBeenCalledWith('AREAS.SNACK_LANGUAGE_UPDATED', 'TOAST.OK', { duration: 3000 }); + }); + + it('reverts the selection and warns when the save fails', () => { + setup({ language: 'en', setLanguageResult: 'error' }); + component.onLanguageChange('de'); + expect(component['selectedLanguage']()).toBe('en'); + expect(localStorage.getItem('poracle-language')).toBe('en'); + expect(snackBar.open).toHaveBeenCalledWith('AREAS.SNACK_LANGUAGE_FAILED', 'TOAST.OK', { duration: 3000 }); + }); +}); 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 index 3a7d6313..954c5f53 100644 --- 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 @@ -1,58 +1,74 @@ -import { Component, inject, signal, OnInit } from '@angular/core'; +import { Component, DestroyRef, OnInit, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { TranslateModule } from '@ngx-translate/core'; +import { I18nService } from '../../../core/services/i18n.service'; import { LocationService } from '../../../core/services/location.service'; -interface LanguageOption { - code: string; - label: string; -} +const STORAGE_KEY = 'poracle-language'; +/** + * Sets the user's Poracle notification (DM) language — the language Poracle uses for alert text + * and Pokémon names — by writing `human.Language` via PUT /api/location/language. This is distinct + * from the toolbar display-language menu, which only changes the Angular UI translations. + */ @Component({ - imports: [MatSelectModule, MatFormFieldModule, MatIconModule], + imports: [MatSelectModule, MatFormFieldModule, MatIconModule, MatSnackBarModule, TranslateModule], selector: 'app-language-selector', standalone: true, styleUrl: './language-selector.component.scss', templateUrl: './language-selector.component.html', }) export class LanguageSelectorComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + private readonly i18n = inject(I18nService); private readonly locationService = inject(LocationService); + private readonly snackBar = inject(MatSnackBar); - 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' }, - ]; + /** Notification-language options, reconciled with the languages the app actually supports. */ + readonly languages = this.i18n.allLanguages; protected readonly selectedLanguage = signal('en'); ngOnInit(): void { - const stored = localStorage.getItem('poracle-language'); + // Seed from the persisted localStorage hint for an instant render, then reconcile with the + // authoritative human.Language from the backend (which the bot can also change out-of-band). + const stored = localStorage.getItem(STORAGE_KEY); if (stored) { this.selectedLanguage.set(stored); } + + this.locationService + .getLanguage() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ language }) => { + if (language && this.languages.some(l => l.code === language)) { + this.selectedLanguage.set(language); + localStorage.setItem(STORAGE_KEY, language); + } + }); } onLanguageChange(locale: string): void { + const previous = this.selectedLanguage(); this.selectedLanguage.set(locale); - localStorage.setItem('poracle-language', locale); - this.locationService.setLanguage(locale).subscribe(); + localStorage.setItem(STORAGE_KEY, locale); + this.locationService + .setLanguage(locale) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + error: () => { + this.selectedLanguage.set(previous); + localStorage.setItem(STORAGE_KEY, previous); + this.snackBar.open(this.i18n.instant('AREAS.SNACK_LANGUAGE_FAILED'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + }, + next: () => { + this.snackBar.open(this.i18n.instant('AREAS.SNACK_LANGUAGE_UPDATED'), this.i18n.instant('TOAST.OK'), { duration: 3000 }); + }, + }); } } 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 aa339af9..a311fef5 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -768,6 +768,10 @@ "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", 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 f38bf40a..54102d7f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -768,6 +768,10 @@ "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", 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 b7a902cf..0187b053 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -772,6 +772,10 @@ "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", 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 25e40902..727682c5 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -768,6 +768,10 @@ "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", 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 a982f45b..084c13e4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -768,6 +768,10 @@ "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", 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 63548015..81ab2124 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -768,6 +768,10 @@ "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", 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 0d5f1200..0a04d05f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -768,6 +768,10 @@ "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", 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 31eeeebe..a5ec3240 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -768,6 +768,10 @@ "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", 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 63568d34..9e9fb410 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 @@ -768,6 +768,10 @@ "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", 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 03b11f58..b9922adf 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -768,6 +768,10 @@ "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", 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 b276e355..15651ae0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -768,6 +768,10 @@ "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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 674d1e2f..879e3d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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). From cfbb91c0a0e197ee249bcc309c5ff85d5fac6206 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:00:41 -0400 Subject: [PATCH 39/59] docs: cut changelog for v2.10.0 (#313) Co-authored-by: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 879e3d3e..dc598755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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. @@ -567,7 +569,8 @@ 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.9.0...HEAD +[Unreleased]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.10.0...HEAD +[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 From f2a6dec4fcad6cf2669e62a84be1dafb86ad89ca Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 5 Jun 2026 13:06:06 -0400 Subject: [PATCH 40/59] fix(geofences): make region optional for user geofences (#314) + operator docs (#315) * fix(geofences): make region optional, set Koji __parent null for region-less geofences (#314) The geofence draw dialog forced the user to pick a region before saving, but the region list is derived from Koji's parent/child nesting. A flat Koji project has no regions, so the dropdown was a dead end and the geofence could not be saved. - Region is now optional in the draw dialog; the picker hides when no regions exist, and the "All Regions" sentinel clears the field instead of leaving a blank chip. - Admins can set or override the region at approval time (approval dialog plus a parentId/groupName override on ApproveSubmissionAsync and the approve endpoint). - KojiService now sends __parent: null (not 0) when parentId <= 0. A live probe confirmed Koji returns HTTP 500 "[GEOFENCE]: Does not exist" on __parent: 0 while still persisting the row, which would otherwise break every region-less promotion. The same guard is applied to the currently-unused PromoteGeofenceAsync. Adds KojiServiceTests plus controller/service/dialog coverage. Updates CHANGELOG. * docs(geofences): add operator guide for custom geofences, areas & regions Replace the single Custom Geofences page with a multi-page, operator-focused section under Features (MkDocs Material + Mermaid): - Overview, with a summary of how PoracleJS/PoracleNG reads geofences from PoracleWeb.NET instead of Koji, and why. - Key concepts: areas vs geofences vs regions. - Koji & regions: connection config plus how to set up regions, with the geofence-to-region relationship diagram. - Private geofences & promotion: private by default, and the promote-to-public flow. - Admin operations: review/approve/reject/delete, Discord forum, limits. - Troubleshooting: empty region dropdown, the combined feed, Koji errors. Wires the new section into mkdocs.yml nav and updates the home-page link. --- .../Controllers/AdminGeofenceController.cs | 15 +- .../core/services/admin-geofence.service.ts | 2 +- .../geofence-submissions.component.spec.ts | 7 + .../geofence-submissions.component.ts | 17 +- .../geofence-approval-dialog.component.html | 13 + .../geofence-approval-dialog.component.scss | 17 ++ ...geofence-approval-dialog.component.spec.ts | 70 +++++- .../geofence-approval-dialog.component.ts | 40 ++- .../geofence-name-dialog.component.html | 45 ++-- .../geofence-name-dialog.component.scss | 7 +- .../geofence-name-dialog.component.spec.ts | 23 +- .../geofence-name-dialog.component.ts | 23 +- .../region-selector.component.spec.ts | 12 + .../region-selector.component.ts | 6 + .../ClientApp/src/assets/i18n/en.json | 2 + CHANGELOG.md | 6 + .../Services/IUserGeofenceService.cs | 2 +- .../KojiService.cs | 13 +- .../UserGeofenceService.cs | 15 +- .../AdminGeofenceControllerTests.cs | 22 +- .../Services/KojiServiceTests.cs | 94 +++++++ .../Services/UserGeofenceServiceTests.cs | 58 +++++ docs/features/custom-geofences.md | 234 ------------------ .../custom-geofences/admin-operations.md | 85 +++++++ docs/features/custom-geofences/index.md | 57 +++++ .../features/custom-geofences/key-concepts.md | 72 ++++++ .../custom-geofences/koji-and-regions.md | 150 +++++++++++ .../custom-geofences/private-and-promotion.md | 120 +++++++++ .../custom-geofences/troubleshooting.md | 88 +++++++ docs/index.md | 2 +- mkdocs.yml | 8 +- 31 files changed, 1035 insertions(+), 290 deletions(-) create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/KojiServiceTests.cs delete mode 100644 docs/features/custom-geofences.md create mode 100644 docs/features/custom-geofences/admin-operations.md create mode 100644 docs/features/custom-geofences/index.md create mode 100644 docs/features/custom-geofences/key-concepts.md create mode 100644 docs/features/custom-geofences/koji-and-regions.md create mode 100644 docs/features/custom-geofences/private-and-promotion.md create mode 100644 docs/features/custom-geofences/troubleshooting.md diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs index f9878df1..b5ec92ce 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AdminGeofenceController.cs @@ -77,7 +77,8 @@ 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); return this.Ok(result); } catch (InvalidOperationException ex) @@ -119,6 +120,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 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/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts index ae4dac1c..ed8fa161 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.spec.ts @@ -12,6 +12,7 @@ import { UserGeofence } from '../../../core/models'; import { AdminGeofenceService } from '../../../core/services/admin-geofence.service'; import { AreaService } from '../../../core/services/area.service'; import { ConfigService } from '../../../core/services/config.service'; +import { UserGeofenceService } from '../../../core/services/user-geofence.service'; // Mock IntersectionObserver for jsdom global.IntersectionObserver = jest.fn().mockImplementation(() => ({ @@ -41,6 +42,7 @@ describe('GeofenceSubmissionsComponent', () => { let component: GeofenceSubmissionsComponent; let adminGeofenceService: { [K in keyof AdminGeofenceService]?: jest.Mock }; let areaService: { getGeofencePolygons: jest.Mock }; + let userGeofenceService: { getRegions: jest.Mock }; let mockDialog: { open: jest.Mock }; let mockSnackBar: { open: jest.Mock }; @@ -132,6 +134,10 @@ describe('GeofenceSubmissionsComponent', () => { getGeofencePolygons: jest.fn().mockReturnValue(of([])), }; + userGeofenceService = { + getRegions: jest.fn().mockReturnValue(of([])), + }; + mockDialog = { open: jest.fn().mockReturnValue({ afterClosed: () => of(null) }), }; @@ -148,6 +154,7 @@ describe('GeofenceSubmissionsComponent', () => { provideHttpClientTesting(), { provide: AdminGeofenceService, useValue: adminGeofenceService }, { provide: AreaService, useValue: areaService }, + { provide: UserGeofenceService, useValue: userGeofenceService }, { provide: ConfigService, useValue: { apiHost: 'http://test-api' } }, ], imports: [NoopAnimationsModule], diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts index 385bd137..1ce189a7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/admin/geofence-submissions/geofence-submissions.component.ts @@ -25,10 +25,11 @@ import { TranslateModule } from '@ngx-translate/core'; import * as L from 'leaflet'; import { firstValueFrom } from 'rxjs'; -import { GeofenceData, UserGeofence } from '../../../core/models'; +import { GeofenceData, GeofenceRegion, UserGeofence } from '../../../core/models'; import { AdminGeofenceService } from '../../../core/services/admin-geofence.service'; import { AreaService } from '../../../core/services/area.service'; import { I18nService } from '../../../core/services/i18n.service'; +import { UserGeofenceService } from '../../../core/services/user-geofence.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/components/confirm-dialog/confirm-dialog.component'; import { GeofenceApprovalDialogComponent, @@ -78,7 +79,9 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe private observer: IntersectionObserver | null = null; private readonly referenceGeofences = signal([]); + private readonly regions = signal([]); private readonly snackBar = inject(MatSnackBar); + private readonly userGeofenceService = inject(UserGeofenceService); readonly activeFilter = signal('all'); readonly allGeofences = signal([]); @@ -234,6 +237,10 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe .getGeofencePolygons() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(geofences => this.referenceGeofences.set(geofences)); + this.userGeofenceService + .getRegions() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ error: () => {}, next: regions => this.regions.set(regions) }); } openDetailDialog(geofence: UserGeofence): void { @@ -249,7 +256,7 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe openReviewDialog(geofence: UserGeofence): void { const ref = this.dialog.open(GeofenceApprovalDialogComponent, { width: '480px', - data: { geofence } as GeofenceApprovalDialogData, + data: { geofence, regions: this.regions() } as GeofenceApprovalDialogData, }); ref @@ -260,7 +267,11 @@ export class GeofenceSubmissionsComponent implements OnInit, AfterViewInit, OnDe if (result.action === 'approve') { this.adminGeofenceService - .approveSubmission(geofence.id, { promotedName: result.promotedName }) + .approveSubmission(geofence.id, { + groupName: result.groupName, + parentId: result.parentId, + promotedName: result.promotedName, + }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe({ error: () => 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..85ed8198 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 @@ -37,6 +37,19 @@

{{ 'ADMIN.APPROVAL_PROMOTED_NAME_HINT' | translate }} + + @if (hasRegions) { +
+ +

{{ 'ADMIN.APPROVAL_REGION_HINT' | translate }}

+ +
+ } } @if (mode === 'reject') { 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..defd90d9 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,65 @@ 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); + }); + }); }); 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..909c002c 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 @@ -9,14 +9,18 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslateModule } 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,6 +35,7 @@ export interface GeofenceApprovalDialogResult { MatFormFieldModule, MatIconModule, MatInputModule, + RegionSelectorComponent, TranslateModule, ], selector: 'app-geofence-approval-dialog', @@ -42,25 +47,54 @@ export class GeofenceApprovalDialogComponent { 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; } 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-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..cb0bdc49 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 @@ -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/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..9637f6a0 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 @@ -89,6 +89,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/assets/i18n/en.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json index 0187b053..5a198fc0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -974,6 +974,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...", @@ -1240,6 +1241,7 @@ "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_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.", diff --git a/CHANGELOG.md b/CHANGELOG.md index dc598755..23f03ebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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 diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs index a205eb55..00905bdf 100644 --- a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IUserGeofenceService.cs @@ -12,7 +12,7 @@ public interface IUserGeofenceService 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.Services/KojiService.cs b/Core/Pgan.PoracleWebNet.Core.Services/KojiService.cs index 854269b3..0c9c8e47 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, @@ -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, diff --git a/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs b/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs index 7269f100..430db1ec 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/UserGeofenceService.cs @@ -338,7 +338,7 @@ public async Task SubmitForReviewAsync(string humanId, string koji 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) @@ -369,6 +369,19 @@ 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) + { + 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( diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs index f80953d8..3ca69513 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AdminGeofenceControllerTests.cs @@ -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" }); @@ -209,7 +209,7 @@ public async Task ApproveSubmissionReturnsOkWithApprovedGeofence() [Fact] public async Task ApproveSubmissionReturnsNotFoundWhenNotFound() { - this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 99, null)) + this._service.Setup(s => s.ApproveSubmissionAsync("123456789", 99, null, null, null)) .ThrowsAsync(new InvalidOperationException("Submission not found.")); var result = await this._sut.ApproveSubmission(99, null); @@ -232,12 +232,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 --- 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/UserGeofenceServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs index 8bb856dc..253f753a 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/UserGeofenceServiceTests.cs @@ -353,6 +353,64 @@ 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; + }); + + 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() { 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..81cc0605 --- /dev/null +++ b/docs/features/custom-geofences/admin-operations.md @@ -0,0 +1,85 @@ +# 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. | + +When configured: + +- **On submit**, a thread is created (titled after the geofence) with a **Pending** tag and an embed showing the name, region, point count, and a map image. +- **On approve/reject**, PoracleWeb.NET posts the outcome in the thread, retags it **Approved**/**Rejected**, then **locks and archives** 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. + +!!! note "Bot token and tags" + PoracleWeb.NET uses the Discord bot token from your PoracleJS/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..b05a4f5f --- /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 PoracleJS/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[PoracleJS / 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 PoracleJS/PoracleNG 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..da3d8d07 --- /dev/null +++ b/docs/features/custom-geofences/key-concepts.md @@ -0,0 +1,72 @@ +# 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. + +## 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) + +PoracleJS/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 PoracleJS/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..84d598de --- /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 "PoracleJS/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**. PoracleJS/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 **PoracleJS/PoracleNG** bot at PoracleWeb.NET's combined feed (a single URL, not Koji) via its geofence-source setting: + +```jsonc +// PoracleJS/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 PoracleJS/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. PoracleJS/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..92de8f7b --- /dev/null +++ b/docs/features/custom-geofences/private-and-promotion.md @@ -0,0 +1,120 @@ +# 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 PoracleJS/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, +- **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? | +|---|---|---| +| `active` | Normal private geofence (the default after drawing). | No | +| `pending_review` | The user has submitted it; waiting on an admin. | No (still private to the owner) | +| `approved` | An admin promoted it; it's now a public Koji area. | **Yes** | +| `rejected` | An admin declined the request; it stays private with a note. | No | + +## 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..377b9529 --- /dev/null +++ b/docs/features/custom-geofences/troubleshooting.md @@ -0,0 +1,88 @@ +# 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 PoracleJS/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 PoracleJS/PoracleNG reload its geofences?** PoracleWeb.NET tells PoracleJS/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. **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] +``` + +PoracleJS/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 PoracleJS/PoracleNG reads PoracleWeb.NET's feed (not Koji directly), and that the geofence wasn't accidentally promoted. + +## Capitalization / name-match issues + +PoracleJS/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 PoracleJS/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[PoracleJS / 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 PoracleJS/PoracleNG can read it on your internal network. Don't expose it to the internet. diff --git a/docs/index.md b/docs/index.md index 116a689e..08e1c20c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -93,7 +93,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/mkdocs.yml b/mkdocs.yml index be9dab1e..e1103540 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,7 +90,13 @@ nav: - Alarm Management: features/alarms.md - Profiles: features/profiles.md - Quest Summary Delivery: features/quest-summary-schedules.md - - Custom Geofences: features/custom-geofences.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 From 0e0acb52195300900ec412942a6148314075bd4c Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 5 Jun 2026 13:57:03 -0400 Subject: [PATCH 41/59] ci(changelog): replace push-to-main writer with a verify-only [Unreleased] check (#316) (#317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old "Update Changelog" workflow ran after merge, auto-generated an entry, and pushed directly to `main` — which is protected, so the push was always rejected (GH006). It failed on every feat/fix PR, risked duplicate entries (its skip-guard keyed on PR number while manual entries reference the issue number), and fought the project's manual-changelog convention. Replace it with a verify-only check on pull_request that confirms the PR adds an entry under "## [Unreleased]". It never writes to the repo, so branch protection is a non-issue; it gives authors feedback before merge instead of red noise after. - Exempt PR types: docs/style/chore/ci/test/build. - Escape hatch: the `skip-changelog` label (re-runs on label change). - Detects new top-level entries by diffing the [Unreleased] block between base and head. - PR title/labels passed via env to avoid shell injection; permissions dropped to read. release-changelog.yml (the release-cut flow) is unchanged. Docs updated. Closes #316. --- .github/workflows/changelog.yml | 172 +++++++++----------------------- docs/development/ci-cd.md | 11 +- 2 files changed, 54 insertions(+), 129 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index dc1bfde1..73bc2852 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -1,150 +1,72 @@ -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. on: pull_request: - types: [closed] + types: [opened, synchronize, reopened, labeled, unlabeled] branches: [main] +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: - name: Checkout uses: actions/checkout@v6 with: - ref: main fetch-depth: 0 - - name: Categorize PR - id: categorize + - name: Require a CHANGELOG entry under [Unreleased] + 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). + if printf '%s' "$TITLE" | grep -qiE '^(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. - - The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + # 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)" - ## [Unreleased] - INIT - fi + # 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)" - # 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 '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/docs/development/ci-cd.md b/docs/development/ci-cd.md index d1b78d42..c01d9b2c 100644 --- a/docs/development/ci-cd.md +++ b/docs/development/ci-cd.md @@ -19,11 +19,14 @@ Runs on push to `main`: ## changelog.yml -Runs on merged PRs: +Runs on every PR to `main` as a **verify-only check** (it never writes to the repo): -- 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 +- Confirms the PR adds an entry under the `## [Unreleased]` section of `CHANGELOG.md`. +- **Exempt** PR types (no entry required): titles prefixed `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. + +> 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 From e9229c65995bd8521f4701457995b4518a109f46 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 5 Jun 2026 14:45:23 -0400 Subject: [PATCH 42/59] feat(pokemon): multi-select forms in alarm add dialog (#318) (#319) * feat(pokemon): multi-select forms in alarm add dialog (#318) The Form & Gender picker only allowed 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. PoracleNG models `form` as a single int per tracking entry (no array support on the wire), so instead of changing the data model end-to-end the add dialog reuses its existing per-Pokemon forkJoin fan-out and now emits one MonsterCreate per (Pokemon x selected form) combination. An empty selection means "all forms" (form 0), matching the previous default; a hint makes that explicit. Scope is the add dialog only -- the edit dialog stays single-form, since splitting one existing alarm into several on edit is a create-plus-delete operation. A dedicated `forms` control backs the multi-select, leaving the manual numeric form-id fallback on the original `form` control. New POKEMON.FORM_MULTI_HINT i18n key added and translated across all 11 locales. * test(pokemon): cover multi-select form fan-out in add dialog Adds pokemon-add-dialog.component.spec.ts verifying save() emits one MonsterCreate per (Pokemon x form): multi-form fan-out, empty selection => all forms (0), cartesian product across pokemon, manual numeric form-id fallback, snackbar total, and the no-selection no-op. MatSnackBar is providedIn MatSnackBarModule (imported by the standalone component), so it resolves from the element injector and shadows an environment-level useValue -- overridden via TestBed.overrideComponent. --- .../pokemon/pokemon-add-dialog.component.html | 4 +- .../pokemon-add-dialog.component.spec.ts | 136 ++++++++++++++++++ .../pokemon/pokemon-add-dialog.component.ts | 76 +++++----- .../ClientApp/src/assets/i18n/da.json | 1 + .../ClientApp/src/assets/i18n/de.json | 1 + .../ClientApp/src/assets/i18n/en.json | 1 + .../ClientApp/src/assets/i18n/es.json | 1 + .../ClientApp/src/assets/i18n/fr.json | 1 + .../ClientApp/src/assets/i18n/it.json | 1 + .../ClientApp/src/assets/i18n/nl.json | 1 + .../ClientApp/src/assets/i18n/pl.json | 1 + .../ClientApp/src/assets/i18n/pt-BR.json | 1 + .../ClientApp/src/assets/i18n/pt.json | 1 + .../ClientApp/src/assets/i18n/sv.json | 1 + CHANGELOG.md | 1 + 15 files changed, 193 insertions(+), 35 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.spec.ts 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 43e91364..c8a21ac7 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 }} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.spec.ts new file mode 100644 index 00000000..fc69ff1b --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-add-dialog.component.spec.ts @@ -0,0 +1,136 @@ +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 { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { PokemonAddDialogComponent } from './pokemon-add-dialog.component'; +import { Monster, MonsterCreate } from '../../core/models'; +import { AlertDefaultsService } from '../../core/services/alert-defaults.service'; +import { AuthService } from '../../core/services/auth.service'; +import { ConfigService } from '../../core/services/config.service'; +import { I18nService } from '../../core/services/i18n.service'; +import { MasterDataService } from '../../core/services/masterdata.service'; +import { MonsterService } from '../../core/services/monster.service'; +import { PoracleConfigService } from '../../core/services/poracle-config.service'; + +describe('PokemonAddDialogComponent', () => { + 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() }; + monsterService = { create: jest.fn().mockReturnValue(of({} as Monster)) }; + snackBar = { open: jest.fn() }; + masterData = { + getFormsForPokemon: jest.fn().mockReturnValue([ + { id: ALOLAN, name: 'Alolan' }, + { id: GALARIAN, name: 'Galarian' }, + ]), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + 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' } }, + { + provide: PoracleConfigService, + useValue: { load: () => of({ defaultPvpCap: 0 }), serverConfig: () => ({ pvpCaps: [] }) }, + }, + { provide: AuthService, useValue: { isImpersonating: () => false } }, + ], + imports: [PokemonAddDialogComponent, TranslateModule.forRoot()], + }); + + // 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 the total alarm count in the success snackbar', () => { + 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: 3000 })); + }); + + 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 ef0066d9..a1643472 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 @@ -77,6 +77,7 @@ export class PokemonAddDialogComponent implements OnInit { 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)]], @@ -156,39 +157,48 @@ export class PokemonAddDialogComponent implements OnInit { 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, - pvpRankingCap: pvp.pvpRankingLeague ? (pvp.pvpRankingCap ?? 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); - }); + // 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 = { + atk: filters.atk ?? 0, + clean: notif.clean ? 1 : 0, + def: filters.def ?? 0, + distance: distanceMeters, + 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, + minWeight: filters.minWeight ?? 0, + ping: notif.ping || null, + pokemonId, + pvpRankingBest: pvp.pvpRankingLeague ? (pvp.pvpRankingBest ?? 1) : 0, + pvpRankingCap: pvp.pvpRankingLeague ? (pvp.pvpRankingCap ?? 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(creates).subscribe({ error: () => { 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 a311fef5..0e653c66 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -228,6 +228,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", 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 54102d7f..1ef380b7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -228,6 +228,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", 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 5a198fc0..0bbc85a6 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -228,6 +228,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", 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 727682c5..b2a7e8bb 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -228,6 +228,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", 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 084c13e4..d0197e9d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -228,6 +228,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", 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 81ab2124..ad06ea05 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -228,6 +228,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", 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 0a04d05f..4793097c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -228,6 +228,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", 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 a5ec3240..6474ecfc 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -228,6 +228,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", 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 9e9fb410..891469bb 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 @@ -228,6 +228,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", 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 b9922adf..632b97d2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -228,6 +228,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", 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 15651ae0..da062188 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -228,6 +228,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", diff --git a/CHANGELOG.md b/CHANGELOG.md index 23f03ebb..19b97dfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.10.0] - 2026-06-03 ### 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 (English; other locales fall back until translated). - **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 From 2626352d57bb7cc95e51a19d8804f0aba3529438 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 5 Jun 2026 14:52:56 -0400 Subject: [PATCH 43/59] docs: move multi-select forms entry to [Unreleased] (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #319 squash merge anchored the new changelog bullet to its surrounding context (the notification-language line), which had since moved under ## [2.10.0] when v2.10.0 was cut (#313). As a result the multi-select forms entry landed under the already-released 2.10.0 section instead of [Unreleased], making it look like the feature shipped in 2.10.0 (it did not). Relocates the bullet to ## [Unreleased] > ### Added and corrects the wording ("translated across all 11 locales" — the locale translations landed in #319; the unit tests too). --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b97dfa..b9c6f551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. @@ -16,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.10.0] - 2026-06-03 ### 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 (English; other locales fall back until translated). - **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 From 3fff64e7e48704638f6a1d4e40dc9d08883b8cda Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:04:13 -0400 Subject: [PATCH 44/59] docs: cut changelog for v2.11.0 (#321) Co-authored-by: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c6f551..85fd4a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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. @@ -578,7 +580,8 @@ 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.10.0...HEAD +[Unreleased]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.11.0...HEAD +[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 From f8141225efa09eb937bb4b5962bde079b1de50a4 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 5 Jun 2026 15:28:15 -0400 Subject: [PATCH 45/59] ci(changelog): auto-merge the release changelog cut via a GitHub App token (#322) * ci(changelog): auto-approve and auto-merge the release changelog cut The cut-changelog PR was opened with GITHUB_TOKEN, which (by GitHub's recursion guard) does not trigger the required CI checks (Backend/Frontend/Changelog). Those required checks therefore never reported, leaving every changelog-cut PR permanently BLOCKED and needing a manual admin merge (e.g. v2.11.0 / #321). Open the PR with a fine-grained PAT (secrets.CHANGELOG_PAT) instead, so the push/PR events trigger the required checks. Then approve from the github-actions[bot] identity (distinct from the PAT author, so it satisfies the 1-approval rule) and enable squash auto-merge. Degrades gracefully: when CHANGELOG_PAT is absent the PR is still opened with GITHUB_TOKEN (a warning is emitted) and the approve/merge steps are skipped, preserving today's manual-merge behavior. * ci(changelog): use a GitHub App token instead of a PAT Switch the auto-merge mechanism from a fine-grained PAT to a GitHub App installation token (actions/create-github-app-token). App tokens are minted per run and short-lived, so there is no token to rotate, and the app's permissions are scoped to exactly what it needs. The PR is authored by the app bot (a real, non-GITHUB_TOKEN identity), so push/PR events trigger the required CI checks. github-actions[bot] then approves (distinct identity -> satisfies the 1-approval rule) and squash auto-merge finishes the job. Falls back to GITHUB_TOKEN (manual merge, with a warning) when the app secrets are absent. Secrets: CHANGELOG_APP_ID, CHANGELOG_APP_PRIVATE_KEY. --- .github/workflows/release-changelog.yml | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index d7f7d32c..a9c968fa 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -34,9 +34,40 @@ jobs: 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 + # 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@v2 + with: + app-id: ${{ secrets.CHANGELOG_APP_ID }} + private-key: ${{ secrets.CHANGELOG_APP_PRIVATE_KEY }} + - name: Open PR with changelog update + 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: | @@ -49,3 +80,23 @@ jobs: 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). + - 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 --delete-branch From 395ba8fb50f00fc479c0c74f6d408a20f07714ae Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 5 Jun 2026 15:54:02 -0400 Subject: [PATCH 46/59] =?UTF-8?q?fix:=20surface=20base/regional-default=20?= =?UTF-8?q?Pok=C3=A9mon=20forms=20in=20the=20form=20picker=20(#324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MasterDataService.loadForms() discarded every form named Normal, but for a species with a regional variant the Normal entry is the original/base form (e.g. Stunfisk lists Normal id 2246 for Unova and Galarian id 2345). That left only All Forms and Galarian, with no way to alert on Unova Stunfisk alone. Keep all real forms (form.id !== 0, including Normal) and only drop a Normal form when it is a species' lone form, where All Forms already covers it. Tests cover keep-when-sibling and drop-when-lone. Fixes #323 --- .../core/services/masterdata.service.spec.ts | 38 +++++++++++++++++++ .../app/core/services/masterdata.service.ts | 15 +++++++- CHANGELOG.md | 3 ++ 3 files changed, 55 insertions(+), 1 deletion(-) 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..b459933b 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 @@ -123,5 +123,43 @@ 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(req => req.url.includes('master-latest-poracle')) + .flush({ + monsters: { + '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 "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(req => req.url.includes('master-latest-poracle')) + .flush({ + monsters: { + '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([]); + }); }); }); 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..4e5b1ef2 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 @@ -159,7 +159,10 @@ export class MasterDataService { const grouped = new Map(); for (const entry of Object.values(monsters)) { - if (!entry.form || entry.form.id === 0 || entry.form.name === 'Normal') continue; + // 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) continue; const pokemonId = entry.id; if (!grouped.has(pokemonId)) { grouped.set(pokemonId, []); @@ -171,6 +174,16 @@ export class MasterDataService { } } + // 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. + for (const [pokemonId, forms] of grouped) { + if (forms.length === 1 && forms[0].name === 'Normal') { + grouped.delete(pokemonId); + } + } + // Sort forms alphabetically within each Pokemon for (const forms of grouped.values()) { forms.sort((a, b) => a.name.localeCompare(b.name)); diff --git a/CHANGELOG.md b/CHANGELOG.md index 85fd4a11..9286c3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [2.11.0] - 2026-06-05 ### Added From 886a79bc21ea06bd082888a8142f9d240796b22e Mon Sep 17 00:00:00 2001 From: "poracleweb-net-release[bot]" <291134500+poracleweb-net-release[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 20:01:22 +0000 Subject: [PATCH 47/59] docs: cut changelog for v2.11.1 (#325) Co-authored-by: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9286c3e5..c9985985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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. @@ -583,7 +585,8 @@ 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.11.0...HEAD +[Unreleased]: https://github.com/PGAN-Dev/PoracleWeb.NET/compare/v2.11.1...HEAD +[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 From 0310c564077794ca179111c4b23cad7714209b7d Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Fri, 5 Jun 2026 16:15:08 -0400 Subject: [PATCH 48/59] ci(docs): redeploy docs on release publish and manual dispatch (#326) The mkdocs-material header version badge is baked into the static HTML at gh-deploy time from the GitHub Releases API. Since docs.yml only triggered on docs/ or mkdocs.yml changes, code-only releases (v2.11.0, v2.11.1) never rebuilt the site, leaving the badge frozen at v2.10.0. Add release:published and workflow_dispatch triggers so the docs (and badge) refresh on every release and on demand. --- .github/workflows/docs.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c178649..2f4bd6fe 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 From 8dfcca5e8b7c64f9c6bfad5874a913b7c18a0821 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Sun, 7 Jun 2026 23:49:08 -0400 Subject: [PATCH 49/59] feat(auth): generic external SSO / OIDC login + refresh-token sessions + docs (#328) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(auth): add generic external SSO / OIDC login provider (#327) Delegate PoracleWeb login to any external OAuth2/OpenID Connect provider, alongside the built-in Discord and Telegram methods. Enables single sign-on (e.g. pointing PoracleWeb at the PogoAlerts OAuth2 server) while staying provider-agnostic so any self-hoster can configure their own IdP. Implemented as a configurable twin of the Discord flow: - OidcSettings (URLs, client id/secret, scopes, claim mapping, PKCE flag) - GET /api/auth/oidc/login + /callback: auth-code exchange with PKCE, CSRF state in HttpOnly cookies; reads a configurable identity claim (default discord_id, falls back to sub), looks it up in the human table, reuses GetRolesAsync + guild-role gating, mints the existing internal JWT - providers endpoint gains an oidc block; enable_oidc site-setting runtime toggle (admins can always log in to re-enable); OIDC_* env bridge with auto-infer; documented in .env.example - Frontend: oidc provider model, loginWithOidc(), /auth/oidc/callback route, login button + disabled hint + error codes, admin External SSO group, English i18n keys Tests: backend providers oidc block + /oidc/login redirect/PKCE (1394 pass); frontend OIDC button visibility + click delegation (48 pass). * feat(auth): OIDC refresh-token sessions, SSO refinements, and docs Optional, provider-agnostic consumption of the external SSO provider's refresh token for silent session renewal + revocation propagation (default OFF via OIDC_USE_REFRESH_TOKENS), plus surrounding SSO login refinements and a comprehensive OIDC documentation set. Fully OIDC-provider-agnostic; PogoAlerts is only the reference provider. Backend: - oidc_sessions table (EF migration AddOidcSessions): the provider refresh token is stored server-side, DataProtection-encrypted, never sent to the browser; the browser holds only an opaque rotating token keyed to a rotation family. - POST /api/auth/oidc/refresh (rotate + live user re-validation + family-revoke on replay/provider-revoke) and /oidc/refresh/revoke; OidcSessionCleanupService reaps expired/stale rows (OIDC_SESSION_REVOKED_RETENTION_DAYS, default 2). - Provider-agnostic IOidcClient shared by login callback and refresh: configurable client_secret_post|client_secret_basic, optional/non-rotating refresh tokens, offline_access auto-appended; no discovery/JWKS/id_token dependency. - Per-login short JWT for refresh-backed OIDC sessions (OIDC_ACCESS_TOKEN_MINUTES, default 30); Discord/Telegram/local logins keep the 24h JWT. - Refresh is env-controlled only (no runtime admin toggle — coupled to JWT lifetime). Frontend: - TokenStoreService (single-flight refresh) + oidcRefreshInterceptor (proactive pre-expiry + reactive 401-retry, null-refresh-token guard so non-refresh logins keep the existing 401->logout path); callback stores the opaque token; logout revokes the session server-side. - SSO login refinements: Local/SSO auth-mode switch, single-logout toggle, auto-redirect + signed-out panel, i18n. Docs (MkDocs / gh-pages): - New "External SSO (OIDC)" setup page + reworked "OIDC Refresh Tokens" page (Mermaid flows, security model, provider matrix: Keycloak/Authentik/Auth0/ Google/Azure-Entra/Okta/PogoAlerts). - OIDC env vars added to the Configuration Reference, enable_oidc/enable_oidc_slo to Site Settings, an OIDC troubleshooting section, and nav entries. Tests: xUnit (session rotation/replay/cap/cleanup over SQLite, provider-agnostic client, providers/callback) + Jest (token-store single-flight, interceptor proactive/reactive/loop-guard). Backend 1422 pass, frontend 872 pass. --- .env.example | 43 ++ .../Configuration/IJwtService.cs | 7 + .../Configuration/JwtService.cs | 12 +- .../Configuration/OidcSettings.cs | 115 +++ .../ServiceCollectionExtensions.cs | 7 + .../Controllers/AuthController.cs | 464 ++++++++++++ .../Controllers/SettingsController.cs | 51 +- .../Pgan.PoracleWebNet.Api/Program.cs | 38 + .../Services/Oidc/IOidcClient.cs | 30 + .../Services/Oidc/IOidcSessionService.cs | 60 ++ .../Services/Oidc/OidcClient.cs | 122 +++ .../Oidc/OidcSessionCleanupService.cs | 54 ++ .../Services/Oidc/OidcSessionService.cs | 173 +++++ .../ClientApp/proxy.local.json | 8 + .../ClientApp/src/app/app.config.ts | 5 +- .../ClientApp/src/app/app.html | 6 + .../ClientApp/src/app/app.routes.ts | 4 + .../ClientApp/src/app/app.ts | 15 +- .../oidc-refresh.interceptor.spec.ts | 111 +++ .../interceptors/oidc-refresh.interceptor.ts | 56 ++ .../ClientApp/src/app/core/models/index.ts | 34 + .../app/core/services/auth.service.spec.ts | 18 +- .../src/app/core/services/auth.service.ts | 34 +- .../src/app/core/services/settings.service.ts | 6 +- .../core/services/token-store.service.spec.ts | 100 +++ .../app/core/services/token-store.service.ts | 131 ++++ .../admin/admin-settings.component.html | 161 ++++ .../admin/admin-settings.component.scss | 18 + .../modules/admin/admin-settings.component.ts | 79 +- .../app/modules/auth/callback.component.ts | 7 +- .../src/app/modules/auth/login.component.html | 38 +- .../src/app/modules/auth/login.component.scss | 15 + .../app/modules/auth/login.component.spec.ts | 96 ++- .../src/app/modules/auth/login.component.ts | 72 +- .../ClientApp/src/assets/i18n/en.json | 36 + CHANGELOG.md | 4 + .../Repositories/IOidcSessionRepository.cs | 33 + .../OidcRefreshRequest.cs | 11 + .../OidcSession.cs | 58 ++ .../OidcSessionRepository.cs | 101 +++ .../SettingsMigrationService.cs | 4 + .../OidcSessionConfiguration.cs | 46 ++ .../Entities/OidcSessionEntity.cs | 84 +++ ...20260608015721_AddOidcSessions.Designer.cs | 394 ++++++++++ .../20260608015721_AddOidcSessions.cs | 69 ++ .../PoracleWebContextModelSnapshot.cs | 702 ++++++++++-------- .../PoracleWebContext.cs | 5 + .../Controllers/AuthControllerMeTests.cs | 3 + .../AuthControllerProvidersTests.cs | 259 ++++++- .../Controllers/SettingsControllerTests.cs | 9 +- .../Pgan.PoracleWebNet.Tests.csproj | 1 + .../OidcSessionRepositoryTests.cs | 136 ++++ .../Services/OidcClientTests.cs | 131 ++++ .../Services/OidcSessionServiceTests.cs | 184 +++++ docs/configuration/external-sso.md | 323 ++++++++ docs/configuration/oidc-refresh-tokens.md | 427 +++++++++++ docs/configuration/reference.md | 37 + docs/configuration/site-settings.md | 17 + docs/troubleshooting.md | 128 ++++ mkdocs.yml | 2 + 60 files changed, 5048 insertions(+), 346 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.Api/Configuration/OidcSettings.cs create mode 100644 Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcClient.cs create mode 100644 Applications/Pgan.PoracleWebNet.Api/Services/Oidc/IOidcSessionService.cs create mode 100644 Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcClient.cs create mode 100644 Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionCleanupService.cs create mode 100644 Applications/Pgan.PoracleWebNet.Api/Services/Oidc/OidcSessionService.cs create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.local.json create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/interceptors/oidc-refresh.interceptor.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/token-store.service.ts create mode 100644 Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IOidcSessionRepository.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/OidcRefreshRequest.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/OidcSession.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Repositories/OidcSessionRepository.cs create mode 100644 Data/Pgan.PoracleWebNet.Data/Configurations/OidcSessionConfiguration.cs create mode 100644 Data/Pgan.PoracleWebNet.Data/Entities/OidcSessionEntity.cs create mode 100644 Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.Designer.cs create mode 100644 Data/Pgan.PoracleWebNet.Data/Migrations/PoracleWeb/20260608015721_AddOidcSessions.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Repositories/OidcSessionRepositoryTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/OidcClientTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/OidcSessionServiceTests.cs create mode 100644 docs/configuration/external-sso.md create mode 100644 docs/configuration/oidc-refresh-tokens.md diff --git a/.env.example b/.env.example index ec5db6b1..b4fb1593 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,49 @@ 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 (e.g. PogoAlerts) for single sign-on. +# 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. +# OIDC_ENABLED=true +# OIDC_PROVIDER_NAME=PogoAlerts +# OIDC_AUTHORIZATION_URL=https://pogoalerts.net/login +# OIDC_TOKEN_URL=https://pogoalerts.net/api/oauth/token +# OIDC_USERINFO_URL=https://pogoalerts.net/api/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 # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/IJwtService.cs index 575a3a60..62c35012 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. diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/JwtService.cs index 87f9a55c..88e9d401 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); @@ -88,7 +94,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 +105,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/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index 4da22e94..551a4d27 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -59,6 +59,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register Services services.AddScoped(); @@ -149,6 +150,11 @@ 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(); @@ -156,6 +162,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv 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/AuthController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs index 2e6353a6..d84f1a21 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/AuthController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Pgan.PoracleWebNet.Api.Configuration; +using Pgan.PoracleWebNet.Api.Services.Oidc; using Pgan.PoracleWebNet.Core.Abstractions.Services; using Pgan.PoracleWebNet.Core.Models; @@ -21,14 +22,19 @@ public partial class AuthController( ISiteSettingService siteSettingService, IWebhookDelegateService webhookDelegateService, IJwtService jwtService, + 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 EnableOidcKey = "enable_oidc"; + private const string EnableOidcSloKey = "enable_oidc_slo"; private readonly IHumanService _humanService = humanService; private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; @@ -36,8 +42,11 @@ public partial class AuthController( private readonly ISiteSettingService _siteSettingService = siteSettingService; private readonly IWebhookDelegateService _webhookDelegateService = webhookDelegateService; private readonly IJwtService _jwtService = jwtService; + 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 ILogger _logger = logger; @@ -212,6 +221,281 @@ 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.Request.Scheme}://{this.Request.Host}"; + 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 = $"{this.Request.Scheme}://{this.Request.Host}/api/auth/oidc/callback"; + + 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.Request.Scheme}://{this.Request.Host}"; + 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. + var redirectUri = $"{this.Request.Scheme}://{this.Request.Host}/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) @@ -376,6 +660,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 @@ -389,6 +697,17 @@ public async Task Providers() enabledByAdmin = !telegramDisabledByAdmin, botUsername = telegramConfigured ? this._telegramSettings.BotUsername : 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, + }, }); } @@ -672,6 +991,148 @@ private string GetFrontendUrl() return $"{this.Request.Scheme}://{this.Request.Host}"; } + /// + /// 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.Warning, Message = "Role-based access enabled but Discord BotToken or GuildId not configured.")] private static partial void LogRoleMisconfigured(ILogger logger); @@ -690,6 +1151,9 @@ private string GetFrontendUrl() [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.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.Warning, Message = "Failed to fetch Poracle config for admin check for {UserId}.")] private static partial void LogPoracleConfigFetchFailed(ILogger logger, Exception ex, string userId); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs index 1ee078fb..41ceebab 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs @@ -13,7 +13,9 @@ public class SettingsController( ISiteSettingService siteSettingService, IOptions discordSettings, IOptions poracleSettings, - IOptions telegramSettings) : BaseApiController + IOptions telegramSettings, + IOptions oidcSettings, + IConfiguration configuration) : BaseApiController { private static readonly HashSet SensitiveKeys = new(StringComparer.OrdinalIgnoreCase) { @@ -32,6 +34,7 @@ public class SettingsController( 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; [HttpGet] @@ -95,6 +98,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) { diff --git a/Applications/Pgan.PoracleWebNet.Api/Program.cs b/Applications/Pgan.PoracleWebNet.Api/Program.cs index fe989898..f887c8fd 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Program.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Program.cs @@ -57,6 +57,31 @@ 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"); @@ -87,6 +112,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(); @@ -150,6 +187,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); // JWT Authentication var jwtSettings = builder.Configuration.GetSection("Jwt").Get()!; 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.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/src/app/app.config.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts index 9b21a077..2e8e5f3c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.config.ts @@ -8,12 +8,15 @@ import { TranslateHttpLoader, provideTranslateHttpLoader } from '@ngx-translate/ import { routes } from './app.routes'; 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: [ 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(), provideTranslateService({ defaultLanguage: 'en', diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html index 86384600..1c85d749 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html @@ -129,6 +129,12 @@ logout {{ 'MENU.LOGOUT' | translate }} + @if (ssoLogoutAvailable()) { + + } + + } @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..e96231df 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,6 +46,7 @@ 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 })), isLoggedIn: jest.fn(() => false), loginWithDiscord: jest.fn(), @@ -53,7 +54,15 @@ describe('LoginComponent', () => { }, }, { 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 +151,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..852a9ba4 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 @@ -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', @@ -158,6 +205,18 @@ export class LoginComponent implements OnInit { } } + /** + * 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 +227,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/assets/i18n/en.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json index 0bbc85a6..bfeb0e4e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Accent Theme", "LANGUAGE": "Language", "LOGOUT": "Logout", + "LOGOUT_EVERYWHERE": "Sign out everywhere", "ACCENT_DEFAULT": "Default", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1157,6 +1158,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.", @@ -1172,6 +1177,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.", @@ -1473,6 +1482,7 @@ "GROUP_COMMANDS": "Commands", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", + "GROUP_OIDC": "External SSO", "GROUP_MAPS_ASSETS": "Maps & Assets", "GROUP_ANALYTICS_LINKS": "Analytics & Links", "GROUP_DEBUG": "Debug", @@ -1545,6 +1555,32 @@ "TELEGRAM_BOT_DESC": "Telegram bot username (without @).", "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", diff --git a/CHANGELOG.md b/CHANGELOG.md index c9985985..d7a61fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **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. + ## [2.11.1] - 2026-06-05 ### Fixed 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..c388ffb9 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IOidcSessionRepository.cs @@ -0,0 +1,33 @@ +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 via EF Core's set-based ExecuteUpdateAsync/ExecuteDeleteAsync. +/// +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. + public Task DeleteExpiredAndStaleAsync(TimeSpan revokedRetention); +} 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.Repositories/OidcSessionRepository.cs b/Core/Pgan.PoracleWebNet.Core.Repositories/OidcSessionRepository.cs new file mode 100644 index 00000000..bb91c6dc --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Repositories/OidcSessionRepository.cs @@ -0,0 +1,101 @@ +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; + return await this._context.OidcSessions + .Where(s => s.ExpiresAt < now || (s.RevokedAt != null && s.RevokedAt < revokedCutoff)) + .ExecuteDeleteAsync(); + } + + 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.Services/SettingsMigrationService.cs b/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs index 837ce7d1..bcc8c812 100644 --- a/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs +++ b/Core/Pgan.PoracleWebNet.Core.Services/SettingsMigrationService.cs @@ -86,6 +86,9 @@ public partial class SettingsMigrationService( ["telegram_bot"] = "telegram", ["telegram_bot_token"] = "telegram", + // oidc (generic external SSO provider) + ["enable_oidc"] = "oidc", + // maps ["provider_url"] = "maps", @@ -120,6 +123,7 @@ public partial class SettingsMigrationService( "disable_profiles", "disable_location", "disable_nominatim", "disable_geomap", "disable_geomap_select", "disable_user_geofences", "enable_templates", "enable_roles", "enable_telegram", "enable_discord", + "enable_oidc", "hide_header_logo", "site_is_https", "debug", }; 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/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/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/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs index 4573c761..d0d03019 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerMeTests.cs @@ -32,8 +32,11 @@ public AuthControllerMeTests() new Mock().Object, new Mock().Object, this._jwtService.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); diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/AuthControllerProvidersTests.cs index a0a81d78..4c448e2e 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,36 @@ 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( + 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, this._siteSettingService.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() { @@ -177,4 +195,241 @@ 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()); + } } diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs index 66044214..85b4c737 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/SettingsControllerTests.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options; using Moq; using Pgan.PoracleWebNet.Api.Configuration; @@ -17,7 +18,9 @@ public class SettingsControllerTests : ControllerTestBase this._siteService.Object, Options.Create(new DiscordSettings()), Options.Create(new PoracleSettings()), - Options.Create(new TelegramSettings())); + Options.Create(new TelegramSettings()), + Options.Create(new OidcSettings()), + new ConfigurationBuilder().Build()); [Fact] public async Task GetAllReturnsOkForAdmin() @@ -148,7 +151,9 @@ public void GetDiscordConfigReturnsOkForAdmin() { AdminIds = "111111111,222222222", }), - Options.Create(new TelegramSettings())); + Options.Create(new TelegramSettings()), + Options.Create(new OidcSettings()), + new ConfigurationBuilder().Build()); SetupUser(controller, isAdmin: true); var result = controller.GetDiscordConfig(); diff --git a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj index 39aab0d6..5ebd9bf0 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj +++ b/Tests/Pgan.PoracleWebNet.Tests/Pgan.PoracleWebNet.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/Tests/Pgan.PoracleWebNet.Tests/Repositories/OidcSessionRepositoryTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Repositories/OidcSessionRepositoryTests.cs new file mode 100644 index 00000000..14dcc07f --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Repositories/OidcSessionRepositoryTests.cs @@ -0,0 +1,136 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +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 +/// and cleanup use ExecuteUpdateAsync/ExecuteDeleteAsync, which the EF InMemory +/// provider cannot translate. Covers the retention semantics (decoupled revoked-row retention) and +/// the atomic rotation guard. +/// +public sealed class OidcSessionRepositoryTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly PoracleWebContext _context; + private readonly OidcSessionRepository _repo; + + public OidcSessionRepositoryTests() + { + this._connection = new SqliteConnection("DataSource=:memory:"); + this._connection.Open(); + var options = new DbContextOptionsBuilder() + .UseSqlite(this._connection) + .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")); + } +} 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/docs/configuration/external-sso.md b/docs/configuration/external-sso.md new file mode 100644 index 00000000..deb132fa --- /dev/null +++ b/docs/configuration/external-sso.md @@ -0,0 +1,323 @@ +# 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. +- **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 lacks one or more required roles. | +| `role_check_failed` | Role gating: the role check itself errored (e.g. bot token / guild misconfigured). | + +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` | *(empty)* | +| 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: + +### PogoAlerts (reference) + +```bash +OIDC_PROVIDER_NAME=PogoAlerts +OIDC_AUTHORIZATION_URL=https://pogoalerts.net/login +OIDC_TOKEN_URL=https://pogoalerts.net/api/oauth/token +OIDC_USERINFO_URL=https://pogoalerts.net/api/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_USE_PKCE=true +``` + +### 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..6b52ea7b --- /dev/null +++ b/docs/configuration/oidc-refresh-tokens.md @@ -0,0 +1,427 @@ +# 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. **Set empty** for providers that issue refresh tokens unconditionally (PogoAlerts) or use a non-standard mechanism (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 ExecuteDeleteAsync) └──────────────────────────┘ +``` + +### 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` | *(empty)* | `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. + +#### PogoAlerts (reference) + +```bash +OIDC_USE_REFRESH_TOKENS=true +OIDC_SCOPES=openid profile email +OIDC_OFFLINE_ACCESS_SCOPE= +OIDC_TOKEN_AUTH_METHOD=client_secret_post +OIDC_IDENTITY_CLAIM=discord_id +``` + +#### 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, set-based +`ExecuteDeleteAsync`). + +--- + +## 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..2b650ea1 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -40,6 +40,43 @@ All configuration can be provided via environment variables or `appsettings.json | 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 | diff --git a/docs/configuration/site-settings.md b/docs/configuration/site-settings.md index 3def34e2..acedc743 100644 --- a/docs/configuration/site-settings.md +++ b/docs/configuration/site-settings.md @@ -112,6 +112,23 @@ Configure Telegram authentication alongside or instead of Discord. --- +## Authentication + +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 | +|---|---|---|---| +| `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). + +--- + ## Maps & Assets Configure the map tile provider used for static map images. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 8aa37855..0d3eb3f4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -322,3 +322,131 @@ docker logs poracleweb.net 2>&1 | grep -i golbat 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. diff --git a/mkdocs.yml b/mkdocs.yml index e1103540..fff8a813 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 From 833f915ed56090e5cd4434a9709fe97c851c257f Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Mon, 8 Jun 2026 11:52:24 -0400 Subject: [PATCH 50/59] chore(config): use generic placeholder URLs in the OIDC example (#329) The EXTERNAL SSO / OIDC sample in .env.example hardcoded PGAN's pogoalerts.net endpoints as the example provider. PoracleWeb is fully OIDC-provider-agnostic and open source, so the example should not be branded to one deployment. Replace the sample provider name and authorize/token/userinfo URLs with neutral placeholders (My SSO / https://sso.example.com/...), generalize the intro to list common providers (Keycloak, Authentik, Auth0, Okta), and add a note to swap in your provider's real endpoints. The per-provider reference matrix (which carries no URLs) is unchanged. --- .env.example | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index b4fb1593..a63568f3 100644 --- a/.env.example +++ b/.env.example @@ -78,15 +78,16 @@ TELEGRAM_ENABLED=false # ═══════════════════════════════════════════════════════════════════════════════ # EXTERNAL SSO / OIDC (optional — delegate login to your own OAuth2/OIDC provider) # ═══════════════════════════════════════════════════════════════════════════════ -# Point PoracleWeb at any OAuth2/OIDC provider (e.g. PogoAlerts) for single sign-on. +# 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=PogoAlerts -# OIDC_AUTHORIZATION_URL=https://pogoalerts.net/login -# OIDC_TOKEN_URL=https://pogoalerts.net/api/oauth/token -# OIDC_USERINFO_URL=https://pogoalerts.net/api/oauth/userinfo +# 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 From fb018721311598d7d804b9a1bfa8d7fac171be39 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Mon, 8 Jun 2026 11:52:28 -0400 Subject: [PATCH 51/59] docs(oidc): require offline_access for PogoAlerts; drop pogoalerts.net URLs (#331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PogoAlerts was made standards-compliant (it now issues a refresh token only when the offline_access scope is requested), so the docs that told operators to leave OIDC_OFFLINE_ACCESS_SCOPE empty for PogoAlerts were stale. PogoAlerts now uses offline_access (the default), like every other standards-compliant provider — this already matched .env.example, which the docs contradicted. - external-sso.md / oidc-refresh-tokens.md provider matrices: PogoAlerts OIDC_OFFLINE_ACCESS_SCOPE (empty) -> offline_access. - oidc-refresh-tokens.md: drop "(PogoAlerts)" from the "set empty / issues unconditionally" guidance; Google's access_type=offline remains the empty case. De-branding (open-source hygiene): remove the dedicated "PogoAlerts (reference)" copy-paste snippets (the only place carrying real pogoalerts.net URLs). PogoAlerts remains documented as a row in the provider matrix; Keycloak/Auth0 serve as the concrete copy-paste examples. No pogoalerts.net URLs remain in the docs. --- docs/configuration/external-sso.md | 19 +++---------------- docs/configuration/oidc-refresh-tokens.md | 14 ++------------ 2 files changed, 5 insertions(+), 28 deletions(-) diff --git a/docs/configuration/external-sso.md b/docs/configuration/external-sso.md index deb132fa..5c73d85b 100644 --- a/docs/configuration/external-sso.md +++ b/docs/configuration/external-sso.md @@ -238,7 +238,7 @@ 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` | *(empty)* | +| 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` | @@ -248,21 +248,8 @@ matter — those columns are included for convenience.) † Only relevant if you also enable refresh tokens. For plain login, these are ignored. -Copy-paste login snippets for the most common providers: - -### PogoAlerts (reference) - -```bash -OIDC_PROVIDER_NAME=PogoAlerts -OIDC_AUTHORIZATION_URL=https://pogoalerts.net/login -OIDC_TOKEN_URL=https://pogoalerts.net/api/oauth/token -OIDC_USERINFO_URL=https://pogoalerts.net/api/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_USE_PKCE=true -``` +Copy-paste login snippets for the most common providers (replace the example URLs with your +provider's actual endpoints): ### Keycloak diff --git a/docs/configuration/oidc-refresh-tokens.md b/docs/configuration/oidc-refresh-tokens.md index 6b52ea7b..b28b5ca8 100644 --- a/docs/configuration/oidc-refresh-tokens.md +++ b/docs/configuration/oidc-refresh-tokens.md @@ -63,7 +63,7 @@ All variables are optional and default-safe. Add them to your `.env` (or use the | `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. **Set empty** for providers that issue refresh tokens unconditionally (PogoAlerts) or use a non-standard mechanism (Google's `access_type=offline`). | +| `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 @@ -296,7 +296,7 @@ variables (`OIDC_ENABLED`, `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USE | Provider | `OIDC_SCOPES` | `OIDC_OFFLINE_ACCESS_SCOPE` | `OIDC_TOKEN_AUTH_METHOD` | `OIDC_IDENTITY_CLAIM` | |---|---|---|---|---| -| PogoAlerts | `openid profile email` | *(empty)* | `client_secret_post` | `discord_id` | +| 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` | @@ -309,16 +309,6 @@ variables (`OIDC_ENABLED`, `OIDC_AUTHORIZATION_URL`, `OIDC_TOKEN_URL`, `OIDC_USE `OIDC_AUTHORIZATION_URL`, so append `?access_type=offline` (and optionally `&prompt=consent`) directly to the URL and leave `OIDC_OFFLINE_ACCESS_SCOPE` empty. -#### PogoAlerts (reference) - -```bash -OIDC_USE_REFRESH_TOKENS=true -OIDC_SCOPES=openid profile email -OIDC_OFFLINE_ACCESS_SCOPE= -OIDC_TOKEN_AUTH_METHOD=client_secret_post -OIDC_IDENTITY_CLAIM=discord_id -``` - #### Keycloak ```bash From 7b1566fd55abbc375d1aa6e60e858cf5dad2ea64 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Mon, 8 Jun 2026 11:53:02 -0400 Subject: [PATCH 52/59] i18n: translate OIDC/SSO keys into all locales (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: translate OIDC/SSO keys into all locales The external SSO / OIDC login feature (#327/#328) added 30 i18n keys to en.json only, so every non-English locale fell back to English for the SSO sign-in button, the signed-out panel, OIDC error messages, and the entire admin Authentication / External SSO settings group. Translate all 30 keys (ADMIN_SETTINGS.* OIDC config + auth-mode + single-logout, AUTH.* SSO sign-in / signed-out / OIDC errors, MENU.LOGOUT_EVERYWHERE) into the 10 remaining locales: da, de, es, fr, it, nl, pl, pt, pt-BR, sv. Placeholders ({{provider}}), technical tokens (OIDC/SSO/PKCE/URL/ID), env names (AUTH_FORCE_LOCAL, OIDC_END_SESSION_URL, OIDC_*, .env) and brand names are preserved verbatim. Additive only — no existing keys changed or removed; all files valid JSON and Prettier-clean. * docs(changelog): note OIDC locale translations --- .../ClientApp/src/assets/i18n/da.json | 30 ++++++++++++++++ .../ClientApp/src/assets/i18n/de.json | 30 ++++++++++++++++ .../ClientApp/src/assets/i18n/es.json | 30 ++++++++++++++++ .../ClientApp/src/assets/i18n/fr.json | 30 ++++++++++++++++ .../ClientApp/src/assets/i18n/it.json | 32 ++++++++++++++++- .../ClientApp/src/assets/i18n/nl.json | 34 +++++++++++++++++-- .../ClientApp/src/assets/i18n/pl.json | 30 ++++++++++++++++ .../ClientApp/src/assets/i18n/pt-BR.json | 30 ++++++++++++++++ .../ClientApp/src/assets/i18n/pt.json | 30 ++++++++++++++++ .../ClientApp/src/assets/i18n/sv.json | 30 ++++++++++++++++ CHANGELOG.md | 3 ++ 11 files changed, 306 insertions(+), 3 deletions(-) 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 0e653c66..cd7dcfce 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Accenttema", "LANGUAGE": "Sprog", "LOGOUT": "Log ud", + "LOGOUT_EVERYWHERE": "Log ud overalt", "ACCENT_DEFAULT": "Standard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1152,6 +1153,9 @@ "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", + "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": "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.", @@ -1167,6 +1171,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": "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": "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.", @@ -1478,6 +1486,7 @@ "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", @@ -1547,6 +1556,27 @@ "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", 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 1ef380b7..2caecb93 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Akzentfarbe", "LANGUAGE": "Sprache", "LOGOUT": "Abmelden", + "LOGOUT_EVERYWHERE": "Überall abmelden", "ACCENT_DEFAULT": "Standard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1152,6 +1153,9 @@ "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", + "SIGN_IN_OIDC": "Mit {{provider}} anmelden", + "SIGNED_OUT_TITLE": "Abgemeldet", + "SIGNED_OUT_DESC": "Du wurdest von DM Alerts abgemeldet.", "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.", @@ -1167,6 +1171,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": "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": "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.", @@ -1545,6 +1553,28 @@ "TELEGRAM_BOT_DESC": "Telegram-Bot-Benutzername (ohne @).", "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", 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 b2a7e8bb..41ca9422 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Tema de acento", "LANGUAGE": "Idioma", "LOGOUT": "Cerrar sesión", + "LOGOUT_EVERYWHERE": "Cerrar sesión en todas partes", "ACCENT_DEFAULT": "Predeterminado", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1152,6 +1153,9 @@ "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", + "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": "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.", @@ -1167,6 +1171,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": "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": "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.", @@ -1473,6 +1481,7 @@ "GROUP_COMMANDS": "Comandos", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", + "GROUP_OIDC": "SSO externo", "GROUP_MAPS_ASSETS": "Mapas y recursos", "GROUP_ANALYTICS_LINKS": "Analítica y enlaces", "GROUP_DEBUG": "Depuración", @@ -1545,6 +1554,27 @@ "TELEGRAM_BOT_DESC": "Nombre de usuario del bot de Telegram (sin @).", "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", 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 d0197e9d..7527f649 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Thème d'accent", "LANGUAGE": "Langue", "LOGOUT": "Déconnexion", + "LOGOUT_EVERYWHERE": "Se déconnecter partout", "ACCENT_DEFAULT": "Par défaut", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1152,6 +1153,9 @@ "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", + "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": "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.", @@ -1167,6 +1171,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": "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": "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.", @@ -1473,6 +1481,7 @@ "GROUP_COMMANDS": "Commandes", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", + "GROUP_OIDC": "SSO externe", "GROUP_MAPS_ASSETS": "Cartes et ressources", "GROUP_ANALYTICS_LINKS": "Analytique et liens", "GROUP_DEBUG": "Débogage", @@ -1545,6 +1554,27 @@ "TELEGRAM_BOT_DESC": "Nom d'utilisateur du bot Telegram (sans @).", "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", 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 ad06ea05..e5de602d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Tema Accento", "LANGUAGE": "Lingua", "LOGOUT": "Esci", + "LOGOUT_EVERYWHERE": "Esci ovunque", "ACCENT_DEFAULT": "Predefinito", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raid", @@ -1173,7 +1174,14 @@ "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_DESC": "Don't have an account? Sign up to get started.", + "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." }, "ERROR": { "SESSION_EXPIRED": "Session expired. Please log in again.", @@ -1545,6 +1553,28 @@ "TELEGRAM_BOT_DESC": "Nome utente del bot Telegram (senza @).", "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", 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 4793097c..63f8e8a4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Accentthema", "LANGUAGE": "Taal", "LOGOUT": "Uitloggen", + "LOGOUT_EVERYWHERE": "Overal uitloggen", "ACCENT_DEFAULT": "Standaard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1173,7 +1174,14 @@ "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_DESC": "Don't have an account? Sign up to get started.", + "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." }, "ERROR": { "SESSION_EXPIRED": "Session expired. Please log in again.", @@ -1478,6 +1486,7 @@ "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", @@ -1576,7 +1585,28 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Naam", 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 6474ecfc..5e06745f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Motyw akcentu", "LANGUAGE": "Język", "LOGOUT": "Wyloguj", + "LOGOUT_EVERYWHERE": "Wyloguj się wszędzie", "ACCENT_DEFAULT": "Domyślny", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Rajdy", @@ -1152,6 +1153,9 @@ "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", + "SIGN_IN_OIDC": "Zaloguj się przez {{provider}}", + "SIGNED_OUT_TITLE": "Wylogowano", + "SIGNED_OUT_DESC": "Wylogowano Cię z DM Alerts.", "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.", @@ -1167,6 +1171,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": "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": "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.", @@ -1473,6 +1481,7 @@ "GROUP_COMMANDS": "Komendy", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", + "GROUP_OIDC": "Zewnętrzne SSO", "GROUP_MAPS_ASSETS": "Mapy i zasoby", "GROUP_ANALYTICS_LINKS": "Analityka i linki", "GROUP_DEBUG": "Debugowanie", @@ -1545,6 +1554,27 @@ "TELEGRAM_BOT_DESC": "Nazwa użytkownika bota Telegram (bez @).", "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", 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 891469bb..65a9188e 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 @@ -55,6 +55,7 @@ "ACCENT_THEME": "Tema de Destaque", "LANGUAGE": "Idioma", "LOGOUT": "Sair", + "LOGOUT_EVERYWHERE": "Sair de todos os lugares", "ACCENT_DEFAULT": "Padrão", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1152,6 +1153,9 @@ "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", + "SIGN_IN_OIDC": "Entrar com {{provider}}", + "SIGNED_OUT_TITLE": "Sessão encerrada", + "SIGNED_OUT_DESC": "Você saiu do Alertas DM.", "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.", @@ -1167,6 +1171,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": "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": "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.", @@ -1545,6 +1553,28 @@ "TELEGRAM_BOT_DESC": "Nome de usuário do bot do Telegram (sem @).", "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", 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 632b97d2..634e22be 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Tema de Destaque", "LANGUAGE": "Idioma", "LOGOUT": "Sair", + "LOGOUT_EVERYWHERE": "Terminar sessão em todo o lado", "ACCENT_DEFAULT": "Predefinição", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1172,6 +1173,13 @@ "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", "ERR_GENERIC": "Erro de autenticação: {{error}}", "ERR_NO_TOKEN": "Nenhum token de autenticação recebido.", + "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": "Sign Up", "SIGN_UP_DESC": "Don't have an account? Sign up to get started." }, @@ -1460,6 +1468,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", 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 da062188..24941a85 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -55,6 +55,7 @@ "ACCENT_THEME": "Accenttema", "LANGUAGE": "Språk", "LOGOUT": "Logga ut", + "LOGOUT_EVERYWHERE": "Logga ut överallt", "ACCENT_DEFAULT": "Standard", "ACCENT_POKEMON": "Pokemon", "ACCENT_RAIDS": "Raids", @@ -1172,6 +1173,13 @@ "ERR_TOKEN_EXCHANGE": "Discord authentication failed. Please try again.", "ERR_GENERIC": "Autentiseringsfel: {{error}}", "ERR_NO_TOKEN": "Ingen autentiseringstoken mottagen.", + "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": "Sign Up", "SIGN_UP_DESC": "Don't have an account? Sign up to get started." }, @@ -1473,6 +1481,7 @@ "GROUP_COMMANDS": "Kommandon", "GROUP_TELEGRAM": "Telegram", "GROUP_DISCORD": "Discord", + "GROUP_OIDC": "Extern SSO", "GROUP_MAPS_ASSETS": "Kartor & resurser", "GROUP_ANALYTICS_LINKS": "Analys & länkar", "GROUP_DEBUG": "Felsökning", @@ -1545,6 +1554,27 @@ "TELEGRAM_BOT_DESC": "Telegram-bot-användarnamn (utan @).", "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", diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a61fb0..2eddf0f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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 +- **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. + ## [2.11.1] - 2026-06-05 ### Fixed From e32b796a9712dfadef056edde593341c6a352217 Mon Sep 17 00:00:00 2001 From: HokiePokeDad Date: Mon, 8 Jun 2026 11:56:12 -0400 Subject: [PATCH 53/59] feat(admin-settings): search, sticky save, collapsible sections, positive toggles (#332) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(admin-settings): search, sticky save, collapsible sections, positive toggles Reworks the admin Server Settings page for clarity and scannability. - Live search/filter: sticky search bar that filters settings across all sections with highlighting; '/' or Ctrl/Cmd+K focuses it, Esc clears. - Sticky save + discard bar: replaces the header-only save button; appears with the unsaved count and a Discard (revert to loaded values) action whenever there are pending changes, so saving is always reachable on the long page. - Regrouped auth: Telegram and Discord now sit directly under the Authentication section so all sign-in config is together. - Collapsible sections (persisted in localStorage) with a per-section "unsaved" chip and a state summary in the header (e.g. "7 of 9 enabled"). - Positive toggles: the alarm-type/feature toggles were a confusing double negative (ON = "Disable X" = feature off) mixed with positive enable_* toggles. They are now uniformly positive — ON = enabled, labels are the feature name ("Pokémon", "Areas", …), descriptions are "Let users …". The stored disable_* keys are UNCHANGED (presentation-only inversion), so backend feature-gating is unaffected. - Polish: staggered fade-in on load (respects prefers-reduced-motion) and a subtle per-section color tint. - i18n: new ADMIN_SETTINGS UX keys and the reframed (positive) alarm/feature labels+descriptions translated across all 11 locales. ng build + prettier + eslint clean. * docs(changelog): note admin settings UX overhaul --- .../admin/admin-settings.component.html | 494 ++++++++++-------- .../admin/admin-settings.component.scss | 143 ++++- .../modules/admin/admin-settings.component.ts | 214 ++++++-- .../ClientApp/src/assets/i18n/da.json | 74 +-- .../ClientApp/src/assets/i18n/de.json | 76 +-- .../ClientApp/src/assets/i18n/en.json | 76 +-- .../ClientApp/src/assets/i18n/es.json | 76 +-- .../ClientApp/src/assets/i18n/fr.json | 76 +-- .../ClientApp/src/assets/i18n/it.json | 76 +-- .../ClientApp/src/assets/i18n/nl.json | 76 +-- .../ClientApp/src/assets/i18n/pl.json | 76 +-- .../ClientApp/src/assets/i18n/pt-BR.json | 76 +-- .../ClientApp/src/assets/i18n/pt.json | 76 +-- .../ClientApp/src/assets/i18n/sv.json | 76 +-- CHANGELOG.md | 1 + 15 files changed, 1057 insertions(+), 629 deletions(-) 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 1347a47a..1738c050 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,16 +3,6 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

{{ 'ADMIN.SETTINGS_DESC' | translate }}

- @if (modifiedSettings().size > 0) { - - }
@@ -21,6 +11,30 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

} @else { + + +
@@ -182,202 +196,221 @@

{{ 'ADMIN.SETTINGS_TITLE' | translate }}

- @for (group of visibleGroups(); track group.labelKey; let last = $last) { -
-
+ @for (group of visibleGroups(); track group.labelKey; let last = $last; let i = $index) { +
+
+ @if (groupSummary(group)) { + {{ groupSummary(group) }} + } + @if (groupModifiedCount(group) > 0) { + {{ 'ADMIN_SETTINGS.UNSAVED_CHANGES' | translate: { count: groupModifiedCount(group) } }} + } + + {{ isCollapsed(group.labelKey) ? 'expand_more' : 'expand_less' }} + -
- @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 (!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 (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 (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 - -
- } -
-
- dns - {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} - - {{ 'ADMIN.READ_ONLY' | translate }} - -
-
-
-
- {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_DESC' | translate }} -
-
- {{ telegramConfig()!.enabled ? 'true' : 'false' }} -
+ @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && telegramConfig()) { + @if (!telegramConfig()!.enabled) { +
+ info_outline +
- -
-
- {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_DESC' | translate }} -
-
- {{ - telegramConfig()!.botToken || ('ADMIN.NOT_CONFIGURED' | translate) - }} -
+ } +
+
+ dns + {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} + + {{ 'ADMIN.READ_ONLY' | translate }} +
- -
-
- {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_USERNAME_DESC' | translate }} +
+
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_ENV_ENABLED_DESC' | translate }} +
+
+ {{ telegramConfig()!.enabled ? 'true' : 'false' }} +
-
- {{ - telegramConfig()!.botUsername || ('ADMIN.NOT_CONFIGURED' | translate) - }} + +
+
+ {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.TELEGRAM_BOT_TOKEN_DESC' | translate }} +
+
+ {{ + telegramConfig()!.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) + }} +
-
- } + } - @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) - }} -
+ @if (group.labelKey === 'ADMIN_SETTINGS.GROUP_DISCORD' && discordConfig()) { +
+
+ dns + {{ 'ADMIN_SETTINGS.SERVER_CONFIG' | translate }} + + {{ 'ADMIN.READ_ONLY' | translate }} +
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_SECRET_DESC' | translate }} -
-
- {{ - discordConfig()!.clientSecret || ('ADMIN.NOT_CONFIGURED' | translate) - }} +
+
+
+ {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_LABEL' | translate }} + {{ 'ADMIN_SETTINGS.DISCORD_CLIENT_ID_DESC' | translate }} +
+
+ {{ + discordConfig()!.clientId || ('ADMIN.NOT_CONFIGURED' | translate) + }} +
-
- -
-
- {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_LABEL' | translate }} - {{ 'ADMIN_SETTINGS.DISCORD_BOT_TOKEN_DESC' | translate }} -
-
- {{ - discordConfig()!.botToken || ('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_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) + }} +
-
+ } }
@@ -387,47 +420,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) {
@@ -453,5 +488,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 70a867af..a5a5ac2d 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 { @@ -364,6 +364,147 @@ margin-top: 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; 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 f9bd7fe2..69af248a 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,4 +1,15 @@ -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'; @@ -44,6 +55,38 @@ interface SettingGroup { } 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', @@ -254,38 +297,6 @@ 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: '#2e7d32', icon: 'map', @@ -359,6 +370,8 @@ 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', @@ -374,6 +387,7 @@ export class AdminSettingsComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); private readonly dialog = inject(MatDialog); + private readonly i18n = inject(I18nService); private readonly internalPrefixes = [ @@ -393,6 +407,8 @@ export class AdminSettingsComponent implements OnInit { 'migration_completed', ]; + private readonly originalSnapshot = signal([]); + readonly settings = signal([]); private readonly settingMap = computed(() => { @@ -402,12 +418,15 @@ 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 bulkSaving = signal(false); + readonly collapsedGroups = signal>(AdminSettingsComponent.loadCollapsed()); readonly discordConfig = signal(null); - readonly iconRepos = [ { name: 'Whitewillem (Ingame)', @@ -479,6 +498,10 @@ export class AdminSettingsComponent implements OnInit { /** 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'); + @ViewChild('searchInput') searchInput?: ElementRef; + + readonly searchQuery = signal(''); + readonly settingsLoading = signal(true); readonly telegramConfig = signal(null); @@ -494,7 +517,8 @@ export class AdminSettingsComponent implements OnInit { // 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'; - return SETTING_GROUPS.filter(g => { + const query = this.searchQuery().trim(); + const base = SETTING_GROUPS.filter(g => { if (oidcMode && localProviderGroups.has(g.labelKey)) return false; return ( g.settings.some(s => this.settingMap().has(s.key)) || @@ -502,8 +526,32 @@ export class AdminSettingsComponent implements OnInit { (g.labelKey === 'ADMIN_SETTINGS.GROUP_TELEGRAM' && this.telegramConfig() !== null) ); }); + 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 { + return this.isInverted(meta) ? !this.getBool(meta.key) : this.getBool(meta.key); + } + getBool(key: string): boolean { return (this.getSettingValue(key) ?? '').toLowerCase() === 'true'; } @@ -513,6 +561,46 @@ 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). + const count = disableKeys.reduce((acc, s) => acc + (this.getBool(s.key) ? 0 : 1), 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()); @@ -539,6 +627,7 @@ export class AdminSettingsComponent implements OnInit { }, next: settings => { this.settings.set(settings); + this.originalSnapshot.set(settings.map(s => ({ ...s }))); this.settingsLoading.set(false); }, }); @@ -563,6 +652,30 @@ export class AdminSettingsComponent implements OnInit { 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'); @@ -658,6 +771,18 @@ export class AdminSettingsComponent implements OnInit { 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); @@ -671,8 +796,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 }) @@ -681,4 +818,11 @@ 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 }); } + + 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/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index cd7dcfce..de2b453f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -1474,6 +1474,14 @@ "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", @@ -1504,39 +1512,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Deaktivér brugerdefinerede geofences", - "DISABLE_USER_GEOFENCES_DESC": "Forhindrer brugere i at tegne, importere eller indsende deres egne geofences. Eksisterende geofences fungerer fortsat.", - "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_GEOMAP_LABEL": "Kortvisning", + "DISABLE_GEOMAP_DESC": "Vis det interaktive geofence-kort.", + "DISABLE_GEOMAP_SELECT_LABEL": "Områdevalg på kort", + "DISABLE_GEOMAP_SELECT_DESC": "Lad brugere vælge områder ved at klikke på kortet.", + "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.", 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 2caecb93..6b05bcd0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -1503,39 +1503,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Eigene Geofences deaktivieren", - "DISABLE_USER_GEOFENCES_DESC": "Hindert Benutzer daran, eigene Geofences zu zeichnen, zu importieren oder einzureichen. Bestehende Geofences bleiben aktiv.", - "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_GEOMAP_LABEL": "Kartenansicht", + "DISABLE_GEOMAP_DESC": "Die interaktive Geofence-Karte anzeigen.", + "DISABLE_GEOMAP_SELECT_LABEL": "Gebietsauswahl auf Karte", + "DISABLE_GEOMAP_SELECT_DESC": "Benutzern erlauben, Gebiete durch Klicken auf die Karte auszuwählen.", + "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.", @@ -1606,7 +1606,15 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Name", 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 bfeb0e4e..de4ab4f7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -1505,39 +1505,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Disable Custom Geofences", - "DISABLE_USER_GEOFENCES_DESC": "Stop users from drawing, importing, or submitting their own geofences. Existing geofences keep working.", - "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_GEOMAP_LABEL": "Map View", + "DISABLE_GEOMAP_DESC": "Show the interactive geofence map.", + "DISABLE_GEOMAP_SELECT_LABEL": "Map Area Selection", + "DISABLE_GEOMAP_SELECT_DESC": "Let users select areas by clicking the map.", + "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.", @@ -1618,7 +1618,15 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Name", 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 41ca9422..8c4f08bd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -1504,39 +1504,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Desactivar geofences personalizadas", - "DISABLE_USER_GEOFENCES_DESC": "Impide que los usuarios dibujen, importen o envíen sus propias geofences. Las geofences existentes siguen funcionando.", - "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_GEOMAP_LABEL": "Vista de mapa", + "DISABLE_GEOMAP_DESC": "Muestra el mapa interactivo de geocercas.", + "DISABLE_GEOMAP_SELECT_LABEL": "Selección de áreas en el mapa", + "DISABLE_GEOMAP_SELECT_DESC": "Permite a los usuarios seleccionar áreas haciendo clic en el mapa.", + "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.", @@ -1606,7 +1606,15 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Nombre", 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 7527f649..8f142786 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -1504,39 +1504,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Désactiver les Geofences personnalisées", - "DISABLE_USER_GEOFENCES_DESC": "Empêche les utilisateurs de dessiner, importer ou soumettre leurs propres Geofences. Les Geofences existantes continuent de fonctionner.", - "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_GEOMAP_LABEL": "Vue carte", + "DISABLE_GEOMAP_DESC": "Afficher la carte interactive des geofences.", + "DISABLE_GEOMAP_SELECT_LABEL": "Sélection de zones sur la carte", + "DISABLE_GEOMAP_SELECT_DESC": "Autoriser les utilisateurs à sélectionner des zones en cliquant sur la carte.", + "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.", @@ -1606,7 +1606,15 @@ "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)" }, "GEOFENCE_DETAIL": { "NAME": "Nom", 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 e5de602d..b2b54be6 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -1503,39 +1503,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Disabilita le geofence personalizzate", - "DISABLE_USER_GEOFENCES_DESC": "Impedisce agli utenti di disegnare, importare o inviare le proprie geofence. Le geofence esistenti continuano a funzionare.", - "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_GEOMAP_LABEL": "Vista mappa", + "DISABLE_GEOMAP_DESC": "Mostra la mappa interattiva dei geofence.", + "DISABLE_GEOMAP_SELECT_LABEL": "Selezione aree dalla mappa", + "DISABLE_GEOMAP_SELECT_DESC": "Consenti agli utenti di selezionare aree cliccando sulla mappa.", + "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.", @@ -1606,7 +1606,15 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Nome", 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 63f8e8a4..3d195951 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -1504,39 +1504,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Eigen geofences uitschakelen", - "DISABLE_USER_GEOFENCES_DESC": "Voorkomt dat gebruikers eigen geofences tekenen, importeren of indienen. Bestaande geofences blijven werken.", - "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_GEOMAP_LABEL": "Kaartweergave", + "DISABLE_GEOMAP_DESC": "Toon de interactieve geofence-kaart.", + "DISABLE_GEOMAP_SELECT_LABEL": "Gebiedsselectie op kaart", + "DISABLE_GEOMAP_SELECT_DESC": "Laat gebruikers gebieden selecteren door op de kaart te klikken.", + "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.", @@ -1606,7 +1606,15 @@ "OIDC_CLIENT_ID_LABEL": "Client-ID", "OIDC_SCOPES_LABEL": "Scopes", "OIDC_IDENTITY_CLAIM_LABEL": "Identity claim", - "OIDC_USE_PKCE_LABEL": "PKCE gebruiken" + "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" }, "GEOFENCE_DETAIL": { "NAME": "Naam", 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 5e06745f..9fc9668f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -1504,39 +1504,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Wyłącz własne geofence", - "DISABLE_USER_GEOFENCES_DESC": "Uniemożliwia użytkownikom rysowanie, importowanie i zgłaszanie własnych geofence. Istniejące geofence nadal działają.", - "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_GEOMAP_LABEL": "Widok mapy", + "DISABLE_GEOMAP_DESC": "Pokaż interaktywną mapę geofence.", + "DISABLE_GEOMAP_SELECT_LABEL": "Wybór obszarów na mapie", + "DISABLE_GEOMAP_SELECT_DESC": "Pozwól użytkownikom wybierać obszary kliknięciem na mapie.", + "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.", @@ -1606,7 +1606,15 @@ "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}}" }, "GEOFENCE_DETAIL": { "NAME": "Nazwa", 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 65a9188e..e5b48df1 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 @@ -1503,39 +1503,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Desativar geofences personalizadas", - "DISABLE_USER_GEOFENCES_DESC": "Impede que os usuários desenhem, importem ou enviem suas próprias geofences. As geofences existentes continuam funcionando.", - "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_GEOMAP_LABEL": "Visualização de mapa", + "DISABLE_GEOMAP_DESC": "Mostra o mapa interativo de geofences.", + "DISABLE_GEOMAP_SELECT_LABEL": "Seleção de áreas no mapa", + "DISABLE_GEOMAP_SELECT_DESC": "Permite que os usuários selecionem áreas clicando no mapa.", + "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.", @@ -1606,7 +1606,15 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Nome", 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 634e22be..4df02e07 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -1525,39 +1525,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Desativar geofences personalizadas", - "DISABLE_USER_GEOFENCES_DESC": "Impede que os utilizadores desenhem, importem ou submetam as suas próprias geofences. As geofences existentes continuam a funcionar.", - "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_GEOMAP_LABEL": "Vista de mapa", + "DISABLE_GEOMAP_DESC": "Mostrar o mapa interativo de geofences.", + "DISABLE_GEOMAP_SELECT_LABEL": "Seleção de áreas no mapa", + "DISABLE_GEOMAP_SELECT_DESC": "Permitir que os utilizadores selecionem áreas ao clicar no mapa.", + "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.", @@ -1606,7 +1606,15 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Nome", 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 24941a85..f949b64a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -1504,39 +1504,39 @@ "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.", - "DISABLE_USER_GEOFENCES_LABEL": "Inaktivera egna geofences", - "DISABLE_USER_GEOFENCES_DESC": "Hindrar användare från att rita, importera eller skicka in egna geofences. Befintliga geofences fortsätter att fungera.", - "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_GEOMAP_LABEL": "Kartvy", + "DISABLE_GEOMAP_DESC": "Visa den interaktiva geofence-kartan.", + "DISABLE_GEOMAP_SELECT_LABEL": "Områdesval på karta", + "DISABLE_GEOMAP_SELECT_DESC": "Låt användare välja områden genom att klicka på kartan.", + "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.", @@ -1606,7 +1606,15 @@ "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" }, "GEOFENCE_DETAIL": { "NAME": "Namn", diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eddf0f7..ae9d5a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **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. ## [2.11.1] - 2026-06-05 From fccd388d32b578779e506fe1f9b173798a9f6f4a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:03:47 -0400 Subject: [PATCH 54/59] ci: bump docker/build-push-action from 5 to 7 (#333) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 7. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/v5...v7) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker-preview.yml | 2 +- .github/workflows/docker-publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml index 495eb217..8d6d82f3 100644 --- a/.github/workflows/docker-preview.yml +++ b/.github/workflows/docker-preview.yml @@ -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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 15f2ab82..d90d5c3b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: type=sha,prefix=,enable=${{ github.event_name == 'release' }} - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7 with: context: . push: true From 6f2f8f4951a7060da3e09f80c5cbc39cc781466c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:03:50 -0400 Subject: [PATCH 55/59] ci: bump actions/setup-python from 5 to 6 (#334) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2f4bd6fe..c4860e2c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.12' From 652d61cbdfdd733f6f5f1ada4109569e6ed01bb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:03:54 -0400 Subject: [PATCH 56/59] ci: bump actions/create-github-app-token from 2 to 3 (#335) Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 2 to 3. - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/create-github-app-token/compare/v2...v3) --- updated-dependencies: - dependency-name: actions/create-github-app-token dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-changelog.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index a9c968fa..594bcf12 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -57,7 +57,7 @@ jobs: - name: Generate GitHub App token id: app-token if: steps.cfg.outputs.has_app == 'true' - uses: actions/create-github-app-token@v2 + uses: actions/create-github-app-token@v3 with: app-id: ${{ secrets.CHANGELOG_APP_ID }} private-key: ${{ secrets.CHANGELOG_APP_PRIVATE_KEY }} From 166373aba4b8f7655d3f18c3f6cd6b86bbc05af8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:03:58 -0400 Subject: [PATCH 57/59] ci: bump dependabot/fetch-metadata from 2 to 3 (#336) Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 2 to 3. - [Release notes](https://github.com/dependabot/fetch-metadata/releases) - [Commits](https://github.com/dependabot/fetch-metadata/compare/v2...v3) --- updated-dependencies: - dependency-name: dependabot/fetch-metadata dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/auto-merge-deps.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-merge-deps.yml b/.github/workflows/auto-merge-deps.yml index 8649e3c5..7c43efa2 100644 --- a/.github/workflows/auto-merge-deps.yml +++ b/.github/workflows/auto-merge-deps.yml @@ -27,7 +27,7 @@ jobs: - 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: From 2ce2bd9df76057f4838d03394efdb6b4e13577c3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:04:02 -0400 Subject: [PATCH 58/59] ci: bump actions/cache from 4 to 5 (#337) Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/cache dependency-version: '5' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771ea3fa..bd53fbbd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: dotnet-version: '10.0.x' - name: Cache NuGet packages - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.nuget/packages key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', '**/*.slnx') }} From 5ce2e00be09080ac05590e61e01d1afa0bdd7aff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:10:41 -0400 Subject: [PATCH 59/59] deps: bump the angular group across 1 directory with 13 updates (#338) Bumps the angular group with 13 updates in the /Applications/Pgan.PoracleWebNet.App/ClientApp directory: | Package | From | To | | --- | --- | --- | | [@angular/animations](https://github.com/angular/angular/tree/HEAD/packages/animations) | `21.2.15` | `21.2.16` | | [@angular/cdk](https://github.com/angular/components) | `21.2.13` | `21.2.14` | | [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `21.2.15` | `21.2.16` | | [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `21.2.15` | `21.2.16` | | [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `21.2.15` | `21.2.16` | | [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `21.2.15` | `21.2.16` | | [@angular/material](https://github.com/angular/components) | `21.2.13` | `21.2.14` | | [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `21.2.15` | `21.2.16` | | [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `21.2.15` | `21.2.16` | | [@angular/build](https://github.com/angular/angular-cli) | `21.2.13` | `21.2.14` | | [@angular/cli](https://github.com/angular/angular-cli) | `21.2.13` | `21.2.14` | | [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `21.2.15` | `21.2.16` | | [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `21.2.15` | `21.2.16` | Updates `@angular/animations` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/animations) Updates `@angular/cdk` from 21.2.13 to 21.2.14 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.13...v21.2.14) Updates `@angular/common` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/common) Updates `@angular/compiler` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/compiler) Updates `@angular/core` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/core) Updates `@angular/forms` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/forms) Updates `@angular/material` from 21.2.13 to 21.2.14 - [Release notes](https://github.com/angular/components/releases) - [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/components/compare/v21.2.13...v21.2.14) Updates `@angular/platform-browser` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/platform-browser) Updates `@angular/router` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/router) Updates `@angular/build` from 21.2.13 to 21.2.14 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.13...v21.2.14) Updates `@angular/cli` from 21.2.13 to 21.2.14 - [Release notes](https://github.com/angular/angular-cli/releases) - [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular-cli/compare/v21.2.13...v21.2.14) Updates `@angular/compiler-cli` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/compiler-cli) Updates `@angular/platform-browser-dynamic` from 21.2.15 to 21.2.16 - [Release notes](https://github.com/angular/angular/releases) - [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md) - [Commits](https://github.com/angular/angular/commits/v21.2.16/packages/platform-browser-dynamic) --- updated-dependencies: - dependency-name: "@angular/animations" dependency-version: 21.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/build" dependency-version: 21.2.14 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cdk" dependency-version: 21.2.14 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/cli" dependency-version: 21.2.14 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/common" dependency-version: 21.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler" dependency-version: 21.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/compiler-cli" dependency-version: 21.2.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/core" dependency-version: 21.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/forms" dependency-version: 21.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/material" dependency-version: 21.2.14 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser" dependency-version: 21.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/platform-browser-dynamic" dependency-version: 21.2.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: angular - dependency-name: "@angular/router" dependency-version: 21.2.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: angular ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../ClientApp/package-lock.json | 200 +++++++++--------- .../ClientApp/package.json | 26 +-- 2 files changed, 113 insertions(+), 113 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json index cc400142..f5772701 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package-lock.json @@ -8,15 +8,15 @@ "name": "client-app", "version": "0.0.0", "dependencies": { - "@angular/animations": "^21.2.15", - "@angular/cdk": "^21.2.13", - "@angular/common": "^21.2.15", - "@angular/compiler": "^21.2.15", - "@angular/core": "^21.2.15", - "@angular/forms": "^21.2.15", - "@angular/material": "^21.2.13", - "@angular/platform-browser": "^21.2.15", - "@angular/router": "^21.2.15", + "@angular/animations": "^21.2.16", + "@angular/cdk": "^21.2.14", + "@angular/common": "^21.2.16", + "@angular/compiler": "^21.2.16", + "@angular/core": "^21.2.16", + "@angular/forms": "^21.2.16", + "@angular/material": "^21.2.14", + "@angular/platform-browser": "^21.2.16", + "@angular/router": "^21.2.16", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -33,10 +33,10 @@ "@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.13", - "@angular/cli": "^21.2.13", - "@angular/compiler-cli": "^21.2.15", - "@angular/platform-browser-dynamic": "^21.2.15", + "@angular/build": "^21.2.14", + "@angular/cli": "^21.2.14", + "@angular/compiler-cli": "^21.2.16", + "@angular/platform-browser-dynamic": "^21.2.16", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.60.1", @@ -348,9 +348,9 @@ } }, "node_modules/@angular-devkit/core": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.13.tgz", - "integrity": "sha512-9jLaHcUr6BumIY9nCsBib1q62p259nf++gd2igYJ7mLm1w/0wEacsZ1cC8wCGEe6vx8a+DrD+EVCQ6zivePG2A==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.14.tgz", + "integrity": "sha512-RSOWXB9bFc2nwRWMxbIT0RbSNFUrwfBo4N5MNxbyQ69Ndc0gVm3h+3ArHv0qotH4d+pJYbm5ttXu8YqR2kc0CA==", "dev": true, "license": "MIT", "dependencies": { @@ -643,9 +643,9 @@ } }, "node_modules/@angular/animations": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.15.tgz", - "integrity": "sha512-Z8AsLTwc++Fcu0fJnclAF9zMfumAd5KXrwtSdyECqLpqd+lEmmsOpeOl6P7loqdDz99KYh/8UF4eJxdMvnsaKw==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.16.tgz", + "integrity": "sha512-YPhph/OC1A0vkT95XZW6lXMNmi5ly91JeXi+5yeG8CCxfqscVfRNPsYbRWjSueO0cQT2HJ8U1CLteQ5a1OaoHA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -654,18 +654,18 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.15" + "@angular/core": "21.2.16" } }, "node_modules/@angular/build": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.13.tgz", - "integrity": "sha512-Y9TDAaTQ+E5LScCKA/hPZmns/7Mpu6J2BiPj2cETA1xNjvgRpeb5Mh32KuhZb20NSFLvjpdnLuBTTtbym7hevw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.14.tgz", + "integrity": "sha512-l8JB326iIwum2WmbopUUFdiuYsbHchix6MH8o6F6FA7LJr8QLTvipwwbw+Jx31/RE50WkGmzsZ1fBDw/cMbmUw==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2102.13", + "@angular-devkit/architect": "0.2102.14", "@babel/core": "7.29.0", "@babel/helper-annotate-as-pure": "7.27.3", "@babel/helper-split-export-declaration": "7.24.7", @@ -708,7 +708,7 @@ "@angular/platform-browser": "^21.0.0", "@angular/platform-server": "^21.0.0", "@angular/service-worker": "^21.0.0", - "@angular/ssr": "^21.2.13", + "@angular/ssr": "^21.2.14", "karma": "^6.4.0", "less": "^4.2.0", "ng-packagr": "^21.0.0", @@ -758,13 +758,13 @@ } }, "node_modules/@angular/build/node_modules/@angular-devkit/architect": { - "version": "0.2102.13", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.13.tgz", - "integrity": "sha512-fheyi0gPx6b7tT+WQ+ePlzdGqKjPLUK72wg5Z9pkVtQ5+VN/8yB9mlRlmoivngd2FeNG9wMeNynWZGYycnOWVw==", + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.14.tgz", + "integrity": "sha512-0+vjVsCkMyJdVjz5XkPW+Bdf/9TI8V2voomx/+o0o+oOaqqiEhptQWFnaIlLr7HasjB0LxXK5P9L0oQ61vxj8Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.13", + "@angular-devkit/core": "21.2.14", "rxjs": "7.8.2" }, "bin": { @@ -800,9 +800,9 @@ } }, "node_modules/@angular/cdk": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.2.13.tgz", - "integrity": "sha512-nQGGJ6Efqi8n0qhT/PllsaIIY+vz+TL7/tpR7F2QKiqzS/9l4m7ea0vvS6fSMGrjEbqbkzTHbjLDsIg6X2hK+w==", + "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": { "parse5": "^8.0.0", @@ -816,19 +816,19 @@ } }, "node_modules/@angular/cli": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.13.tgz", - "integrity": "sha512-j1kOV/f0og/3xCwG7Y8RyPd6V7uYfX2NuvXbvN1mzgxLLN2mu6CTsvPg5l/9Pu9SJI3KOPRgDxWyuP3k8KuzMg==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.14.tgz", + "integrity": "sha512-S8jExTjxPJILwpg2lu3DohSASVZ8DLhSNCmOe7z0qF9VskRSjC7SIQv1rq36tsJkenxuA72gjVOHZv+uSRT8HA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2102.13", - "@angular-devkit/core": "21.2.13", - "@angular-devkit/schematics": "21.2.13", + "@angular-devkit/architect": "0.2102.14", + "@angular-devkit/core": "21.2.14", + "@angular-devkit/schematics": "21.2.14", "@inquirer/prompts": "7.10.1", "@listr2/prompt-adapter-inquirer": "3.0.5", "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.13", + "@schematics/angular": "21.2.14", "@yarnpkg/lockfile": "1.1.0", "algoliasearch": "5.48.1", "ini": "6.0.0", @@ -851,13 +851,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { - "version": "0.2102.13", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.13.tgz", - "integrity": "sha512-fheyi0gPx6b7tT+WQ+ePlzdGqKjPLUK72wg5Z9pkVtQ5+VN/8yB9mlRlmoivngd2FeNG9wMeNynWZGYycnOWVw==", + "version": "0.2102.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.14.tgz", + "integrity": "sha512-0+vjVsCkMyJdVjz5XkPW+Bdf/9TI8V2voomx/+o0o+oOaqqiEhptQWFnaIlLr7HasjB0LxXK5P9L0oQ61vxj8Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.13", + "@angular-devkit/core": "21.2.14", "rxjs": "7.8.2" }, "bin": { @@ -870,13 +870,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.13.tgz", - "integrity": "sha512-gifpOcMNiAy49lQmQKhzpxoSfS3qJQSEdJSF5m7RVFkAcmllfcCD76GPN4dhho3wdAnbZ3qr54LtDqrGY4xNjw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.14.tgz", + "integrity": "sha512-KMJlQSBEzI4+Cy1Zh72gmGQNN2I1vY+nj9CoRcZPBIi1si+0ZAc49XT85eYl+eQumNTVQviUG7LQqgLDAHml+g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.13", + "@angular-devkit/core": "21.2.14", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", @@ -1050,9 +1050,9 @@ } }, "node_modules/@angular/common": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.15.tgz", - "integrity": "sha512-PHbICQe4YCXnax2FcmKUpiffs8XPW9A0KlZF35qgJoQyBMBZx5F8c8geCh25jxtq77n3eBTmOa/WIAdSqiitkQ==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-21.2.16.tgz", + "integrity": "sha512-htHNepKzjIjkc5BQ7MKDN0bVDOfQpFr/fGUxa6irC0kFLfWt7idUTdNcxypRvjCCTuBYHkjr74fH4QKu+qvPXg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1061,14 +1061,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "21.2.15", + "@angular/core": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.15.tgz", - "integrity": "sha512-nwpNb+NbVUNzR3cck0QXbU/oFK7BpmXOXVnN/w7+P4+TsFUYeTtO1Ojbc15jkqe6mSM0lBvGlcoztVblHQkqcw==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-21.2.16.tgz", + "integrity": "sha512-hVjp93gYgNj5aRbCQUK7L+pOfdqk96lCtmSL2hOL725Pmib9NyNIrA3ISfAQHN+Qo70763WUZahOiqBBOzfAcg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1078,9 +1078,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.15.tgz", - "integrity": "sha512-/MU7OA9d/e9P5SthR+N6JJObBmzcGsgNQaeQ2YfSUnU0lCRVQweTWwxLFDbfU6UX8MZFWB6pdI57zod8r5kXUw==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-21.2.16.tgz", + "integrity": "sha512-w2ck3o+uw29AZEGK3HvOsF/ZRiPcfoq2TaDtiNjdH+svhwawt9PfMXrDbbIKF30prWzKLpT3UsCqTz1awv7Ubw==", "dev": true, "license": "MIT", "dependencies": { @@ -1101,7 +1101,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.15", + "@angular/compiler": "21.2.16", "typescript": ">=5.9 <6.1" }, "peerDependenciesMeta": { @@ -1111,9 +1111,9 @@ } }, "node_modules/@angular/core": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.15.tgz", - "integrity": "sha512-J5JsUnNtQURdeA7EA3DoCsMBizW3l01gfqM326Al72Ou3woFWmRb5P3LOXpIOzAeMQhO6Z5tW+B1t+4qmoq7uw==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-21.2.16.tgz", + "integrity": "sha512-uufKORlB0jeYdqOvjAfMYgqIqmJentOj8XvTUxsFP5k85xxzXsDarSpP199YQz6jhJJQYNOWIloDkUTQJi5rNA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1122,7 +1122,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "21.2.15", + "@angular/compiler": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -1136,9 +1136,9 @@ } }, "node_modules/@angular/forms": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.15.tgz", - "integrity": "sha512-swGUHgbBrPNvODPR9qBP6+vT2EHiyW361iEgS3HpTmvDhF/kD4l8NE0vh3P5N0DnEtGh4umOCKfQ1w6hPJ7lqA==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-21.2.16.tgz", + "integrity": "sha512-2djTJmTpg/MkQ2kdCI9k0LT4RL9/Hg03fDUNN2eN5c04FIk99D3yHXUJYLwiaErLuLQNkU8HaijluKHdH93cWQ==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1148,22 +1148,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.15", - "@angular/core": "21.2.15", - "@angular/platform-browser": "21.2.15", + "@angular/common": "21.2.16", + "@angular/core": "21.2.16", + "@angular/platform-browser": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/material": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.13.tgz", - "integrity": "sha512-6gWFb9LNh4cRIvkdocktej6MUVuGa9HQvap+j9gbZOtiveD7ER+FByUPlLlypreRebF29G2MRZeshKSdmv4NbA==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.2.14.tgz", + "integrity": "sha512-fMQca8VRtei93JRRG9qQ+u08DCb0nga59Esoakq5yx3+A1NfdpFeUS1tBns56U04o8KAaIAwZK3NBqXz8ZKNqg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": "21.2.13", + "@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", @@ -1172,9 +1172,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.15.tgz", - "integrity": "sha512-O4ZHVV/rxkK1AuiD9M3UssL/HkoQvBcZy2+U421IMNibclGhwH9aRwc/0ZlQ7zpseS9+KPZ23FebvN4/92IbPg==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.2.16.tgz", + "integrity": "sha512-59ToWYDb+O3fS0+Y4ubQqV0zY6sf2esLZ19AT7JKXN7Akqbz7aQ2/3k3PKmfhwKWek5o3lkuNz8YhxKQruNh8Q==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1183,9 +1183,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "21.2.15", - "@angular/common": "21.2.15", - "@angular/core": "21.2.15" + "@angular/animations": "21.2.16", + "@angular/common": "21.2.16", + "@angular/core": "21.2.16" }, "peerDependenciesMeta": { "@angular/animations": { @@ -1194,9 +1194,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.15.tgz", - "integrity": "sha512-3xvlWLZlsWjPyJFGatOOsod/f5AFjmSUDoOXo0zsr2ckHc4TxbDTnkLULhRSWv6m68fKOdQb8Si8rI15gC5yqA==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.2.16.tgz", + "integrity": "sha512-WtTnkJOmKiGccHRQfBdkwODAkpTB4zbPN3IKhcqCjlezKaPqZB5tjrIu72Z5pmi5VIgJz1LmfO1LSVCMC5h7dA==", "dev": true, "license": "MIT", "dependencies": { @@ -1206,16 +1206,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.15", - "@angular/compiler": "21.2.15", - "@angular/core": "21.2.15", - "@angular/platform-browser": "21.2.15" + "@angular/common": "21.2.16", + "@angular/compiler": "21.2.16", + "@angular/core": "21.2.16", + "@angular/platform-browser": "21.2.16" } }, "node_modules/@angular/router": { - "version": "21.2.15", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.15.tgz", - "integrity": "sha512-Cej4hYkmaTB6wXn1xQPlr4O1wHgUD0WLv//Oue1IssKqL8vkzic5f5x/H/bxtxxGlSnc+i6uIUF/lvjdGoWk/A==", + "version": "21.2.16", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.2.16.tgz", + "integrity": "sha512-0+Pyh0uT4vCLabKoGCARYWlwpz4DgZI9AE01n8s9u/nKAZuEMnJtLLnaUtHEMI8nJSqpgnS/5AthuJZdDEfkYw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -1224,9 +1224,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "21.2.15", - "@angular/core": "21.2.15", - "@angular/platform-browser": "21.2.15", + "@angular/common": "21.2.16", + "@angular/core": "21.2.16", + "@angular/platform-browser": "21.2.16", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -5550,14 +5550,14 @@ "license": "MIT" }, "node_modules/@schematics/angular": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.13.tgz", - "integrity": "sha512-e5guslSLKbb3PJ6gUuVqM+V9xgn68cJkG1IyBohho34shbpOeoWW2eYdWQQjxvn0KUdgEhYSRBluBamCHngaUA==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.14.tgz", + "integrity": "sha512-rIEdtNTdCCTwuo7B4tMoq5qmbLXdBgmW6Ays1hyno//4OE+HFtvlWZd+hl6KceEyN00IcZ2HRaPnfd71E1JnoA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.13", - "@angular-devkit/schematics": "21.2.13", + "@angular-devkit/core": "21.2.14", + "@angular-devkit/schematics": "21.2.14", "jsonc-parser": "3.3.1" }, "engines": { @@ -5567,13 +5567,13 @@ } }, "node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": { - "version": "21.2.13", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.13.tgz", - "integrity": "sha512-gifpOcMNiAy49lQmQKhzpxoSfS3qJQSEdJSF5m7RVFkAcmllfcCD76GPN4dhho3wdAnbZ3qr54LtDqrGY4xNjw==", + "version": "21.2.14", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.14.tgz", + "integrity": "sha512-KMJlQSBEzI4+Cy1Zh72gmGQNN2I1vY+nj9CoRcZPBIi1si+0ZAc49XT85eYl+eQumNTVQviUG7LQqgLDAHml+g==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "21.2.13", + "@angular-devkit/core": "21.2.14", "jsonc-parser": "3.3.1", "magic-string": "0.30.21", "ora": "9.3.0", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json index 31af849c..cb3765cd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/package.json @@ -15,15 +15,15 @@ "private": true, "packageManager": "npm@11.5.2", "dependencies": { - "@angular/animations": "^21.2.15", - "@angular/cdk": "^21.2.13", - "@angular/common": "^21.2.15", - "@angular/compiler": "^21.2.15", - "@angular/core": "^21.2.15", - "@angular/forms": "^21.2.15", - "@angular/material": "^21.2.13", - "@angular/platform-browser": "^21.2.15", - "@angular/router": "^21.2.15", + "@angular/animations": "^21.2.16", + "@angular/cdk": "^21.2.14", + "@angular/common": "^21.2.16", + "@angular/compiler": "^21.2.16", + "@angular/core": "^21.2.16", + "@angular/forms": "^21.2.16", + "@angular/material": "^21.2.14", + "@angular/platform-browser": "^21.2.16", + "@angular/router": "^21.2.16", "@ngx-translate/core": "^17.0.0", "@ngx-translate/http-loader": "^17.0.0", "@types/leaflet": "^1.9.21", @@ -40,10 +40,10 @@ "@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.13", - "@angular/cli": "^21.2.13", - "@angular/compiler-cli": "^21.2.15", - "@angular/platform-browser-dynamic": "^21.2.15", + "@angular/build": "^21.2.14", + "@angular/cli": "^21.2.14", + "@angular/compiler-cli": "^21.2.16", + "@angular/platform-browser-dynamic": "^21.2.16", "@jest/globals": "^30.4.1", "@types/jest": "^30.0.0", "@typescript-eslint/eslint-plugin": "^8.60.1",