瀏覽代碼

Merge branch 'main' into 1-11

pull/17/head
Daniel Covington 2 天之前
父節點
當前提交
e8d12a0579
共有 22 個檔案被更改,包括 1736 行新增41 行删除
  1. +12
    -5
      Campaign_Tracker.Server.Tests/AuthEndpointTests.cs
  2. +214
    -0
      Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs
  3. +188
    -0
      Campaign_Tracker.Server.Tests/MunicipalityProfileRepositoryTests.cs
  4. +161
    -0
      Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs
  5. +47
    -0
      Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs
  6. +13
    -13
      Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs
  7. +23
    -0
      Campaign_Tracker.Server/Municipalities/IMunicipalityProfileRepository.cs
  8. +144
    -0
      Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs
  9. +21
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityProfile.cs
  10. +17
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs
  11. +10
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityProfileView.cs
  12. +14
    -1
      Campaign_Tracker.Server/Program.cs
  13. +61
    -0
      Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl
  14. +238
    -0
      Campaign_Tracker.Server/seed-data.json.c92561d951cd49b0b53ec61b8330edc2.tmp
  15. +65
    -19
      _bmad-output/implementation-artifacts/1-10-municipality-account-profile.md
  16. +1
    -1
      _bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md
  17. +8
    -0
      _bmad-output/implementation-artifacts/deferred-work.md
  18. +2
    -2
      _bmad-output/implementation-artifacts/sprint-status.yaml
  19. +255
    -0
      campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx
  20. +138
    -0
      campaign-tracker-client/src/municipalities/municipalityContracts.test.ts
  21. +92
    -0
      campaign-tracker-client/src/municipalities/municipalityContracts.ts
  22. +12
    -0
      campaign-tracker-client/src/workspace/WorkspaceShell.tsx

+ 12
- 5
Campaign_Tracker.Server.Tests/AuthEndpointTests.cs 查看文件

@@ -7,6 +7,7 @@ using System.Security.Claims;
using System.Text;
using Campaign_Tracker.Server.Audit;
using Campaign_Tracker.Server.Authentication;
using Campaign_Tracker.Server.LegacyData;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
@@ -44,13 +45,19 @@ public sealed class AuthIntegrationTestFactory : WebApplicationFactory<Program>
// Replace the file-backed IAuditService with an in-memory passthrough.
// File persistence is validated in AuditServiceTests; integration tests
// should not depend on file-system state.
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService));
if (descriptor is not null)
{
services.Remove(descriptor);
}
var auditDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(IAuditService));
if (auditDescriptor is not null)
services.Remove(auditDescriptor);

services.AddSingleton<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());
});
}



+ 214
- 0
Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs 查看文件

@@ -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);
}

+ 188
- 0
Campaign_Tracker.Server.Tests/MunicipalityProfileRepositoryTests.cs 查看文件

@@ -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;
}
}

+ 161
- 0
Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs 查看文件

@@ -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);

+ 47
- 0
Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs 查看文件

@@ -1,3 +1,4 @@
using System.Text.Json;
using Campaign_Tracker.Server.LegacyData.Models;

namespace Campaign_Tracker.Server.LegacyData;
@@ -29,6 +30,52 @@ public sealed class InMemoryLegacyDataAccess : ILegacyDataAccess
_kitLabels = kitLabels ?? DefaultKitLabels;
}

/// <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 ──────────────────────────────────────────────────────────

