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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Nothing on screen: the last code that could write an alarm straight to Poracle's database has been taken out.** Every alarm write has gone through PoracleNG's API since 2.0, so its deduplication, its field defaults and its immediate state reload all run -- but the database tables were still mapped in code beside it, one line away from being used again. That mapping is gone, along with a set of profile methods nothing had called since the same migration. The two places that still reach the alarm tables directly are unchanged and are there for reasons written down beside them.

- **A refused alarm explains itself the same way whichever Poracle surface answered.** The v2 write path already read PoracleNG's newer RFC 9457 error bodies and named the individual field it refused; the older v1 path, still the one most installs use, was reading only the older shape and answering a validation refusal as though the server had broken. Both paths now share one reader, and where many fields are refused at once the message names the first few and counts the rest rather than rendering a dozen clauses into a snackbar ([#803](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/803)).
- **Pokemon alarm edits are written through PoracleNG 5.2.1’s strict `/api/v2` surface, where the server can tell an edit apart from a takeover.** Nothing changes on screen. What changes is underneath: an edit now addresses the rule by its id, so Poracle refuses outright if the uid is not yours or if the result would duplicate an alarm you already have, instead of PoracleWeb.NET having to work that out from a success response and undo it afterwards. Poracle also explains a rejected filter field by name now, so the message on the dialog says which one. Anything older than 5.2.0 keeps the surface it has always used, unchanged, and so do the other nine alarm types; an edit carrying anything the new surface cannot express takes the old path rather than failing. Set `PORACLE_TRACKING_API_VERSION` to `v1` or `v2` to pin it ([#805](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/805)).

### Fixed

- **Turning on auto-delete for invasions stops turning it on for your Pokéstop Events too.** Showcase, Kecleon and Gold Stop rules live in the same table invasions do, and the invasion switch on the Cleaning page was sweeping them up: it read every row of that table, set the auto-delete bit on all of them and wrote them back, so a switch that never mentions Pokéstop Events was quietly changing them. The invasion list and the dashboard both draw that line already; cleaning did not. It now leaves them alone -- unless the Pokéstop Events page is switched off entirely, in which case the invasion switch is the only control you have over those rules and it keeps them, as it always did.
- **The PVP rank range is readable again on a dark-themed alarm card.** The band under a PVP alarm showed its league and nothing else, so the ranks you had set looked like they had been dropped. They were being drawn, in white, on a band that stays light in both themes. The league name beside them is darker now too -- it was legible, but thin ([#800](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/800)).
- **A refused profile, area, place or account change now says what was wrong with it.** Saving a profile with no number, a location that is not a location, an area list Poracle could not read, or banning a user through an older path all came back as *An unexpected error occurred* -- the wording this site uses when it genuinely does not know -- and were recorded as server faults. Poracle had explained every one of them; the explanation was being thrown away one layer below the screen. It now reaches the dialog that asked, in Poracle's own words, whether the server answered in its long-standing format or the newer one 5.2.1 introduced. The alarm pages were fixed this way some time ago; this is the same fix on the half of the site it never reached.
- **Switching to a profile that is no longer there stops signing you out.** Any *not found* answer from Poracle was read as *your account has been deleted*, which is what one of them means. A missing profile is not, and neither is a route an older Poracle does not have -- but both ended the session and sent you back to the login page, and one of them did it while a profile duplicate was tidying up after itself, hiding whatever had actually gone wrong. A deleted account still signs you out, exactly as before.
Expand All @@ -36,6 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Documentation

- **Why this site is not going to start requiring PoracleNG 5.2.0 is now written down.** Moving to Poracle's newer API was the obvious moment to make 5.2.0 a hard minimum and delete the older code paths, and it was refused: a self-hoster on 5.1.0 would lose their Areas page, their saved places, the location pin, delegated webhooks and all alarm editing, and almost nothing would be gained in exchange, because the newer surface mostly renames things rather than adding them. The compatibility page now carries that reasoning, and the enhancement-request page carries the nine findings behind it -- each one confirmed by calling a 5.2.1 server rather than reading its source, including two that stop whole features moving: a Sunday schedule cannot be expressed on the new API at all, and an invasion rule for a named grunt cannot be read back and written again.
- **A workaround this site has carried for months turns out to rest on something that does not happen.** Per-alarm delivery scope is written through a class built around Poracle refusing a private geofence name. Tested properly for the first time -- with a real user-drawn geofence rather than a made-up name, from an account proven to have no admin rights -- 5.1.0 and 5.2.1 both accepted it and stored it, on both API surfaces. The workaround stays, because it produces the right result either way and nobody should delete one on an unexplained result, but the finding is recorded with its method so whoever removes it does so on evidence.
- **The PoracleNG version compatibility page has been rewritten, and its premise replaced.** It described PoracleNG as having two long-lived branches and this site supporting both; develop merged and shipped, so the real question is 5.1.0 versus 5.2.0 and newer. It now covers how support is decided, why a database migration number is a better thing to gate on than a version string, which features need a newer server, how to add another, and two traps worth knowing: PoracleNG's v1 API is unchanged on 5.2.1 -- the new error format and status codes in its release notes apply to v2 only -- and its OpenAPI document numbers the days of the week differently from the scheduler that reads them.
- **The documentation has been audited against the code and brought up to date with everything that landed this cycle.** Nine changes shipped and none of them were written down, so the pages a self-hoster reads described a site with ten alarm types, five quest reward tabs, no costume filter and no way to quiet anything. Corrected throughout: the alarm type tables and feature lists now carry all eleven types, the test-alert coverage arithmetic is right in the three places that state it, the scope chip is on seven card types rather than six, and the claim that a Pokemon edit keeps its rule id is gone -- it stopped being true when edits moved to the newer write surface. **Quiet periods have a page of their own**, covering what they are, how they differ from *Pause Alerts*, why one rule cannot be quieted on its own, and that a restart of the Poracle processor clears them. Costume filters, PokéCoin rewards, Pokéstop Events, rule descriptions and repeating schedule rules are all written up where they belong, each with the version or database migration it needs.
- **Every version requirement in the docs said 5.2.1 and every gate in the code says 5.2.0.** 5.2.1 is the release production happens to run, not the release that added the features, so a self-hoster on exactly 5.2.0 was told four working features were unavailable to them. Corrected on the compatibility page, and the page now names the three constants it is describing so the next drift is visible.
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ Pgan.PoracleWebNet.slnx
- **PoracleNG handles**: field defaults (template, PVP, size, etc.), dedup detection, immediate state reload on every mutation, grunt_type normalization, area dual-writes on profile switch.

### Repository Layer
- Repositories remain for **non-alarm** data: `HumanRepository` (admin bulk ops only -- `GetAllAsync`, `DeleteUserAsync`), `ProfileRepository` (admin bulk ops and non-active profile cleanup in `UserGeofenceService`), and all PoracleWeb-owned tables (`SiteSettingRepository`, `WebhookDelegateRepository`, `UserGeofenceRepository`, `QuickPickDefinitionRepository`, `QuickPickAppliedStateRepository`). Single-user human and profile reads/writes are fully proxied through `IPoracleHumanProxy`.
- **Removed**: 8 alarm repository classes (MonsterRepository, RaidRepository, etc.), `BaseRepository<TEntity, TModel>`, `PoracleUnitOfWork`, `IUnitOfWork`, and all alarm repository interfaces. `EnsureNotNullDefaults()` is no longer needed for alarm writes (PoracleNG handles NULL defaults).
- Repositories remain for **non-alarm** data: `HumanRepository` (admin bulk ops only -- `GetAllAsync`, `DeleteUserAsync`), `ProfileRepository` (down to `RenameAsync` and `UpdateAsync` -- the two writes PoracleNG's API cannot serve), and all PoracleWeb-owned tables (`SiteSettingRepository`, `WebhookDelegateRepository`, `UserGeofenceRepository`, `QuickPickDefinitionRepository`, `QuickPickAppliedStateRepository`). Single-user human and profile reads/writes are fully proxied through `IPoracleHumanProxy`.
- **Removed**: 8 alarm repository classes (MonsterRepository, RaidRepository, etc.), `BaseRepository<TEntity, TModel>`, `PoracleUnitOfWork`, `IUnitOfWork`, and all alarm repository interfaces. `PoracleContext` no longer maps the eight alarm tables at all -- the DbSets, the entity classes and their `OnModelCreating` configuration are gone, so a direct alarm write is not one `DbSet` away. `IUserAreaDualWriter` reaches those tables by raw SQL over a validated table name, not through EF. `EnsureNotNullDefaults()` is no longer needed for alarm writes (PoracleNG handles NULL defaults).

### Manual Mapping Extensions
- **No AutoMapper dependency.** All mappings use static extension methods in `Core.Mappings/`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

namespace Pgan.PoracleWebNet.Core.Abstractions.Repositories;

/// <summary>
/// The two profile writes PoracleNG's API cannot serve. Everything else about profiles — reads,
/// create, delete, copy, switch — goes through <c>IPoracleHumanProxy</c>.
/// </summary>
public interface IProfileRepository
{
public Task<IEnumerable<Profile>> GetByUserAsync(string userId);
public Task<Profile?> GetByUserAndProfileNoAsync(string userId, int profileNo);
public Task<Profile> CreateAsync(Profile profile);
public Task<Profile> UpdateAsync(Profile profile);
/// <summary>
/// Renames a profile, touching only <c>profiles.name</c>.
Expand All @@ -20,6 +21,4 @@ public interface IProfileRepository
/// </remarks>
/// <returns><c>false</c> if no such profile exists.</returns>
public Task<bool> RenameAsync(string userId, int profileNo, string name);

public Task<bool> DeleteAsync(string userId, int profileNo);
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,5 @@ public interface IProfileService
{
public Task<IEnumerable<Profile>> GetByUserAsync(string userId);
public Task<Profile?> GetByUserAndProfileNoAsync(string userId, int profileNo);
public Task<Profile> CreateAsync(Profile profile);
public Task<Profile> UpdateAsync(Profile profile);
public Task<bool> DeleteAsync(string userId, int profileNo);
public Task CopyAsync(string userId, int fromProfileNo, int toProfileNo);
}
40 changes: 0 additions & 40 deletions Core/Pgan.PoracleWebNet.Core.Repositories/ProfileRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,6 @@ public class ProfileRepository(PoracleContext context) : IProfileRepository
{
private readonly PoracleContext _context = context;

public async Task<IEnumerable<Profile>> GetByUserAsync(string userId)
{
var entities = await this._context.Profiles
.Where(p => p.Id == userId)
.ToListAsync();

return entities.Select(e => e.ToModel());
}

public async Task<Profile?> GetByUserAndProfileNoAsync(string userId, int profileNo)
{
var entity = await this._context.Profiles
.FirstOrDefaultAsync(p => p.Id == userId && p.ProfileNo == profileNo);

return entity is null ? null : entity.ToModel();
}

public async Task<Profile> CreateAsync(Profile profile)
{
var entity = profile.ToEntity();
this._context.Profiles.Add(entity);
await this._context.SaveChangesAsync();
return entity.ToModel();
}

public async Task<Profile> UpdateAsync(Profile profile)
{
var entity = await this._context.Profiles
Expand All @@ -63,19 +38,4 @@ public async Task<bool> RenameAsync(string userId, int profileNo, string name)
await this._context.SaveChangesAsync();
return true;
}

public async Task<bool> DeleteAsync(string userId, int profileNo)
{
var entity = await this._context.Profiles
.FirstOrDefaultAsync(p => p.Id == userId && p.ProfileNo == profileNo);

if (entity is null)
{
return false;
}

this._context.Profiles.Remove(entity);
await this._context.SaveChangesAsync();
return true;
}
}
62 changes: 55 additions & 7 deletions Core/Pgan.PoracleWebNet.Core.Services/CleaningService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task<Dictionary<string, bool>> GetCleanStatusAsync(string userId, i
["raids"] = AllClean(allTracking, "raid"),
["eggs"] = AllClean(allTracking, "egg"),
["quests"] = AllClean(allTracking, "quest"),
["invasions"] = AllClean(allTracking, "invasion"),
["invasions"] = AllClean(allTracking, "invasion", await this.OwnRowPredicateAsync("invasion")),
["lures"] = AllClean(allTracking, "lure"),
["nests"] = AllClean(allTracking, "nest"),
["gyms"] = AllClean(allTracking, "gym"),
Expand Down Expand Up @@ -72,6 +72,35 @@ public async Task<int> ToggleCleanMaxBattlesAsync(string userId, int profileNo,
await this.ToggleCleanAsync("maxbattle", userId, clean);


/// <summary>
/// Which rows of a tracking type this page owns.
/// </summary>
/// <remarks>
/// Invasion and incident rules share one table, and PoracleWeb reads invasions over v1, which does
/// not partition them. So a clean toggle over "invasion" swept up the user's Kecleon, Showcase and
/// Gold Stop rules, set the auto-delete bit on them and POSTed them back through the invasion
/// endpoint -- a write to rows that belong to the Pokestop Events page, from a switch that never
/// mentions it. <see cref="InvasionService"/> and <c>DashboardService</c> both draw this line at the
/// same boundary; cleaning did not.
/// </remarks>
/// <remarks>
/// Conditional for the same reason as <see cref="InvasionService"/>: when the Pokestop Events
/// surface is switched off there is no other page holding those rows, and the invasion switch is
/// the only control the user has over them.
/// </remarks>
private async Task<Func<JsonElement, bool>> OwnRowPredicateAsync(string type)
{
if (type != "invasion" || !await this._featureGate.IsEnabledAsync(DisableFeatureKeys.PokestopEvents))
{
return _ => true;
}

return row => !PokestopEventTypes.IsEventName(
row.TryGetProperty("grunt_type", out var gt) && gt.ValueKind == JsonValueKind.String
? gt.GetString()
: null);
}

/// <summary>
/// Workaround: PoracleNG has no bulk clean toggle endpoint. We fetch all alarms of the type,
/// set the clean field on each, and POST them back via CreateAsync (which upserts by UID).
Expand Down Expand Up @@ -99,10 +128,18 @@ private async Task<int> ToggleCleanAsync(string type, string userId, int clean)
return 0;
}

var count = trackingJson.GetArrayLength();
var isOurs = await this.OwnRowPredicateAsync(type);
var ownRows = trackingJson.EnumerateArray().Where(isOurs).ToList();

if (ownRows.Count == 0)
{
return 0;
}

var count = ownRows.Count;
var updatedAlarms = new JsonArray();

foreach (var alarm in trackingJson.EnumerateArray())
foreach (var alarm in ownRows)
{
var dict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(alarm.GetRawText())!;

Expand All @@ -121,7 +158,7 @@ private async Task<int> ToggleCleanAsync(string type, string userId, int clean)
// upsert on uid and must not be deleted.
if (InsertOnlyTypes.Contains(type))
{
var uids = trackingJson.EnumerateArray()
var uids = ownRows
.Where(a => a.TryGetProperty("uid", out var u) && u.ValueKind == JsonValueKind.Number)
.Select(a => a.GetProperty("uid").GetInt32())
.ToList();
Expand All @@ -135,7 +172,8 @@ private async Task<int> ToggleCleanAsync(string type, string userId, int clean)
catch
{
// Put the originals back rather than leaving the user with no alarms at all.
await this._trackingProxy.CreateAsync(type, userId, trackingJson);
await this._trackingProxy.CreateAsync(
type, userId, JsonSerializer.SerializeToElement(ownRows));
throw;
}

Expand Down Expand Up @@ -167,15 +205,24 @@ await BulkUidRemap.ApplyAsync(
/// Checks whether all items in a tracking array have clean == true or clean == 1.
/// Returns false if the array is empty or missing.
/// </summary>
private static bool AllClean(JsonElement root, string key)
private static bool AllClean(JsonElement root, string key, Func<JsonElement, bool>? isOurs = null)
{
if (!root.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array || arr.GetArrayLength() == 0)
{
return false;
}

var any = false;

foreach (var item in arr.EnumerateArray())
{
if (isOurs != null && !isOurs(item))
{
continue;
}

any = true;

if (!item.TryGetProperty("clean", out var cleanVal))
{
return false;
Expand All @@ -200,6 +247,7 @@ private static bool AllClean(JsonElement root, string key)
}
}

return true;
// An array holding nothing but somebody else's rows is the empty case, not the all-clean one.
return any;
}
}
Loading
Loading