fix: custom geofence toggle not persisting (#163)#164
Conversation
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.
hokiepokedad2
left a comment
There was a problem hiding this comment.
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
-
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.
-
HACK tags are exhaustive and discoverable. All 8 sites use the same
HACK: trusted-set-areasmarker.grep -rn "HACK: trusted-set-areas" --include="*.cs"lists every reversion point. -
The reload-after-direct-DB bug caught in self-review is real. PoracleNG's
HandleSetAreasterminates withreloadState(deps), so the pre-direct-DB code got the state refresh for free. Commitfc6e34acaught this before it shipped — without it the toggle would only take effect on the next organic reload (minutes). -
Test coverage matches the new surface.
AddToProfileAsyncWritesToCurrentProfileNoFromHumanis a good regression guard — it passes a stale JWTprofileNoand asserts the write lands on the human's authoritativeCurrentProfileNo.PreserveOwnedAreasInHumanAsyncSkipsReloadWhenNothingToRestorepins the don't-double-reload optimization. -
Ownership filtering in
PreserveOwnedAreasInHumanAsyncis not optional. The method validates candidates against_repository.GetByHumanIdAsync(humanId)so an attacker can't inject arbitrary strings intohumans.areaviaPUT /api/areas— the merge only happens for names the user actually owns. -
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-600 — AddAreaToHumanAsync 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].area → humans.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.UpdateAsyncthrowing after_humanRepo.UpdateAsyncsucceeds is not asserted anywhere. - No test for the silent
human is nullbranch —AddAreaToHumanAsyncearly-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— newTrusted setAreasgap 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
PreserveOwnedAreasInHumanAsyncto 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-areasgap 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.
hokiepokedad2
left a comment
There was a problem hiding this comment.
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:
- Transactional drift →
IUserAreaDualWriterownsPoracleContextdirectly and commits bothhumans.areaandprofiles.areain a singleSaveChangesAsync. EF Core wraps that in one implicit DB transaction, so the two writes cannot drift even if the process crashes between reads. - Silent no-op on null human →
AddAreaToActiveProfileAsync/RemoveAreaFromActiveProfileAsync/AddAreasToActiveProfileAsyncthrowInvalidOperationExceptionwhen the human row is missing. The controller's existingcatch (InvalidOperationException)maps to 404.RemoveAreaFromAllProfilesAsyncstays permissive by design (deletion shouldn't 500 on ghost users). - Redundant
Union→AreaController.UpdateAreasnow has_ = await … PreserveOwnedAreasInHumanAsync(…)andreturn this.Ok(normalizedAreas). Frontend ignored the response body anyway. - Loop re-reads → New
AddAreasToActiveProfileAsyncbulk method: one human read + one profile read + oneSaveChangesAsyncregardless 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
-
The writer abstraction is the right shape. One scoped class, holds the
PoracleContextdirectly, 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. -
UserAreaDualWriterTestsis genuine unit testing, not mock dance. 19 tests against EF Core's in-memory provider exercise the real read-modify-write code path.AddAreaToActiveProfileAsyncWritesToCurrentProfileNotProfile1is 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. -
UserGeofenceServiceTestsgot simpler. Before: complexIt.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. -
New
AddToProfileAsyncPropagatesWriterExceptionsAsNotFoundtest 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. -
RemoveAreaFromAllProfilesAsynchas 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. -
AddAreasToActiveProfileAsyncDeduplicatesCaseInsensitiveInputtest uses a regex match count to prove the dedup. Slightly overkill but correct — it guards against the obvious refactor that strips the.Distinct()call. -
IProfileRepositoryis dropped fromUserGeofenceService. 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 fromRemoveAreaFromAllProfiles. Good. - Interface uses explicit
publicmodifier on methods to match the codebase convention (IUserGeofenceServicedoes the same). - HACK comment format consistent across all 11 sites.
- Writer tests use
IDisposablefor context cleanup, unique DB name per test (UserAreaDualWriter_{Guid.NewGuid()}), and helper methods for seeding. Standard and clean.
Documentation
CLAUDE.md— updated to referenceIUserAreaDualWriterin 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.md—Trusted setAreasgap entry updated to reference the writer abstraction and the single-SaveChanges atomicity guarantee.
HACK inventory (11 sites)
AreaController.cs(1):UpdateAreasmerge callIUserAreaDualWriter.cs(1): interface-level HACK remarkUserGeofenceService.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:
- Delete
IUserAreaDualWriterandUserAreaDualWriter(and their tests). - Replace writer calls in
UserGeofenceService.Create/Delete/AdminDelete/AddToProfile/RemoveFromProfilewith_humanProxy.SetAreasAsync(...)calls. - Delete
PreserveOwnedAreasInHumanAsyncand its callsite inAreaController.UpdateAreas. - Delete the 3 manual
ReloadGeofencesSafeAsynccalls 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-areasgap 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.
hokiepokedad2
left a comment
There was a problem hiding this comment.
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
-
AreaListJsonis the right shape for the extract. 40 lines, two static methods, clear XML docs explaining why it lives inCore.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 existingCore.Abstractions→Core.Modelslink. -
Serializeis opinionated about empty input. Returns"[]"literally for empty lists instead of relying onJsonSerializer.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 sameCount > 0 ? Serialize : "[]"pattern inline — now it's one place. -
AreaListJsonTestscovers the legacy CSV fallback. Rows written by older PoracleWeb versions used comma-separated values instead of JSON arrays.ParseFallsBackToCommaSeparatedForLegacyRowspins that backwards compatibility. Without this test, a future refactor that drops thecatch (JsonException)branch would silently break rows in old databases. -
AddAreasToActiveProfileAsyncstrips blank entries from input. A caller passing["my park", "", " ", "my square"]now gets only the two real names persisted. TheAddAreasToActiveProfileAsyncStripsBlankEntriesFromInputtest assertsAssert.DoesNotContain("\"\"", human.Area)which is a nice defensive check against the empty-JSON-string landing in the column. Also —AddAreasToActiveProfileAsyncReturnsFalseWhenAllInputIsBlankcatches the all-blank case so we don't hit the DB for a no-op. -
Guard-clause tests use
ThrowsAnyAsync<ArgumentException>with an explanatory comment in the commit message. xUnit'sThrowsAsync<T>is exact-type match and rejectsArgumentNullExceptionwhen you ask forArgumentException. The tests cover both null and whitespace paths in a single[Theory]by usingThrowsAnyAsync— that's the correct idiom and is worth a mention so future readers don't wonder why it's notThrowsAsync. -
HashSetoptimization keeps the backing list. A naïve rewrite would've replacedList<string>withHashSet<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.SerializetakesIReadOnlyList<string>for broad compatibility. Call sites passList<string>which satisfies the interface.- Guard-clause order in writer methods:
ThrowIfNullOrWhiteSpace(humanId)first, thenareaName. 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-changespasses on the files I touched; pre-existing IDE/CA warnings on unrelated files are unchanged).
Documentation
AreaListJsonXML doc explains the legacy CSV fallback and the"[]"empty-array contract.IUserAreaDualWriterXML doc still accurate after the refactor (interface contract unchanged).CLAUDE.md,CHANGELOG.md,docs/poracleng-enhancement-requests.mdunchanged 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 utilityIUserAreaDualWriter(Core.Abstractions) — atomic-write contractUserAreaDualWriter(Core.Repositories) — EF Core implementationUserGeofenceService(Core.Services) — orchestration + ownership/reloadAreaController(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.
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.
hokiepokedad2
left a comment
There was a problem hiding this comment.
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:
103f47arefactor: harden AreaListJson and unify writer single-item add path — addresses two of the three pass-4 nits.0e75cbfstyle: rename areasArray to AreasArray (IDE1006 PascalCase) — pre-existing IDE1006 violation inAreaControllerTests.cs:17that 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
-
The unify-via-delegation refactor was the right call.
AddAreaToActiveProfileAsyncis 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. -
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-splitbranch. 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 theParseStillFallsBackToCsvForNonBracketedInputtest pins the legacy CSV behavior so the next refactor that touchesParsedoesn't accidentally drop the CSV branch. -
The
[InlineData(/*lang=json,strict*/ ...)]hint comments silence the JSON002 analyzer warning without disabling it globally. Matches the existing convention used inAreaControllerTests. 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. -
The pre-existing
areasArrayIDE1006 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 inAreaControllerTests.cswhich I had already touched. Two lines, no behavior change, format-clean. -
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 on0e75cbfand103f47aare 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, andt/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-changespasses 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 inAreaControllerTests. - Guard clauses in writer methods always run before any DB I/O — consistent across all 4 public methods.
AreaListJson.ParseandAreaListJson.Serializesymmetric 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 referencesIUserAreaDualWriterCHANGELOG.md— Unreleased entry describes the writer + atomic single-SaveChanges contractdocs/poracleng-enhancement-requests.md—Trusted setAreasgap entry references the writer abstractionIUserAreaDualWriterXML doc still accurate after the delegation refactor (interface contract unchanged)
HACK inventory (11 sites, unchanged)
AreaController.cs(1):UpdateAreasmerge callIUserAreaDualWriter.cs(1): interface-level remarkUserGeofenceService.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.
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.
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}/setAreashandler (processor/internal/api/humans.go:416-498) intersects the submitted area list against fences whereUserSelectable == truefor non-admin users:PoracleWeb's
GeofenceFeedController.cs:77intentionally serves user-drawn geofences withuserSelectable: falseto hide them from the Poracle bot's!areapicker. The two interact such that everySetAreasAsynccall containing a user geofence name silently strips that name — no error, no warning, just dropped fromhumans.area.Regression introduced in v2.0.0 (#88) — the PoracleNG proxy migration replaced the pre-migration direct-DB write in
UserGeofenceService.AddAreaToHumanAsyncwith_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 throughsetAreasand stripped any active user geofences.Fix
UserGeofenceService.AddAreaToHumanAsync/RemoveAreaFromHumanAsync/RemoveAreaFromAllProfilesAsync— restored the pre-feat: migrate Poracle DB writes to PoracleNG API proxy #88 direct-DB path. Loads the human viaIHumanRepository.GetByIdAsync, mutateshuman.Area, writes back, then mirrors toprofiles.areafor the authoritativehuman.CurrentProfileNo(not the JWT claim — those can drift from Poracle's idea of the active profile).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 againstIUserGeofenceRepository.GetByHumanIdAsyncso arbitrary strings cannot be injected.AreaController.UpdateAreas— afterSetAreasAsyncreturns, callsPreserveOwnedAreasInHumanAsyncwith the normalized request. Response returns the effective merged list.CLAUDE.md+CHANGELOG.md— documented the PoracleNG filter behavior and why direct-DB is load-bearing for this specific case.dotnet formatcleanups — included pre-existing whitespace fixes onProfile.cs,ProfileControllerTests.cs,ProfileServiceTests.cs,ActiveHoursValidationTests.csthat the formatter auto-applied. No behavior change.Tests
CreateAsyncUpdatesHumanAreaJsonArray,DeleteAsyncRemovesFromHumanAreaAndDeletesRecord,DeleteAsyncRemovesAreaFromAllProfiles,AddToProfileAsyncAddsAreaNameViaProxy→…ViaDirectDb,RemoveFromProfileAsyncRemovesAreaNameViaProxy→…ViaDirectDb) to assert direct-DB writes and verifySetAreasAsyncis never called for user geofence mutations.AddToProfileAsyncWritesToCurrentProfileNoFromHuman— guards against the JWTprofileNodrifting fromhuman.CurrentProfileNo. Passes a stale JWT claim and asserts the write lands on the Poracle-DB-authoritative profile row.PreserveOwnedAreasInHumanAsynctests (3) — owned-filter logic, empty-candidates short-circuit, no-owned-geofences short-circuit.UpdateAreasPreservesUserOwnedGeofencesAfterProxyCall— regression guard onAreaControllerverifying the post-setAreasmerge fires and returns the unioned list.Test plan