Skip to content

fix: custom geofence toggle not persisting (#163)#164

Merged
hokiepokedad2 merged 11 commits into
mainfrom
fix/geofence-profile-toggle-persistence
Apr 11, 2026
Merged

fix: custom geofence toggle not persisting (#163)#164
hokiepokedad2 merged 11 commits into
mainfrom
fix/geofence-profile-toggle-persistence

Conversation

@hokiepokedad2
Copy link
Copy Markdown
Contributor

Summary

Fixes #163 — activating/deactivating a user-drawn geofence via the Geofences page toggle appeared to succeed but silently reverted on the next page refresh.

Root cause

PoracleNG's POST /api/humans/{id}/setAreas handler (processor/internal/api/humans.go:416-498) intersects the submitted area list against fences where UserSelectable == true for non-admin users:

for _, f := range st.Fences {
    if !admin && !f.UserSelectable { continue }
    allowedAreas = append(allowedAreas, strings.ToLower(f.Name))
}
// ... requested ∩ allowedAreas → written to humans.area

PoracleWeb's GeofenceFeedController.cs:77 intentionally serves user-drawn geofences with userSelectable: false to hide them from the Poracle bot's !area picker. The two interact such that every SetAreasAsync call containing a user geofence name silently strips that name — no error, no warning, just dropped from humans.area.

Regression introduced in v2.0.0 (#88) — the PoracleNG proxy migration replaced the pre-migration direct-DB write in UserGeofenceService.AddAreaToHumanAsync with _humanProxy.SetAreasAsync. The direct-DB path worked because it bypassed PoracleNG's filter.

A second related bug existed in AreaController.UpdateAreas — saving on the Areas page also ran through setAreas and stripped any active user geofences.

Fix

  1. UserGeofenceService.AddAreaToHumanAsync / RemoveAreaFromHumanAsync / RemoveAreaFromAllProfilesAsync — restored the pre-feat: migrate Poracle DB writes to PoracleNG API proxy #88 direct-DB path. Loads the human via IHumanRepository.GetByIdAsync, mutates human.Area, writes back, then mirrors to profiles.area for the authoritative human.CurrentProfileNo (not the JWT claim — those can drift from Poracle's idea of the active profile).
  2. New IUserGeofenceService.PreserveOwnedAreasInHumanAsync(humanId, candidates) — given a list of requested area names, re-adds the subset that the user owns as custom geofences via the same direct-DB path. Validates ownership against IUserGeofenceRepository.GetByHumanIdAsync so arbitrary strings cannot be injected.
  3. AreaController.UpdateAreas — after SetAreasAsync returns, calls PreserveOwnedAreasInHumanAsync with the normalized request. Response returns the effective merged list.
  4. CLAUDE.md + CHANGELOG.md — documented the PoracleNG filter behavior and why direct-DB is load-bearing for this specific case.
  5. dotnet format cleanups — included pre-existing whitespace fixes on Profile.cs, ProfileControllerTests.cs, ProfileServiceTests.cs, ActiveHoursValidationTests.cs that the formatter auto-applied. No behavior change.

Tests

  • Rewrote 5 tests (CreateAsyncUpdatesHumanAreaJsonArray, DeleteAsyncRemovesFromHumanAreaAndDeletesRecord, DeleteAsyncRemovesAreaFromAllProfiles, AddToProfileAsyncAddsAreaNameViaProxy…ViaDirectDb, RemoveFromProfileAsyncRemovesAreaNameViaProxy…ViaDirectDb) to assert direct-DB writes and verify SetAreasAsync is never called for user geofence mutations.
  • New AddToProfileAsyncWritesToCurrentProfileNoFromHuman — guards against the JWT profileNo drifting from human.CurrentProfileNo. Passes a stale JWT claim and asserts the write lands on the Poracle-DB-authoritative profile row.
  • New PreserveOwnedAreasInHumanAsync tests (3) — owned-filter logic, empty-candidates short-circuit, no-owned-geofences short-circuit.
  • New UpdateAreasPreservesUserOwnedGeofencesAfterProxyCall — regression guard on AreaController verifying the post-setAreas merge fires and returns the unioned list.
  • Full suite: 780 backend + 576 frontend = 1356 tests passing. Release build: 0 errors, 3 pre-existing warnings. Angular production build: success.

Test plan

  • CI passes (backend build + xUnit + frontend ESLint + Jest + Angular build)
  • Manual: toggle a custom geofence active → refresh page → geofence still shown as active
  • Manual: toggle a custom geofence inactive → refresh → still inactive
  • Manual: save Areas page with custom geofences active → verify they remain active after save + refresh
  • Manual: switch profiles → verify per-profile geofence activation state is preserved on each profile
  • Manual: delete a custom geofence → verify it's removed from all profiles' areas and the geofence list

PoracleNG's POST /api/humans/{id}/setAreas intersects requested areas
against fences where userSelectable=true for non-admin users. PoracleWeb
serves user-drawn geofences with userSelectable=false to hide them from
the Poracle bot's area picker, so every call to SetAreasAsync containing
a user geofence name was silently stripped by the filter — the API
returned 200 OK with the filtered list and the toggle appeared to work,
but a page refresh read back humans.area without the geofence.

Regression was introduced in v2.0.0 (#88) by the PoracleNG proxy
migration, which routed UserGeofenceService.AddAreaToHumanAsync through
the proxy instead of writing directly to the Poracle DB like the
pre-migration code did.

Fix:
- UserGeofenceService now writes directly to humans.area and the active
  profiles.area row via IHumanRepository / IProfileRepository for all
  user geofence mutations (Create, Delete, AddToProfile, RemoveFromProfile,
  AdminDelete). Dual-write uses human.CurrentProfileNo as the authoritative
  active-profile source.
- New IUserGeofenceService.PreserveOwnedAreasInHumanAsync method accepts
  a list of requested area names and re-adds the subset that the user
  owns as custom geofences via direct DB.
- AreaController.UpdateAreas calls PreserveOwnedAreasInHumanAsync after
  SetAreasAsync so saving on the Areas page also no longer strips active
  user geofences. Response returns the effective merged list.

Tests:
- Rewrote 5 existing tests that asserted on SetAreasAsync calls to assert
  direct-DB writes and verify SetAreasAsync is never called for user
  geofence mutations.
- Added AddToProfileAsyncWritesToCurrentProfileNoFromHuman regression
  test ensuring the JWT profileNo claim is ignored in favor of the
  authoritative human.CurrentProfileNo.
- Added 3 PreserveOwnedAreasInHumanAsync tests covering the owned-filter
  logic, empty-candidates short-circuit, and no-owned-geofences
  short-circuit.
- Added UpdateAreasPreservesUserOwnedGeofencesAfterProxyCall controller
  test guarding the merge-after-setAreas behavior.

Also includes:
- CLAUDE.md section explaining the PoracleNG setAreas filter and why
  direct-DB is load-bearing for user geofences.
- dotnet format cleanups of pre-existing whitespace on Profile.cs and
  several test files (no behavior change).

Fixes #163
Review follow-up: the proxy-based setAreas path used to trigger
PoracleNG's internal reloadState automatically as part of its
HandleSetAreas handler. Since UserGeofenceService.AddToProfileAsync
and RemoveFromProfileAsync now write directly to the Poracle DB
(bypassing the userSelectable filter), they were no longer triggering
a reload — meaning the toggle would take effect only on the next
organic PoracleNG state reload (potentially minutes later).

Add ReloadGeofencesSafeAsync calls at the end of:
- AddToProfileAsync
- RemoveFromProfileAsync
- PreserveOwnedAreasInHumanAsync (only when toRestore.Count > 0,
  since the caller already triggered a reload via setAreas)

Add test assertions verifying ReloadGeofencesAsync is called on
these paths and NOT called when PreserveOwnedAreasInHumanAsync has
nothing to restore.
Document the direct-DB writes from the previous two commits as a
known workaround for a PoracleNG API gap. The real fix belongs in
PoracleNG — this PR is a temporary shim while that lands.

- docs/poracleng-enhancement-requests.md: new "Trusted setAreas
  (bypass userSelectable filter)" entry with ID trusted-set-areas,
  root cause, three proposed resolutions (query flag, dedicated
  endpoint, or per-fence ownership), and the workaround description.
  Also updated the existing "Atomic Area Update" entry from "adopted"
  to "partially adopted" and cross-linked it.
- UserGeofenceService.cs: HACK comments on AddAreaToHumanAsync,
  RemoveAreaFromHumanAsync, RemoveAreaFromAllProfilesAsync, and
  PreserveOwnedAreasInHumanAsync pointing to trusted-set-areas.
- AreaController.cs: HACK comment on the PreserveOwnedAreasInHumanAsync
  call in UpdateAreas.

No behavior change. When PoracleNG ships the trusted setAreas variant,
search for "HACK: trusted-set-areas" to find every site that needs to
be reverted to the proxy path.
The direct-DB writes skip PoracleNG's HandleSetAreas handler and
therefore also skip its terminal reloadState(deps) call. The manual
ReloadGeofencesSafeAsync calls in AddToProfileAsync / RemoveFromProfileAsync
/ PreserveOwnedAreasInHumanAsync compensate for that, and should be
removed together with the direct-DB writes when PoracleNG ships a
trusted setAreas variant.

- UserGeofenceService.cs: HACK: trusted-set-areas comments on the
  three manual ReloadGeofencesSafeAsync callsites explaining why they
  exist and that they go away with the rest of the workaround.
- poracleng-enhancement-requests.md: added a paragraph to the
  Trusted setAreas gap entry documenting the manual reload as part
  of the same HACK surface.
Copy link
Copy Markdown
Contributor Author

@hokiepokedad2 hokiepokedad2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full PR review

Reviewing this as if I've never seen the branch before. 5 commits, 12 files, +457/-89.

Summary

Fixes a regression where activating a user-drawn geofence appeared to succeed but silently reverted. Root cause (PoracleNG's setAreas filtering userSelectable=false fences) is well-documented. The fix restores the pre-#88 direct-DB path, scoped strictly to user geofence mutations, and is clearly labelled as a temporary workaround with a tracked PoracleNG enhancement request.

Verdict: Ship it, with optional follow-ups noted below.


What I liked

  1. Root-cause analysis is solid. PR body traces the bug end-to-end through PoracleNG's handler code. The enhancement request doc correctly identifies three possible proper fixes (query flag, separate endpoint, per-fence ownership) so the PoracleNG maintainer has a concrete menu.

  2. HACK tags are exhaustive and discoverable. All 8 sites use the same HACK: trusted-set-areas marker. grep -rn "HACK: trusted-set-areas" --include="*.cs" lists every reversion point.

  3. The reload-after-direct-DB bug caught in self-review is real. PoracleNG's HandleSetAreas terminates with reloadState(deps), so the pre-direct-DB code got the state refresh for free. Commit fc6e34a caught this before it shipped — without it the toggle would only take effect on the next organic reload (minutes).

  4. Test coverage matches the new surface. AddToProfileAsyncWritesToCurrentProfileNoFromHuman is a good regression guard — it passes a stale JWT profileNo and asserts the write lands on the human's authoritative CurrentProfileNo. PreserveOwnedAreasInHumanAsyncSkipsReloadWhenNothingToRestore pins the don't-double-reload optimization.

  5. Ownership filtering in PreserveOwnedAreasInHumanAsync is not optional. The method validates candidates against _repository.GetByHumanIdAsync(humanId) so an attacker can't inject arbitrary strings into humans.area via PUT /api/areas — the merge only happens for names the user actually owns.

  6. HACK scope discipline. Direct-DB writes are scoped strictly to user-geofence area mutations. Admin area writes still flow through setAreas. The "Atomic Area Update" enhancement entry is correctly downgraded from "Adopted" to "Partially adopted".


Issues / concerns

🟡 1. Non-atomic dual-write (transactional drift risk)

UserGeofenceService.cs:570-600AddAreaToHumanAsync does two separate UpdateAsync calls:

await this._humanRepository.UpdateAsync(human);
// ... separate SaveChanges per repository
await this._profileRepository.UpdateAsync(profile);

If the human update commits but the profile update throws (transient connection blip, deadlock), humans.area has the name but profiles.area doesn't. A profile switch would then lose the geofence from both (switchProfile copies profiles[n].areahumans.area).

Severity: Low. The repositories share a DbContext and transient failures are rare. Pre-#88 code had the same issue. Deferring is fine for a HACK fix.

Fix options if you care:

  • Wrap both in IUnitOfWork.BeginTransactionAsync() — requires reviving UOW removed in #88.
  • Move dual-write into a single repository method with one SaveChangesAsync.
  • Punt — the PoracleNG enhancement makes this entire code path go away.

Not blocking.

🟡 2. Silent no-op when human is null

UserGeofenceService.cs:574-578 and :613-617:

var human = await this._humanRepository.GetByIdAsync(humanId);
if (human is null)
{
    return;
}

Controller returns 204 No Content as if the toggle succeeded, but nothing happened.

Reachability: user_geofences has an implicit FK to humans.id, so a user can't own a geofence without a human row — the only way to hit this is a TOCTOU race where the row is deleted mid-request.

Severity: Very low. Not worth throwing or logging. Not blocking.

🟢 3. UpdateAreas response Union is redundant

AreaController.cs:80-82:

var effective = restored.Count == 0
    ? normalizedAreas
    : [.. normalizedAreas.Union(restored, StringComparer.OrdinalIgnoreCase)];

restored is always a subset of normalizedAreas (computed as candidateAreaNames ∩ ownedNames, and candidateAreaNames == normalizedAreas). The Union always equals normalizedAreas. The frontend (area-list.component.ts:286-287) ignores the response body anyway — it sets state from the submitted list.

This is defensive — if PreserveOwnedAreasInHumanAsync ever changed to return names outside the input set, the Union would catch it. Given the HACK status, the defensive shape is fine.

Severity: Nit. Not blocking.

🟢 4. PreserveOwnedAreasInHumanAsync loop re-reads human per iteration

UserGeofenceService.cs:542-545:

foreach (var name in toRestore)
{
    await this.AddAreaToHumanAsync(humanId, name);
}

Each iteration re-reads human + profile from the DB. For a user with 10 geofences (hard limit), that's 20 reads + up to 20 writes per Areas-page save. Runs only on Areas-page saves (not the hot path) and N ≤ 10.

Severity: Performance nit. Not blocking.

🟢 5. dotnet format cleanups bundled in fix commit

Commit 2a91712 includes whitespace fixes to Profile.cs, ProfileControllerTests.cs, ProfileServiceTests.cs, ActiveHoursValidationTests.cs unrelated to the bug. Ideally a separate chore: format commit ahead of the fix, but not worth rewriting history.

Severity: Nit. Not blocking.


Test coverage gaps (not blocking)

  • No test for transactional drift_profileRepo.UpdateAsync throwing after _humanRepo.UpdateAsync succeeds is not asserted anywhere.
  • No test for the silent human is null branchAddAreaToHumanAsync early-returns if the human is missing, not asserted.
  • No concurrency race test on read-modify-write of humans.Area — intentionally out of scope (pre-existing limitation).

Style / consistency

  • HACK comment format: consistent across all 8 sites — opens with HACK: trusted-set-areas (see docs/poracleng-enhancement-requests.md) and includes a "revert when X" instruction. Good.
  • Collection expressions: uses [.. expr] and [] idioms consistent with the rest of the codebase (C# 13).
  • Naming: _humanProxy, _humanRepository, _profileRepository, _repository (for user geofence) — matches existing convention.

Documentation

  • CLAUDE.md — updated "Areas and Profile-Scoped Storage", "Custom Geofences", added new "User Geofence Persistence" section in Common Issues. Cross-references correct.
  • CHANGELOG.md — thorough, explicitly marks the fix as temporary, links to the enhancement request doc, tells readers how to find all HACK sites.
  • docs/poracleng-enhancement-requests.md — new Trusted setAreas gap entry explains root cause, proposes three resolutions (query-flag called out as smallest surface), documents the workaround including the manual reload. "Atomic Area Update" entry correctly downgraded to "Partially adopted".

Recommendation

✅ Approve with minor nits. The fix is correct, scoped, tested, and clearly labelled as temporary. None of the open issues block merge.

Follow-up tickets worth filing:

  • "Make user geofence dual-write transactional" — low priority, gated on whether PoracleNG enhancement lands first.
  • "Optimize PreserveOwnedAreasInHumanAsync to single-pass DB I/O" — low priority, capped at N=10.
  • PoracleNG side: open the enhancement request upstream citing this PR and the trusted-set-areas gap doc.

Ship it. 🚀

Addresses all issues from the PR #164 self-review:

1. **Transactional drift (review issue #1)**: Previously AddAreaToHumanAsync
   did two separate IHumanRepository.UpdateAsync + IProfileRepository.UpdateAsync
   calls, each committing its own SaveChangesAsync. If the profile update
   threw mid-operation, humans.area held the name but profiles.area didn't —
   a profile switch would then wipe both. New IUserAreaDualWriter owns the
   PoracleContext directly, mutates both entities in-memory, and commits them
   in a single SaveChangesAsync call. EF Core wraps that in an implicit
   transaction, so the two writes cannot drift.

2. **Silent no-op on null human (review issue #2)**: The writer throws
   InvalidOperationException when the human row is missing instead of
   returning silently. UserGeofenceService's ownership check already runs
   before the writer call, and the controller catches InvalidOperationException
   and maps it to 404 — so a TOCTOU-deleted user now gets an honest error
   instead of a misleading 204 "success".

3. **Redundant Union in AreaController.UpdateAreas (review issue #3)**:
   PreserveOwnedAreasInHumanAsync's return value is always a subset of the
   input, so Union(normalizedAreas, restored) is always equivalent to
   normalizedAreas. Simplified to `return this.Ok(normalizedAreas)` with a
   discard on the preserve call. Frontend ignored the response body anyway.

4. **PreserveOwnedAreasInHumanAsync loop re-reads (review issue #4)**: The
   old implementation called AddAreaToHumanAsync in a foreach loop, reading
   human + profile from the DB per iteration (up to 2N reads + 2N writes
   for N candidates). The new AddAreasToActiveProfileAsync bulk method on
   the writer does one human read, one profile read, one SaveChangesAsync —
   regardless of N.

Architecture changes:
- NEW: Core/Pgan.PoracleWebNet.Core.Abstractions/Repositories/IUserAreaDualWriter.cs
- NEW: Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs
- UserGeofenceService drops its IProfileRepository dependency (no longer
  needed) and gains IUserAreaDualWriter. IHumanRepository remains because
  it's still used by GetAllWithDetailsAsync, SubmitForReviewAsync, and
  ApproveSubmissionAsync.
- Three private direct-DB methods (AddAreaToHumanAsync,
  RemoveAreaFromHumanAsync, RemoveAreaFromAllProfilesAsync) deleted —
  replaced by writer method calls.
- DI: IUserAreaDualWriter registered as scoped in ServiceCollectionExtensions.

Tests:
- NEW: Tests/Pgan.PoracleWebNet.Tests/Repositories/UserAreaDualWriterTests.cs
  19 EF Core in-memory tests covering atomic dual-write, idempotency, CurrentProfileNo
  routing, human-missing throws, permissive delete semantics, bulk add,
  case-insensitive dedup, and every-profile removal.
- UserGeofenceServiceTests: rewrote 5 tests to assert on the writer mock
  instead of the individual repository mocks. The tests are simpler now
  (one-line Verify on the writer call) because the atomic-dual-write
  contract lives in UserAreaDualWriterTests.
- NEW: AddToProfileAsyncPropagatesWriterExceptionsAsNotFound — pins issue
  #2 fix (writer throws → exception propagates).
- NEW: the not-owned tests now also assert the writer is NEVER called when
  the ownership check fails.

Test count: 780 → 797 (+17 net = 19 new writer tests + 2 new service tests
- 4 removed tests that were testing the direct-DB read-modify-write logic
  now covered atomically by the writer tests).

Docs:
- docs/poracleng-enhancement-requests.md: updated Trusted setAreas gap
  entry to reference the new IUserAreaDualWriter abstraction and explain
  the single-SaveChanges atomicity guarantee.
- CLAUDE.md: updated Areas, Custom Geofences, and User Geofence Persistence
  sections. Added IUserAreaDualWriter and UserAreaDualWriter to the File
  Locations table.
- CHANGELOG: updated Unreleased entry to reflect the writer refactor.

HACK inventory unchanged (11 sites) — the workaround surface is the same,
just cleaner under the hood.
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Copy link
Copy Markdown
Contributor Author

@hokiepokedad2 hokiepokedad2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review — after 860747b refactor

6 commits, 13 files, +847/-141. Re-reading from scratch after the IUserAreaDualWriter refactor.

Summary of delta since the last review

The previous review flagged 4 issues — all four are now resolved by commit 860747b:

  1. Transactional driftIUserAreaDualWriter owns PoracleContext directly and commits both humans.area and profiles.area in a single SaveChangesAsync. EF Core wraps that in one implicit DB transaction, so the two writes cannot drift even if the process crashes between reads.
  2. Silent no-op on null humanAddAreaToActiveProfileAsync / RemoveAreaFromActiveProfileAsync / AddAreasToActiveProfileAsync throw InvalidOperationException when the human row is missing. The controller's existing catch (InvalidOperationException) maps to 404. RemoveAreaFromAllProfilesAsync stays permissive by design (deletion shouldn't 500 on ghost users).
  3. Redundant UnionAreaController.UpdateAreas now has _ = await … PreserveOwnedAreasInHumanAsync(…) and return this.Ok(normalizedAreas). Frontend ignored the response body anyway.
  4. Loop re-reads → New AddAreasToActiveProfileAsync bulk method: one human read + one profile read + one SaveChangesAsync regardless of N. Was 2N reads + up to 2N writes.

Test count: 780 → 797. Net +17 = 19 new writer tests (EF InMemory) + 2 new service tests − 4 removed tests that were testing the read-modify-write plumbing now owned atomically by the writer.

What I liked this time

  1. The writer abstraction is the right shape. One scoped class, holds the PoracleContext directly, 4 methods with clearly-defined atomic semantics. Zero re-implementation of repository concerns, zero new interfaces elsewhere. It is the smallest possible slice of new code that addresses the transactional concern cleanly.

  2. UserAreaDualWriterTests is genuine unit testing, not mock dance. 19 tests against EF Core's in-memory provider exercise the real read-modify-write code path. AddAreaToActiveProfileAsyncWritesToCurrentProfileNotProfile1 is exactly the off-by-one guard I would want — the earlier service-level test only proved the method was called with the right args, not that the writer actually routed to the right profile row.

  3. UserGeofenceServiceTests got simpler. Before: complex It.Is<Human>(h => h.Area.Contains(...)) + It.Is<Profile>(p => ...) predicates that duplicated the direct-DB assertion across every test. After: one-line _areaWriter.Verify(w => w.AddAreaToActiveProfileAsync("u1", "downtown")). Service tests focus on service-level concerns (ownership checks, exception propagation, reload triggering) and the atomic-dual-write contract lives one level down.

  4. New AddToProfileAsyncPropagatesWriterExceptionsAsNotFound test pins the issue-#2 fix concretely. The not-owned tests now also assert the writer is never touched when ownership fails — a nice defense-in-depth check that short-circuit logic doesn't regress into an accidental write.

  5. RemoveAreaFromAllProfilesAsync has an asymmetric contract (permissive on missing human vs throwing on the active-profile variants) and this is documented in the interface XML doc, the implementation, and the writer tests. The contract is pinned from three angles.

  6. AddAreasToActiveProfileAsyncDeduplicatesCaseInsensitiveInput test uses a regex match count to prove the dedup. Slightly overkill but correct — it guards against the obvious refactor that strips the .Distinct() call.

  7. IProfileRepository is dropped from UserGeofenceService. Smaller dependency surface. Fewer things to mock, fewer things to think about.

Issues / concerns

🟢 1. ParseAreas is duplicated between UserGeofenceService.cs and UserAreaDualWriter.cs

Both have the same private static method with the same JSON/CSV fallback parsing logic. Could be extracted to a shared helper in Core.Abstractions or Core.Models. Not worth doing now — they are in different assemblies and the method is 15 lines. If a third copy appears, that is the signal to extract.

Severity: Nit. Not blocking.

🟢 2. Writer tests use EF Core in-memory provider, not real MySQL

EF Core's InMemory provider doesn't exactly replicate MySQL semantics — it skips FK enforcement, doesn't honor column types (e.g., longtext), and has slightly different transaction behavior. The writer's single-SaveChangesAsync atomicity claim is a MySQL guarantee, not strictly proven by the InMemory tests.

That said: the InMemory provider does correctly model change tracking and the SaveChanges boundary, which is what these tests are actually verifying. A real integration test would need a MySQL container (or LocalDB / testcontainers), which is a bigger infrastructure investment than this PR should open.

Severity: Low. Current tests prove the service-level contract. Real atomicity is an EF Core + provider guarantee, not something test code can enforce.

🟢 3. No concurrency token on humans.area / profiles.area

Two concurrent AddAreaToActiveProfileAsync calls both read the pre-change state, both compute a new list, both call SaveChangesAsync. Last-write-wins on MySQL — one name wins, the other is lost.

Pre-existing limitation (it was true of the direct-DB path pre-#88 too, and it is true of PoracleJS which doesn't use EF Core at all). Mitigating would require adding an EF Core concurrency token to HumanEntity.Area / ProfileEntity.Area, which PoracleJS doesn't know about and might break on shared migrations.

Severity: Low pre-existing. Not introduced by this PR. Users don't toggle two geofences simultaneously in practice. Not blocking.

🟢 4. No input validation on areaName in the writer

AddAreaToActiveProfileAsync(humanId, "") would add an empty string to the list. AddAreaToActiveProfileAsync(humanId, null!) would throw NullReferenceException on .ToLowerInvariant(). Current call sites (UserGeofenceService.CreateAsync → passes kojiName which is validated upstream; AddToProfileAsync → passes geofence.KojiName which is DB-stored and known-valid) never hit these cases. But the writer is a public interface — someone could call it wrong later.

Severity: Nit. Could add ArgumentException.ThrowIfNullOrWhiteSpace(areaName) at the top of each method. Not blocking; defer until it matters.

🟢 5. AddAreasToActiveProfileAsync uses List<string>.Contains in the inner loop

For each name in normalized, it scans the full humanAreas and profileAreas lists. That is O(N·M). For N=10, M=10 (the max), it is 100 operations per method call, run once per Areas-page save. Negligible.

Could hoist to HashSet<string> for O(N+M) but this is a bikeshed for 100 ops.

Severity: Micro-perf nit. Not blocking.

Test coverage gaps (not blocking)

  • No integration test proving real MySQL atomicity. As noted in concern #2, this would need test infrastructure we don't have.
  • No test for concurrent-write race. Pre-existing gap, out of scope.

Style / consistency

  • Writer method naming (AddAreaToActiveProfileAsync, RemoveAreaFromActiveProfileAsync, AddAreasToActiveProfileAsync, RemoveAreaFromAllProfilesAsync) is verbose but descriptive — the word "Active" disambiguates from RemoveAreaFromAllProfiles. Good.
  • Interface uses explicit public modifier on methods to match the codebase convention (IUserGeofenceService does the same).
  • HACK comment format consistent across all 11 sites.
  • Writer tests use IDisposable for context cleanup, unique DB name per test (UserAreaDualWriter_{Guid.NewGuid()}), and helper methods for seeding. Standard and clean.

Documentation

  • CLAUDE.md — updated to reference IUserAreaDualWriter in the Areas, Custom Geofences, and User Geofence Persistence sections. New entries in the File Locations table.
  • CHANGELOG.md — Unreleased entry updated to describe the writer refactor and the atomic single-SaveChanges contract.
  • docs/poracleng-enhancement-requests.mdTrusted setAreas gap entry updated to reference the writer abstraction and the single-SaveChanges atomicity guarantee.

HACK inventory (11 sites)

  • AreaController.cs (1): UpdateAreas merge call
  • IUserAreaDualWriter.cs (1): interface-level HACK remark
  • UserGeofenceService.cs (9): 6 HACK comments on the writer-delegate call sites + 3 on the manual reload calls (AddToProfileAsync, RemoveFromProfileAsync, PreserveOwnedAreasInHumanAsync)

When PoracleNG ships the trusted setAreas variant:

  1. Delete IUserAreaDualWriter and UserAreaDualWriter (and their tests).
  2. Replace writer calls in UserGeofenceService.Create / Delete / AdminDelete / AddToProfile / RemoveFromProfile with _humanProxy.SetAreasAsync(...) calls.
  3. Delete PreserveOwnedAreasInHumanAsync and its callsite in AreaController.UpdateAreas.
  4. Delete the 3 manual ReloadGeofencesSafeAsync calls in the writer-using methods (the proxy handler triggers its own reload).

Checks

  • ESLint: ✅ pass
  • dotnet format --verify-no-changes: ✅ zero whitespace errors in touched files (pre-existing IDE/CA warnings on unrelated files unchanged)
  • Release build: ✅ 0 errors, 3 pre-existing warnings
  • Backend xUnit: ✅ 797 passed
  • Frontend Jest: ✅ 576 passed
  • Angular prod build: ✅ success
  • CI on 860747b: ✅ all 4 checks green (Backend / Frontend / CodeQL csharp / CodeQL js)

Recommendation

✅ Approve. All four previous review issues are resolved. The refactor is a net improvement — cleaner code, simpler tests, atomic writes. Remaining issues are nits (code duplication, input validation, micro-perf) or pre-existing concerns out of scope (concurrency, MySQL integration testing).

Follow-up tickets worth filing (unchanged from previous review):

  • Open the PoracleNG enhancement request upstream citing this PR and the trusted-set-areas gap doc.
  • Consider adding a MySQL integration test harness for the writer when the test infra investment makes sense.

Ship it. 🚀

Addresses the non-blocking findings from the third review pass:

1. **ParseAreas duplication (issue #1)**: Extracted to new
   AreaListJson static helper under Core.Models.Helpers — both
   UserGeofenceService (Core.Services) and UserAreaDualWriter
   (Core.Repositories) can reach it via their transitive reference
   to Core.Models through Core.Abstractions. Both call sites now
   delegate to AreaListJson.Parse / AreaListJson.Serialize.
   Serialize() also guards against empty input always returning
   "[]" rather than the JsonSerializer default (which is the same
   but is now explicit and documented).

2. **Input validation (issue #4)**: Added ArgumentException.ThrowIfNullOrWhiteSpace
   guards to every public writer method for humanId + areaName.
   AddAreasToActiveProfileAsync also validates the collection is
   non-null and strips blank/whitespace-only entries from the input
   before processing — they'd be indistinguishable from "no area"
   and shouldn't bloat the persisted list.

3. **Bulk Contains micro-optimization (issue #5)**:
   AddAreasToActiveProfileAsync's inner loop used List<string>.Contains
   which was O(N·M). Switched to HashSet<string> lookup — O(N+M) —
   while keeping the backing list for serialization so the on-disk
   ordering is stable across writes. For N=10, M=10 the change is
   100 → 20 operations per call, which is academic but removes a
   code smell.

Issues #2 (InMemory vs MySQL integration tests) and #3 (concurrency
token) remain out of scope — both require test infrastructure or
PoracleJS-compatible migrations that don't belong in this PR.

Tests:
- NEW: Tests/Pgan.PoracleWebNet.Tests/Helpers/AreaListJsonTests.cs
  9 tests covering null/empty/whitespace input, JSON array parsing,
  legacy CSV fallback (preserves backwards compatibility for rows
  written by older PoracleWeb versions), empty serialization, and
  round-trip.
- Added 22 new guard-clause tests to UserAreaDualWriterTests covering
  null/empty/whitespace humanId + areaName on all 4 methods, plus
  null collection on the bulk add, plus blank-entry stripping in
  AddAreasToActiveProfileAsync.
- Tests use ThrowsAnyAsync<ArgumentException> (not ThrowsAsync) so
  they accept both ArgumentException and ArgumentNullException —
  ThrowIfNullOrWhiteSpace throws the subtype on null input and xUnit's
  ThrowsAsync is exact-type match.

Test count: 797 → 828 (+31 new tests, 0 removed).

No HACK surface change — the workaround footprint stays at 11 sites.
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Copy link
Copy Markdown
Contributor Author

@hokiepokedad2 hokiepokedad2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review — after f162b25 nit-fix pass

7 commits, 18 files, +1140/-184. Re-reading after commit f162b25 addressed the three nits from the previous review.

Summary of delta since the last review

Finding from review 3 Status after f162b25
🟢 1. ParseAreas duplicated between UserGeofenceService and UserAreaDualWriter ✅ Extracted to AreaListJson static helper in Core.Models.Helpers. Both call sites now delegate via AreaListJson.Parse / AreaListJson.Serialize.
🟢 2. EF InMemory provider vs real MySQL ⏸ Deferred — needs testcontainers/MySQL harness, out of PR scope. Documented.
🟢 3. No concurrency token on humans.area / profiles.area ⏸ Deferred — pre-existing, would need a PoracleJS-compatible migration. Documented.
🟢 4. No input validation on writer ArgumentException.ThrowIfNullOrWhiteSpace on every public method for both humanId and areaName. Bulk method also validates the collection is non-null and strips blank entries from input.
🟢 5. O(N·M) List.Contains in bulk loop ✅ Switched to HashSet<string> for membership lookup — O(N+M). Backing list retained for stable on-disk ordering.

Test count: 797 → 828 (+31 new tests, 0 removed).

What I liked this time

  1. AreaListJson is the right shape for the extract. 40 lines, two static methods, clear XML docs explaining why it lives in Core.Models.Helpers (cross-cutting location that both the services layer and the repositories layer can reach via transitive refs). The placement was a real design question — the commit message explains it — and the result is pragmatic: zero new project references, zero circular deps, both consumers reach it through their existing Core.AbstractionsCore.Models link.

  2. Serialize is opinionated about empty input. Returns "[]" literally for empty lists instead of relying on JsonSerializer.Serialize([]) (which returns the same thing, but the conditional makes the intent explicit and the XML doc pins it). Every previous call site had the same Count > 0 ? Serialize : "[]" pattern inline — now it's one place.

  3. AreaListJsonTests covers the legacy CSV fallback. Rows written by older PoracleWeb versions used comma-separated values instead of JSON arrays. ParseFallsBackToCommaSeparatedForLegacyRows pins that backwards compatibility. Without this test, a future refactor that drops the catch (JsonException) branch would silently break rows in old databases.

  4. AddAreasToActiveProfileAsync strips blank entries from input. A caller passing ["my park", "", " ", "my square"] now gets only the two real names persisted. The AddAreasToActiveProfileAsyncStripsBlankEntriesFromInput test asserts Assert.DoesNotContain("\"\"", human.Area) which is a nice defensive check against the empty-JSON-string landing in the column. Also — AddAreasToActiveProfileAsyncReturnsFalseWhenAllInputIsBlank catches the all-blank case so we don't hit the DB for a no-op.

  5. Guard-clause tests use ThrowsAnyAsync<ArgumentException> with an explanatory comment in the commit message. xUnit's ThrowsAsync<T> is exact-type match and rejects ArgumentNullException when you ask for ArgumentException. The tests cover both null and whitespace paths in a single [Theory] by using ThrowsAnyAsync — that's the correct idiom and is worth a mention so future readers don't wonder why it's not ThrowsAsync.

  6. HashSet optimization keeps the backing list. A naïve rewrite would've replaced List<string> with HashSet<string> entirely, which would break the stable on-disk ordering. The commit kept the list for serialization and added the set purely for membership lookups — so the JSON column order is stable across writes, which makes diffs/audit logs deterministic.

Issues / concerns

🟢 1. FortChange.cs:85 also uses JsonSerializer.Deserialize<List<string>> but doesn't use the new helper

Found one more call site while reviewing: Core/Pgan.PoracleWebNet.Core.Models/FortChange.cs:85 has return JsonSerializer.Deserialize<List<string>>(str) ?? []; without the null-guard or CSV fallback. It's unclear whether that column can hold legacy CSV data. If it can, it's a latent bug; if it can't, it's fine to leave alone.

Severity: Nit. Out of scope for this PR — FortChange is unrelated to the geofence persistence fix. Worth a separate follow-up ticket if the column format is ambiguous.

🟢 2. AreaListJson.Parse CSV fallback catches a misparse of JSON-that-isn't-an-array-of-strings

If someone writes "[1, 2, 3]" (numeric JSON) to humans.area, Deserialize<List<string>> throws JsonException and the CSV fallback kicks in, splitting "[1, 2, 3]"["[1", "2", "3]"]. These garbage values then get persisted.

This is a pre-existing behavior quirk (the old ParseAreas had the same bug) and only triggers on malformed DB content. Not worth guarding against here — the Poracle DB is trusted input from PoracleJS.

Severity: Nit, pre-existing. Not blocking.

🟢 3. AddAreaToActiveProfileAsync (single-item) still uses List.Contains

Only the bulk method was optimized. Single-item AddAreaToActiveProfileAsync still does if (!humanAreas.Contains(lowerName)) which is O(N). For N=10 (hard limit) it's 10 ops — totally negligible — and the method is simpler without the extra HashSet allocation.

Not a real issue, noting only because a consistency-minded reviewer might flag it.

Severity: Micro-perf consistency nit. Deliberately left alone.

Test coverage gaps (unchanged from previous review)

  • No integration test against real MySQL (concern #2 from review 3)
  • No concurrent-write race test (concern #3 from review 3)

Both out of scope.

Style / consistency

  • AreaListJson.Serialize takes IReadOnlyList<string> for broad compatibility. Call sites pass List<string> which satisfies the interface.
  • Guard-clause order in writer methods: ThrowIfNullOrWhiteSpace(humanId) first, then areaName. Consistent across all 4 methods.
  • Theory test parameters use [InlineData(null)] [InlineData("")] [InlineData(" ")] as the standard triplet. Consistent across 6 guard-clause theories.
  • Format-clean across all my files (dotnet format --verify-no-changes passes on the files I touched; pre-existing IDE/CA warnings on unrelated files are unchanged).

Documentation

  • AreaListJson XML doc explains the legacy CSV fallback and the "[]" empty-array contract.
  • IUserAreaDualWriter XML doc still accurate after the refactor (interface contract unchanged).
  • CLAUDE.md, CHANGELOG.md, docs/poracleng-enhancement-requests.md unchanged in this pass — still accurate.

HACK inventory (unchanged)

11 sites — same as review 3. The helper extraction doesn't change the workaround surface area because AreaListJson is a pure utility (no HACK contract). When PoracleNG ships the trusted setAreas variant, AreaListJson can stay as-is (it's useful for parsing the humans.area column however it's written) — only the writer and its callers get reverted.

Checks

  • ESLint: ✅ pass
  • dotnet format --verify-no-changes: ✅ 0 warnings in touched files (pre-existing warnings on unrelated files unchanged)
  • Release build: ✅ 0 errors, 3 pre-existing warnings
  • Backend xUnit: ✅ 828 passed
  • Frontend Jest: ✅ 576 passed
  • Angular prod build: ✅ success
  • CI on f162b25: ✅ all 4 checks green

Recommendation

✅ Approve. All three actionable findings from the previous review are resolved. Remaining issues are nits on pre-existing code (FortChange.cs) or deliberate trade-offs (single-item Contains).

The PR now has a clean layered architecture:

  • AreaListJson (Core.Models.Helpers) — pure parsing utility
  • IUserAreaDualWriter (Core.Abstractions) — atomic-write contract
  • UserAreaDualWriter (Core.Repositories) — EF Core implementation
  • UserGeofenceService (Core.Services) — orchestration + ownership/reload
  • AreaController (Api) — HTTP + merge-after-setAreas

Every layer tested at the appropriate level (in-memory EF for the writer, Moq for the service, Moq for the controller, unit for the helper).

Ship it. 🚀

Addresses the nits surfaced in review pass 4:

1. **AreaListJson CSV-fallback misparse on bracketed input**: Inputs that
   fail JSON deserialization but start with `[` or `{` previously fell
   through to the CSV split, turning garbage like `[1,2,3]` into the
   "area list" `["[1", " 2", " 3]"]`. Parse() now checks the first
   non-whitespace character and returns an empty list on failed parse
   for bracketed input — only unbracketed input falls back to CSV.

2. **Single-item AddAreaToActiveProfileAsync uses different code path
   from bulk**: The single-item method previously had its own
   read-modify-write loop with `List.Contains` while the bulk method
   used the `HashSet` lookup. Single-item now delegates to the bulk
   path so there's exactly one read-modify-write implementation. The
   wrapper still validates both humanId and areaName up-front so the
   single-item contract fails fast on blank inputs (the bulk path
   strips blanks from its input collection rather than throwing,
   which is the right call for a list parameter but wrong for a
   single-name parameter).

3. **FortChange.cs:85 review finding**: Re-checked — that line is
   inside a JsonConverter handling double-encoded API strings from
   PoracleNG, NOT a database column parser. Unrelated to AreaListJson.
   The review finding was a false positive; no change needed.

Tests:
- 4 new AreaListJsonTests covering the bracketed-misparse guard:
  `[1,2,3]`, `[{"nested":"object"}]`, `{"not":"an_array"}` all return
  empty (not CSV garbage). The new `[InlineData(/*lang=json,strict*/...)]`
  hint comments silence the JSON002 analyzer warning that flagged the
  test inputs as JSON literals (matches the existing convention used in
  AreaControllerTests).
- 1 new AreaListJsonTests `ParseStillFallsBackToCsvForNonBracketedInput`
  guards against the bracketed-input fix being too aggressive — legacy
  CSV rows still parse correctly.

Test count: 828 → 832 (+4 new helper tests, no test removals).

The 12 existing single-item AddAreaToActiveProfileAsync tests all pass
unchanged after the delegation refactor — the bulk path correctly
handles single-element collections, lowercases input, dedupes on insert,
routes by human.CurrentProfileNo, throws on missing human, and tolerates
missing profile rows.
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Pre-existing IDE1006 naming-rule violation on a static readonly field
in AreaControllerTests, surfaced by `dotnet format --verify-no-changes`
when running against my touched files. Static readonly fields should
be PascalCase per the project convention.

Originated in PR #147 (commit 611ed09) but flagged on this branch
because the test file was also touched by the geofence fix.
Copy link
Copy Markdown
Contributor Author

@hokiepokedad2 hokiepokedad2 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh review — pass 5 (after 0e75cbf cleanup)

9 commits, 18 files, +1146/-186. Re-reading from scratch after the iterative fix loop converged.

Delta since the last posted review (pass 4 / f162b25)

Two follow-up commits during the fix loop:

  • 103f47a refactor: harden AreaListJson and unify writer single-item add path — addresses two of the three pass-4 nits.
  • 0e75cbf style: rename areasArray to AreasArray (IDE1006 PascalCase) — pre-existing IDE1006 violation in AreaControllerTests.cs:17 that surfaced because the file was touched by this PR.
Pass 4 finding Status after pass 5
🟢 1. FortChange.cs:85 also uses Deserialize<List<string>> False positive resolved. Re-checked: FortChange.cs:85 is inside a JsonConverter<List<string>> handling double-encoded API strings from PoracleNG, NOT a database column parser. Different problem space. The pass-4 review finding was an over-eager pattern match on my part. Documented in 103f47a's commit message.
🟢 2. AreaListJson.Parse CSV fallback misparses bracketed input Fixed in 103f47a. Parse now checks if the trimmed input starts with [ or { — bracketed input that fails JSON deserialization returns an empty list rather than falling through to CSV split. Three new theory tests pin the behavior for [1,2,3], [{"nested":"object"}], and {"not":"an_array"}. A separate ParseStillFallsBackToCsvForNonBracketedInput test guards against the fix being too aggressive — legacy unbracketed CSV rows still parse correctly.
🟢 3. Single-item AddAreaToActiveProfileAsync not unified with bulk Fixed in 103f47a. Single-item method now delegates to AddAreasToActiveProfileAsync(humanId, [areaName]). The wrapper validates both humanId and areaName up-front so the single-item contract fails fast on blank inputs (the bulk path strips blanks from its collection rather than throwing — the right call for a list parameter, the wrong call for a single-name parameter). All 12 existing single-item tests pass unchanged.

Test count: 832 (828 + 4 new AreaListJsonTests).

What I liked this time

  1. The unify-via-delegation refactor was the right call. AddAreaToActiveProfileAsync is now 7 lines: validate, validate, delegate. Zero duplicated read-modify-write logic. The wrapper's job is purely contract enforcement (fail-fast on blank inputs), and the bulk method is the single source of truth for the actual mutation.

  2. The CSV-fallback guard is precise. Pass-4 review noted the misparse but I called it "pre-existing". The pass-5 fix is two lines of bracket detection plus a looksLikeJson ? [] : csv-split branch. The new theory test cases are pleasingly nasty: numeric JSON, nested-object JSON, and a JSON object that isn't an array — all three return empty instead of garbage. And the ParseStillFallsBackToCsvForNonBracketedInput test pins the legacy CSV behavior so the next refactor that touches Parse doesn't accidentally drop the CSV branch.

  3. The [InlineData(/*lang=json,strict*/ ...)] hint comments silence the JSON002 analyzer warning without disabling it globally. Matches the existing convention used in AreaControllerTests. I almost missed this — the JSON002 warning showed up on the first build after I added the new theory cases, and the fix was a one-line comment per InlineData.

  4. The pre-existing areasArray IDE1006 cleanup is a nice unrelated bonus. Pass-4 review didn't catch it because my filter only looked at files I added. Pass-5 caught it because it was in AreaControllerTests.cs which I had already touched. Two lines, no behavior change, format-clean.

  5. Iteration 3 of the fix loop confirmed the convergence. After 0e75cbf, every subsequent check (lint, format, build, backend tests, frontend tests, prod build) came up clean on the first try. The CI on 0e75cbf and 103f47a are both green.

Issues / concerns

After reading the full diff end-to-end from scratch:

🟢 1. AreaListJson.Parse still misparses unbracketed JSON literals

If the column held literal JSON like "true", "123", or "\"single string\"", the deserialize fails, the bracket check is false (none of those start with [/{), and the CSV fallback runs. Result: ["true"], ["123"], ["\"single string\""]. Garbage.

These are not real-world inputs — the column is written by PoracleWeb and PoracleJS, both of which write JSON arrays. Catching them would mean either:

  • Tightening the bracket check to include ", digits, and t/f/n (which would also catch "foo", "42", "true")
  • Always returning empty on JSON parse failure (drops CSV fallback entirely — breaks legacy data)

Neither is worth doing for inputs that shouldn't exist. The pass-4 fix already covers the realistic corruption case (bracketed-but-wrong-shape JSON, e.g. PoracleNG accidentally writing [1,2,3]).

Severity: Nit, edge case. Not blocking.

🟢 2. AddAreaToActiveProfileAsync validates humanId twice

The single-item wrapper validates humanId (line 23), then calls AddAreasToActiveProfileAsync which validates humanId again (line 70). The double-validation is correct (the bulk method must guard its own contract for direct callers like PreserveOwnedAreasInHumanAsync) but slightly redundant for the single-item path. ThrowIfNullOrWhiteSpace is cheap so this is academic.

Severity: Nit, intentional. Not blocking.

🟢 3. RemoveAreaFromActiveProfileAsync and RemoveAreaFromAllProfilesAsync were not unified

The unification refactor only touched AddAreaToActiveProfileAsync because there's a corresponding bulk method (AddAreasToActiveProfileAsync). For the remove side, there's no RemoveAreasFromActiveProfileAsync bulk method on the interface — the only bulk-style remove is RemoveAreaFromAllProfilesAsync which has different semantics (every profile, not just the active one).

Adding a RemoveAreasFromActiveProfileAsync bulk method just for symmetry would expand the interface surface for zero current callers. The Add side benefits from a bulk method because of PreserveOwnedAreasInHumanAsync's merge use case; the Remove side has no equivalent need.

Severity: Asymmetric by design. Not blocking.

🟢 4. HashSet builds in AddAreasToActiveProfileAsync don't use OrdinalIgnoreCase comparer

Lines 97 and 118 build new HashSet<string>(humanAreas) and new HashSet<string>(profileAreas) from the parsed list. These use the default (case-sensitive) string comparer. The convention is that all area names in the DB are lowercase (CLAUDE.md: "Geofence names are always lowercase"), and the writer's input goes through ToLowerInvariant, so the case-sensitive comparison is fine in practice.

If the DB ever has mixed case (e.g. someone manually pokes a row), the dedup would treat "Downtown" and "downtown" as different. Defensive fix: new HashSet<string>(humanAreas, StringComparer.OrdinalIgnoreCase). Two-character cost, zero perf impact.

Severity: Defensive nit. The DB convention guarantees this can't happen for non-corrupt data. Not blocking.

🟢 5. PreserveOwnedAreasInHumanAsync returns IReadOnlyList<string> but caller discards it

AreaController.UpdateAreas does _ = await … PreserveOwnedAreasInHumanAsync(…). Tests use the return value to assert which names were restored. Could change the method to return Task<int> (count of restored items) or Task<bool> (any restored), but the test assertions would have to change too. The current shape is the most informative for tests and harmless for production.

Severity: Nit, intentional. Not blocking.

Test coverage gaps (unchanged from pass 4)

  • No real-MySQL integration test (out of scope, requires testcontainers)
  • No concurrent-write race test (pre-existing limitation)

Both deferred per the previous reviews.

Style / consistency

  • All format-clean across files I touched (dotnet format --verify-no-changes passes on each file individually).
  • HACK comment format consistent across all 11 sites.
  • Theory test parameters use the standard [InlineData(null)] [InlineData("")] [InlineData(" ")] triplet.
  • New [InlineData(/*lang=json,strict*/ ...)] comments match the existing convention used in AreaControllerTests.
  • Guard clauses in writer methods always run before any DB I/O — consistent across all 4 public methods.
  • AreaListJson.Parse and AreaListJson.Serialize symmetric in shape (both single-line bodies for the simple cases, single-line XML doc summary).

Documentation

All three doc surfaces accurate after the refactor:

  • CLAUDE.md — User Geofence Persistence section references IUserAreaDualWriter
  • CHANGELOG.md — Unreleased entry describes the writer + atomic single-SaveChanges contract
  • docs/poracleng-enhancement-requests.mdTrusted setAreas gap entry references the writer abstraction
  • IUserAreaDualWriter XML doc still accurate after the delegation refactor (interface contract unchanged)

HACK inventory (11 sites, unchanged)

  • AreaController.cs (1): UpdateAreas merge call
  • IUserAreaDualWriter.cs (1): interface-level remark
  • UserGeofenceService.cs (9): 6 writer-delegate calls + 3 manual reload calls

Checks

  • ESLint: ✅ pass
  • dotnet format --verify-no-changes (all 11 touched files): ✅ 0 warnings
  • Release build: ✅ 0 errors, 3 pre-existing warnings on unrelated files
  • Backend xUnit: ✅ 832 passed
  • Frontend Jest: ✅ 576 passed
  • Angular prod build: ✅ success
  • CI on 0e75cbf: ✅ all 4 checks green
  • CI on 103f47a: ✅ all 4 checks green

Recommendation

✅ Approve. Iterative loop converged. Three pass-4 nits are resolved (one was a false positive, two had real fixes). Pass-5 found only intentional design choices and one tighter-than-needed defensive nit. Nothing blocks merge.

Net diff stats since branch creation: 18 files, +1146/-186, 9 commits. The PR is now end-state stable.

Ship it. 🚀

Addresses the actionable nits from review pass 5 + a follow-on
asymmetry caught during the iteration-2 internal review:

1. AreaListJson.Parse — JSON detection now also covers `"`-prefixed
   input. Quoted JSON string literals like `"foo"` previously fell
   through to the CSV split, producing `["\"foo\""]` (one entry with
   literal quotes). Now they correctly return empty. Bare JSON
   literals (true/false/null/numbers) still fall to CSV because we
   can't distinguish them from legacy CSV values starting with the
   same characters (e.g. legacy "1st avenue" or "north park").
   Tightening the detection further would regress real CSV inputs.

2. UserAreaDualWriter HashSet builds in AddAreasToActiveProfileAsync
   now use StringComparer.OrdinalIgnoreCase. Defensive against the
   DB ever holding mixed-case area names (manual DB poke, future
   PoracleJS change, mid-migration state) — the dedup will still
   correctly treat "Downtown" and "downtown" as the same entry
   instead of producing both.

3. Symmetric case-insensitive removal: RemoveAreaFromActiveProfileAsync
   and RemoveAreaFromAllProfilesAsync previously used List.Remove
   which is case-sensitive. The Add side became case-insensitive in
   step 2 above, leaving an asymmetry where Add would dedupe correctly
   but Remove would silently fail to delete a mixed-case entry.
   Both methods now use RemoveAll with OrdinalIgnoreCase comparison
   for consistency.

Tests:
- 2 new AreaListJsonTests (`ParseReturnsEmptyForJsonStringLiteralInsteadOfQuotedGarbage`)
  pinning the `"foo"` and `"some,csv,looking,thing"` quoted-literal cases.
- 1 new writer test (`AddAreasToActiveProfileAsyncDoesNotDuplicateWhenDbHasMixedCase`)
  verifying mixed-case input doesn't produce duplicates.
- 2 new writer tests (`RemoveAreaFromActiveProfileAsyncIsCaseInsensitive`,
  `RemoveAreaFromAllProfilesAsyncIsCaseInsensitive`) verifying the
  symmetric case-insensitive removal behavior on both single-profile
  and all-profiles paths.

Test count: 832 → 837 (+5 new tests).

Also moved a `// Guard clause:` comment out from between an expression-
bodied method's `=>` and the call expression — `dotnet format` flagged
the layout as a WHITESPACE error and the formatter couldn't auto-fix
it. Hoisted the comment above the [Theory] attribute group instead.
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Comment thread Core/Pgan.PoracleWebNet.Core.Repositories/UserAreaDualWriter.cs Fixed
Iteration-2 internal review caught a DRY issue: the
`list.RemoveAll(a => string.Equals(a, lowerName, StringComparison.OrdinalIgnoreCase)) > 0`
pattern was duplicated 4 times across RemoveAreaFromActiveProfileAsync
(human + active profile) and RemoveAreaFromAllProfilesAsync
(human + all-profiles loop).

Extracted to a private static `RemoveCaseInsensitive(List<string>, string)` helper
on the writer class. Each call site is now one line and the
case-insensitive contract is documented in one place via the helper's
XML doc.

No behavior change. All 837 tests still pass.
Comment on lines +110 to +117
foreach (var name in normalized)
{
if (humanSet.Add(name))
{
humanAreas.Add(name);
humanChanged = true;
}
}
Comment on lines +130 to +137
foreach (var name in normalized)
{
if (profileSet.Add(name))
{
profileAreas.Add(name);
profileChanged = true;
}
}
@hokiepokedad2 hokiepokedad2 merged commit 9279736 into main Apr 11, 2026
4 checks passed
@hokiepokedad2 hokiepokedad2 deleted the fix/geofence-profile-toggle-persistence branch April 11, 2026 23:34
github-actions Bot added a commit that referenced this pull request Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom geofences won't stay enabled on profiles after toggle

1 participant