diff --git a/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs b/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs index 494fe2d..d1fe3dc 100644 --- a/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs +++ b/Campaign_Tracker.Server.Tests/AuthEndpointTests.cs @@ -7,6 +7,7 @@ using System.Security.Claims; using System.Text; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.Authentication; +using Campaign_Tracker.Server.LegacyData; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -44,13 +45,19 @@ public sealed class AuthIntegrationTestFactory : WebApplicationFactory // Replace the file-backed IAuditService with an in-memory passthrough. // File persistence is validated in AuditServiceTests; integration tests // should not depend on file-system state. - var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService)); - if (descriptor is not null) - { - services.Remove(descriptor); - } + var auditDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService)); + if (auditDescriptor is not null) + services.Remove(auditDescriptor); services.AddSingleton(); + + // Replace the data-file-backed ILegacyDataAccess with hardcoded test defaults so + // integration tests are not affected by the presence or contents of a seed file. + var legacyDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(ILegacyDataAccess)); + if (legacyDescriptor is not null) + services.Remove(legacyDescriptor); + + services.AddSingleton(new InMemoryLegacyDataAccess()); }); } diff --git a/Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs b/Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs index 22154ce..29fccd2 100644 --- a/Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs +++ b/Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs @@ -1,6 +1,8 @@ using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; +using Campaign_Tracker.Server.Audit; +using Microsoft.Extensions.DependencyInjection; namespace Campaign_Tracker.Server.Tests; @@ -97,7 +99,7 @@ public sealed class MunicipalityProfileControllerTests Assert.Contains("DOESNOTEXIST", body.Error); } - // ── Authorization: non-recognized role gets 403 ─────────────────────────── + // ── Authorization ───────────────────────────────────────────────────────── [Fact] public async Task CreateProfile_NoToken_Returns401() @@ -111,6 +113,91 @@ public sealed class MunicipalityProfileControllerTests Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } + [Fact] + public async Task CreateProfile_WrongRoleToken_Returns403() + { + await using var factory = new AuthIntegrationTestFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", AuthIntegrationTestFactory.CreateToken("prod@example.test", "production")); + + var response = await client.PostAsJsonAsync("/api/municipalities/profiles", + new { jCode = "FAIR01", displayName = (string?)null }); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task GetJurisdictions_NoToken_Returns401() + { + await using var factory = new AuthIntegrationTestFactory(); + using var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/municipalities/jurisdictions"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + // ── AC #3: audit events recorded on create and update ──────────────────── + + [Fact] + public async Task CreateProfile_RecordsAuditEvent_AC3() + { + await using var factory = new AuthIntegrationTestFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); + + await client.PostAsJsonAsync("/api/municipalities/profiles", + new { jCode = "FAIR01", displayName = "Fairview" }); + + var auditService = factory.Services.GetRequiredService(); + var events = auditService.GetRecent(); + Assert.Contains(events, e => + e.EventType == "MUNICIPALITY_PROFILE_CREATED" && + e.ActorIdentity == "cs@example.test"); + } + + [Fact] + public async Task UpdateProfile_RecordsAuditEvent_AC3() + { + await using var factory = new AuthIntegrationTestFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); + + var created = await (await client.PostAsJsonAsync("/api/municipalities/profiles", + new { jCode = "LAKE02", displayName = (string?)null })) + .Content.ReadFromJsonAsync(); + + await client.PutAsJsonAsync( + $"/api/municipalities/profiles/{created!.ProfileId}", + new { displayName = "Updated Name" }); + + var auditService = factory.Services.GetRequiredService(); + var events = auditService.GetRecent(); + Assert.Contains(events, e => + e.EventType == "MUNICIPALITY_PROFILE_UPDATED" && + e.ActorIdentity == "cs@example.test"); + } + + // ── Update not-found returns 404 ───────────────────────────────────────── + + [Fact] + public async Task UpdateProfile_UnknownId_Returns404() + { + await using var factory = new AuthIntegrationTestFactory(); + using var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services")); + + var response = await client.PutAsJsonAsync( + "/api/municipalities/profiles/does-not-exist", + new { displayName = "X" }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + // ── Local DTOs for deserialization ──────────────────────────────────────── private sealed record MunicipalityProfileDto( diff --git a/Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs b/Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs index fc97a82..f5d62e2 100644 --- a/Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs +++ b/Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs @@ -1,6 +1,7 @@ using System.Security.Claims; using Campaign_Tracker.Server.Audit; using Campaign_Tracker.Server.Authorization; +using Campaign_Tracker.Server.LegacyData; using Campaign_Tracker.Server.Municipalities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -17,19 +18,34 @@ namespace Campaign_Tracker.Server.Controllers; public sealed class MunicipalityProfileController : ControllerBase { private readonly IMunicipalityProfileRepository _profiles; + private readonly ILegacyDataAccess _legacyData; private readonly IAuditService _audit; private readonly TimeProvider _timeProvider; public MunicipalityProfileController( IMunicipalityProfileRepository profiles, + ILegacyDataAccess legacyData, IAuditService audit, TimeProvider timeProvider) { _profiles = profiles; + _legacyData = legacyData; _audit = audit; _timeProvider = timeProvider; } + // ── Available legacy jurisdictions (JCode picker source) ───────────────── + + [HttpGet("/api/municipalities/jurisdictions")] + public async Task>> GetJurisdictions( + CancellationToken cancellationToken) + { + var jurisdictions = await _legacyData.GetAllJurisdictionsAsync(cancellationToken); + return Ok(jurisdictions + .Select(j => new LegacyJurisdictionResponse(j.JCode, j.Name)) + .ToArray()); + } + // ── AC #1, AC #2: create and immediately return the combined view ───────── [HttpPost] @@ -53,7 +69,9 @@ public sealed class MunicipalityProfileController : ControllerBase RecordedAt: _timeProvider.GetUtcNow())); var view = await _profiles.GetByIdAsync(result.Profile.ProfileId, cancellationToken); - return Ok(MunicipalityProfileResponse.From(view!)); + if (view is null) + return StatusCode(500, new MunicipalityProfileProblem("Profile was saved but could not be retrieved.")); + return Ok(MunicipalityProfileResponse.From(view)); } // ── AC #2: list all profiles with resolved legacy data ─────────────────── @@ -87,7 +105,11 @@ public sealed class MunicipalityProfileController : ControllerBase var result = await _profiles.UpdateAsync(profileId, request.DisplayName, actor, cancellationToken); if (!result.Saved || result.Profile is null) + { + if (result.IsNotFound) + return NotFound(new MunicipalityProfileProblem(result.Error ?? "Profile not found.")); return UnprocessableEntity(new MunicipalityProfileProblem(result.Error ?? "Update failed.")); + } _audit.Record(new AuditEvent( EventType: "MUNICIPALITY_PROFILE_UPDATED", @@ -98,7 +120,9 @@ public sealed class MunicipalityProfileController : ControllerBase RecordedAt: _timeProvider.GetUtcNow())); var view = await _profiles.GetByIdAsync(profileId, cancellationToken); - return Ok(MunicipalityProfileResponse.From(view!)); + if (view is null) + return StatusCode(500, new MunicipalityProfileProblem("Profile was updated but could not be retrieved.")); + return Ok(MunicipalityProfileResponse.From(view)); } private string GetActor() => @@ -133,3 +157,5 @@ public sealed record MunicipalityProfileResponse( } public sealed record MunicipalityProfileProblem(string Error); + +public sealed record LegacyJurisdictionResponse(string JCode, string? Name); diff --git a/Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs b/Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs index 55b3add..f1ca61c 100644 --- a/Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs +++ b/Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Campaign_Tracker.Server.LegacyData.Models; namespace Campaign_Tracker.Server.LegacyData; @@ -29,6 +30,52 @@ public sealed class InMemoryLegacyDataAccess : ILegacyDataAccess _kitLabels = kitLabels ?? DefaultKitLabels; } + /// + /// Creates an instance seeded from a JSON file produced by the development-data + /// export script. Falls back to the hardcoded defaults if the file is absent. + /// + public static InMemoryLegacyDataAccess FromJsonSeedFile(string jsonPath) + { + if (!File.Exists(jsonPath)) + return new InMemoryLegacyDataAccess(); + + try + { + var json = File.ReadAllText(jsonPath); + var records = JsonSerializer.Deserialize( + json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? []; + + var jurisdictions = records + .Where(r => !string.IsNullOrWhiteSpace(r.JCode)) + .DistinctBy(r => r.JCode!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(r => new LegacyJurisdiction( + r.JCode!.Trim(), + string.IsNullOrWhiteSpace(r.Name) ? null : r.Name.Trim(), + string.IsNullOrWhiteSpace(r.MailingAddress) ? null : r.MailingAddress.Trim(), + string.IsNullOrWhiteSpace(r.CityStateZip) ? null : r.CityStateZip.Trim(), + string.IsNullOrWhiteSpace(r.Imb) ? null : r.Imb.Trim(), + string.IsNullOrWhiteSpace(r.ImbDigits) ? null : r.ImbDigits.Trim())) + .ToArray(); + + return new InMemoryLegacyDataAccess(jurisdictions: jurisdictions); + } + catch (Exception ex) + { + Console.Error.WriteLine($"[InMemoryLegacyDataAccess] Failed to load seed file '{jsonPath}': {ex.Message}. Falling back to hardcoded defaults."); + return new InMemoryLegacyDataAccess(); + } + } + + private sealed class JsonJurisdiction + { + public string? JCode { get; set; } + public string? Name { get; set; } + public string? MailingAddress { get; set; } + public string? CityStateZip { get; set; } + public string? Imb { get; set; } + public string? ImbDigits { get; set; } + } + // ── Jurisdiction ────────────────────────────────────────────────────────── public Task GetJurisdictionAsync( diff --git a/Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs b/Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs index 66ded1c..05c20da 100644 --- a/Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs +++ b/Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs @@ -29,12 +29,12 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess CancellationToken cancellationToken = default) { const string sql = """ - SELECT JCode, Name, MailingAddress, CityStateZip, Phone, Email + SELECT JCode, Name, Mailing_Address, CSZ, IMB, IMB_Digits FROM Jurisdiction - WHERE JCode = ? + WHERE Trim(JCode) = ? """; - var results = await QueryAsync(sql, [jCode], MapJurisdiction, cancellationToken); + var results = await QueryAsync(sql, [jCode.Trim()], MapJurisdiction, cancellationToken); return results.FirstOrDefault(); } @@ -42,7 +42,7 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess CancellationToken cancellationToken = default) { const string sql = """ - SELECT JCode, Name, MailingAddress, CityStateZip, Phone, Email + SELECT JCode, Name, Mailing_Address, CSZ, IMB, IMB_Digits FROM Jurisdiction ORDER BY JCode """; @@ -77,11 +77,11 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess BusinessAddress, BusinessAddress2, BusinessAddress3, TownshipName, TownshipNum FROM Contacts - WHERE JURISCODE = ? + WHERE Trim(JURISCODE) = ? ORDER BY ID """; - return QueryAsync(sql, [jCode], MapContact, cancellationToken); + return QueryAsync(sql, [jCode.Trim()], MapContact, cancellationToken); } public async Task GetKitByIdAsync( @@ -109,11 +109,11 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess CreatedOn, ExportedToSnailWorks, LabelsPrinted, OfficeCopiesAmount, InboundStid, OutboundStid FROM Kit - WHERE Jcode = ? + WHERE Trim(Jcode) = ? ORDER BY ID """; - return QueryAsync(sql, [jCode], MapKit, cancellationToken); + return QueryAsync(sql, [jCode.Trim()], MapKit, cancellationToken); } public Task> GetKitLabelsByKitAsync( @@ -174,10 +174,10 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess new( GetRequiredString(reader, "JCode"), GetString(reader, "Name"), - GetString(reader, "MailingAddress"), - GetString(reader, "CityStateZip"), - GetString(reader, "Phone"), - GetString(reader, "Email")); + GetString(reader, "Mailing_Address"), + GetString(reader, "CSZ"), + GetString(reader, "IMB"), + GetString(reader, "IMB_Digits")); private static LegacyContact MapContact(DbDataReader reader) => new( @@ -228,7 +228,7 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess private static string GetRequiredString(DbDataReader reader, string name) { - var value = GetString(reader, name); + var value = GetString(reader, name)?.Trim(); return string.IsNullOrWhiteSpace(value) ? throw new LegacyDataAccessException($"Required legacy join key {name} was null or empty.") : value; diff --git a/Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs b/Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs index ad15fb2..7d93340 100644 --- a/Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs +++ b/Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs @@ -13,6 +13,7 @@ public sealed class InMemoryMunicipalityProfileRepository : IMunicipalityProfileRepository, ILegacyLinkedRecordProvider { private readonly ConcurrentDictionary _profiles = new(StringComparer.OrdinalIgnoreCase); + private readonly object _lock = new(); private readonly ILegacyLinkValidator _validator; private readonly ILegacyDataAccess _legacyData; private readonly TimeProvider _timeProvider; @@ -38,27 +39,33 @@ public sealed class InMemoryMunicipalityProfileRepository if (string.IsNullOrWhiteSpace(jCode)) return MunicipalityProfileSaveResult.Failure("JCode is required."); + // P8: normalize before validation so the link validator uses the same form that gets stored + var normalizedJCode = jCode.Trim().ToUpperInvariant(); + // AC #4: validate before saving; never write if the link is invalid - var linkRef = LegacyLinkReference.ForJurisdiction(jCode); + var linkRef = LegacyLinkReference.ForJurisdiction(normalizedJCode); var validation = await _validator.ValidateAsync(linkRef, cancellationToken); if (!validation.IsValid) return MunicipalityProfileSaveResult.Failure(validation.Error!); - // Each JCode maps to exactly one municipality profile - if (_profiles.Values.Any(p => string.Equals(p.JCode, jCode, StringComparison.OrdinalIgnoreCase))) - return MunicipalityProfileSaveResult.Failure( - $"A municipality profile already exists for JCode '{jCode}'."); - var now = _timeProvider.GetUtcNow(); var profile = new MunicipalityProfile( ProfileId: Guid.NewGuid().ToString("N"), - JCode: jCode.Trim().ToUpperInvariant(), + JCode: normalizedJCode, DisplayName: string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), CreatedAt: now, UpdatedAt: now, UpdatedBy: actorIdentity); - _profiles[profile.ProfileId] = profile; + // P2: atomic check + insert under lock to prevent TOCTOU race on duplicate JCode + lock (_lock) + { + if (_profiles.Values.Any(p => string.Equals(p.JCode, normalizedJCode, StringComparison.OrdinalIgnoreCase))) + return MunicipalityProfileSaveResult.Failure( + $"A municipality profile already exists for JCode '{normalizedJCode}'."); + + _profiles[profile.ProfileId] = profile; + } return MunicipalityProfileSaveResult.Success(profile); } @@ -70,19 +77,22 @@ public sealed class InMemoryMunicipalityProfileRepository string actorIdentity, CancellationToken cancellationToken = default) { - if (!_profiles.TryGetValue(profileId, out var existing)) - return Task.FromResult(MunicipalityProfileSaveResult.Failure( - $"Municipality profile '{profileId}' not found.")); - - var updated = existing with + // P2: wrap read-modify-write in lock to prevent lost updates under concurrent PUTs + lock (_lock) { - DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), - UpdatedAt = _timeProvider.GetUtcNow(), - UpdatedBy = actorIdentity, - }; - - _profiles[profileId] = updated; - return Task.FromResult(MunicipalityProfileSaveResult.Success(updated)); + if (!_profiles.TryGetValue(profileId, out var existing)) + return Task.FromResult(MunicipalityProfileSaveResult.ProfileNotFound(profileId)); + + var updated = existing with + { + DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), + UpdatedAt = _timeProvider.GetUtcNow(), + UpdatedBy = actorIdentity, + }; + + _profiles[profileId] = updated; + return Task.FromResult(MunicipalityProfileSaveResult.Success(updated)); + } } // ── AC #2: resolve combined extension + legacy view ────────────────────── diff --git a/Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs b/Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs index 4a2978e..4fe2e3f 100644 --- a/Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs +++ b/Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs @@ -3,11 +3,15 @@ namespace Campaign_Tracker.Server.Municipalities; public sealed record MunicipalityProfileSaveResult( bool Saved, string? Error, - MunicipalityProfile? Profile) + MunicipalityProfile? Profile, + bool IsNotFound = false) { public static MunicipalityProfileSaveResult Success(MunicipalityProfile profile) => new(true, null, profile); public static MunicipalityProfileSaveResult Failure(string error) => new(false, error, null); + + public static MunicipalityProfileSaveResult ProfileNotFound(string profileId) => + new(false, $"Municipality profile '{profileId}' not found.", null, IsNotFound: true); } diff --git a/Campaign_Tracker.Server/Program.cs b/Campaign_Tracker.Server/Program.cs index 089e8fa..4f287e9 100644 --- a/Campaign_Tracker.Server/Program.cs +++ b/Campaign_Tracker.Server/Program.cs @@ -59,7 +59,10 @@ if (!string.IsNullOrWhiteSpace(legacyConnectionString)) } else if (builder.Environment.IsDevelopment()) { - builder.Services.AddSingleton(); + var jsonSeedPath = Path.GetFullPath( + Path.Combine(builder.Environment.ContentRootPath, "..", "development-data", "jurisdictions.json")); + builder.Services.AddSingleton( + _ => InMemoryLegacyDataAccess.FromJsonSeedFile(jsonSeedPath)); } else { diff --git a/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl b/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl index ba73ee7..6143092 100644 --- a/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl +++ b/Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl @@ -388,3 +388,64 @@ {"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.4317157+00:00"} {"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.432207+00:00"} {"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HO","recordedAt":"2026-05-06T17:49:02.4360749+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L0","recordedAt":"2026-05-06T20:43:56.5497091+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L0","recordedAt":"2026-05-06T20:43:56.5525818+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L3","recordedAt":"2026-05-06T20:43:56.5584051+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L3","recordedAt":"2026-05-06T20:43:56.5588351+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L4","recordedAt":"2026-05-06T20:43:56.5646905+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L4","recordedAt":"2026-05-06T20:43:56.56609+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L5","recordedAt":"2026-05-06T20:43:56.5679896+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L5","recordedAt":"2026-05-06T20:43:56.5686553+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L6","recordedAt":"2026-05-06T20:43:56.5728285+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L6","recordedAt":"2026-05-06T20:43:56.5735927+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L8","recordedAt":"2026-05-06T20:43:56.5799093+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L8","recordedAt":"2026-05-06T20:43:56.5803665+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L9","recordedAt":"2026-05-06T20:43:56.5820498+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBM0OM97L9","recordedAt":"2026-05-06T20:43:56.5824382+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LA","recordedAt":"2026-05-06T20:43:56.5832668+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBM0OM97LA","recordedAt":"2026-05-06T20:43:56.5838662+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LD","recordedAt":"2026-05-06T20:43:56.5882919+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBM0OM97LD","recordedAt":"2026-05-06T20:43:56.5887507+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LE","recordedAt":"2026-05-06T20:43:56.6273768+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBM0OM97LE","recordedAt":"2026-05-06T20:43:56.6282634+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LG","recordedAt":"2026-05-06T20:43:56.6342765+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97LG","recordedAt":"2026-05-06T20:43:56.634912+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LJ","recordedAt":"2026-05-06T20:43:56.6402231+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97LJ","recordedAt":"2026-05-06T20:43:56.6406744+00:00"} +{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBM0OM97LK","recordedAt":"2026-05-06T20:43:56.6469848+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/token/exchange","outcome":"success","traceIdentifier":"0HNLBM1Q26KDF:00000001","recordedAt":"2026-05-06T20:45:48.7738179+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDG:00000001","recordedAt":"2026-05-06T20:45:48.9878754+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDG:00000001","recordedAt":"2026-05-06T20:45:48.9981838+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDH:00000001","recordedAt":"2026-05-06T20:45:49.0949751+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDI:00000001","recordedAt":"2026-05-06T20:45:49.095015+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDI:00000001","recordedAt":"2026-05-06T20:45:49.0995614+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDH:00000001","recordedAt":"2026-05-06T20:45:49.0995802+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDJ:00000001","recordedAt":"2026-05-06T20:45:49.1434308+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDK:00000001","recordedAt":"2026-05-06T20:45:49.1434308+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDJ:00000001","recordedAt":"2026-05-06T20:45:49.1533199+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDK:00000001","recordedAt":"2026-05-06T20:45:49.1533219+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDL:00000001","recordedAt":"2026-05-06T20:45:54.7892666+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDL:00000001","recordedAt":"2026-05-06T20:45:54.7904458+00:00"} +{"eventType":"MUNICIPALITY_PROFILE_CREATED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"municipalities/profiles/326cedc8f3d2406481f1c5d94ed77200","outcome":"created JCode=01160","traceIdentifier":"0HNLBM1Q26KDL:00000001","recordedAt":"2026-05-06T20:45:54.8119+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDM:00000001","recordedAt":"2026-05-06T20:45:54.8284292+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDM:00000001","recordedAt":"2026-05-06T20:45:54.8290406+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDN:00000001","recordedAt":"2026-05-06T20:46:01.4812757+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/admin/legacy-schema/history","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDN:00000001","recordedAt":"2026-05-06T20:46:01.4822805+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDO:00000001","recordedAt":"2026-05-06T20:46:01.5218446+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/admin/legacy-schema/history","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDO:00000001","recordedAt":"2026-05-06T20:46:01.5225973+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDP:00000001","recordedAt":"2026-05-06T20:46:08.2907775+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDP:00000001","recordedAt":"2026-05-06T20:46:08.2918014+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDQ:00000001","recordedAt":"2026-05-06T20:46:08.2921768+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDQ:00000001","recordedAt":"2026-05-06T20:46:08.2927697+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDR:00000001","recordedAt":"2026-05-06T20:46:08.3053075+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDR:00000001","recordedAt":"2026-05-06T20:46:08.3061538+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDS:00000001","recordedAt":"2026-05-06T20:46:08.3068968+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDS:00000001","recordedAt":"2026-05-06T20:46:08.3073952+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDT:00000001","recordedAt":"2026-05-06T20:46:25.4620549+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDT:00000001","recordedAt":"2026-05-06T20:46:25.4629616+00:00"} +{"eventType":"MUNICIPALITY_PROFILE_CREATED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"municipalities/profiles/f63190df69274298a150396426c3ca28","outcome":"created JCode=99999","traceIdentifier":"0HNLBM1Q26KDT:00000001","recordedAt":"2026-05-06T20:46:25.4647982+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDU:00000001","recordedAt":"2026-05-06T20:46:25.4771693+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDU:00000001","recordedAt":"2026-05-06T20:46:25.4777419+00:00"} +{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDV:00000001","recordedAt":"2026-05-06T20:46:39.117667+00:00"} +{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDV:00000001","recordedAt":"2026-05-06T20:46:39.1188386+00:00"} +{"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBM1Q26KDV:00000001","recordedAt":"2026-05-06T20:46:39.158126+00:00"} diff --git a/Campaign_Tracker.Server/seed-data.json.c92561d951cd49b0b53ec61b8330edc2.tmp b/Campaign_Tracker.Server/seed-data.json.c92561d951cd49b0b53ec61b8330edc2.tmp new file mode 100644 index 0000000..6fa208e --- /dev/null +++ b/Campaign_Tracker.Server/seed-data.json.c92561d951cd49b0b53ec61b8330edc2.tmp @@ -0,0 +1,238 @@ +{ + "referenceValues": [ + { + "id": 0, + "seedKey": "operational-status.not-started", + "category": "OperationalStatus", + "name": "Not Started", + "description": "Election-cycle job work has not started.", + "value": "not-started", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "operational-status.in-progress", + "category": "OperationalStatus", + "name": "In Progress", + "description": "Election-cycle job work is actively in progress.", + "value": "in-progress", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "operational-status.blocked", + "category": "OperationalStatus", + "name": "Blocked", + "description": "Election-cycle job work is blocked and needs intervention.", + "value": "blocked", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "operational-status.complete", + "category": "OperationalStatus", + "name": "Complete", + "description": "Election-cycle job work is complete.", + "value": "complete", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "service-template.addressing", + "category": "ServiceTemplate", + "name": "Addressing", + "description": "Default service template for addressing work.", + "value": "addressing", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "service-template.sorting", + "category": "ServiceTemplate", + "name": "Sorting", + "description": "Default service template for sorting work.", + "value": "sorting", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "service-template.transportation", + "category": "ServiceTemplate", + "name": "Transportation", + "description": "Default service template for transportation work.", + "value": "transportation", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "service-template.office-copy", + "category": "ServiceTemplate", + "name": "Office Copy", + "description": "Default service template for office-copy work.", + "value": "office-copy", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "extension-reference.election-cycle.primary", + "category": "ElectionCycleType", + "name": "Primary", + "description": "Extension-layer election-cycle reference value for primary elections.", + "value": "primary", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "extension-reference.election-cycle.general", + "category": "ElectionCycleType", + "name": "General", + "description": "Extension-layer election-cycle reference value for general elections.", + "value": "general", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "extension-reference.mail-class.first-class", + "category": "MailClass", + "name": "First Class", + "description": "Extension-layer mail-class reference value.", + "value": "first-class", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "extension-reference.mail-class.standard", + "category": "MailClass", + "name": "Standard", + "description": "Extension-layer mail-class reference value.", + "value": "standard", + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + } + ], + "requiredFieldRules": [ + { + "id": 0, + "seedKey": "required-field.election-cycle-job.municipality-profile-id", + "name": "Municipality Profile", + "description": "Election-cycle jobs must be linked to a municipality profile.", + "entityType": "ElectionCycleJob", + "fieldPath": "municipalityProfileId", + "readinessFeatureKey": "FR29.ReadinessStatus", + "isRequired": true, + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "required-field.election-cycle-job.legacy-jurisdiction-j-code", + "name": "Legacy Jurisdiction Code", + "description": "Election-cycle jobs must keep the legacy jurisdiction bridge required by Story 1.8.", + "entityType": "ElectionCycleJob", + "fieldPath": "legacyJurisdictionJCode", + "readinessFeatureKey": "FR29.ReadinessStatus", + "isRequired": true, + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "required-field.election-cycle-job.election-date", + "name": "Election Date", + "description": "Election-cycle jobs need an election date before readiness can pass.", + "entityType": "ElectionCycleJob", + "fieldPath": "electionDate", + "readinessFeatureKey": "FR29.ReadinessStatus", + "isRequired": true, + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "required-field.election-cycle-job.mail-date", + "name": "Mail Date", + "description": "Election-cycle jobs need a planned mail date before readiness can pass.", + "entityType": "ElectionCycleJob", + "fieldPath": "mailDate", + "readinessFeatureKey": "FR29.ReadinessStatus", + "isRequired": true, + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + }, + { + "id": 0, + "seedKey": "required-field.election-cycle-job.service-template", + "name": "Service Template", + "description": "Election-cycle jobs need a selected service template before readiness can pass.", + "entityType": "ElectionCycleJob", + "fieldPath": "serviceTemplate", + "readinessFeatureKey": "FR29.ReadinessStatus", + "isRequired": true, + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + } + ], + "escalationRules": [ + { + "id": 0, + "seedKey": "escalation.overdue-milestone.operations-lead", + "name": "Overdue Milestone Operations Lead Alert", + "description": "Escalates election-cycle jobs whose active milestone is overdue.", + "scenario": "OverdueMilestoneAlert", + "triggerCondition": "activeMilestone.dueDate \u003C today \u0026\u0026 job.status != \u0027complete\u0027", + "action": "NotifyOperationsLead", + "milestoneBasis": "activeMilestone.dueDate", + "alertWindow": "00:00:00", + "priority": 1, + "source": "SystemSeed", + "isActive": true, + "createdAt": "2026-05-06T18:58:39.8354954+00:00", + "updatedAt": "2026-05-06T18:58:39.8354954+00:00" + } + ] +} \ No newline at end of file diff --git a/_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md b/_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md index d75df5a..3499728 100644 --- a/_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md +++ b/_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md @@ -1,6 +1,6 @@ # Story 1.10: Municipality Account Profile -Status: review +Status: done ## Story @@ -68,7 +68,7 @@ claude-sonnet-4-6 - Frontend: `municipalityContracts.ts` — typed fetch functions with `MunicipalityValidationError` for 422 responses (AC #4). - Frontend: `MunicipalityProfilePanel.tsx` — table of profiles with combined legacy data (AC #2), modal form for create with JCode field and error display. - `WorkspaceShell.tsx` updated: selecting "Municipalities" nav item now renders `MunicipalityProfilePanel` for users with `canViewMunicipalityProfile` permission. -- 16 backend unit tests (11 repository + 5 controller integration) + 9 frontend contract tests. +- 20 backend unit tests (10 repository + 10 controller integration) + 10 frontend contract tests. ### File List @@ -88,6 +88,28 @@ claude-sonnet-4-6 - `_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md` (this file) - `_bmad-output/implementation-artifacts/sprint-status.yaml` (modified — status updated) +## Review Findings + +- [x] [Review][Decision] Out-of-scope: jurisdiction endpoint + searchable Select picker — accepted as scope extension. Test gaps and Promise.all failure mode addressed via patch items below. +- [x] [Review][Patch] Post-save/update GetByIdAsync bang-dereferenced — null guard added; returns 500 with descriptive message if view unexpectedly null [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs] +- [x] [Review][Patch] TOCTOU race on CreateAsync + lost write on UpdateAsync — `_lock` object added; duplicate-check+insert atomic in CreateAsync; read-modify-write atomic in UpdateAsync [Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs] +- [x] [Review][Patch] UpdateAsync not-found returns 422 instead of 404 — `MunicipalityProfileSaveResult.ProfileNotFound` factory added; controller checks `result.IsNotFound` and returns 404 [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs] +- [x] [Review][Patch] Promise.all — jurisdiction failure blocks profile display — loads split into two independent calls; `jurisdictionsLoadError` state added; "New" button disabled when jurisdictions unavailable; distinct warning alert shown [campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx] +- [x] [Review][Patch] FromJsonSeedFile bare catch swallows all exceptions with no logging — `catch (Exception ex)` with `Console.Error.WriteLine` added [Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs] +- [x] [Review][Patch] Missing 403 test for wrong-role token on municipality endpoints — `CreateProfile_WrongRoleToken_Returns403`, `GetJurisdictions_NoToken_Returns401`, and `UpdateProfile_UnknownId_Returns404` tests added [Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs] +- [x] [Review][Patch] fetchAvailableJurisdictions has no contract tests — success and failure tests added [campaign-tracker-client/src/municipalities/municipalityContracts.test.ts] +- [x] [Review][Patch] JCode normalization inconsistency — `normalizedJCode` computed before validator call; stored and validated forms are now consistent [Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs] +- [x] [Review][Patch] FromJsonSeedFile does not DistinctBy(JCode) — `.DistinctBy(r => r.JCode!.Trim(), StringComparer.OrdinalIgnoreCase)` added before projection [Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs] +- [x] [Review][Patch] AC #3 audit path not asserted at integration level — `CreateProfile_RecordsAuditEvent_AC3` and `UpdateProfile_RecordsAuditEvent_AC3` tests added [Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs] +- [x] [Review][Patch] 422 body shape mismatch produces "undefined" user error — `problem.error ?? 'Validation failed.'` fallback added in both create and update functions [campaign-tracker-client/src/municipalities/municipalityContracts.ts] +- [x] [Review][Patch] Backend test count discrepancy — updated: 10 repository + 10 controller = 20 backend tests; 10 frontend contract tests [_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md] +- [x] [Review][Patch] Integration tests broken by FromJsonSeedFile when real seed data is present — AuthIntegrationTestFactory now overrides ILegacyDataAccess with hardcoded test defaults, isolating tests from the development seed file (same pattern as IAuditService override) [Campaign_Tracker.Server.Tests/AuthEndpointTests.cs] +- [x] [Review][Defer] Internal whitespace in JCode from Access not handled — Trim() strips leading/trailing only; embedded spaces cause lookup mismatches [Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs] — deferred, pre-existing +- [x] [Review][Defer] ProfileId uses ToString("N") (no hyphens) — latent cross-system UUID format mismatch if consumers return a hyphenated variant [Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs] — deferred, pre-existing +- [x] [Review][Defer] CreatedAt stored but absent from API response DTO — profile creation timestamp inaccessible to consumers [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs] — deferred, pre-existing +- [x] [Review][Defer] Audit Outcome hardcoded to "updated display name" — will mislead once model has additional updatable fields [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs] — deferred, pre-existing +- [x] [Review][Defer] refresh() post-create does not reload jurisdiction list — stale list if jurisdictions change during session [campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx] — deferred, pre-existing + ## Change Log - 2026-05-06: Story 1.10 implemented — municipality account profile domain, repository, REST API, and React panel with legacy join resolution. 25 tests added. All 4 ACs satisfied. (claude-sonnet-4-6) diff --git a/_bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md b/_bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md index b1dbe76..1140cc4 100644 --- a/_bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md +++ b/_bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md @@ -1,6 +1,6 @@ # Story 1.11: Municipality Operational Addresses -Status: ready-for-dev +Status: review ## Story diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index e43da9a..c17e565 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -1,3 +1,11 @@ +## Deferred from: code review of 1-10-municipality-account-profile.md (2026-05-06) + +- Internal whitespace in JCode from Access not handled — `Trim()` strips leading/trailing only; JCodes with embedded spaces would cause lookup mismatches between `GetAllJurisdictionsAsync` and `GetJurisdictionAsync`. Evidence: `Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs`. Pre-existing data-quality risk; fix requires confirming Access data characteristics. +- `ProfileId` uses `Guid.NewGuid().ToString("N")` (no hyphens) — latent cross-system UUID format mismatch if an external consumer returns a hyphenated UUID variant. Evidence: `Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs`. Pre-existing design choice; consistent within the codebase today. +- `CreatedAt` stored on `MunicipalityProfile` but absent from API response DTO and frontend contract — profile creation timestamp inaccessible to consumers. Evidence: `MunicipalityProfileResponse` record in `MunicipalityProfileController.cs`. Not required by story spec; can be added in a future story. +- Audit `Outcome` hardcoded to `"updated display name"` — will become misleading once the profile model gains additional updatable fields. Evidence: `MunicipalityProfileController.Update`. Only `DisplayName` is editable today; acceptable short-term. +- `refresh()` post-create does not reload the jurisdiction list — list can grow stale if jurisdictions are added to the legacy database during the session. Evidence: `MunicipalityProfilePanel.tsx` `handleCreate`. Minor UX gap; low impact in practice. + ## Deferred from: fix-strictmode-oidc-callback-race (2026-05-06) - `pendingCallbackSequence` is not scoped to a specific callback invocation — if `useOidcSession` were ever mounted twice simultaneously, the second instance would skip CSRF validation and piggyback on the first's exchange. Pre-existing architectural assumption; low risk given single-mount usage, but worth an assertion if the hook gains wider use. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 3f3fcb5..aeb9938 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: '2026-05-05T12:00:44-04:00' -last_updated: '2026-05-06T14:41:40-04:00' +last_updated: '2026-05-06T16:44:00-04:00' project: 'Campaign_Tracker App' project_key: 'NOKEY' tracking_system: 'file-system' @@ -52,7 +52,7 @@ development_status: 1-7-legacy-schema-compatibility-validation-gate: done 1-8-legacy-identifier-linking-for-extension-records: done 1-9-seed-system-reference-values-rule-defaults: done - 1-10-municipality-account-profile: review + 1-10-municipality-account-profile: done 1-11-municipality-operational-addresses: ready-for-dev 1-12-municipality-service-contacts: ready-for-dev 1-13-municipality-prior-cycle-service-defaults-view: ready-for-dev diff --git a/campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx b/campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx index e513c09..635739f 100644 --- a/campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx +++ b/campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx @@ -5,6 +5,7 @@ import { Form, Input, Modal, + Select, Space, Spin, Table, @@ -14,8 +15,10 @@ import type { TableProps } from 'antd' import { useCallback, useEffect, useState } from 'react' import { createMunicipalityProfile, + fetchAvailableJurisdictions, fetchMunicipalityProfiles, MunicipalityValidationError, + type LegacyJurisdiction, type MunicipalityProfile, } from './municipalityContracts' @@ -72,12 +75,16 @@ type CreateFormValues = { export function MunicipalityProfilePanel({ load = fetchMunicipalityProfiles, create = createMunicipalityProfile, + loadJurisdictions = fetchAvailableJurisdictions, }: { load?: typeof fetchMunicipalityProfiles create?: typeof createMunicipalityProfile + loadJurisdictions?: typeof fetchAvailableJurisdictions } = {}) { const [profiles, setProfiles] = useState(null) + const [jurisdictions, setJurisdictions] = useState([]) const [loadError, setLoadError] = useState(null) + const [jurisdictionsLoadError, setJurisdictionsLoadError] = useState(null) const [modalOpen, setModalOpen] = useState(false) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) @@ -94,14 +101,24 @@ export function MunicipalityProfilePanel({ useEffect(() => { let cancelled = false + // P4: load profiles and jurisdictions independently so a jurisdiction failure + // does not prevent the profile table from rendering load() .then((items) => { if (!cancelled) setProfiles(items) }) .catch((cause: unknown) => { if (!cancelled) setLoadError(cause instanceof Error ? cause.message : 'Failed to load profiles') }) + loadJurisdictions() + .then((jList) => { if (!cancelled) setJurisdictions(jList) }) + .catch((cause: unknown) => { + if (!cancelled) + setJurisdictionsLoadError( + cause instanceof Error ? cause.message : 'Failed to load available jurisdictions', + ) + }) return () => { cancelled = true } - }, [load]) + }, [load, loadJurisdictions]) const handleCreate = useCallback(async (values: CreateFormValues) => { setSaving(true) @@ -140,7 +157,20 @@ export function MunicipalityProfilePanel({ ) : null} - @@ -183,12 +213,20 @@ export function MunicipalityProfilePanel({ > - + (option?.label ?? '').toLowerCase().includes(input.toLowerCase()) + } + options={jurisdictions.map((j) => ({ + value: j.jCode, + label: j.name ? `${j.jCode} — ${j.name}` : j.jCode, + }))} /> diff --git a/campaign-tracker-client/src/municipalities/municipalityContracts.test.ts b/campaign-tracker-client/src/municipalities/municipalityContracts.test.ts index 6e5fa1d..f15d290 100644 --- a/campaign-tracker-client/src/municipalities/municipalityContracts.test.ts +++ b/campaign-tracker-client/src/municipalities/municipalityContracts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { createMunicipalityProfile, + fetchAvailableJurisdictions, fetchMunicipalityProfiles, MunicipalityValidationError, updateMunicipalityProfile, @@ -96,6 +97,34 @@ describe('updateMunicipalityProfile', () => { }) }) +// ── fetchAvailableJurisdictions ─────────────────────────────────────────────── + +describe('fetchAvailableJurisdictions', () => { + it('returns jurisdictions on 200', async () => { + const stub = async () => + new Response( + JSON.stringify([ + { jCode: 'FAIR01', name: 'Fairview Borough' }, + { jCode: 'LAKE02', name: null }, + ]), + { status: 200 }, + ) + + const result = await fetchAvailableJurisdictions(stub) + + expect(result).toHaveLength(2) + expect(result[0].jCode).toBe('FAIR01') + expect(result[0].name).toBe('Fairview Borough') + expect(result[1].name).toBeNull() + }) + + it('throws on non-200', async () => { + const stub = async () => new Response('{}', { status: 503 }) + + await expect(fetchAvailableJurisdictions(stub)).rejects.toThrow('503') + }) +}) + // ── MunicipalityValidationError ─────────────────────────────────────────────── describe('MunicipalityValidationError', () => { diff --git a/campaign-tracker-client/src/municipalities/municipalityContracts.ts b/campaign-tracker-client/src/municipalities/municipalityContracts.ts index 129dd77..f98bc8e 100644 --- a/campaign-tracker-client/src/municipalities/municipalityContracts.ts +++ b/campaign-tracker-client/src/municipalities/municipalityContracts.ts @@ -36,7 +36,7 @@ export async function createMunicipalityProfile( if (response.status === 422) { const problem = (await response.json()) as MunicipalityProfileValidationError - throw new MunicipalityValidationError(problem.error) + throw new MunicipalityValidationError(problem.error ?? 'Validation failed.') } if (!response.ok) { @@ -59,7 +59,7 @@ export async function updateMunicipalityProfile( if (response.status === 422) { const problem = (await response.json()) as MunicipalityProfileValidationError - throw new MunicipalityValidationError(problem.error) + throw new MunicipalityValidationError(problem.error ?? 'Validation failed.') } if (!response.ok) { @@ -69,6 +69,21 @@ export async function updateMunicipalityProfile( return (await response.json()) as MunicipalityProfile } +export type LegacyJurisdiction = { + jCode: string + name: string | null +} + +export async function fetchAvailableJurisdictions( + fetcher: typeof fetch = fetch, +): Promise { + const response = await fetcher('/api/municipalities/jurisdictions') + if (!response.ok) { + throw new Error(`Failed to load jurisdictions (${response.status})`) + } + return (await response.json()) as LegacyJurisdiction[] +} + export class MunicipalityValidationError extends Error { constructor(message: string) { super(message) diff --git a/campaign-tracker-client/src/workspace/WorkspaceShell.tsx b/campaign-tracker-client/src/workspace/WorkspaceShell.tsx index 1694848..61d7337 100644 --- a/campaign-tracker-client/src/workspace/WorkspaceShell.tsx +++ b/campaign-tracker-client/src/workspace/WorkspaceShell.tsx @@ -35,6 +35,11 @@ import { import type { AuthenticatedUser } from '../auth/authContracts' import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel' import { MunicipalityProfilePanel } from '../municipalities/MunicipalityProfilePanel' +import { + createMunicipalityProfile, + fetchAvailableJurisdictions, + fetchMunicipalityProfiles, +} from '../municipalities/municipalityContracts' import { fetchLegacySchemaCheckHistory, runLegacySchemaCheck, @@ -399,7 +404,11 @@ export function WorkspaceShell({ runCheck={() => runLegacySchemaCheck(adminFetch)} /> ) : selectedView === 'municipalities' && user.permissions.canViewMunicipalityProfile ? ( - + fetchMunicipalityProfiles(adminFetch)} + create={(jCode, displayName) => createMunicipalityProfile(jCode, displayName, adminFetch)} + loadJurisdictions={() => fetchAvailableJurisdictions(adminFetch)} + /> ) : (