diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index 99be717a..0ce238a1 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -91,6 +91,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -173,7 +174,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv // Register HttpClient for PoracleNG summary schedule proxy (quest summary delivery) services.AddHttpClient(); - // Register HttpClient for PoracleNG's v2 mute store (quiet periods). The only /api/v2 caller. + // Register HttpClient for PoracleNG's v2 mute store (quiet periods). services.AddHttpClient(); // Register HttpClient for Discord notification service diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs index b6913a9c..8e3e9549 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/LocationController.cs @@ -15,6 +15,7 @@ public class LocationController( IPoracleHumanProxy humanProxy, IPoracleApiProxy poracleApiProxy, IHttpClientFactory httpClientFactory, + IPlaceUpdateCapabilityService placeUpdateCapability, IScannerService? scannerService = null) : BaseApiController { private readonly IHumanService _humanService = humanService; @@ -22,6 +23,7 @@ public class LocationController( private readonly IPoracleHumanProxy _humanProxy = humanProxy; private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + private readonly IPlaceUpdateCapabilityService _placeUpdateCapability = placeUpdateCapability; private readonly IScannerService? _scannerService = scannerService; [HttpGet] @@ -283,9 +285,79 @@ public double? Longitude /// /// The user's saved places, plus the profile pin every alarm falls back to. /// + /// + /// canEdit rides along rather than sitting on its own endpoint, matching + /// MuteController: the list is read on every visit to the Areas page, and a second call for + /// one boolean would double that for no gain. + /// [HttpGet("places")] - public async Task GetPlaces() => - this.Ok(await this._humanProxy.GetPlacesAsync(this.UserId)); + public async Task GetPlaces(CancellationToken cancellationToken) => + this.Ok(await this.PlacesWithCapabilityAsync(cancellationToken)); + + /// + /// The place list in the one shape every caller gets, capability included. The PUT answers it too: + /// a reply missing canEdit would clear the flag the SPA is holding and hide the control the user + /// just used. + /// + private async Task PlacesWithCapabilityAsync(CancellationToken cancellationToken) + { + var places = await this._humanProxy.GetPlacesAsync(this.UserId); + + return new + { + places.Default, + places.Named, + canEdit = await this._placeUpdateCapability.IsPlaceUpdateAvailableAsync(cancellationToken), + }; + } + + /// + /// Moves a saved place, keeping its label so every alarm pointing at it follows. + /// + /// + /// New on PoracleNG 5.2.0. Before it, a place an alarm referenced could not be moved at all: the + /// delete answers 409 while anything still points at it, so the only route was to repoint every + /// alarm, delete, re-add and repoint back. An older server answers 501 and the SPA hides the control. + /// + [HttpPut("places/{label}")] + public async Task UpdatePlace( + string label, [FromBody] PlaceMoveRequest request, CancellationToken cancellationToken = default) + { + if (request.Latitude is not { } latitude || request.Longitude is not { } longitude) + { + return this.BadRequest(new { error = "Latitude and longitude are required." }); + } + + if (latitude is < -90 or > 90 || longitude is < -180 or > 180) + { + return this.BadRequest(new { error = "Latitude must be -90 to 90 and longitude -180 to 180." }); + } + + var moved = await this._humanProxy.UpdatePlaceAsync(this.UserId, label, latitude, longitude); + + return moved + ? this.Ok(await this.PlacesWithCapabilityAsync(cancellationToken)) + : this.StatusCode(StatusCodes.Status501NotImplemented, new + { + error = "This Poracle server cannot move a saved place. Delete it and add it again.", + }); + } + + /// New coordinates for a saved place. The label is the path segment and does not change. + public class PlaceMoveRequest + { + [Range(-90, 90)] + public double? Latitude + { + get; set; + } + + [Range(-180, 180)] + public double? Longitude + { + get; set; + } + } /// /// Saves a place an alarm can be anchored to. @@ -295,12 +367,16 @@ public async Task GetPlaces() => /// refusal is unwrapped here and returned as a 400 the SPA can show against the field. /// [HttpPost("places")] - public async Task AddPlace([FromBody] SavedPlace place) + public async Task AddPlace( + [FromBody] SavedPlace place, CancellationToken cancellationToken = default) { var refusal = await this._humanProxy.AddPlaceAsync(this.UserId, place); + // Answers the same shape as the GET and the PUT. The SPA replaces its whole places signal + // from this reply, so a body without canEdit cleared the flag and took the edit control off + // every card until the next reload -- the hazard already noted on the PUT, one path along. return refusal is null - ? this.Ok(await this._humanProxy.GetPlacesAsync(this.UserId)) + ? this.Ok(await this.PlacesWithCapabilityAsync(cancellationToken)) : this.BadRequest(new { error = refusal }); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/NotificationLanguageController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/NotificationLanguageController.cs index 8786b205..d081fc9e 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/NotificationLanguageController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/NotificationLanguageController.cs @@ -49,12 +49,16 @@ public async Task UpdateLanguage([FromBody] LanguageUpdateRequest return this.BadRequest(new { error = "Language must be 255 characters or fewer." }); } - human.Language = request.Language; - await this._humanService.UpdateAsync(human); + await this._humanService.SetLanguageAsync(this.UserId, request.Language); + + // Read back rather than echo. PoracleNG lowercases and trims what it stores -- "pt-BR" becomes + // "pt-br" -- and an endpoint that reported the request instead of the row would leave the SPA + // holding a value the server does not have. Verified on 5.2.1 against both API versions. + var stored = await this._humanService.GetByIdAsync(this.UserId); return this.Ok(new { - language = human.Language + language = stored?.Language ?? request.Language }); } diff --git a/Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs b/Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs index ed32b6a8..20227a0f 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs @@ -46,6 +46,7 @@ public interface IUserRoleResolver /// public sealed partial class UserRoleResolver( IPoracleApiProxy poracleApiProxy, + IPoracleHumanProxy poracleHumanProxy, IWebhookDelegateService webhookDelegateService, IHumanService humanService, IOptions poracleSettings, @@ -57,6 +58,7 @@ public sealed partial class UserRoleResolver( private readonly IMemoryCache _cache = cache; private readonly ILogger _logger = logger; private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly IPoracleHumanProxy _poracleHumanProxy = poracleHumanProxy; private readonly PoracleSettings _poracleSettings = poracleSettings.Value; private readonly IWebhookDelegateService _webhookDelegateService = webhookDelegateService; private readonly IHumanService _humanService = humanService; @@ -115,44 +117,34 @@ private async Task ResolveUncachedAsync(string userId) configReadable = false; } - // Call getAdministrationRoles once — resolves delegation including Discord guild roles + // Ask PoracleNG once for the delegated webhooks, Discord guild roles included. var managed = new HashSet(StringComparer.OrdinalIgnoreCase); - var isAdmin = false; try { - var rolesJson = await this._poracleApiProxy.GetAdminRolesAsync(userId); + var rolesJson = await this._poracleHumanProxy.GetAdminRolesAsync(userId); if (!string.IsNullOrEmpty(rolesJson)) { using var doc = JsonDocument.Parse(rolesJson); var root = doc.RootElement; - // Some versions return isAdmin at root; others wrap under admin.discord - if (root.TryGetProperty("isAdmin", out var isAdminProp) && isAdminProp.ValueKind == JsonValueKind.True) - { - isAdmin = true; - } - - // Parse admin.discord.webhooks — the authoritative delegate webhook list + // admin.discord.webhooks is the authoritative delegate webhook list. + // + // Two isAdmin branches used to sit here, one at the root and one under admin.discord. + // Neither has ever fired: both API versions build this body from the same + // adminRolesResult, whose only fields are channels, webhooks and users, and v2's schema + // is additionalProperties:false so an isAdmin could not appear even by accident. + // Admin status is resolved above, from the configured ids and Poracle's own config. if (root.TryGetProperty("admin", out var adminEl) && - adminEl.TryGetProperty("discord", out var discordEl)) + adminEl.TryGetProperty("discord", out var discordEl) && + discordEl.TryGetProperty("webhooks", out var webhooks) && + webhooks.ValueKind == JsonValueKind.Array) { - if (!isAdmin && - discordEl.TryGetProperty("isAdmin", out var discordAdmin) && - discordAdmin.ValueKind == JsonValueKind.True) + foreach (var wh in webhooks.EnumerateArray()) { - isAdmin = true; - } - - if (discordEl.TryGetProperty("webhooks", out var webhooks) && - webhooks.ValueKind == JsonValueKind.Array) - { - foreach (var wh in webhooks.EnumerateArray()) + if (wh.GetString() is { } id) { - if (wh.GetString() is { } id) - { - managed.Add(id); - } + managed.Add(id); } } } @@ -164,11 +156,6 @@ private async Task ResolveUncachedAsync(string userId) rolesReadable = false; } - if (isAdmin) - { - return new UserRoles(true, null); - } - // Also merge our own webhook delegate service layer try { 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 972c3e46..53f84b09 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 @@ -717,6 +717,11 @@ export interface SavedPlace { /** Everywhere a user's alarms can be anchored: the profile pin, plus whatever they have named. */ export interface SavedPlaces { + /** + * Whether this Poracle server can move a place without deleting it first. Absent on an older + * PoracleWeb.NET API, and treated as false, because the edit is what would 404. + */ + canEdit?: boolean; /** The profile pin every alarm falls back to. Absent when the user has never set a location. */ default?: null | SavedPlace; named: SavedPlace[]; diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.spec.ts index ec69280e..fd6cb1d3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.spec.ts @@ -96,6 +96,33 @@ describe('AlertLanguageService', () => { expect(store['poracle-language']).toBe('it'); }); + it('should recognise a stored language whose case Poracle changed', () => { + // Poracle lowercases what it stores, on both API versions and from the bot's !language command, so + // humans.language reads back 'pt-br' where this list says 'pt-BR'. An exact match dropped it and + // the picker silently reverted to the server default. + locationService.getLanguage.mockReturnValue(of({ language: 'pt-br' })); + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + alert.load(); + + expect(alert.selected()).toBe('pt-BR'); + expect(store['poracle-language']).toBe('pt-BR'); + }); + + it('should still ignore a language this UI does not ship', () => { + // The other half: Poracle carries translations we do not, and coercing one of them onto a UI + // language would put Japanese prose behind an English flag. + locationService.getLanguage.mockReturnValue(of({ language: 'ja' })); + const { alert, i18n } = create(); + i18n.init(undefined, 'de'); + + alert.load(); + + expect(alert.selected()).toBe('de'); + expect(store['poracle-language']).toBeUndefined(); + }); + it('should keep the server locale when humans.language is unset', () => { const { alert, i18n } = create(); i18n.init(undefined, 'de'); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts index 4c3a7dc9..01429a6b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/alert-language.service.ts @@ -65,9 +65,14 @@ export class AlertLanguageService { this.locationService.getLanguage().subscribe({ error: () => undefined, next: ({ language }) => { - if (language && this.languages.some(l => l.code === language)) { - this.chosen.set(language); - localStorage.setItem(STORAGE_KEY, language); + // Case-insensitively, and stored back in this list's casing. Poracle lowercases what it stores, + // on both API versions and from the bot's own !language command, so humans.language for a + // Brazilian Portuguese user reads back as 'pt-br' while the code here is 'pt-BR'. An exact + // comparison dropped it silently and the picker fell back to the server default. + const known = language ? this.languages.find(l => l.code.toLowerCase() === language.toLowerCase()) : undefined; + if (known) { + this.chosen.set(known.code); + localStorage.setItem(STORAGE_KEY, known.code); } }, }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.spec.ts new file mode 100644 index 00000000..e37728f1 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.spec.ts @@ -0,0 +1,72 @@ +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 { PlacesService } from './places.service'; + +const API = 'http://test'; + +describe('PlacesService', () => { + let httpMock: HttpTestingController; + let service: PlacesService; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [PlacesService, { provide: ConfigService, useValue: { apiHost: API } }, provideHttpClient(), provideHttpClientTesting()], + }); + + service = TestBed.inject(PlacesService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('holds canEdit from the list response', () => { + service.load().subscribe(); + httpMock.expectOne(`${API}/api/location/places`).flush({ named: [], canEdit: true, default: null }); + + expect(service.canEdit()).toBe(true); + }); + + it('treats a response with no canEdit as not editable', () => { + // An older PoracleWeb.NET API, or one talking to a Poracle without the route. The edit is what + // would 404, so absent has to mean no. + service.load().subscribe(); + httpMock.expectOne(`${API}/api/location/places`).flush({ named: [], default: null }); + + expect(service.canEdit()).toBe(false); + }); + + it('keeps canEdit after adding a place', () => { + // add() replaces the whole signal from the POST reply, so that reply has to carry canEdit too. + // Without it the edit control vanished from every card the moment a place was added. + service.load().subscribe(); + httpMock.expectOne(`${API}/api/location/places`).flush({ named: [], canEdit: true, default: null }); + + service.add({ label: 'work', latitude: 1, longitude: 2 }).subscribe(); + httpMock + .expectOne(r => r.method === 'POST') + .flush({ named: [{ label: 'work', latitude: 1, longitude: 2 }], canEdit: true, default: null }); + + expect(service.canEdit()).toBe(true); + }); + + it('puts the new point under the existing label', () => { + service.move('work', 9.5, 8.5).subscribe(); + + const request = httpMock.expectOne(`${API}/api/location/places/work`); + expect(request.request.method).toBe('PUT'); + expect(request.request.body).toEqual({ latitude: 9.5, longitude: 8.5 }); + request.flush({ named: [{ label: 'work', latitude: 9.5, longitude: 8.5 }], canEdit: true, default: null }); + + expect(service.named()[0].latitude).toBe(9.5); + }); + + it('encodes a label with a slash in it rather than growing a path segment', () => { + service.move('home/office', 1, 2).subscribe(); + + httpMock.expectOne(`${API}/api/location/places/home%2Foffice`).flush({ named: [], canEdit: true, default: null }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts index e024ff12..3287014f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts @@ -18,6 +18,13 @@ export class PlacesService { private readonly http = inject(HttpClient); private readonly places = signal(null); + /** + * Whether this Poracle server can move a place without deleting it first. Needs PoracleNG 5.2.0; + * before it, a place an alarm referenced could not be moved at all, because the delete answers 409 + * while anything still points at it. False until {@link load} has run. + */ + readonly canEdit = computed(() => this.places()?.canEdit === true); + /** Named places only. Empty until {@link load} has run. */ readonly named = computed(() => this.places()?.named ?? []); @@ -33,6 +40,16 @@ export class PlacesService { return this.http.get(`${this.config.apiHost}/api/location/places`).pipe(tap(places => this.places.set(places))); } + /** + * Moves a place, keeping its label so every alarm pointing at it follows. Answers 501 on a server + * without the endpoint, which is why {@link canEdit} gates the control that calls this. + */ + move(label: string, latitude: number, longitude: number): Observable { + return this.http + .put(`${this.config.apiHost}/api/location/places/${encodeURIComponent(label)}`, { latitude, longitude }) + .pipe(tap(updated => this.places.set(updated))); + } + /** * Deletes a place. Answers 409 with `referencingRules` when alarms still point at it — the caller * should name them rather than reporting a bare failure. diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.html index e693a599..0d8ce5bb 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-section/places-section.component.html @@ -46,6 +46,11 @@