Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv
services.AddScoped<ISummaryCapabilityService, SummaryCapabilityService>();
services.AddScoped<IQuestPokecoinCapabilityService, QuestPokecoinCapabilityService>();
services.AddScoped<IMuteCapabilityService, MuteCapabilityService>();
services.AddScoped<IPlaceUpdateCapabilityService, PlaceUpdateCapabilityService>();
services.AddScoped<ICostumeCapabilityService, CostumeCapabilityService>();
services.AddScoped<IUpstreamFeatureFlagService, UpstreamFeatureFlagService>();
services.AddScoped<IFeatureGate, FeatureGate>();
Expand Down Expand Up @@ -173,7 +174,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv
// Register HttpClient for PoracleNG summary schedule proxy (quest summary delivery)
services.AddHttpClient<IPoracleSummaryProxy, PoracleSummaryProxy>();

// 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<IPoracleMuteProxy, PoracleMuteProxy>();

// Register HttpClient for Discord notification service
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@ public class LocationController(
IPoracleHumanProxy humanProxy,
IPoracleApiProxy poracleApiProxy,
IHttpClientFactory httpClientFactory,
IPlaceUpdateCapabilityService placeUpdateCapability,
IScannerService? scannerService = null) : BaseApiController
{
private readonly IHumanService _humanService = humanService;
private readonly IProfileService _profileService = profileService;
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]
Expand Down Expand Up @@ -283,9 +285,79 @@ public double? Longitude
/// <summary>
/// The user's saved places, plus the profile pin every alarm falls back to.
/// </summary>
/// <remarks>
/// <c>canEdit</c> rides along rather than sitting on its own endpoint, matching
/// <c>MuteController</c>: the list is read on every visit to the Areas page, and a second call for
/// one boolean would double that for no gain.
/// </remarks>
[HttpGet("places")]
public async Task<IActionResult> GetPlaces() =>
this.Ok(await this._humanProxy.GetPlacesAsync(this.UserId));
public async Task<IActionResult> GetPlaces(CancellationToken cancellationToken) =>
this.Ok(await this.PlacesWithCapabilityAsync(cancellationToken));

/// <summary>
/// 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.
/// </summary>
private async Task<object> PlacesWithCapabilityAsync(CancellationToken cancellationToken)
{
var places = await this._humanProxy.GetPlacesAsync(this.UserId);

return new
{
places.Default,
places.Named,
canEdit = await this._placeUpdateCapability.IsPlaceUpdateAvailableAsync(cancellationToken),
};
}

/// <summary>
/// Moves a saved place, keeping its label so every alarm pointing at it follows.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[HttpPut("places/{label}")]
public async Task<IActionResult> 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.",
});
}

/// <summary>New coordinates for a saved place. The label is the path segment and does not change.</summary>
public class PlaceMoveRequest
{
[Range(-90, 90)]
public double? Latitude
{
get; set;
}

[Range(-180, 180)]
public double? Longitude
{
get; set;
}
}

/// <summary>
/// Saves a place an alarm can be anchored to.
Expand All @@ -295,12 +367,16 @@ public async Task<IActionResult> GetPlaces() =>
/// refusal is unwrapped here and returned as a 400 the SPA can show against the field.
/// </remarks>
[HttpPost("places")]
public async Task<IActionResult> AddPlace([FromBody] SavedPlace place)
public async Task<IActionResult> 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 });
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,16 @@ public async Task<IActionResult> 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
});
}

Expand Down
47 changes: 17 additions & 30 deletions Applications/Pgan.PoracleWebNet.Api/Services/UserRoleResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ public interface IUserRoleResolver
/// </remarks>
public sealed partial class UserRoleResolver(
IPoracleApiProxy poracleApiProxy,
IPoracleHumanProxy poracleHumanProxy,
IWebhookDelegateService webhookDelegateService,
IHumanService humanService,
IOptions<PoracleSettings> poracleSettings,
Expand All @@ -57,6 +58,7 @@ public sealed partial class UserRoleResolver(
private readonly IMemoryCache _cache = cache;
private readonly ILogger<UserRoleResolver> _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;
Expand Down Expand Up @@ -115,44 +117,34 @@ private async Task<UserRoles> 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<string>(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);
}
}
}
Expand All @@ -164,11 +156,6 @@ private async Task<UserRoles> ResolveUncachedAsync(string userId)
rolesReadable = false;
}

if (isAdmin)
{
return new UserRoles(true, null);
}

// Also merge our own webhook delegate service layer
try
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
/** The sentence PoracleNG renders for this rule, in the alert language. Read-only. */
description?: null | string;
/** Costume filter on the boss: 9000 any, 0 none, N that costume. See shared/utils/costumes.ts. */
costume: number;

Check warning on line 61 in Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts

View workflow job for this annotation

GitHub Actions / Frontend (Angular)

Expected "costume" to come before "description"
distance: number;
evolution: number;
exclusive: number;
Expand Down Expand Up @@ -717,6 +717,11 @@

/** 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[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading