| @@ -7,6 +7,7 @@ using System.Security.Claims; | |||||
| using System.Text; | using System.Text; | ||||
| using Campaign_Tracker.Server.Audit; | using Campaign_Tracker.Server.Audit; | ||||
| using Campaign_Tracker.Server.Authentication; | using Campaign_Tracker.Server.Authentication; | ||||
| using Campaign_Tracker.Server.LegacyData; | |||||
| using Microsoft.AspNetCore.Hosting; | using Microsoft.AspNetCore.Hosting; | ||||
| using Microsoft.AspNetCore.Mvc.Testing; | using Microsoft.AspNetCore.Mvc.Testing; | ||||
| using Microsoft.Extensions.DependencyInjection; | using Microsoft.Extensions.DependencyInjection; | ||||
| @@ -44,13 +45,19 @@ public sealed class AuthIntegrationTestFactory : WebApplicationFactory<Program> | |||||
| // Replace the file-backed IAuditService with an in-memory passthrough. | // Replace the file-backed IAuditService with an in-memory passthrough. | ||||
| // File persistence is validated in AuditServiceTests; integration tests | // File persistence is validated in AuditServiceTests; integration tests | ||||
| // should not depend on file-system state. | // 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<IAuditService, InMemoryPassthroughAuditService>(); | services.AddSingleton<IAuditService, InMemoryPassthroughAuditService>(); | ||||
| // 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<ILegacyDataAccess>(new InMemoryLegacyDataAccess()); | |||||
| }); | }); | ||||
| } | } | ||||
| @@ -0,0 +1,214 @@ | |||||
| 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; | |||||
| public sealed class MunicipalityProfileControllerTests | |||||
| { | |||||
| // ── AC #1: profile created and saved with legacy link ──────────────────── | |||||
| [Fact] | |||||
| public async Task CreateProfile_ValidJCode_Returns200WithCombinedView_AC1_AC2() | |||||
| { | |||||
| 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.PostAsJsonAsync("/api/municipalities/profiles", new | |||||
| { | |||||
| jCode = "FAIR01", | |||||
| displayName = "Fairview Borough Profile", | |||||
| }); | |||||
| Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |||||
| var body = await response.Content.ReadFromJsonAsync<MunicipalityProfileDto>(); | |||||
| Assert.NotNull(body); | |||||
| Assert.Equal("FAIR01", body.JCode); | |||||
| Assert.Equal("Fairview Borough Profile", body.DisplayName); | |||||
| // AC #2: combined view includes resolved legacy name | |||||
| Assert.Equal("Fairview Borough", body.LegacyName); | |||||
| } | |||||
| // ── AC #2: list returns combined extension + legacy fields ──────────────── | |||||
| [Fact] | |||||
| public async Task GetAllProfiles_ReturnsResolvedLegacyData_AC2() | |||||
| { | |||||
| 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 = "LAKE02", displayName = (string?)null }); | |||||
| var response = await client.GetAsync("/api/municipalities/profiles"); | |||||
| Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |||||
| var profiles = await response.Content.ReadFromJsonAsync<MunicipalityProfileDto[]>(); | |||||
| Assert.NotNull(profiles); | |||||
| var lake = Assert.Single(profiles, p => p.JCode == "LAKE02"); | |||||
| Assert.Equal("Lake Township", lake.LegacyName); | |||||
| } | |||||
| // ── AC #3: update audits the change (server-side; response includes actor) ─ | |||||
| [Fact] | |||||
| public async Task UpdateProfile_ChangesDisplayName_Returns200_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 = "FAIR01", displayName = "Old Name" })) | |||||
| .Content.ReadFromJsonAsync<MunicipalityProfileDto>(); | |||||
| var response = await client.PutAsJsonAsync( | |||||
| $"/api/municipalities/profiles/{created!.ProfileId}", | |||||
| new { displayName = "New Name" }); | |||||
| Assert.Equal(HttpStatusCode.OK, response.StatusCode); | |||||
| var updated = await response.Content.ReadFromJsonAsync<MunicipalityProfileDto>(); | |||||
| Assert.Equal("New Name", updated!.DisplayName); | |||||
| } | |||||
| // ── AC #4: invalid JCode rejected before save ───────────────────────────── | |||||
| [Fact] | |||||
| public async Task CreateProfile_InvalidJCode_Returns422WithDescription_AC4() | |||||
| { | |||||
| 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.PostAsJsonAsync("/api/municipalities/profiles", new | |||||
| { | |||||
| jCode = "DOESNOTEXIST", | |||||
| displayName = (string?)null, | |||||
| }); | |||||
| Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode); | |||||
| var body = await response.Content.ReadFromJsonAsync<MunicipalityProfileProblemDto>(); | |||||
| Assert.NotNull(body); | |||||
| Assert.Contains("DOESNOTEXIST", body.Error); | |||||
| } | |||||
| // ── Authorization ───────────────────────────────────────────────────────── | |||||
| [Fact] | |||||
| public async Task CreateProfile_NoToken_Returns401() | |||||
| { | |||||
| await using var factory = new AuthIntegrationTestFactory(); | |||||
| using var client = factory.CreateClient(); | |||||
| var response = await client.PostAsJsonAsync("/api/municipalities/profiles", | |||||
| new { jCode = "FAIR01", displayName = (string?)null }); | |||||
| 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<IAuditService>(); | |||||
| 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<MunicipalityProfileDto>(); | |||||
| await client.PutAsJsonAsync( | |||||
| $"/api/municipalities/profiles/{created!.ProfileId}", | |||||
| new { displayName = "Updated Name" }); | |||||
| var auditService = factory.Services.GetRequiredService<IAuditService>(); | |||||
| 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( | |||||
| string ProfileId, | |||||
| string JCode, | |||||
| string? DisplayName, | |||||
| string UpdatedAt, | |||||
| string UpdatedBy, | |||||
| string? LegacyName, | |||||
| string? LegacyMailingAddress, | |||||
| string? LegacyCityStateZip); | |||||
| private sealed record MunicipalityProfileProblemDto(string Error); | |||||
| } | |||||
| @@ -0,0 +1,188 @@ | |||||
| using Campaign_Tracker.Server.ExtensionData; | |||||
| using Campaign_Tracker.Server.LegacyData; | |||||
| using Campaign_Tracker.Server.LegacyData.Models; | |||||
| using Campaign_Tracker.Server.Municipalities; | |||||
| namespace Campaign_Tracker.Server.Tests; | |||||
| public sealed class MunicipalityProfileRepositoryTests | |||||
| { | |||||
| private static readonly DateTimeOffset FixedNow = | |||||
| new(2026, 5, 6, 12, 0, 0, TimeSpan.Zero); | |||||
| private static InMemoryMunicipalityProfileRepository BuildSut( | |||||
| ILegacyDataAccess? data = null) | |||||
| { | |||||
| data ??= new InMemoryLegacyDataAccess( | |||||
| jurisdictions: | |||||
| [ | |||||
| new("FAIR01", "Fairview Borough", "100 Main St", "Fairview, PA 16415", null, null), | |||||
| new("LAKE02", "Lake Township", "200 Lake Rd", "Lake City, PA 16423", null, null), | |||||
| ]); | |||||
| return new InMemoryMunicipalityProfileRepository( | |||||
| new LegacyLinkValidator(data), | |||||
| data, | |||||
| new FakeTimeProvider(FixedNow)); | |||||
| } | |||||
| // ── AC #1: profile saved with required JCode link ──────────────────────── | |||||
| [Fact] | |||||
| public async Task CreateAsync_ValidJCode_SavesProfileWithLegacyLink_AC1() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| var result = await sut.CreateAsync("FAIR01", "Fairview Profile", "user@test.com"); | |||||
| Assert.True(result.Saved); | |||||
| Assert.NotNull(result.Profile); | |||||
| Assert.Equal("FAIR01", result.Profile.JCode); | |||||
| Assert.Equal("MunicipalityProfile", result.Profile.RecordType); | |||||
| Assert.Equal(LegacyLinkType.JurisdictionJCode, result.Profile.LegacyLink.Type); | |||||
| Assert.Equal("FAIR01", result.Profile.LegacyLink.Value); | |||||
| } | |||||
| [Fact] | |||||
| public async Task CreateAsync_ProfileIdIsGeneratedGuid_AC1() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| var result = await sut.CreateAsync("FAIR01", null, "user@test.com"); | |||||
| Assert.NotNull(result.Profile); | |||||
| Assert.True(Guid.TryParse(result.Profile.ProfileId, out _), "ProfileId should be a valid GUID."); | |||||
| } | |||||
| // ── AC #2: combined view resolves legacy jurisdiction data ──────────────── | |||||
| [Fact] | |||||
| public async Task GetAllAsync_ReturnsLegacyJurisdictionFieldsAlongsideExtensionData_AC2() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| await sut.CreateAsync("FAIR01", "Fairview Display", "user@test.com"); | |||||
| var views = await sut.GetAllAsync(); | |||||
| Assert.Single(views); | |||||
| var view = views[0]; | |||||
| Assert.Equal("FAIR01", view.Profile.JCode); | |||||
| Assert.Equal("Fairview Borough", view.LegacyName); | |||||
| Assert.Equal("100 Main St", view.LegacyMailingAddress); | |||||
| Assert.Equal("Fairview, PA 16415", view.LegacyCityStateZip); | |||||
| } | |||||
| [Fact] | |||||
| public async Task GetByIdAsync_ReturnsLegacyAndExtensionDataCombined_AC2() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| var created = await sut.CreateAsync("LAKE02", null, "user@test.com"); | |||||
| var view = await sut.GetByIdAsync(created.Profile!.ProfileId); | |||||
| Assert.NotNull(view); | |||||
| Assert.Equal("LAKE02", view.Profile.JCode); | |||||
| Assert.Equal("Lake Township", view.LegacyName); | |||||
| } | |||||
| [Fact] | |||||
| public async Task GetByIdAsync_UnknownId_ReturnsNull_AC2() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| var view = await sut.GetByIdAsync("does-not-exist"); | |||||
| Assert.Null(view); | |||||
| } | |||||
| // ── AC #3: update records actor and timestamp ───────────────────────────── | |||||
| [Fact] | |||||
| public async Task UpdateAsync_ChangesDisplayNameAndCapturesActor_AC3() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| var created = await sut.CreateAsync("FAIR01", "Old Name", "creator@test.com"); | |||||
| var updated = await sut.UpdateAsync(created.Profile!.ProfileId, "New Name", "updater@test.com"); | |||||
| Assert.True(updated.Saved); | |||||
| Assert.Equal("New Name", updated.Profile!.DisplayName); | |||||
| Assert.Equal("updater@test.com", updated.Profile.UpdatedBy); | |||||
| Assert.Equal(FixedNow, updated.Profile.UpdatedAt); | |||||
| } | |||||
| [Fact] | |||||
| public async Task UpdateAsync_UnknownId_ReturnsFailure_AC3() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| var result = await sut.UpdateAsync("ghost-id", "name", "actor"); | |||||
| Assert.False(result.Saved); | |||||
| Assert.Contains("ghost-id", result.Error); | |||||
| } | |||||
| // ── AC #4: invalid JCode rejected before save ───────────────────────────── | |||||
| [Fact] | |||||
| public async Task CreateAsync_InvalidJCode_ReturnsFailureWithDescription_AC4() | |||||
| { | |||||
| var sut = BuildSut(new InMemoryLegacyDataAccess(jurisdictions: [])); | |||||
| var result = await sut.CreateAsync("UNKNOWN", null, "user@test.com"); | |||||
| Assert.False(result.Saved); | |||||
| Assert.NotNull(result.Error); | |||||
| Assert.Contains("UNKNOWN", result.Error); | |||||
| } | |||||
| [Fact] | |||||
| public async Task CreateAsync_BlankJCode_ReturnsFailureWithDescription_AC4() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| var result = await sut.CreateAsync("", null, "user@test.com"); | |||||
| Assert.False(result.Saved); | |||||
| Assert.NotNull(result.Error); | |||||
| } | |||||
| [Fact] | |||||
| public async Task CreateAsync_DuplicateJCode_ReturnsFailure_AC4() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| await sut.CreateAsync("FAIR01", null, "user@test.com"); | |||||
| var second = await sut.CreateAsync("FAIR01", "Another", "user@test.com"); | |||||
| Assert.False(second.Saved); | |||||
| Assert.Contains("FAIR01", second.Error); | |||||
| } | |||||
| // ── ILegacyLinkedRecordProvider participates in integrity check ────────── | |||||
| [Fact] | |||||
| public async Task GetAllAsync_AsLinkedRecordProvider_ReturnsProfilesForIntegrityCheck_AC1() | |||||
| { | |||||
| var sut = BuildSut(); | |||||
| await sut.CreateAsync("FAIR01", null, "user@test.com"); | |||||
| await sut.CreateAsync("LAKE02", null, "user@test.com"); | |||||
| var provider = (ILegacyLinkedRecordProvider)sut; | |||||
| var records = await provider.GetAllAsync(); | |||||
| Assert.Equal(2, records.Count); | |||||
| Assert.All(records, r => | |||||
| { | |||||
| Assert.Equal("MunicipalityProfile", r.RecordType); | |||||
| Assert.Equal(LegacyLinkType.JurisdictionJCode, r.LegacyLink.Type); | |||||
| }); | |||||
| } | |||||
| // ── Helpers ─────────────────────────────────────────────────────────────── | |||||
| private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider | |||||
| { | |||||
| public override DateTimeOffset GetUtcNow() => utcNow; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,161 @@ | |||||
| 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; | |||||
| namespace Campaign_Tracker.Server.Controllers; | |||||
| /// <summary> | |||||
| /// Municipality account profile management (Story 1.10). | |||||
| /// Accessible to ClientServices and Admin roles (HasAny check includes Admin bypass). | |||||
| /// </summary> | |||||
| [ApiController] | |||||
| [Authorize(Policy = ApplicationPolicy.ClientServicesAccess)] | |||||
| [Route("api/municipalities/profiles")] | |||||
| 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<ActionResult<IReadOnlyList<LegacyJurisdictionResponse>>> 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] | |||||
| public async Task<ActionResult<MunicipalityProfileResponse>> Create( | |||||
| [FromBody] CreateMunicipalityProfileRequest request, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| var actor = GetActor(); | |||||
| var result = await _profiles.CreateAsync(request.JCode, request.DisplayName, actor, cancellationToken); | |||||
| if (!result.Saved || result.Profile is null) | |||||
| return UnprocessableEntity(new MunicipalityProfileProblem(result.Error ?? "Save failed.")); | |||||
| // AC #3: audit the creation | |||||
| _audit.Record(new AuditEvent( | |||||
| EventType: "MUNICIPALITY_PROFILE_CREATED", | |||||
| ActorIdentity: actor, | |||||
| Resource: $"municipalities/profiles/{result.Profile.ProfileId}", | |||||
| Outcome: $"created JCode={result.Profile.JCode}", | |||||
| TraceIdentifier: HttpContext.TraceIdentifier, | |||||
| RecordedAt: _timeProvider.GetUtcNow())); | |||||
| var view = await _profiles.GetByIdAsync(result.Profile.ProfileId, cancellationToken); | |||||
| 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 ─────────────────── | |||||
| [HttpGet] | |||||
| public async Task<ActionResult<IReadOnlyList<MunicipalityProfileResponse>>> GetAll( | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| var views = await _profiles.GetAllAsync(cancellationToken); | |||||
| return Ok(views.Select(MunicipalityProfileResponse.From).ToArray()); | |||||
| } | |||||
| [HttpGet("{profileId}")] | |||||
| public async Task<ActionResult<MunicipalityProfileResponse>> GetById( | |||||
| string profileId, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| var view = await _profiles.GetByIdAsync(profileId, cancellationToken); | |||||
| return view is null ? NotFound() : Ok(MunicipalityProfileResponse.From(view)); | |||||
| } | |||||
| // ── AC #3: update with audit log ───────────────────────────────────────── | |||||
| [HttpPut("{profileId}")] | |||||
| public async Task<ActionResult<MunicipalityProfileResponse>> Update( | |||||
| string profileId, | |||||
| [FromBody] UpdateMunicipalityProfileRequest request, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| var actor = GetActor(); | |||||
| 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", | |||||
| ActorIdentity: actor, | |||||
| Resource: $"municipalities/profiles/{profileId}", | |||||
| Outcome: "updated display name", | |||||
| TraceIdentifier: HttpContext.TraceIdentifier, | |||||
| RecordedAt: _timeProvider.GetUtcNow())); | |||||
| var view = await _profiles.GetByIdAsync(profileId, cancellationToken); | |||||
| 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() => | |||||
| User.Identity?.Name | |||||
| ?? User.FindFirstValue(ClaimTypes.NameIdentifier) | |||||
| ?? "unknown"; | |||||
| } | |||||
| public sealed record CreateMunicipalityProfileRequest(string JCode, string? DisplayName); | |||||
| public sealed record UpdateMunicipalityProfileRequest(string? DisplayName); | |||||
| public sealed record MunicipalityProfileResponse( | |||||
| string ProfileId, | |||||
| string JCode, | |||||
| string? DisplayName, | |||||
| string UpdatedAt, | |||||
| string UpdatedBy, | |||||
| string? LegacyName, | |||||
| string? LegacyMailingAddress, | |||||
| string? LegacyCityStateZip) | |||||
| { | |||||
| public static MunicipalityProfileResponse From(MunicipalityProfileView view) => | |||||
| new(view.Profile.ProfileId, | |||||
| view.Profile.JCode, | |||||
| view.Profile.DisplayName, | |||||
| view.Profile.UpdatedAt.ToString("O"), | |||||
| view.Profile.UpdatedBy, | |||||
| view.LegacyName, | |||||
| view.LegacyMailingAddress, | |||||
| view.LegacyCityStateZip); | |||||
| } | |||||
| public sealed record MunicipalityProfileProblem(string Error); | |||||
| public sealed record LegacyJurisdictionResponse(string JCode, string? Name); | |||||
| @@ -1,3 +1,4 @@ | |||||
| using System.Text.Json; | |||||
| using Campaign_Tracker.Server.LegacyData.Models; | using Campaign_Tracker.Server.LegacyData.Models; | ||||
| namespace Campaign_Tracker.Server.LegacyData; | namespace Campaign_Tracker.Server.LegacyData; | ||||
| @@ -29,6 +30,52 @@ public sealed class InMemoryLegacyDataAccess : ILegacyDataAccess | |||||
| _kitLabels = kitLabels ?? DefaultKitLabels; | _kitLabels = kitLabels ?? DefaultKitLabels; | ||||
| } | } | ||||
| /// <summary> | |||||
| /// 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. | |||||
| /// </summary> | |||||
| public static InMemoryLegacyDataAccess FromJsonSeedFile(string jsonPath) | |||||
| { | |||||
| if (!File.Exists(jsonPath)) | |||||
| return new InMemoryLegacyDataAccess(); | |||||
| try | |||||
| { | |||||
| var json = File.ReadAllText(jsonPath); | |||||
| var records = JsonSerializer.Deserialize<JsonJurisdiction[]>( | |||||
| 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 ────────────────────────────────────────────────────────── | // ── Jurisdiction ────────────────────────────────────────────────────────── | ||||
| public Task<LegacyJurisdiction?> GetJurisdictionAsync( | public Task<LegacyJurisdiction?> GetJurisdictionAsync( | ||||
| @@ -29,12 +29,12 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess | |||||
| CancellationToken cancellationToken = default) | CancellationToken cancellationToken = default) | ||||
| { | { | ||||
| const string sql = """ | const string sql = """ | ||||
| SELECT JCode, Name, MailingAddress, CityStateZip, Phone, Email | |||||
| SELECT JCode, Name, Mailing_Address, CSZ, IMB, IMB_Digits | |||||
| FROM Jurisdiction | 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(); | return results.FirstOrDefault(); | ||||
| } | } | ||||
| @@ -42,7 +42,7 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess | |||||
| CancellationToken cancellationToken = default) | CancellationToken cancellationToken = default) | ||||
| { | { | ||||
| const string sql = """ | const string sql = """ | ||||
| SELECT JCode, Name, MailingAddress, CityStateZip, Phone, Email | |||||
| SELECT JCode, Name, Mailing_Address, CSZ, IMB, IMB_Digits | |||||
| FROM Jurisdiction | FROM Jurisdiction | ||||
| ORDER BY JCode | ORDER BY JCode | ||||
| """; | """; | ||||
| @@ -77,11 +77,11 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess | |||||
| BusinessAddress, BusinessAddress2, BusinessAddress3, | BusinessAddress, BusinessAddress2, BusinessAddress3, | ||||
| TownshipName, TownshipNum | TownshipName, TownshipNum | ||||
| FROM Contacts | FROM Contacts | ||||
| WHERE JURISCODE = ? | |||||
| WHERE Trim(JURISCODE) = ? | |||||
| ORDER BY ID | ORDER BY ID | ||||
| """; | """; | ||||
| return QueryAsync(sql, [jCode], MapContact, cancellationToken); | |||||
| return QueryAsync(sql, [jCode.Trim()], MapContact, cancellationToken); | |||||
| } | } | ||||
| public async Task<LegacyKit?> GetKitByIdAsync( | public async Task<LegacyKit?> GetKitByIdAsync( | ||||
| @@ -109,11 +109,11 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess | |||||
| CreatedOn, ExportedToSnailWorks, LabelsPrinted, OfficeCopiesAmount, | CreatedOn, ExportedToSnailWorks, LabelsPrinted, OfficeCopiesAmount, | ||||
| InboundStid, OutboundStid | InboundStid, OutboundStid | ||||
| FROM Kit | FROM Kit | ||||
| WHERE Jcode = ? | |||||
| WHERE Trim(Jcode) = ? | |||||
| ORDER BY ID | ORDER BY ID | ||||
| """; | """; | ||||
| return QueryAsync(sql, [jCode], MapKit, cancellationToken); | |||||
| return QueryAsync(sql, [jCode.Trim()], MapKit, cancellationToken); | |||||
| } | } | ||||
| public Task<IReadOnlyList<LegacyKitLabel>> GetKitLabelsByKitAsync( | public Task<IReadOnlyList<LegacyKitLabel>> GetKitLabelsByKitAsync( | ||||
| @@ -174,10 +174,10 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess | |||||
| new( | new( | ||||
| GetRequiredString(reader, "JCode"), | GetRequiredString(reader, "JCode"), | ||||
| GetString(reader, "Name"), | 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) => | private static LegacyContact MapContact(DbDataReader reader) => | ||||
| new( | new( | ||||
| @@ -228,7 +228,7 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess | |||||
| private static string GetRequiredString(DbDataReader reader, string name) | private static string GetRequiredString(DbDataReader reader, string name) | ||||
| { | { | ||||
| var value = GetString(reader, name); | |||||
| var value = GetString(reader, name)?.Trim(); | |||||
| return string.IsNullOrWhiteSpace(value) | return string.IsNullOrWhiteSpace(value) | ||||
| ? throw new LegacyDataAccessException($"Required legacy join key {name} was null or empty.") | ? throw new LegacyDataAccessException($"Required legacy join key {name} was null or empty.") | ||||
| : value; | : value; | ||||
| @@ -0,0 +1,23 @@ | |||||
| namespace Campaign_Tracker.Server.Municipalities; | |||||
| public interface IMunicipalityProfileRepository | |||||
| { | |||||
| Task<MunicipalityProfileSaveResult> CreateAsync( | |||||
| string jCode, | |||||
| string? displayName, | |||||
| string actorIdentity, | |||||
| CancellationToken cancellationToken = default); | |||||
| Task<MunicipalityProfileSaveResult> UpdateAsync( | |||||
| string profileId, | |||||
| string? displayName, | |||||
| string actorIdentity, | |||||
| CancellationToken cancellationToken = default); | |||||
| Task<MunicipalityProfileView?> GetByIdAsync( | |||||
| string profileId, | |||||
| CancellationToken cancellationToken = default); | |||||
| Task<IReadOnlyList<MunicipalityProfileView>> GetAllAsync( | |||||
| CancellationToken cancellationToken = default); | |||||
| } | |||||
| @@ -0,0 +1,144 @@ | |||||
| using System.Collections.Concurrent; | |||||
| using Campaign_Tracker.Server.ExtensionData; | |||||
| using Campaign_Tracker.Server.LegacyData; | |||||
| namespace Campaign_Tracker.Server.Municipalities; | |||||
| /// <summary> | |||||
| /// In-memory municipality profile store for development and integration testing. | |||||
| /// Implements <see cref="ILegacyLinkedRecordProvider"/> so profiles are included in | |||||
| /// the nightly extension-to-legacy link integrity check (Story 1.8 AC #4). | |||||
| /// </summary> | |||||
| public sealed class InMemoryMunicipalityProfileRepository | |||||
| : IMunicipalityProfileRepository, ILegacyLinkedRecordProvider | |||||
| { | |||||
| private readonly ConcurrentDictionary<string, MunicipalityProfile> _profiles = new(StringComparer.OrdinalIgnoreCase); | |||||
| private readonly object _lock = new(); | |||||
| private readonly ILegacyLinkValidator _validator; | |||||
| private readonly ILegacyDataAccess _legacyData; | |||||
| private readonly TimeProvider _timeProvider; | |||||
| public InMemoryMunicipalityProfileRepository( | |||||
| ILegacyLinkValidator validator, | |||||
| ILegacyDataAccess legacyData, | |||||
| TimeProvider timeProvider) | |||||
| { | |||||
| _validator = validator; | |||||
| _legacyData = legacyData; | |||||
| _timeProvider = timeProvider; | |||||
| } | |||||
| // ── AC #1: create with required JCode link ──────────────────────────────── | |||||
| public async Task<MunicipalityProfileSaveResult> CreateAsync( | |||||
| string jCode, | |||||
| string? displayName, | |||||
| string actorIdentity, | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| 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(normalizedJCode); | |||||
| var validation = await _validator.ValidateAsync(linkRef, cancellationToken); | |||||
| if (!validation.IsValid) | |||||
| return MunicipalityProfileSaveResult.Failure(validation.Error!); | |||||
| var now = _timeProvider.GetUtcNow(); | |||||
| var profile = new MunicipalityProfile( | |||||
| ProfileId: Guid.NewGuid().ToString("N"), | |||||
| JCode: normalizedJCode, | |||||
| DisplayName: string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(), | |||||
| CreatedAt: now, | |||||
| UpdatedAt: now, | |||||
| UpdatedBy: actorIdentity); | |||||
| // 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); | |||||
| } | |||||
| // ── AC #3: update with audit trail captured by caller ──────────────────── | |||||
| public Task<MunicipalityProfileSaveResult> UpdateAsync( | |||||
| string profileId, | |||||
| string? displayName, | |||||
| string actorIdentity, | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| // P2: wrap read-modify-write in lock to prevent lost updates under concurrent PUTs | |||||
| lock (_lock) | |||||
| { | |||||
| 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 ────────────────────── | |||||
| public async Task<MunicipalityProfileView?> GetByIdAsync( | |||||
| string profileId, | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| if (!_profiles.TryGetValue(profileId, out var profile)) | |||||
| return null; | |||||
| return await BuildViewAsync(profile, cancellationToken); | |||||
| } | |||||
| public async Task<IReadOnlyList<MunicipalityProfileView>> GetAllAsync( | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| var profiles = _profiles.Values | |||||
| .OrderBy(p => p.JCode, StringComparer.OrdinalIgnoreCase) | |||||
| .ToArray(); | |||||
| var views = new List<MunicipalityProfileView>(profiles.Length); | |||||
| foreach (var profile in profiles) | |||||
| views.Add(await BuildViewAsync(profile, cancellationToken)); | |||||
| return views; | |||||
| } | |||||
| // ── ILegacyLinkedRecordProvider ─────────────────────────────────────────── | |||||
| Task<IReadOnlyList<ILegacyLinkedRecord>> ILegacyLinkedRecordProvider.GetAllAsync( | |||||
| CancellationToken cancellationToken) | |||||
| => Task.FromResult<IReadOnlyList<ILegacyLinkedRecord>>( | |||||
| _profiles.Values.Cast<ILegacyLinkedRecord>().ToArray()); | |||||
| // ── helpers ─────────────────────────────────────────────────────────────── | |||||
| private async Task<MunicipalityProfileView> BuildViewAsync( | |||||
| MunicipalityProfile profile, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| var jurisdiction = await _legacyData.GetJurisdictionAsync(profile.JCode, cancellationToken); | |||||
| return new MunicipalityProfileView( | |||||
| Profile: profile, | |||||
| LegacyName: jurisdiction?.Name, | |||||
| LegacyMailingAddress: jurisdiction?.MailingAddress, | |||||
| LegacyCityStateZip: jurisdiction?.CityStateZip); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,21 @@ | |||||
| using Campaign_Tracker.Server.ExtensionData; | |||||
| namespace Campaign_Tracker.Server.Municipalities; | |||||
| /// <summary> | |||||
| /// Extension-layer entity that stores municipality account data linked to a legacy jurisdiction. | |||||
| /// Implements <see cref="ILegacyLinkedRecord"/> so the nightly integrity check can verify | |||||
| /// the JCode reference is still valid (Story 1.8 AC #4 / NFR13). | |||||
| /// </summary> | |||||
| public sealed record MunicipalityProfile( | |||||
| string ProfileId, | |||||
| string JCode, | |||||
| string? DisplayName, | |||||
| DateTimeOffset CreatedAt, | |||||
| DateTimeOffset UpdatedAt, | |||||
| string UpdatedBy) : ILegacyLinkedRecord | |||||
| { | |||||
| public string RecordType => "MunicipalityProfile"; | |||||
| public string RecordId => ProfileId; | |||||
| public LegacyLinkReference LegacyLink => LegacyLinkReference.ForJurisdiction(JCode); | |||||
| } | |||||
| @@ -0,0 +1,17 @@ | |||||
| namespace Campaign_Tracker.Server.Municipalities; | |||||
| public sealed record MunicipalityProfileSaveResult( | |||||
| bool Saved, | |||||
| string? Error, | |||||
| 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); | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| namespace Campaign_Tracker.Server.Municipalities; | |||||
| /// <summary> | |||||
| /// Combined view of extension-layer profile data and resolved legacy jurisdiction fields (AC #2). | |||||
| /// </summary> | |||||
| public sealed record MunicipalityProfileView( | |||||
| MunicipalityProfile Profile, | |||||
| string? LegacyName, | |||||
| string? LegacyMailingAddress, | |||||
| string? LegacyCityStateZip); | |||||
| @@ -6,6 +6,7 @@ using Campaign_Tracker.Server.Authorization; | |||||
| using Campaign_Tracker.Server.Configuration; | using Campaign_Tracker.Server.Configuration; | ||||
| using Campaign_Tracker.Server.ExtensionData; | using Campaign_Tracker.Server.ExtensionData; | ||||
| using Campaign_Tracker.Server.LegacyData; | using Campaign_Tracker.Server.LegacyData; | ||||
| using Campaign_Tracker.Server.Municipalities; | |||||
| using Campaign_Tracker.Server.LegacyData.Schema; | using Campaign_Tracker.Server.LegacyData.Schema; | ||||
| using Campaign_Tracker.Server.Seed; | using Campaign_Tracker.Server.Seed; | ||||
| using Microsoft.AspNetCore.Authentication.JwtBearer; | using Microsoft.AspNetCore.Authentication.JwtBearer; | ||||
| @@ -58,7 +59,10 @@ if (!string.IsNullOrWhiteSpace(legacyConnectionString)) | |||||
| } | } | ||||
| else if (builder.Environment.IsDevelopment()) | else if (builder.Environment.IsDevelopment()) | ||||
| { | { | ||||
| builder.Services.AddSingleton<ILegacyDataAccess, InMemoryLegacyDataAccess>(); | |||||
| var jsonSeedPath = Path.GetFullPath( | |||||
| Path.Combine(builder.Environment.ContentRootPath, "..", "development-data", "jurisdictions.json")); | |||||
| builder.Services.AddSingleton<ILegacyDataAccess>( | |||||
| _ => InMemoryLegacyDataAccess.FromJsonSeedFile(jsonSeedPath)); | |||||
| } | } | ||||
| else | else | ||||
| { | { | ||||
| @@ -132,6 +136,15 @@ builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp => | |||||
| sp.GetRequiredService<InMemoryExtensionRecordStore>()); | sp.GetRequiredService<InMemoryExtensionRecordStore>()); | ||||
| builder.Services.AddHostedService<LegacyLinkIntegrityHostedService>(); | builder.Services.AddHostedService<LegacyLinkIntegrityHostedService>(); | ||||
| // Municipality account profiles (Story 1.10). | |||||
| // InMemoryMunicipalityProfileRepository also implements ILegacyLinkedRecordProvider, | |||||
| // so profiles participate in the nightly link integrity check automatically. | |||||
| builder.Services.AddSingleton<InMemoryMunicipalityProfileRepository>(); | |||||
| builder.Services.AddSingleton<IMunicipalityProfileRepository>(sp => | |||||
| sp.GetRequiredService<InMemoryMunicipalityProfileRepository>()); | |||||
| builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp => | |||||
| sp.GetRequiredService<InMemoryMunicipalityProfileRepository>()); | |||||
| var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? []; | var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? []; | ||||
| builder.Services.AddCors(options => | builder.Services.AddCors(options => | ||||
| { | { | ||||
| @@ -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":"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_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":"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"} | |||||
| @@ -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" | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -1,6 +1,6 @@ | |||||
| # Story 1.10: Municipality Account Profile | # Story 1.10: Municipality Account Profile | ||||
| Status: ready-for-dev | |||||
| Status: done | |||||
| ## Story | ## Story | ||||
| @@ -17,18 +17,18 @@ so that permanent municipality data is managed in the extension layer without mo | |||||
| ## Tasks / Subtasks | ## Tasks / Subtasks | ||||
| - [ ] Implement story behavior in aligned backend/frontend modules (AC: #1) | |||||
| - [ ] Add or update API/service/UI components required by the story scope | |||||
| - [ ] Keep legacy Access entities read-only and route writes to extension-layer structures | |||||
| - [ ] Cover acceptance criteria #2 in implementation and tests (AC: #2) | |||||
| - [ ] Add validation/error handling and UX state updates as needed | |||||
| - [ ] Cover acceptance criteria #3 in implementation and tests (AC: #3) | |||||
| - [ ] Add validation/error handling and UX state updates as needed | |||||
| - [ ] Cover acceptance criteria #4 in implementation and tests (AC: #4) | |||||
| - [ ] Add validation/error handling and UX state updates as needed | |||||
| - [ ] Validate and document completion evidence | |||||
| - [ ] Verify build/tests for touched modules | |||||
| - [ ] Capture changed files and any migration/config implications | |||||
| - [x] Implement story behavior in aligned backend/frontend modules (AC: #1) | |||||
| - [x] Add or update API/service/UI components required by the story scope | |||||
| - [x] Keep legacy Access entities read-only and route writes to extension-layer structures | |||||
| - [x] Cover acceptance criteria #2 in implementation and tests (AC: #2) | |||||
| - [x] Add validation/error handling and UX state updates as needed | |||||
| - [x] Cover acceptance criteria #3 in implementation and tests (AC: #3) | |||||
| - [x] Add validation/error handling and UX state updates as needed | |||||
| - [x] Cover acceptance criteria #4 in implementation and tests (AC: #4) | |||||
| - [x] Add validation/error handling and UX state updates as needed | |||||
| - [x] Validate and document completion evidence | |||||
| - [x] Verify build/tests for touched modules | |||||
| - [x] Capture changed files and any migration/config implications | |||||
| ## Dev Notes | ## Dev Notes | ||||
| @@ -52,18 +52,64 @@ so that permanent municipality data is managed in the extension layer without mo | |||||
| ### Agent Model Used | ### Agent Model Used | ||||
| GPT-5 Codex | |||||
| claude-sonnet-4-6 | |||||
| ### Debug Log References | ### Debug Log References | ||||
| - Story generated from epic source and architecture/UX planning artifacts. | |||||
| - 133/133 backend tests pass; 36/36 frontend tests pass. No regressions. | |||||
| ### Completion Notes List | ### Completion Notes List | ||||
| - Story context created and marked ready-for-dev. | |||||
| - Introduced `Campaign_Tracker.Server/Municipalities/` namespace with domain entity and repository. | |||||
| - `MunicipalityProfile` record implements `ILegacyLinkedRecord` — participates in Story 1.8 nightly integrity check automatically. | |||||
| - `InMemoryMunicipalityProfileRepository` validates JCode via `ILegacyLinkValidator` before save (AC #4). Resolves legacy jurisdiction fields via `ILegacyDataAccess` for combined views (AC #2). Returns `MunicipalityProfileView` combining both layers. | |||||
| - `MunicipalityProfileController` (`/api/municipalities/profiles`) — POST/GET/PUT with `ClientServicesAccess` policy (Admin bypass via `HasAny`). Records audit events on create and update (AC #3). | |||||
| - Repository registered as singleton + as `ILegacyLinkedRecordProvider` so integrity check covers municipality profiles. | |||||
| - 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. | |||||
| - 20 backend unit tests (10 repository + 10 controller integration) + 10 frontend contract tests. | |||||
| ### File List | ### File List | ||||
| - `_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md` | |||||
| - `Campaign_Tracker.Server/Municipalities/MunicipalityProfile.cs` (new) | |||||
| - `Campaign_Tracker.Server/Municipalities/MunicipalityProfileView.cs` (new) | |||||
| - `Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs` (new) | |||||
| - `Campaign_Tracker.Server/Municipalities/IMunicipalityProfileRepository.cs` (new) | |||||
| - `Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs` (new) | |||||
| - `Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs` (new) | |||||
| - `Campaign_Tracker.Server/Program.cs` (modified — added Municipalities using + repository registrations) | |||||
| - `Campaign_Tracker.Server.Tests/MunicipalityProfileRepositoryTests.cs` (new — 11 tests) | |||||
| - `Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs` (new — 5 tests) | |||||
| - `campaign-tracker-client/src/municipalities/municipalityContracts.ts` (new) | |||||
| - `campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx` (new) | |||||
| - `campaign-tracker-client/src/municipalities/municipalityContracts.test.ts` (new — 9 tests) | |||||
| - `campaign-tracker-client/src/workspace/WorkspaceShell.tsx` (modified — municipalities view wired) | |||||
| - `_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) | |||||
| @@ -1,6 +1,6 @@ | |||||
| # Story 1.11: Municipality Operational Addresses | # Story 1.11: Municipality Operational Addresses | ||||
| Status: ready-for-dev | |||||
| Status: review | |||||
| ## Story | ## Story | ||||
| @@ -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) | ## 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. | - `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. | ||||
| @@ -35,7 +35,7 @@ | |||||
| # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | ||||
| generated: '2026-05-05T12:00:44-04:00' | 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: 'Campaign_Tracker App' | ||||
| project_key: 'NOKEY' | project_key: 'NOKEY' | ||||
| tracking_system: 'file-system' | tracking_system: 'file-system' | ||||
| @@ -52,7 +52,7 @@ development_status: | |||||
| 1-7-legacy-schema-compatibility-validation-gate: done | 1-7-legacy-schema-compatibility-validation-gate: done | ||||
| 1-8-legacy-identifier-linking-for-extension-records: done | 1-8-legacy-identifier-linking-for-extension-records: done | ||||
| 1-9-seed-system-reference-values-rule-defaults: done | 1-9-seed-system-reference-values-rule-defaults: done | ||||
| 1-10-municipality-account-profile: ready-for-dev | |||||
| 1-10-municipality-account-profile: done | |||||
| 1-11-municipality-operational-addresses: ready-for-dev | 1-11-municipality-operational-addresses: ready-for-dev | ||||
| 1-12-municipality-service-contacts: ready-for-dev | 1-12-municipality-service-contacts: ready-for-dev | ||||
| 1-13-municipality-prior-cycle-service-defaults-view: ready-for-dev | 1-13-municipality-prior-cycle-service-defaults-view: ready-for-dev | ||||
| @@ -0,0 +1,255 @@ | |||||
| import { | |||||
| Alert, | |||||
| Button, | |||||
| Empty, | |||||
| Form, | |||||
| Input, | |||||
| Modal, | |||||
| Select, | |||||
| Space, | |||||
| Spin, | |||||
| Table, | |||||
| Typography, | |||||
| } from 'antd' | |||||
| import type { TableProps } from 'antd' | |||||
| import { useCallback, useEffect, useState } from 'react' | |||||
| import { | |||||
| createMunicipalityProfile, | |||||
| fetchAvailableJurisdictions, | |||||
| fetchMunicipalityProfiles, | |||||
| MunicipalityValidationError, | |||||
| type LegacyJurisdiction, | |||||
| type MunicipalityProfile, | |||||
| } from './municipalityContracts' | |||||
| const { Text, Title } = Typography | |||||
| const profileColumns: TableProps<MunicipalityProfile>['columns'] = [ | |||||
| { | |||||
| title: 'JCode', | |||||
| dataIndex: 'jCode', | |||||
| key: 'jCode', | |||||
| render: (value: string) => <Text code>{value}</Text>, | |||||
| width: 100, | |||||
| }, | |||||
| { | |||||
| title: 'Display Name', | |||||
| key: 'displayName', | |||||
| render: (_: unknown, record: MunicipalityProfile) => | |||||
| record.displayName ?? <Text type="secondary">{record.legacyName ?? '—'}</Text>, | |||||
| }, | |||||
| { | |||||
| title: 'Legacy Name', | |||||
| dataIndex: 'legacyName', | |||||
| key: 'legacyName', | |||||
| render: (value: string | null) => value ?? <Text type="secondary">—</Text>, | |||||
| }, | |||||
| { | |||||
| title: 'Address', | |||||
| key: 'address', | |||||
| render: (_: unknown, record: MunicipalityProfile) => { | |||||
| const addr = [record.legacyMailingAddress, record.legacyCityStateZip] | |||||
| .filter(Boolean) | |||||
| .join(', ') | |||||
| return addr || <Text type="secondary">—</Text> | |||||
| }, | |||||
| }, | |||||
| { | |||||
| title: 'Last updated', | |||||
| key: 'updatedAt', | |||||
| render: (_: unknown, record: MunicipalityProfile) => { | |||||
| const date = new Date(record.updatedAt) | |||||
| return isNaN(date.getTime()) | |||||
| ? record.updatedAt | |||||
| : date.toLocaleString() | |||||
| }, | |||||
| width: 180, | |||||
| }, | |||||
| ] | |||||
| type CreateFormValues = { | |||||
| jCode: string | |||||
| displayName?: string | |||||
| } | |||||
| export function MunicipalityProfilePanel({ | |||||
| load = fetchMunicipalityProfiles, | |||||
| create = createMunicipalityProfile, | |||||
| loadJurisdictions = fetchAvailableJurisdictions, | |||||
| }: { | |||||
| load?: typeof fetchMunicipalityProfiles | |||||
| create?: typeof createMunicipalityProfile | |||||
| loadJurisdictions?: typeof fetchAvailableJurisdictions | |||||
| } = {}) { | |||||
| const [profiles, setProfiles] = useState<MunicipalityProfile[] | null>(null) | |||||
| const [jurisdictions, setJurisdictions] = useState<LegacyJurisdiction[]>([]) | |||||
| const [loadError, setLoadError] = useState<string | null>(null) | |||||
| const [jurisdictionsLoadError, setJurisdictionsLoadError] = useState<string | null>(null) | |||||
| const [modalOpen, setModalOpen] = useState(false) | |||||
| const [saving, setSaving] = useState(false) | |||||
| const [saveError, setSaveError] = useState<string | null>(null) | |||||
| const [form] = Form.useForm<CreateFormValues>() | |||||
| const refresh = useCallback(async () => { | |||||
| try { | |||||
| const items = await load() | |||||
| setProfiles(items) | |||||
| } catch (cause) { | |||||
| setLoadError(cause instanceof Error ? cause.message : 'Failed to load profiles') | |||||
| } | |||||
| }, [load]) | |||||
| 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, loadJurisdictions]) | |||||
| const handleCreate = useCallback(async (values: CreateFormValues) => { | |||||
| setSaving(true) | |||||
| setSaveError(null) | |||||
| try { | |||||
| await create(values.jCode.trim().toUpperCase(), values.displayName?.trim() ?? null) | |||||
| setModalOpen(false) | |||||
| form.resetFields() | |||||
| await refresh() | |||||
| } catch (cause) { | |||||
| setSaveError( | |||||
| cause instanceof MunicipalityValidationError | |||||
| ? cause.message | |||||
| : cause instanceof Error | |||||
| ? cause.message | |||||
| : 'Save failed', | |||||
| ) | |||||
| } finally { | |||||
| setSaving(false) | |||||
| } | |||||
| }, [create, form, refresh]) | |||||
| return ( | |||||
| <section aria-label="Municipality account profiles" className="municipality-panel"> | |||||
| <Space direction="vertical" size={16} style={{ width: '100%' }}> | |||||
| <div> | |||||
| <Text className="workspace-kicker">Account management</Text> | |||||
| <Title level={2}>Municipality Profiles</Title> | |||||
| <Text type="secondary"> | |||||
| Extension-layer profiles linked to legacy jurisdiction records. | |||||
| Combined data is displayed from both sources. | |||||
| </Text> | |||||
| </div> | |||||
| {loadError ? ( | |||||
| <Alert type="error" showIcon message="Load error" description={loadError} /> | |||||
| ) : null} | |||||
| {jurisdictionsLoadError ? ( | |||||
| <Alert | |||||
| type="warning" | |||||
| showIcon | |||||
| message="Jurisdictions unavailable" | |||||
| description={`${jurisdictionsLoadError} — creating new profiles is disabled until jurisdictions load.`} | |||||
| /> | |||||
| ) : null} | |||||
| <Button | |||||
| type="primary" | |||||
| onClick={() => { setSaveError(null); setModalOpen(true) }} | |||||
| disabled={jurisdictionsLoadError !== null} | |||||
| > | |||||
| New Municipality Profile | |||||
| </Button> | |||||
| {profiles === null ? ( | |||||
| <Spin aria-label="Loading municipality profiles" /> | |||||
| ) : profiles.length === 0 ? ( | |||||
| <Empty description="No municipality profiles yet. Create one to get started." /> | |||||
| ) : ( | |||||
| <Table<MunicipalityProfile> | |||||
| rowKey="profileId" | |||||
| size="small" | |||||
| pagination={{ pageSize: 25 }} | |||||
| columns={profileColumns} | |||||
| dataSource={profiles} | |||||
| scroll={{ x: 800 }} | |||||
| /> | |||||
| )} | |||||
| <Modal | |||||
| title="New Municipality Profile" | |||||
| open={modalOpen} | |||||
| onCancel={() => { setModalOpen(false); form.resetFields(); setSaveError(null) }} | |||||
| footer={null} | |||||
| destroyOnHidden | |||||
| > | |||||
| {saveError ? ( | |||||
| <Alert | |||||
| type="error" | |||||
| showIcon | |||||
| message="Validation error" | |||||
| description={saveError} | |||||
| style={{ marginBottom: 16 }} | |||||
| /> | |||||
| ) : null} | |||||
| <Form<CreateFormValues> | |||||
| form={form} | |||||
| layout="vertical" | |||||
| onFinish={handleCreate} | |||||
| > | |||||
| <Form.Item | |||||
| name="jCode" | |||||
| label="Jurisdiction" | |||||
| rules={[{ required: true, message: 'Jurisdiction is required' }]} | |||||
| > | |||||
| <Select | |||||
| showSearch | |||||
| placeholder="Search by JCode or name…" | |||||
| aria-label="Legacy jurisdiction" | |||||
| filterOption={(input, option) => | |||||
| (option?.label ?? '').toLowerCase().includes(input.toLowerCase()) | |||||
| } | |||||
| options={jurisdictions.map((j) => ({ | |||||
| value: j.jCode, | |||||
| label: j.name ? `${j.jCode} — ${j.name}` : j.jCode, | |||||
| }))} | |||||
| /> | |||||
| </Form.Item> | |||||
| <Form.Item | |||||
| name="displayName" | |||||
| label="Display name (optional override)" | |||||
| > | |||||
| <Input placeholder="Leave blank to use the legacy name" /> | |||||
| </Form.Item> | |||||
| <Form.Item style={{ marginBottom: 0 }}> | |||||
| <Space> | |||||
| <Button type="primary" htmlType="submit" loading={saving}> | |||||
| Create Profile | |||||
| </Button> | |||||
| <Button onClick={() => { setModalOpen(false); form.resetFields(); setSaveError(null) }}> | |||||
| Cancel | |||||
| </Button> | |||||
| </Space> | |||||
| </Form.Item> | |||||
| </Form> | |||||
| </Modal> | |||||
| </Space> | |||||
| </section> | |||||
| ) | |||||
| } | |||||
| @@ -0,0 +1,138 @@ | |||||
| import { describe, expect, it } from 'vitest' | |||||
| import { | |||||
| createMunicipalityProfile, | |||||
| fetchAvailableJurisdictions, | |||||
| fetchMunicipalityProfiles, | |||||
| MunicipalityValidationError, | |||||
| updateMunicipalityProfile, | |||||
| type MunicipalityProfile, | |||||
| } from './municipalityContracts' | |||||
| const makeProfile = (overrides: Partial<MunicipalityProfile> = {}): MunicipalityProfile => ({ | |||||
| profileId: 'abc123', | |||||
| jCode: 'FAIR01', | |||||
| displayName: 'Fairview Borough', | |||||
| updatedAt: '2026-05-06T12:00:00Z', | |||||
| updatedBy: 'user@test.com', | |||||
| legacyName: 'Fairview Borough', | |||||
| legacyMailingAddress: '100 Main St', | |||||
| legacyCityStateZip: 'Fairview, PA 16415', | |||||
| ...overrides, | |||||
| }) | |||||
| // ── fetchMunicipalityProfiles ───────────────────────────────────────────────── | |||||
| describe('fetchMunicipalityProfiles', () => { | |||||
| it('returns profiles on 200', async () => { | |||||
| const stub = async () => | |||||
| new Response(JSON.stringify([makeProfile()]), { status: 200 }) | |||||
| const result = await fetchMunicipalityProfiles(stub) | |||||
| expect(result).toHaveLength(1) | |||||
| expect(result[0].jCode).toBe('FAIR01') | |||||
| expect(result[0].legacyName).toBe('Fairview Borough') | |||||
| }) | |||||
| it('throws on non-200', async () => { | |||||
| const stub = async () => new Response('{}', { status: 500 }) | |||||
| await expect(fetchMunicipalityProfiles(stub)).rejects.toThrow('500') | |||||
| }) | |||||
| }) | |||||
| // ── createMunicipalityProfile ───────────────────────────────────────────────── | |||||
| describe('createMunicipalityProfile', () => { | |||||
| it('returns profile on 200', async () => { | |||||
| const stub = async () => | |||||
| new Response(JSON.stringify(makeProfile()), { status: 200 }) | |||||
| const result = await createMunicipalityProfile('FAIR01', 'Fairview', stub) | |||||
| expect(result.profileId).toBe('abc123') | |||||
| expect(result.jCode).toBe('FAIR01') | |||||
| }) | |||||
| it('throws MunicipalityValidationError on 422 with descriptive message', async () => { | |||||
| const stub = async () => | |||||
| new Response(JSON.stringify({ error: "No legacy jurisdiction found for JCode 'NOPE'." }), { | |||||
| status: 422, | |||||
| }) | |||||
| await expect(createMunicipalityProfile('NOPE', null, stub)).rejects.toSatisfy( | |||||
| (e) => e instanceof MunicipalityValidationError && e.message.includes('NOPE'), | |||||
| ) | |||||
| }) | |||||
| it('throws generic Error on other non-200 status', async () => { | |||||
| const stub = async () => new Response('{}', { status: 500 }) | |||||
| await expect(createMunicipalityProfile('FAIR01', null, stub)).rejects.toThrow('500') | |||||
| await expect(createMunicipalityProfile('FAIR01', null, stub)).rejects.not.toSatisfy( | |||||
| (e) => e instanceof MunicipalityValidationError, | |||||
| ) | |||||
| }) | |||||
| }) | |||||
| // ── updateMunicipalityProfile ───────────────────────────────────────────────── | |||||
| describe('updateMunicipalityProfile', () => { | |||||
| it('returns updated profile on 200', async () => { | |||||
| const stub = async () => | |||||
| new Response(JSON.stringify(makeProfile({ displayName: 'New Name' })), { status: 200 }) | |||||
| const result = await updateMunicipalityProfile('abc123', 'New Name', stub) | |||||
| expect(result.displayName).toBe('New Name') | |||||
| }) | |||||
| it('throws MunicipalityValidationError on 422', async () => { | |||||
| const stub = async () => | |||||
| new Response(JSON.stringify({ error: 'Profile not found.' }), { status: 422 }) | |||||
| await expect(updateMunicipalityProfile('ghost', 'X', stub)).rejects.toSatisfy( | |||||
| (e) => e instanceof MunicipalityValidationError, | |||||
| ) | |||||
| }) | |||||
| }) | |||||
| // ── 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', () => { | |||||
| it('has correct name and message', () => { | |||||
| const err = new MunicipalityValidationError('JCode not found') | |||||
| expect(err.name).toBe('MunicipalityValidationError') | |||||
| expect(err.message).toBe('JCode not found') | |||||
| expect(err).toBeInstanceOf(Error) | |||||
| }) | |||||
| }) | |||||
| @@ -0,0 +1,92 @@ | |||||
| export type MunicipalityProfile = { | |||||
| profileId: string | |||||
| jCode: string | |||||
| displayName: string | null | |||||
| updatedAt: string | |||||
| updatedBy: string | |||||
| legacyName: string | null | |||||
| legacyMailingAddress: string | null | |||||
| legacyCityStateZip: string | null | |||||
| } | |||||
| export type MunicipalityProfileValidationError = { | |||||
| error: string | |||||
| } | |||||
| export async function fetchMunicipalityProfiles( | |||||
| fetcher: typeof fetch = fetch, | |||||
| ): Promise<MunicipalityProfile[]> { | |||||
| const response = await fetcher('/api/municipalities/profiles') | |||||
| if (!response.ok) { | |||||
| throw new Error(`Failed to load municipality profiles (${response.status})`) | |||||
| } | |||||
| return (await response.json()) as MunicipalityProfile[] | |||||
| } | |||||
| export async function createMunicipalityProfile( | |||||
| jCode: string, | |||||
| displayName: string | null, | |||||
| fetcher: typeof fetch = fetch, | |||||
| ): Promise<MunicipalityProfile> { | |||||
| const response = await fetcher('/api/municipalities/profiles', { | |||||
| method: 'POST', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ jCode, displayName }), | |||||
| }) | |||||
| if (response.status === 422) { | |||||
| const problem = (await response.json()) as MunicipalityProfileValidationError | |||||
| throw new MunicipalityValidationError(problem.error ?? 'Validation failed.') | |||||
| } | |||||
| if (!response.ok) { | |||||
| throw new Error(`Failed to create municipality profile (${response.status})`) | |||||
| } | |||||
| return (await response.json()) as MunicipalityProfile | |||||
| } | |||||
| export async function updateMunicipalityProfile( | |||||
| profileId: string, | |||||
| displayName: string | null, | |||||
| fetcher: typeof fetch = fetch, | |||||
| ): Promise<MunicipalityProfile> { | |||||
| const response = await fetcher(`/api/municipalities/profiles/${profileId}`, { | |||||
| method: 'PUT', | |||||
| headers: { 'Content-Type': 'application/json' }, | |||||
| body: JSON.stringify({ displayName }), | |||||
| }) | |||||
| if (response.status === 422) { | |||||
| const problem = (await response.json()) as MunicipalityProfileValidationError | |||||
| throw new MunicipalityValidationError(problem.error ?? 'Validation failed.') | |||||
| } | |||||
| if (!response.ok) { | |||||
| throw new Error(`Failed to update municipality profile (${response.status})`) | |||||
| } | |||||
| return (await response.json()) as MunicipalityProfile | |||||
| } | |||||
| export type LegacyJurisdiction = { | |||||
| jCode: string | |||||
| name: string | null | |||||
| } | |||||
| export async function fetchAvailableJurisdictions( | |||||
| fetcher: typeof fetch = fetch, | |||||
| ): Promise<LegacyJurisdiction[]> { | |||||
| 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) | |||||
| this.name = 'MunicipalityValidationError' | |||||
| } | |||||
| } | |||||
| @@ -34,6 +34,12 @@ import { | |||||
| } from './workspaceContracts' | } from './workspaceContracts' | ||||
| import type { AuthenticatedUser } from '../auth/authContracts' | import type { AuthenticatedUser } from '../auth/authContracts' | ||||
| import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel' | import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel' | ||||
| import { MunicipalityProfilePanel } from '../municipalities/MunicipalityProfilePanel' | |||||
| import { | |||||
| createMunicipalityProfile, | |||||
| fetchAvailableJurisdictions, | |||||
| fetchMunicipalityProfiles, | |||||
| } from '../municipalities/municipalityContracts' | |||||
| import { | import { | ||||
| fetchLegacySchemaCheckHistory, | fetchLegacySchemaCheckHistory, | ||||
| runLegacySchemaCheck, | runLegacySchemaCheck, | ||||
| @@ -397,6 +403,12 @@ export function WorkspaceShell({ | |||||
| loadHistory={() => fetchLegacySchemaCheckHistory(adminFetch)} | loadHistory={() => fetchLegacySchemaCheckHistory(adminFetch)} | ||||
| runCheck={() => runLegacySchemaCheck(adminFetch)} | runCheck={() => runLegacySchemaCheck(adminFetch)} | ||||
| /> | /> | ||||
| ) : selectedView === 'municipalities' && user.permissions.canViewMunicipalityProfile ? ( | |||||
| <MunicipalityProfilePanel | |||||
| load={() => fetchMunicipalityProfiles(adminFetch)} | |||||
| create={(jCode, displayName) => createMunicipalityProfile(jCode, displayName, adminFetch)} | |||||
| loadJurisdictions={() => fetchAvailableJurisdictions(adminFetch)} | |||||
| /> | |||||
| ) : ( | ) : ( | ||||
| <section | <section | ||||
| className="workspace-board" | className="workspace-board" | ||||
Powered by TurnKey Linux.