public Task<LegacyJurisdiction?> GetJurisdictionAsync(


+ 13
- 13
Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs 查看文件

@@ -29,12 +29,12 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT JCode, Name, MailingAddress, CityStateZip, Phone, Email
SELECT JCode, Name, Mailing_Address, CSZ, IMB, IMB_Digits
FROM Jurisdiction
WHERE JCode = ?
WHERE Trim(JCode) = ?
""";

var results = await QueryAsync(sql, [jCode], MapJurisdiction, cancellationToken);
var results = await QueryAsync(sql, [jCode.Trim()], MapJurisdiction, cancellationToken);
return results.FirstOrDefault();
}

@@ -42,7 +42,7 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess
CancellationToken cancellationToken = default)
{
const string sql = """
SELECT JCode, Name, MailingAddress, CityStateZip, Phone, Email
SELECT JCode, Name, Mailing_Address, CSZ, IMB, IMB_Digits
FROM Jurisdiction
ORDER BY JCode
""";
@@ -77,11 +77,11 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess
BusinessAddress, BusinessAddress2, BusinessAddress3,
TownshipName, TownshipNum
FROM Contacts
WHERE JURISCODE = ?
WHERE Trim(JURISCODE) = ?
ORDER BY ID
""";

return QueryAsync(sql, [jCode], MapContact, cancellationToken);
return QueryAsync(sql, [jCode.Trim()], MapContact, cancellationToken);
}

