Переглянути джерело

feat(1.10): municipality account profile — extension layer + workspace UI

Introduces the Municipalities namespace with domain entity, in-memory
repository, and REST API. Extends the workspace shell with a live panel.

Backend:
- MunicipalityProfile record (ILegacyLinkedRecord) — participates in
  Story 1.8 nightly link integrity check automatically
- InMemoryMunicipalityProfileRepository — validates JCode before save
  (AC #4), resolves legacy jurisdiction for combined views (AC #2)
- POST/GET/PUT /api/municipalities/profiles — ClientServicesAccess policy;
  audit-logged creates and updates (AC #3)

Frontend:
- municipalityContracts.ts — typed fetch helpers, MunicipalityValidationError
- MunicipalityProfilePanel — table with combined legacy+extension data,
  modal create form with JCode validation error display
- WorkspaceShell — municipalities nav now renders the panel

Tests: 16 backend + 9 frontend (133 server / 36 client total, 0 failures)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pull/19/head
Daniel Covington 2 дні тому
джерело
коміт
0700e32171
15 змінених файлів з 1111 додано та 20 видалено
  1. +127
    -0
      Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs
  2. +188
    -0
      Campaign_Tracker.Server.Tests/MunicipalityProfileRepositoryTests.cs
  3. +135
    -0
      Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs
  4. +23
    -0
      Campaign_Tracker.Server/Municipalities/IMunicipalityProfileRepository.cs
  5. +134
    -0
      Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs
  6. +21
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityProfile.cs
  7. +13
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs
  8. +10
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityProfileView.cs
  9. +10
    -0
      Campaign_Tracker.Server/Program.cs
  10. +43
    -19
      _bmad-output/implementation-artifacts/1-10-municipality-account-profile.md
  11. +1
    -1
      _bmad-output/implementation-artifacts/sprint-status.yaml
  12. +217
    -0
      campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx
  13. +109
    -0
      campaign-tracker-client/src/municipalities/municipalityContracts.test.ts
  14. +77
    -0
      campaign-tracker-client/src/municipalities/municipalityContracts.ts
  15. +3
    -0
      campaign-tracker-client/src/workspace/WorkspaceShell.tsx

+ 127
- 0
Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs Переглянути файл

@@ -0,0 +1,127 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;

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: non-recognized role gets 403 ───────────────────────────

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

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

+ 135
- 0
Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs Переглянути файл

@@ -0,0 +1,135 @@
using System.Security.Claims;
using Campaign_Tracker.Server.Audit;
using Campaign_Tracker.Server.Authorization;
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 IAuditService _audit;
private readonly TimeProvider _timeProvider;

public MunicipalityProfileController(
IMunicipalityProfileRepository profiles,
IAuditService audit,
TimeProvider timeProvider)
{
_profiles = profiles;
_audit = audit;
_timeProvider = timeProvider;
}

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

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

+ 134
- 0
Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs Переглянути файл

@@ -0,0 +1,134 @@
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 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.");

// AC #4: validate before saving; never write if the link is invalid
var linkRef = LegacyLinkReference.ForJurisdiction(jCode);
var validation = await _validator.ValidateAsync(linkRef, cancellationToken);
if (!validation.IsValid)
return MunicipalityProfileSaveResult.Failure(validation.Error!);

// Each JCode maps to exactly one municipality profile
if (_profiles.Values.Any(p => string.Equals(p.JCode, jCode, StringComparison.OrdinalIgnoreCase)))
return MunicipalityProfileSaveResult.Failure(
$"A municipality profile already exists for JCode '{jCode}'.");

var now = _timeProvider.GetUtcNow();
var profile = new MunicipalityProfile(
ProfileId: Guid.NewGuid().ToString("N"),
JCode: jCode.Trim().ToUpperInvariant(),
DisplayName: string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
CreatedAt: now,
UpdatedAt: now,
UpdatedBy: actorIdentity);

_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)
{
if (!_profiles.TryGetValue(profileId, out var existing))
return Task.FromResult(MunicipalityProfileSaveResult.Failure(
$"Municipality profile '{profileId}' not found."));

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

+ 13
- 0
Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs Переглянути файл

@@ -0,0 +1,13 @@
namespace Campaign_Tracker.Server.Municipalities;

public sealed record MunicipalityProfileSaveResult(
bool Saved,
string? Error,
MunicipalityProfile? Profile)
{
public static MunicipalityProfileSaveResult Success(MunicipalityProfile profile) =>
new(true, null, profile);

public static MunicipalityProfileSaveResult Failure(string error) =>
new(false, error, null);
}

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

+ 10
- 0
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;
@@ -132,6 +133,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 =>
{


+ 43
- 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: review

## 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,42 @@ 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.
- 16 backend unit tests (11 repository + 5 controller integration) + 9 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)

## 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/sprint-status.yaml Переглянути файл

@@ -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: review
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


+ 217
- 0
campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx Переглянути файл

@@ -0,0 +1,217 @@
import {
Alert,
Button,
Empty,
Form,
Input,
Modal,
Space,
Spin,
Table,
Typography,
} from 'antd'
import type { TableProps } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import {
createMunicipalityProfile,
fetchMunicipalityProfiles,
MunicipalityValidationError,
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,
}: {
load?: typeof fetchMunicipalityProfiles
create?: typeof createMunicipalityProfile
} = {}) {
const [profiles, setProfiles] = useState<MunicipalityProfile[] | null>(null)
const [loadError, setLoadError] = 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
load()
.then((items) => { if (!cancelled) setProfiles(items) })
.catch((cause: unknown) => {
if (!cancelled)
setLoadError(cause instanceof Error ? cause.message : 'Failed to load profiles')
})
return () => { cancelled = true }
}, [load])

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}

<Button type="primary" onClick={() => { setSaveError(null); setModalOpen(true) }}>
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="JCode (legacy jurisdiction identifier)"
rules={[{ required: true, message: 'JCode is required' }]}
>
<Input
placeholder="e.g. FAIR01"
aria-label="Legacy jurisdiction 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>
)
}

+ 109
- 0
campaign-tracker-client/src/municipalities/municipalityContracts.test.ts Переглянути файл

@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest'
import {
createMunicipalityProfile,
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,
)
})
})

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

+ 77
- 0
campaign-tracker-client/src/municipalities/municipalityContracts.ts Переглянути файл

@@ -0,0 +1,77 @@
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)
}

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

if (!response.ok) {
throw new Error(`Failed to update municipality profile (${response.status})`)
}

return (await response.json()) as MunicipalityProfile
}

export class MunicipalityValidationError extends Error {
constructor(message: string) {
super(message)
this.name = 'MunicipalityValidationError'
}
}

+ 3
- 0
campaign-tracker-client/src/workspace/WorkspaceShell.tsx Переглянути файл

@@ -34,6 +34,7 @@ import {
} from './workspaceContracts'
import type { AuthenticatedUser } from '../auth/authContracts'
import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel'
import { MunicipalityProfilePanel } from '../municipalities/MunicipalityProfilePanel'
import {
fetchLegacySchemaCheckHistory,
runLegacySchemaCheck,
@@ -397,6 +398,8 @@ export function WorkspaceShell({
loadHistory={() => fetchLegacySchemaCheckHistory(adminFetch)}
runCheck={() => runLegacySchemaCheck(adminFetch)}
/>
) : selectedView === 'municipalities' && user.permissions.canViewMunicipalityProfile ? (
<MunicipalityProfilePanel />
) : (
<section
className="workspace-board"


Завантаження…
Відмінити
Зберегти

Powered by TurnKey Linux.