public async Task<LegacyKit?> GetKitByIdAsync(
@@ -109,11 +109,11 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess
CreatedOn, ExportedToSnailWorks, LabelsPrinted, OfficeCopiesAmount,
InboundStid, OutboundStid
FROM Kit
WHERE Jcode = ?
WHERE Trim(Jcode) = ?
ORDER BY ID
""";

return QueryAsync(sql, [jCode], MapKit, cancellationToken);
return QueryAsync(sql, [jCode.Trim()], MapKit, cancellationToken);
}

public Task<IReadOnlyList<LegacyKitLabel>> GetKitLabelsByKitAsync(
@@ -174,10 +174,10 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess
new(
GetRequiredString(reader, "JCode"),
GetString(reader, "Name"),
GetString(reader, "MailingAddress"),
GetString(reader, "CityStateZip"),
GetString(reader, "Phone"),
GetString(reader, "Email"));
GetString(reader, "Mailing_Address"),
GetString(reader, "CSZ"),
GetString(reader, "IMB"),
GetString(reader, "IMB_Digits"));

private static LegacyContact MapContact(DbDataReader reader) =>
new(
@@ -228,7 +228,7 @@ public sealed class OleDbLegacyDataAccess : ILegacyDataAccess

private static string GetRequiredString(DbDataReader reader, string name)
{
var value = GetString(reader, name);
var value = GetString(reader, name)?.Trim();
return string.IsNullOrWhiteSpace(value)
? throw new LegacyDataAccessException($"Required legacy join key {name} was null or empty.")
: value;


+ 23
- 0
Campaign_Tracker.Server/Municipalities/IMunicipalityProfileRepository.cs 查看文件

@@ -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);
}

+ 144
- 0
Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs 查看文件

@@ -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);
}
}

+ 21
- 0
Campaign_Tracker.Server/Municipalities/MunicipalityProfile.cs 查看文件

@@ -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);
}

+ 17
- 0
Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs 查看文件

@@ -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);
}

+ 10
- 0
Campaign_Tracker.Server/Municipalities/MunicipalityProfileView.cs 查看文件

@@ -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);

+ 14
- 1
Campaign_Tracker.Server/Program.cs 查看文件

@@ -6,6 +6,7 @@ using Campaign_Tracker.Server.Authorization;
using Campaign_Tracker.Server.Configuration;
using Campaign_Tracker.Server.ExtensionData;
using Campaign_Tracker.Server.LegacyData;
using Campaign_Tracker.Server.Municipalities;
using Campaign_Tracker.Server.LegacyData.Schema;
using Campaign_Tracker.Server.Seed;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -58,7 +59,10 @@ if (!string.IsNullOrWhiteSpace(legacyConnectionString))
}
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
{
@@ -132,6 +136,15 @@ builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
sp.GetRequiredService<InMemoryExtensionRecordStore>());
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[]>() ?? [];
builder.Services.AddCors(options =>
{


+ 61
- 0
Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl 查看文件

@@ -388,3 +388,64 @@
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.4317157+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.432207+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HO","recordedAt":"2026-05-06T17:49:02.4360749+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L0","recordedAt":"2026-05-06T20:43:56.5497091+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L0","recordedAt":"2026-05-06T20:43:56.5525818+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L3","recordedAt":"2026-05-06T20:43:56.5584051+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L3","recordedAt":"2026-05-06T20:43:56.5588351+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L4","recordedAt":"2026-05-06T20:43:56.5646905+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L4","recordedAt":"2026-05-06T20:43:56.56609+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L5","recordedAt":"2026-05-06T20:43:56.5679896+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L5","recordedAt":"2026-05-06T20:43:56.5686553+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L6","recordedAt":"2026-05-06T20:43:56.5728285+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L6","recordedAt":"2026-05-06T20:43:56.5735927+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L8","recordedAt":"2026-05-06T20:43:56.5799093+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBM0OM97L8","recordedAt":"2026-05-06T20:43:56.5803665+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97L9","recordedAt":"2026-05-06T20:43:56.5820498+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBM0OM97L9","recordedAt":"2026-05-06T20:43:56.5824382+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LA","recordedAt":"2026-05-06T20:43:56.5832668+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBM0OM97LA","recordedAt":"2026-05-06T20:43:56.5838662+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LD","recordedAt":"2026-05-06T20:43:56.5882919+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBM0OM97LD","recordedAt":"2026-05-06T20:43:56.5887507+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LE","recordedAt":"2026-05-06T20:43:56.6273768+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBM0OM97LE","recordedAt":"2026-05-06T20:43:56.6282634+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LG","recordedAt":"2026-05-06T20:43:56.6342765+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97LG","recordedAt":"2026-05-06T20:43:56.634912+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM0OM97LJ","recordedAt":"2026-05-06T20:43:56.6402231+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBM0OM97LJ","recordedAt":"2026-05-06T20:43:56.6406744+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBM0OM97LK","recordedAt":"2026-05-06T20:43:56.6469848+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/token/exchange","outcome":"success","traceIdentifier":"0HNLBM1Q26KDF:00000001","recordedAt":"2026-05-06T20:45:48.7738179+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDG:00000001","recordedAt":"2026-05-06T20:45:48.9878754+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDG:00000001","recordedAt":"2026-05-06T20:45:48.9981838+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDH:00000001","recordedAt":"2026-05-06T20:45:49.0949751+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDI:00000001","recordedAt":"2026-05-06T20:45:49.095015+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDI:00000001","recordedAt":"2026-05-06T20:45:49.0995614+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDH:00000001","recordedAt":"2026-05-06T20:45:49.0995802+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDJ:00000001","recordedAt":"2026-05-06T20:45:49.1434308+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDK:00000001","recordedAt":"2026-05-06T20:45:49.1434308+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDJ:00000001","recordedAt":"2026-05-06T20:45:49.1533199+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDK:00000001","recordedAt":"2026-05-06T20:45:49.1533219+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDL:00000001","recordedAt":"2026-05-06T20:45:54.7892666+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDL:00000001","recordedAt":"2026-05-06T20:45:54.7904458+00:00"}
{"eventType":"MUNICIPALITY_PROFILE_CREATED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"municipalities/profiles/326cedc8f3d2406481f1c5d94ed77200","outcome":"created JCode=01160","traceIdentifier":"0HNLBM1Q26KDL:00000001","recordedAt":"2026-05-06T20:45:54.8119+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDM:00000001","recordedAt":"2026-05-06T20:45:54.8284292+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDM:00000001","recordedAt":"2026-05-06T20:45:54.8290406+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDN:00000001","recordedAt":"2026-05-06T20:46:01.4812757+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/admin/legacy-schema/history","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDN:00000001","recordedAt":"2026-05-06T20:46:01.4822805+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDO:00000001","recordedAt":"2026-05-06T20:46:01.5218446+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/admin/legacy-schema/history","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDO:00000001","recordedAt":"2026-05-06T20:46:01.5225973+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDP:00000001","recordedAt":"2026-05-06T20:46:08.2907775+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDP:00000001","recordedAt":"2026-05-06T20:46:08.2918014+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDQ:00000001","recordedAt":"2026-05-06T20:46:08.2921768+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDQ:00000001","recordedAt":"2026-05-06T20:46:08.2927697+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDR:00000001","recordedAt":"2026-05-06T20:46:08.3053075+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDR:00000001","recordedAt":"2026-05-06T20:46:08.3061538+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDS:00000001","recordedAt":"2026-05-06T20:46:08.3068968+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/jurisdictions","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDS:00000001","recordedAt":"2026-05-06T20:46:08.3073952+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDT:00000001","recordedAt":"2026-05-06T20:46:25.4620549+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDT:00000001","recordedAt":"2026-05-06T20:46:25.4629616+00:00"}
{"eventType":"MUNICIPALITY_PROFILE_CREATED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"municipalities/profiles/f63190df69274298a150396426c3ca28","outcome":"created JCode=99999","traceIdentifier":"0HNLBM1Q26KDT:00000001","recordedAt":"2026-05-06T20:46:25.4647982+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDU:00000001","recordedAt":"2026-05-06T20:46:25.4771693+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/municipalities/profiles","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDU:00000001","recordedAt":"2026-05-06T20:46:25.4777419+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBM1Q26KDV:00000001","recordedAt":"2026-05-06T20:46:39.117667+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBM1Q26KDV:00000001","recordedAt":"2026-05-06T20:46:39.1188386+00:00"}
{"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBM1Q26KDV:00000001","recordedAt":"2026-05-06T20:46:39.158126+00:00"}

+ 238
- 0
Campaign_Tracker.Server/seed-data.json.c92561d951cd49b0b53ec61b8330edc2.tmp 查看文件

@@ -0,0 +1,238 @@
{
"referenceValues": [
{
"id": 0,
"seedKey": "operational-status.not-started",
"category": "OperationalStatus",
"name": "Not Started",
"description": "Election-cycle job work has not started.",
"value": "not-started",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "operational-status.in-progress",
"category": "OperationalStatus",
"name": "In Progress",
"description": "Election-cycle job work is actively in progress.",
"value": "in-progress",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "operational-status.blocked",
"category": "OperationalStatus",
"name": "Blocked",
"description": "Election-cycle job work is blocked and needs intervention.",
"value": "blocked",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "operational-status.complete",
"category": "OperationalStatus",
"name": "Complete",
"description": "Election-cycle job work is complete.",
"value": "complete",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "service-template.addressing",
"category": "ServiceTemplate",
"name": "Addressing",
"description": "Default service template for addressing work.",
"value": "addressing",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "service-template.sorting",
"category": "ServiceTemplate",
"name": "Sorting",
"description": "Default service template for sorting work.",
"value": "sorting",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "service-template.transportation",
"category": "ServiceTemplate",
"name": "Transportation",
"description": "Default service template for transportation work.",
"value": "transportation",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "service-template.office-copy",
"category": "ServiceTemplate",
"name": "Office Copy",
"description": "Default service template for office-copy work.",
"value": "office-copy",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "extension-reference.election-cycle.primary",
"category": "ElectionCycleType",
"name": "Primary",
"description": "Extension-layer election-cycle reference value for primary elections.",
"value": "primary",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "extension-reference.election-cycle.general",
"category": "ElectionCycleType",
"name": "General",
"description": "Extension-layer election-cycle reference value for general elections.",
"value": "general",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "extension-reference.mail-class.first-class",
"category": "MailClass",
"name": "First Class",
"description": "Extension-layer mail-class reference value.",
"value": "first-class",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "extension-reference.mail-class.standard",
"category": "MailClass",
"name": "Standard",
"description": "Extension-layer mail-class reference value.",
"value": "standard",
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
}
],
"requiredFieldRules": [
{
"id": 0,
"seedKey": "required-field.election-cycle-job.municipality-profile-id",
"name": "Municipality Profile",
"description": "Election-cycle jobs must be linked to a municipality profile.",
"entityType": "ElectionCycleJob",
"fieldPath": "municipalityProfileId",
"readinessFeatureKey": "FR29.ReadinessStatus",
"isRequired": true,
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "required-field.election-cycle-job.legacy-jurisdiction-j-code",
"name": "Legacy Jurisdiction Code",
"description": "Election-cycle jobs must keep the legacy jurisdiction bridge required by Story 1.8.",
"entityType": "ElectionCycleJob",
"fieldPath": "legacyJurisdictionJCode",
"readinessFeatureKey": "FR29.ReadinessStatus",
"isRequired": true,
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "required-field.election-cycle-job.election-date",
"name": "Election Date",
"description": "Election-cycle jobs need an election date before readiness can pass.",
"entityType": "ElectionCycleJob",
"fieldPath": "electionDate",
"readinessFeatureKey": "FR29.ReadinessStatus",
"isRequired": true,
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "required-field.election-cycle-job.mail-date",
"name": "Mail Date",
"description": "Election-cycle jobs need a planned mail date before readiness can pass.",
"entityType": "ElectionCycleJob",
"fieldPath": "mailDate",
"readinessFeatureKey": "FR29.ReadinessStatus",
"isRequired": true,
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
},
{
"id": 0,
"seedKey": "required-field.election-cycle-job.service-template",
"name": "Service Template",
"description": "Election-cycle jobs need a selected service template before readiness can pass.",
"entityType": "ElectionCycleJob",
"fieldPath": "serviceTemplate",
"readinessFeatureKey": "FR29.ReadinessStatus",
"isRequired": true,
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
}
],
"escalationRules": [
{
"id": 0,
"seedKey": "escalation.overdue-milestone.operations-lead",
"name": "Overdue Milestone Operations Lead Alert",
"description": "Escalates election-cycle jobs whose active milestone is overdue.",
"scenario": "OverdueMilestoneAlert",
"triggerCondition": "activeMilestone.dueDate \u003C today \u0026\u0026 job.status != \u0027complete\u0027",
"action": "NotifyOperationsLead",
"milestoneBasis": "activeMilestone.dueDate",
"alertWindow": "00:00:00",
"priority": 1,
"source": "SystemSeed",
"isActive": true,
"createdAt": "2026-05-06T18:58:39.8354954+00:00",
"updatedAt": "2026-05-06T18:58:39.8354954+00:00"
}
]
}

+ 65
- 19
_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md 查看文件

@@ -1,6 +1,6 @@
# Story 1.10: Municipality Account Profile

Status: ready-for-dev
Status: done

## Story

@@ -17,18 +17,18 @@ so that permanent municipality data is managed in the extension layer without mo

## 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

@@ -52,18 +52,64 @@ so that permanent municipality data is managed in the extension layer without mo

### Agent Model Used

GPT-5 Codex
claude-sonnet-4-6

### 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

- 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

- `_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
- 1
_bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md 查看文件

@@ -1,6 +1,6 @@
# Story 1.11: Municipality Operational Addresses

Status: ready-for-dev
Status: review

## Story



+ 8
- 0
_bmad-output/implementation-artifacts/deferred-work.md 查看文件

@@ -1,3 +1,11 @@
## Deferred from: code review of 1-10-municipality-account-profile.md (2026-05-06)

- Internal whitespace in JCode from Access not handled — `Trim()` strips leading/trailing only; JCodes with embedded spaces would cause lookup mismatches between `GetAllJurisdictionsAsync` and `GetJurisdictionAsync`. Evidence: `Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs`. Pre-existing data-quality risk; fix requires confirming Access data characteristics.
- `ProfileId` uses `Guid.NewGuid().ToString("N")` (no hyphens) — latent cross-system UUID format mismatch if an external consumer returns a hyphenated UUID variant. Evidence: `Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs`. Pre-existing design choice; consistent within the codebase today.
- `CreatedAt` stored on `MunicipalityProfile` but absent from API response DTO and frontend contract — profile creation timestamp inaccessible to consumers. Evidence: `MunicipalityProfileResponse` record in `MunicipalityProfileController.cs`. Not required by story spec; can be added in a future story.
- Audit `Outcome` hardcoded to `"updated display name"` — will become misleading once the profile model gains additional updatable fields. Evidence: `MunicipalityProfileController.Update`. Only `DisplayName` is editable today; acceptable short-term.
- `refresh()` post-create does not reload the jurisdiction list — list can grow stale if jurisdictions are added to the legacy database during the session. Evidence: `MunicipalityProfilePanel.tsx` `handleCreate`. Minor UX gap; low impact in practice.

## Deferred from: fix-strictmode-oidc-callback-race (2026-05-06)

- `pendingCallbackSequence` is not scoped to a specific callback invocation — if `useOidcSession` were ever mounted twice simultaneously, the second instance would skip CSRF validation and piggyback on the first's exchange. Pre-existing architectural assumption; low risk given single-mount usage, but worth an assertion if the hook gains wider use.


+ 2
- 2
_bmad-output/implementation-artifacts/sprint-status.yaml 查看文件

@@ -35,7 +35,7 @@
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)

generated: '2026-05-05T12:00:44-04:00'
last_updated: '2026-05-06T14:41:40-04:00'
last_updated: '2026-05-06T16:44:00-04:00'
project: 'Campaign_Tracker App'
project_key: 'NOKEY'
tracking_system: 'file-system'
@@ -52,7 +52,7 @@ development_status:
1-7-legacy-schema-compatibility-validation-gate: done
1-8-legacy-identifier-linking-for-extension-records: done
1-9-seed-system-reference-values-rule-defaults: done
1-10-municipality-account-profile: ready-for-dev
1-10-municipality-account-profile: done
1-11-municipality-operational-addresses: ready-for-dev
1-12-municipality-service-contacts: ready-for-dev
1-13-municipality-prior-cycle-service-defaults-view: ready-for-dev


+ 255
- 0
campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx 查看文件

@@ -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>
)
}

+ 138
- 0
campaign-tracker-client/src/municipalities/municipalityContracts.test.ts 查看文件

@@ -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)
})
})

+ 92
- 0
campaign-tracker-client/src/municipalities/municipalityContracts.ts 查看文件

@@ -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'
}
}

+ 12
- 0
campaign-tracker-client/src/workspace/WorkspaceShell.tsx 查看文件

@@ -34,6 +34,12 @@ import {
} from './workspaceContracts'
import type { AuthenticatedUser } from '../auth/authContracts'
import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel'
import { MunicipalityProfilePanel } from '../municipalities/MunicipalityProfilePanel'
import {
createMunicipalityProfile,
fetchAvailableJurisdictions,
fetchMunicipalityProfiles,
} from '../municipalities/municipalityContracts'
import {
fetchLegacySchemaCheckHistory,
runLegacySchemaCheck,
@@ -397,6 +403,12 @@ export function WorkspaceShell({
loadHistory={() => fetchLegacySchemaCheckHistory(adminFetch)}
runCheck={() => runLegacySchemaCheck(adminFetch)}
/>
) : selectedView === 'municipalities' && user.permissions.canViewMunicipalityProfile ? (
<MunicipalityProfilePanel
load={() => fetchMunicipalityProfiles(adminFetch)}
create={(jCode, displayName) => createMunicipalityProfile(jCode, displayName, adminFetch)}
loadJurisdictions={() => fetchAvailableJurisdictions(adminFetch)}
/>
) : (
<section
className="workspace-board"


Loading…
取消
儲存

Powered by TurnKey Linux.