Quellcode durchsuchen

can I get a commit message for these two stories

pull/19/head
Daniel Covington vor 2 Tagen
Ursprung
Commit
fe2e798f20
27 geänderte Dateien mit 2468 neuen und 85 gelöschten Zeilen
  1. +190
    -0
      Campaign_Tracker.Server.Tests/MunicipalityContactControllerTests.cs
  2. +116
    -0
      Campaign_Tracker.Server.Tests/MunicipalityContactRepositoryTests.cs
  3. +100
    -0
      Campaign_Tracker.Server.Tests/MunicipalityPriorCycleDefaultsControllerTests.cs
  4. +52
    -0
      Campaign_Tracker.Server.Tests/MunicipalityPriorCycleDefaultsRepositoryTests.cs
  5. +208
    -0
      Campaign_Tracker.Server/Controllers/MunicipalityContactsController.cs
  6. +79
    -0
      Campaign_Tracker.Server/Controllers/MunicipalityPriorCycleDefaultsController.cs
  7. +37
    -0
      Campaign_Tracker.Server/Municipalities/IMunicipalityContactRepository.cs
  8. +8
    -0
      Campaign_Tracker.Server/Municipalities/IMunicipalityPriorCycleDefaultsRepository.cs
  9. +157
    -0
      Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityContactRepository.cs
  10. +108
    -0
      Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityPriorCycleDefaultsRepository.cs
  11. +15
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityContact.cs
  12. +22
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityContactSaveResult.cs
  13. +13
    -0
      Campaign_Tracker.Server/Municipalities/MunicipalityPriorCycleDefaults.cs
  14. +8
    -0
      Campaign_Tracker.Server/Program.cs
  15. +41
    -13
      _bmad-output/implementation-artifacts/1-12-municipality-service-contacts.md
  16. +34
    -11
      _bmad-output/implementation-artifacts/1-13-municipality-prior-cycle-service-defaults-view.md
  17. +77
    -0
      _bmad-output/implementation-artifacts/2-1-municipality-to-cycle-kanban-entry-point.md
  18. +75
    -0
      _bmad-output/implementation-artifacts/2-2-create-election-cycle-job.md
  19. +77
    -0
      _bmad-output/implementation-artifacts/2-3-election-cycle-key-dates.md
  20. +78
    -0
      _bmad-output/implementation-artifacts/2-4-prior-cycle-defaults-application.md
  21. +83
    -0
      _bmad-output/implementation-artifacts/2-5-election-cycle-readiness-status-publication.md
  22. +82
    -0
      _bmad-output/implementation-artifacts/2-6-spreadsheet-import-column-mapping.md
  23. +10
    -10
      _bmad-output/implementation-artifacts/sprint-status.yaml
  24. +487
    -51
      campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx
  25. +145
    -0
      campaign-tracker-client/src/municipalities/municipalityContracts.test.ts
  26. +152
    -0
      campaign-tracker-client/src/municipalities/municipalityContracts.ts
  27. +14
    -0
      campaign-tracker-client/src/workspace/WorkspaceShell.tsx

+ 190
- 0
Campaign_Tracker.Server.Tests/MunicipalityContactControllerTests.cs Datei anzeigen

@@ -0,0 +1,190 @@
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 MunicipalityContactControllerTests
{
[Fact]
public async Task AddContact_ValidRequest_Returns201WithContactTypeAndDetails_AC1()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);
var profile = await CreateProfile(client);

var response = await client.PostAsJsonAsync(
$"/api/municipalities/{profile.ProfileId}/contacts",
new
{
contactType = "Primary",
name = "Ada Clerk",
roleTitle = "Town Clerk",
phone = "555-0100",
email = "ada@example.test",
});

Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MunicipalityContactDto>();
Assert.NotNull(body);
Assert.Equal("Primary", body.ContactType);
Assert.Equal("Ada Clerk", body.Name);
Assert.Equal("Town Clerk", body.RoleTitle);
Assert.Equal("555-0100", body.Phone);
Assert.Equal("ada@example.test", body.Email);
}

[Fact]
public async Task GetContacts_DisplaysPrimaryAndSecondaryDesignations_AC2()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);
var profile = await CreateProfile(client);

await client.PostAsJsonAsync(
$"/api/municipalities/{profile.ProfileId}/contacts",
new { contactType = "Secondary", name = "Backup Clerk" });
await client.PostAsJsonAsync(
$"/api/municipalities/{profile.ProfileId}/contacts",
new { contactType = "Primary", name = "Main Clerk" });

var response = await client.GetAsync($"/api/municipalities/{profile.ProfileId}/contacts");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<MunicipalityContactDto[]>();
Assert.NotNull(body);
Assert.Equal(["Primary", "Secondary"], body.Select(c => c.ContactType).ToArray());
}

[Fact]
public async Task UpdateAndDeleteContact_RecordAuditEvents_AC3()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);
var profile = await CreateProfile(client);
var created = await (await client.PostAsJsonAsync(
$"/api/municipalities/{profile.ProfileId}/contacts",
new { contactType = "Primary", name = "Ada Clerk" }))
.Content.ReadFromJsonAsync<MunicipalityContactDto>();

var updateResponse = await client.PutAsJsonAsync(
$"/api/municipalities/{profile.ProfileId}/contacts/{created!.ContactId}",
new { contactType = "Secondary", name = "Ada Updated", roleTitle = "Manager" });
var deleteResponse = await client.DeleteAsync(
$"/api/municipalities/{profile.ProfileId}/contacts/{created.ContactId}");

Assert.Equal(HttpStatusCode.OK, updateResponse.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
var auditService = factory.Services.GetRequiredService<IAuditService>();
var events = auditService.GetRecent();
Assert.Contains(events, e =>
e.EventType == "MUNICIPALITY_CONTACT_UPDATED" &&
e.ActorIdentity == "cs@example.test");
Assert.Contains(events, e =>
e.EventType == "MUNICIPALITY_CONTACT_DELETED" &&
e.ActorIdentity == "cs@example.test");
}

[Theory]
[InlineData("", "Primary", "Name is required.")]
[InlineData("Ada Clerk", "", "Contact type is required.")]
public async Task AddContact_MissingRequiredFields_Returns422Problem_AC4(
string name,
string contactType,
string expectedError)
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);
var profile = await CreateProfile(client);

var response = await client.PostAsJsonAsync(
$"/api/municipalities/{profile.ProfileId}/contacts",
new { contactType, name });

Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<MunicipalityContactProblemDto>();
Assert.NotNull(problem);
Assert.Equal(expectedError, problem.Error);
}

[Fact]
public async Task AddContact_UnknownProfile_Returns404()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);

var response = await client.PostAsJsonAsync(
"/api/municipalities/does-not-exist/contacts",
new { contactType = "Primary", name = "Ada Clerk" });

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

[Fact]
public async Task ContactRoutes_RejectContactFromDifferentProfile()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);
var profileA = await CreateProfile(client, "FAIR01");
var profileB = await CreateProfile(client, "LAKE02");
var created = await (await client.PostAsJsonAsync(
$"/api/municipalities/{profileA.ProfileId}/contacts",
new { contactType = "Primary", name = "Ada Clerk" }))
.Content.ReadFromJsonAsync<MunicipalityContactDto>();

var getResponse = await client.GetAsync(
$"/api/municipalities/{profileB.ProfileId}/contacts/{created!.ContactId}");
var updateResponse = await client.PutAsJsonAsync(
$"/api/municipalities/{profileB.ProfileId}/contacts/{created.ContactId}",
new { contactType = "Secondary", name = "Wrong Profile" });
var deleteResponse = await client.DeleteAsync(
$"/api/municipalities/{profileB.ProfileId}/contacts/{created.ContactId}");

Assert.Equal(HttpStatusCode.NotFound, getResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, updateResponse.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, deleteResponse.StatusCode);

var originalProfileResponse = await client.GetAsync(
$"/api/municipalities/{profileA.ProfileId}/contacts/{created.ContactId}");
Assert.Equal(HttpStatusCode.OK, originalProfileResponse.StatusCode);
}

private static HttpClient CreateClient(AuthIntegrationTestFactory factory)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services"));
return client;
}

private static async Task<MunicipalityProfileDto> CreateProfile(
HttpClient client,
string jCode = "FAIR01")
{
var created = await (await client.PostAsJsonAsync("/api/municipalities/profiles", new
{
jCode,
displayName = $"{jCode} Profile",
})).Content.ReadFromJsonAsync<MunicipalityProfileDto>();

Assert.NotNull(created);
return created;
}

private sealed record MunicipalityProfileDto(string ProfileId);

private sealed record MunicipalityContactDto(
string ContactId,
string ProfileId,
string ContactType,
string Name,
string? RoleTitle,
string? Phone,
string? Email,
string UpdatedAt,
string UpdatedBy);

private sealed record MunicipalityContactProblemDto(string Error);
}

+ 116
- 0
Campaign_Tracker.Server.Tests/MunicipalityContactRepositoryTests.cs Datei anzeigen

@@ -0,0 +1,116 @@
using Campaign_Tracker.Server.Municipalities;

namespace Campaign_Tracker.Server.Tests;

public sealed class MunicipalityContactRepositoryTests
{
private static readonly DateTimeOffset FixedNow =
new(2026, 5, 7, 12, 0, 0, TimeSpan.Zero);

private static InMemoryMunicipalityContactRepository BuildSut() =>
new(new FakeTimeProvider(FixedNow));

[Fact]
public async Task AddAsync_WithPrimaryContact_SavesExtensionContact_AC1()
{
var sut = BuildSut();

var result = await sut.AddAsync(
"profile-1",
"Primary",
"Ada Clerk",
"Town Clerk",
"555-0100",
"ada@example.test",
"actor@example.test");

Assert.True(result.Saved);
Assert.NotNull(result.Contact);
Assert.Equal("Primary", result.Contact.ContactType);
Assert.Equal("Ada Clerk", result.Contact.Name);
Assert.Equal("Town Clerk", result.Contact.RoleTitle);
Assert.Equal("555-0100", result.Contact.Phone);
Assert.Equal("ada@example.test", result.Contact.Email);
Assert.Equal("actor@example.test", result.Contact.UpdatedBy);
Assert.Equal(FixedNow, result.Contact.UpdatedAt);
}

[Fact]
public async Task GetByProfileIdAsync_ReturnsPrimaryBeforeSecondaryWithLabels_AC2()
{
var sut = BuildSut();
await sut.AddAsync("profile-1", "Secondary", "Backup Clerk", null, null, null, "actor");
await sut.AddAsync("profile-1", "Primary", "Main Clerk", null, null, null, "actor");

var contacts = await sut.GetByProfileIdAsync("profile-1");

Assert.Collection(contacts,
primary =>
{
Assert.Equal("Primary", primary.ContactType);
Assert.Equal("Main Clerk", primary.Name);
},
secondary =>
{
Assert.Equal("Secondary", secondary.ContactType);
Assert.Equal("Backup Clerk", secondary.Name);
});
}

[Fact]
public async Task UpdateAsync_ChangesContactAndCapturesActor_AC3()
{
var sut = BuildSut();
var created = await sut.AddAsync("profile-1", "Primary", "Old", null, null, null, "creator");

var updated = await sut.UpdateAsync(
created.Contact!.ContactId,
"Secondary",
"New",
"Manager",
"555-0101",
"new@example.test",
"updater@example.test");

Assert.True(updated.Saved);
Assert.Equal("Secondary", updated.Contact!.ContactType);
Assert.Equal("New", updated.Contact.Name);
Assert.Equal("updater@example.test", updated.Contact.UpdatedBy);
Assert.Equal(FixedNow, updated.Contact.UpdatedAt);
}

[Fact]
public async Task SoftDeleteAsync_RemovesContactFromCurrentView_AC3()
{
var sut = BuildSut();
var created = await sut.AddAsync("profile-1", "Primary", "Ada", null, null, null, "actor");

var deleted = await sut.SoftDeleteAsync(created.Contact!.ContactId, "deleter@example.test");
var contacts = await sut.GetByProfileIdAsync("profile-1");

Assert.True(deleted.Saved);
Assert.Empty(contacts);
}

[Theory]
[InlineData("", "Primary", "Name is required.")]
[InlineData("Ada", "", "Contact type is required.")]
[InlineData("Ada", "Emergency", "Contact type must be Primary or Secondary.")]
public async Task AddAsync_InvalidRequiredFields_ReturnsFailure_AC4(
string name,
string contactType,
string expectedError)
{
var sut = BuildSut();

var result = await sut.AddAsync("profile-1", contactType, name, null, null, null, "actor");

Assert.False(result.Saved);
Assert.Equal(expectedError, result.Error);
}

private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => utcNow;
}
}

+ 100
- 0
Campaign_Tracker.Server.Tests/MunicipalityPriorCycleDefaultsControllerTests.cs Datei anzeigen

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

namespace Campaign_Tracker.Server.Tests;

public sealed class MunicipalityPriorCycleDefaultsControllerTests
{
[Fact]
public async Task GetDefaults_WithPriorCycles_ReturnsMostRecentSelectedAndReadOnlyServices_AC1_AC2()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);
var profile = await CreateProfile(client, "LAKE02");

var response = await client.GetAsync(
$"/api/municipalities/{profile.ProfileId}/prior-cycle-defaults");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PriorCycleDefaultsDto>();
Assert.NotNull(body);
Assert.True(body.HasPriorCycles);
Assert.Equal(2, body.Cycles.Length);
Assert.Equal(body.Cycles[0].CycleId, body.SelectedCycleId);
Assert.True(body.Cycles[0].CompletedAt.CompareTo(body.Cycles[1].CompletedAt) > 0);
Assert.NotEmpty(body.Cycles[0].Services);
Assert.All(body.Cycles[0].Services, service => Assert.NotEmpty(service.Values));
}

[Fact]
public async Task GetDefaults_NoPriorCycles_ReturnsClearEmptyState_AC3()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);
var profile = await CreateProfile(client, "PINE03");

var response = await client.GetAsync(
$"/api/municipalities/{profile.ProfileId}/prior-cycle-defaults");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<PriorCycleDefaultsDto>();
Assert.NotNull(body);
Assert.False(body.HasPriorCycles);
Assert.Equal("No prior cycle defaults available.", body.EmptyStateMessage);
Assert.Empty(body.Cycles);
Assert.Null(body.SelectedCycleId);
}

[Fact]
public async Task GetDefaults_UnknownProfile_Returns404()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = CreateClient(factory);

var response = await client.GetAsync(
"/api/municipalities/does-not-exist/prior-cycle-defaults");

Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}

private static HttpClient CreateClient(AuthIntegrationTestFactory factory)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services"));
return client;
}

private static async Task<MunicipalityProfileDto> CreateProfile(HttpClient client, string jCode)
{
var created = await (await client.PostAsJsonAsync("/api/municipalities/profiles", new
{
jCode,
displayName = (string?)null,
})).Content.ReadFromJsonAsync<MunicipalityProfileDto>();

Assert.NotNull(created);
return created;
}

private sealed record MunicipalityProfileDto(string ProfileId);

private sealed record PriorCycleDefaultsDto(
string ProfileId,
bool HasPriorCycles,
string? SelectedCycleId,
string EmptyStateMessage,
PriorCycleDto[] Cycles);

private sealed record PriorCycleDto(
string CycleId,
string CycleName,
string CompletedAt,
PriorCycleServiceDto[] Services);

private sealed record PriorCycleServiceDto(
string ServiceType,
string Summary,
Dictionary<string, string> Values);
}

+ 52
- 0
Campaign_Tracker.Server.Tests/MunicipalityPriorCycleDefaultsRepositoryTests.cs Datei anzeigen

@@ -0,0 +1,52 @@
using Campaign_Tracker.Server.Municipalities;

namespace Campaign_Tracker.Server.Tests;

public sealed class MunicipalityPriorCycleDefaultsRepositoryTests
{
[Fact]
public async Task GetByJCodeAsync_ReturnsMostRecentCompletedCycleFirst_AC1()
{
var sut = new InMemoryMunicipalityPriorCycleDefaultsRepository([
new MunicipalityPriorCycleDefaults(
CycleId: "fair-2024-general",
JCode: "FAIR01",
CycleName: "2024 General",
CompletedAt: new DateTimeOffset(2024, 11, 20, 0, 0, 0, TimeSpan.Zero),
Services:
[
new MunicipalityPriorCycleServiceDefault(
ServiceType: "Addressing",
Summary: "Standard addressing run",
Values: new Dictionary<string, string> { ["Quantity"] = "1200" }),
]),
new MunicipalityPriorCycleDefaults(
CycleId: "fair-2026-primary",
JCode: "FAIR01",
CycleName: "2026 Primary",
CompletedAt: new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero),
Services:
[
new MunicipalityPriorCycleServiceDefault(
ServiceType: "Sorting",
Summary: "Daily sort enabled",
Values: new Dictionary<string, string> { ["Daily Sort"] = "Yes" }),
]),
]);

var cycles = await sut.GetByJCodeAsync("fair01");

Assert.Equal(["fair-2026-primary", "fair-2024-general"], cycles.Select(c => c.CycleId).ToArray());
Assert.Equal("Sorting", cycles[0].Services[0].ServiceType);
}

[Fact]
public async Task GetByJCodeAsync_WhenNoCyclesExist_ReturnsEmptyReadOnlyResult_AC3()
{
var sut = new InMemoryMunicipalityPriorCycleDefaultsRepository([]);

var cycles = await sut.GetByJCodeAsync("PINE03");

Assert.Empty(cycles);
}
}

+ 208
- 0
Campaign_Tracker.Server/Controllers/MunicipalityContactsController.cs Datei anzeigen

@@ -0,0 +1,208 @@
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;

[ApiController]
[Authorize(Policy = ApplicationPolicy.ClientServicesAccess)]
[Route("api/municipalities/{profileId}/contacts")]
public sealed class MunicipalityContactsController : ControllerBase
{
private readonly IMunicipalityProfileRepository _profiles;
private readonly IMunicipalityContactRepository _contacts;
private readonly IAuditService _audit;
private readonly TimeProvider _timeProvider;

public MunicipalityContactsController(
IMunicipalityProfileRepository profiles,
IMunicipalityContactRepository contacts,
IAuditService audit,
TimeProvider timeProvider)
{
_profiles = profiles;
_contacts = contacts;
_audit = audit;
_timeProvider = timeProvider;
}

[HttpGet]
public async Task<ActionResult<IReadOnlyList<MunicipalityContactResponse>>> GetAll(
string profileId,
CancellationToken cancellationToken)
{
if (!await ProfileExists(profileId, cancellationToken))
return NotFound();

var contacts = await _contacts.GetByProfileIdAsync(profileId, cancellationToken);
return Ok(contacts.Select(MunicipalityContactResponse.From).ToArray());
}

[HttpGet("{contactId}")]
public async Task<ActionResult<MunicipalityContactResponse>> GetById(
string profileId,
string contactId,
CancellationToken cancellationToken)
{
var contact = await _contacts.GetByIdAsync(contactId, cancellationToken);
return IsContactInProfile(contact, profileId)
? Ok(MunicipalityContactResponse.From(contact!))
: NotFound();
}

[HttpPost]
public async Task<ActionResult<MunicipalityContactResponse>> Add(
string profileId,
[FromBody] AddMunicipalityContactRequest request,
CancellationToken cancellationToken)
{
if (!await ProfileExists(profileId, cancellationToken))
return NotFound();

var actor = GetActor();
var result = await _contacts.AddAsync(
profileId,
request.ContactType,
request.Name,
request.RoleTitle,
request.Phone,
request.Email,
actor,
cancellationToken);

if (!result.Saved || result.Contact is null)
return UnprocessableEntity(new MunicipalityContactProblem(result.Error ?? "Save failed."));

_audit.Record(new AuditEvent(
EventType: "MUNICIPALITY_CONTACT_ADDED",
ActorIdentity: actor,
Resource: $"municipalities/{profileId}/contacts/{result.Contact.ContactId}",
Outcome: $"added {result.Contact.ContactType} contact",
TraceIdentifier: HttpContext.TraceIdentifier,
RecordedAt: _timeProvider.GetUtcNow()));

return CreatedAtAction(nameof(GetById),
new { profileId, contactId = result.Contact.ContactId },
MunicipalityContactResponse.From(result.Contact));
}

[HttpPut("{contactId}")]
public async Task<ActionResult<MunicipalityContactResponse>> Update(
string profileId,
string contactId,
[FromBody] UpdateMunicipalityContactRequest request,
CancellationToken cancellationToken)
{
var existing = await _contacts.GetByIdAsync(contactId, cancellationToken);
if (!IsContactInProfile(existing, profileId))
return NotFound(new MunicipalityContactProblem("Contact not found."));

var actor = GetActor();
var result = await _contacts.UpdateAsync(
contactId,
request.ContactType,
request.Name,
request.RoleTitle,
request.Phone,
request.Email,
actor,
cancellationToken);

if (!result.Saved || result.Contact is null)
{
if (result.IsNotFound)
return NotFound(new MunicipalityContactProblem(result.Error ?? "Contact not found."));
return UnprocessableEntity(new MunicipalityContactProblem(result.Error ?? "Update failed."));
}

_audit.Record(new AuditEvent(
EventType: "MUNICIPALITY_CONTACT_UPDATED",
ActorIdentity: actor,
Resource: $"municipalities/{profileId}/contacts/{contactId}",
Outcome: $"updated {result.Contact.ContactType} contact",
TraceIdentifier: HttpContext.TraceIdentifier,
RecordedAt: _timeProvider.GetUtcNow()));

return Ok(MunicipalityContactResponse.From(result.Contact));
}

[HttpDelete("{contactId}")]
public async Task<IActionResult> Delete(
string profileId,
string contactId,
CancellationToken cancellationToken)
{
var existing = await _contacts.GetByIdAsync(contactId, cancellationToken);
if (!IsContactInProfile(existing, profileId))
return NotFound();

var actor = GetActor();
var result = await _contacts.SoftDeleteAsync(contactId, actor, cancellationToken);

if (!result.Saved)
return result.IsNotFound ? NotFound() : UnprocessableEntity();

_audit.Record(new AuditEvent(
EventType: "MUNICIPALITY_CONTACT_DELETED",
ActorIdentity: actor,
Resource: $"municipalities/{profileId}/contacts/{contactId}",
Outcome: "soft-deleted",
TraceIdentifier: HttpContext.TraceIdentifier,
RecordedAt: _timeProvider.GetUtcNow()));

return NoContent();
}

private string GetActor() =>
User.Identity?.Name
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "unknown";

private async Task<bool> ProfileExists(
string profileId,
CancellationToken cancellationToken) =>
await _profiles.GetByIdAsync(profileId, cancellationToken) is not null;

private static bool IsContactInProfile(
MunicipalityContact? contact,
string profileId) =>
contact is not null &&
string.Equals(contact.ProfileId, profileId, StringComparison.OrdinalIgnoreCase);
}

public sealed record AddMunicipalityContactRequest(
string ContactType,
string Name,
string? RoleTitle,
string? Phone,
string? Email);

public sealed record UpdateMunicipalityContactRequest(
string ContactType,
string Name,
string? RoleTitle,
string? Phone,
string? Email);

public sealed record MunicipalityContactResponse(
string ContactId,
string ProfileId,
string ContactType,
string Name,
string? RoleTitle,
string? Phone,
string? Email,
string CreatedAt,
string CreatedBy,
string UpdatedAt,
string UpdatedBy)
{
public static MunicipalityContactResponse From(MunicipalityContact c) =>
new(c.ContactId, c.ProfileId, c.ContactType, c.Name, c.RoleTitle, c.Phone, c.Email,
c.CreatedAt.ToString("O"), c.CreatedBy, c.UpdatedAt.ToString("O"), c.UpdatedBy);
}

public sealed record MunicipalityContactProblem(string Error);

+ 79
- 0
Campaign_Tracker.Server/Controllers/MunicipalityPriorCycleDefaultsController.cs Datei anzeigen

@@ -0,0 +1,79 @@
using Campaign_Tracker.Server.Authorization;
using Campaign_Tracker.Server.Municipalities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Campaign_Tracker.Server.Controllers;

[ApiController]
[Authorize(Policy = ApplicationPolicy.ClientServicesAccess)]
[Route("api/municipalities/{profileId}/prior-cycle-defaults")]
public sealed class MunicipalityPriorCycleDefaultsController : ControllerBase
{
private const string EmptyMessage = "No prior cycle defaults available.";
private readonly IMunicipalityProfileRepository _profiles;
private readonly IMunicipalityPriorCycleDefaultsRepository _defaults;

public MunicipalityPriorCycleDefaultsController(
IMunicipalityProfileRepository profiles,
IMunicipalityPriorCycleDefaultsRepository defaults)
{
_profiles = profiles;
_defaults = defaults;
}

[HttpGet]
public async Task<ActionResult<MunicipalityPriorCycleDefaultsResponse>> Get(
string profileId,
CancellationToken cancellationToken)
{
var profile = await _profiles.GetByIdAsync(profileId, cancellationToken);
if (profile is null)
return NotFound();

var cycles = await _defaults.GetByJCodeAsync(
profile.Profile.JCode,
cancellationToken);

var responseCycles = cycles
.Select(PriorCycleResponse.From)
.ToArray();

return Ok(new MunicipalityPriorCycleDefaultsResponse(
ProfileId: profileId,
HasPriorCycles: responseCycles.Length > 0,
SelectedCycleId: responseCycles.FirstOrDefault()?.CycleId,
EmptyStateMessage: responseCycles.Length == 0 ? EmptyMessage : string.Empty,
Cycles: responseCycles));
}
}

public sealed record MunicipalityPriorCycleDefaultsResponse(
string ProfileId,
bool HasPriorCycles,
string? SelectedCycleId,
string EmptyStateMessage,
IReadOnlyList<PriorCycleResponse> Cycles);

public sealed record PriorCycleResponse(
string CycleId,
string CycleName,
string CompletedAt,
IReadOnlyList<PriorCycleServiceResponse> Services)
{
public static PriorCycleResponse From(MunicipalityPriorCycleDefaults cycle) =>
new(
cycle.CycleId,
cycle.CycleName,
cycle.CompletedAt.ToString("O"),
cycle.Services.Select(PriorCycleServiceResponse.From).ToArray());
}

public sealed record PriorCycleServiceResponse(
string ServiceType,
string Summary,
IReadOnlyDictionary<string, string> Values)
{
public static PriorCycleServiceResponse From(MunicipalityPriorCycleServiceDefault service) =>
new(service.ServiceType, service.Summary, service.Values);
}

+ 37
- 0
Campaign_Tracker.Server/Municipalities/IMunicipalityContactRepository.cs Datei anzeigen

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

public interface IMunicipalityContactRepository
{
Task<IReadOnlyList<MunicipalityContact>> GetByProfileIdAsync(
string profileId,
CancellationToken cancellationToken = default);

Task<MunicipalityContact?> GetByIdAsync(
string contactId,
CancellationToken cancellationToken = default);

Task<MunicipalityContactSaveResult> AddAsync(
string profileId,
string contactType,
string name,
string? roleTitle,
string? phone,
string? email,
string actorIdentity,
CancellationToken cancellationToken = default);

Task<MunicipalityContactSaveResult> UpdateAsync(
string contactId,
string contactType,
string name,
string? roleTitle,
string? phone,
string? email,
string actorIdentity,
CancellationToken cancellationToken = default);

Task<MunicipalityContactSaveResult> SoftDeleteAsync(
string contactId,
string actorIdentity,
CancellationToken cancellationToken = default);
}

+ 8
- 0
Campaign_Tracker.Server/Municipalities/IMunicipalityPriorCycleDefaultsRepository.cs Datei anzeigen

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

public interface IMunicipalityPriorCycleDefaultsRepository
{
Task<IReadOnlyList<MunicipalityPriorCycleDefaults>> GetByJCodeAsync(
string jCode,
CancellationToken cancellationToken = default);
}

+ 157
- 0
Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityContactRepository.cs Datei anzeigen

@@ -0,0 +1,157 @@
using System.Collections.Concurrent;

namespace Campaign_Tracker.Server.Municipalities;

public sealed class InMemoryMunicipalityContactRepository : IMunicipalityContactRepository
{
private static readonly Dictionary<string, int> ContactTypeSortOrder =
new(StringComparer.OrdinalIgnoreCase)
{
["Primary"] = 0,
["Secondary"] = 1,
};

private readonly ConcurrentDictionary<string, MunicipalityContact> _contacts =
new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
private readonly TimeProvider _timeProvider;

public InMemoryMunicipalityContactRepository(TimeProvider timeProvider)
{
_timeProvider = timeProvider;
}

public Task<IReadOnlyList<MunicipalityContact>> GetByProfileIdAsync(
string profileId,
CancellationToken cancellationToken = default)
{
var result = _contacts.Values
.Where(c => c.ProfileId == profileId && !c.IsDeleted)
.OrderBy(c => ContactTypeSortOrder.GetValueOrDefault(c.ContactType, 99))
.ThenBy(c => c.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
return Task.FromResult<IReadOnlyList<MunicipalityContact>>(result);
}

public Task<MunicipalityContact?> GetByIdAsync(
string contactId,
CancellationToken cancellationToken = default)
{
_contacts.TryGetValue(contactId, out var contact);
return Task.FromResult(contact is { IsDeleted: false } ? contact : null);
}

public Task<MunicipalityContactSaveResult> AddAsync(
string profileId,
string contactType,
string name,
string? roleTitle,
string? phone,
string? email,
string actorIdentity,
CancellationToken cancellationToken = default)
{
var validation = Validate(contactType, name);
if (validation is not null)
return Task.FromResult(MunicipalityContactSaveResult.Failure(validation));

var now = _timeProvider.GetUtcNow();
var contact = new MunicipalityContact(
ContactId: Guid.NewGuid().ToString("N"),
ProfileId: profileId,
ContactType: NormalizeContactType(contactType),
Name: name.Trim(),
RoleTitle: NormalizeOptional(roleTitle),
Phone: NormalizeOptional(phone),
Email: NormalizeOptional(email),
IsDeleted: false,
CreatedAt: now,
CreatedBy: actorIdentity,
UpdatedAt: now,
UpdatedBy: actorIdentity);

_contacts[contact.ContactId] = contact;
return Task.FromResult(MunicipalityContactSaveResult.Success(contact));
}

public Task<MunicipalityContactSaveResult> UpdateAsync(
string contactId,
string contactType,
string name,
string? roleTitle,
string? phone,
string? email,
string actorIdentity,
CancellationToken cancellationToken = default)
{
var validation = Validate(contactType, name);
if (validation is not null)
return Task.FromResult(MunicipalityContactSaveResult.Failure(validation));

var now = _timeProvider.GetUtcNow();

lock (_lock)
{
if (!_contacts.TryGetValue(contactId, out var existing) || existing.IsDeleted)
return Task.FromResult(MunicipalityContactSaveResult.NotFound(contactId));

var updated = existing with
{
ContactType = NormalizeContactType(contactType),
Name = name.Trim(),
RoleTitle = NormalizeOptional(roleTitle),
Phone = NormalizeOptional(phone),
Email = NormalizeOptional(email),
UpdatedAt = now,
UpdatedBy = actorIdentity,
};
_contacts[contactId] = updated;
return Task.FromResult(MunicipalityContactSaveResult.Success(updated));
}
}

public Task<MunicipalityContactSaveResult> SoftDeleteAsync(
string contactId,
string actorIdentity,
CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();

lock (_lock)
{
if (!_contacts.TryGetValue(contactId, out var existing) || existing.IsDeleted)
return Task.FromResult(MunicipalityContactSaveResult.NotFound(contactId));

var deleted = existing with
{
IsDeleted = true,
UpdatedAt = now,
UpdatedBy = actorIdentity,
};
_contacts[contactId] = deleted;
return Task.FromResult(MunicipalityContactSaveResult.Success(deleted));
}
}

private static string? Validate(string contactType, string name)
{
if (string.IsNullOrWhiteSpace(name))
return "Name is required.";

if (string.IsNullOrWhiteSpace(contactType))
return "Contact type is required.";

if (!ContactTypeSortOrder.ContainsKey(contactType.Trim()))
return "Contact type must be Primary or Secondary.";

return null;
}

private static string NormalizeContactType(string contactType) =>
string.Equals(contactType.Trim(), "Primary", StringComparison.OrdinalIgnoreCase)
? "Primary"
: "Secondary";

private static string? NormalizeOptional(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

+ 108
- 0
Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityPriorCycleDefaultsRepository.cs Datei anzeigen

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

public sealed class InMemoryMunicipalityPriorCycleDefaultsRepository
: IMunicipalityPriorCycleDefaultsRepository
{
private readonly IReadOnlyList<MunicipalityPriorCycleDefaults> _cycles;

public InMemoryMunicipalityPriorCycleDefaultsRepository()
: this(DefaultCycles)
{
}

public InMemoryMunicipalityPriorCycleDefaultsRepository(
IReadOnlyList<MunicipalityPriorCycleDefaults> cycles)
{
_cycles = cycles;
}

public Task<IReadOnlyList<MunicipalityPriorCycleDefaults>> GetByJCodeAsync(
string jCode,
CancellationToken cancellationToken = default)
{
IReadOnlyList<MunicipalityPriorCycleDefaults> result = _cycles
.Where(c => string.Equals(c.JCode, jCode, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(c => c.CompletedAt)
.ThenBy(c => c.CycleName, StringComparer.OrdinalIgnoreCase)
.ToArray();
return Task.FromResult(result);
}

private static readonly IReadOnlyList<MunicipalityPriorCycleDefaults> DefaultCycles =
[
new(
CycleId: "fair01-2026-primary",
JCode: "FAIR01",
CycleName: "2026 Primary",
CompletedAt: new DateTimeOffset(2026, 5, 1, 0, 0, 0, TimeSpan.Zero),
Services:
[
new(
ServiceType: "Addressing",
Summary: "Standard addressing with tracking",
Values: new Dictionary<string, string>
{
["Addressing"] = "Yes",
["Addressing Quantity"] = "1200",
["Address File Tracking"] = "Yes",
}),
new(
ServiceType: "Sorting",
Summary: "Daily sort with non-profit class",
Values: new Dictionary<string, string>
{
["KCI Sorting"] = "Yes",
["Daily Sort"] = "Yes",
["Class"] = "NP",
}),
]),
new(
CycleId: "lake02-2026-primary",
JCode: "LAKE02",
CycleName: "2026 Primary",
CompletedAt: new DateTimeOffset(2026, 5, 3, 0, 0, 0, TimeSpan.Zero),
Services:
[
new(
ServiceType: "Envelope",
Summary: "Purple envelope service",
Values: new Dictionary<string, string>
{
["KCI Env Purple"] = "Yes",
["Permit/Meter"] = "Permit",
}),
new(
ServiceType: "Office Copy",
Summary: "Office copies for clerk review",
Values: new Dictionary<string, string>
{
["Office Copies"] = "25",
}),
]),
new(
CycleId: "lake02-2024-general",
JCode: "LAKE02",
CycleName: "2024 General",
CompletedAt: new DateTimeOffset(2024, 11, 20, 0, 0, 0, TimeSpan.Zero),
Services:
[
new(
ServiceType: "Addressing",
Summary: "Addressing without tracking",
Values: new Dictionary<string, string>
{
["Addressing"] = "Yes",
["Addressing Quantity"] = "980",
["Address File Tracking"] = "No",
}),
new(
ServiceType: "Sorting",
Summary: "Standard class sort",
Values: new Dictionary<string, string>
{
["KCI Sorting"] = "Yes",
["Class"] = "Std",
}),
]),
];
}

+ 15
- 0
Campaign_Tracker.Server/Municipalities/MunicipalityContact.cs Datei anzeigen

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

public sealed record MunicipalityContact(
string ContactId,
string ProfileId,
string ContactType,
string Name,
string? RoleTitle,
string? Phone,
string? Email,
bool IsDeleted,
DateTimeOffset CreatedAt,
string CreatedBy,
DateTimeOffset UpdatedAt,
string UpdatedBy);

+ 22
- 0
Campaign_Tracker.Server/Municipalities/MunicipalityContactSaveResult.cs Datei anzeigen

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

public sealed record MunicipalityContactSaveResult
{
public bool Saved { get; init; }
public bool IsNotFound { get; init; }
public string? Error { get; init; }
public MunicipalityContact? Contact { get; init; }

public static MunicipalityContactSaveResult Success(MunicipalityContact contact) =>
new() { Saved = true, Contact = contact };

public static MunicipalityContactSaveResult Failure(string error) =>
new() { Saved = false, Error = error };

public static MunicipalityContactSaveResult NotFound(string contactId) =>
new()
{
IsNotFound = true,
Error = $"Contact '{contactId}' not found."
};
}

+ 13
- 0
Campaign_Tracker.Server/Municipalities/MunicipalityPriorCycleDefaults.cs Datei anzeigen

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

public sealed record MunicipalityPriorCycleDefaults(
string CycleId,
string JCode,
string CycleName,
DateTimeOffset CompletedAt,
IReadOnlyList<MunicipalityPriorCycleServiceDefault> Services);

public sealed record MunicipalityPriorCycleServiceDefault(
string ServiceType,
string Summary,
IReadOnlyDictionary<string, string> Values);

+ 8
- 0
Campaign_Tracker.Server/Program.cs Datei anzeigen

@@ -148,6 +148,14 @@ builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
// Municipality operational addresses (Story 1.11).
builder.Services.AddSingleton<IMunicipalityAddressRepository, InMemoryMunicipalityAddressRepository>();

// Municipality service contacts (Story 1.12).
builder.Services.AddSingleton<IMunicipalityContactRepository, InMemoryMunicipalityContactRepository>();

// Municipality prior-cycle service defaults read model (Story 1.13).
builder.Services.AddSingleton<
IMunicipalityPriorCycleDefaultsRepository,
InMemoryMunicipalityPriorCycleDefaultsRepository>();

var allowedOrigins = builder.Configuration.GetSection("AllowedOrigins").Get<string[]>() ?? [];
builder.Services.AddCors(options =>
{


+ 41
- 13
_bmad-output/implementation-artifacts/1-12-municipality-service-contacts.md Datei anzeigen

@@ -1,6 +1,6 @@
# Story 1.12: Municipality Service Contacts

Status: ready-for-dev
Status: done

## Story

@@ -17,18 +17,22 @@ so that the right people can be reached during election operations without consu

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

### Review Findings

- [x] [Review][Patch] Scope contact APIs to an existing parent municipality profile before create/read/update/delete [Campaign_Tracker.Server/Controllers/MunicipalityContactsController.cs:49]

## Dev Notes

@@ -57,13 +61,37 @@ GPT-5 Codex
### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.
- 2026-05-07: Added failing backend repository/controller tests and frontend contract tests for municipality service contacts before implementation.
- 2026-05-07: Verified targeted contact tests, full backend test suite, frontend unit tests, frontend lint, and frontend production build.

### Completion Notes List

- Story context created and marked ready-for-dev.
- Implemented extension-layer municipality service contact storage with primary/secondary designation, required name/contact-type validation, and soft delete behavior.
- Added authenticated client-services REST endpoints for listing, adding, updating, and removing service contacts; add/update/delete write shared audit events with actor identity and timestamp.
- Added frontend municipality contact contracts and a service-contact management modal on the municipality profile panel with primary/secondary labels, inline required-field validation, edit, and remove flows.
- Code review patch: contact endpoints now require an existing parent profile and reject read/update/delete attempts through a different municipality profile route.
- Validation evidence: `dotnet test Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj /p:UseAppHost=false /p:BaseOutputPath="..\_bmad-output\test-bin\"` passed 150 tests; `npm test` passed 42 tests; `npm run lint` passed; `npm run build` passed.
- Review patch validation: `dotnet test Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj --filter "FullyQualifiedName~MunicipalityContact" /p:UseAppHost=false /p:BaseOutputPath="..\_bmad-output\test-bin\"` passed 14 tests.

### File List

- `_bmad-output/implementation-artifacts/1-12-municipality-service-contacts.md`
- `_bmad-output/implementation-artifacts/sprint-status.yaml`
- `Campaign_Tracker.Server/Program.cs`
- `Campaign_Tracker.Server/Controllers/MunicipalityContactsController.cs`
- `Campaign_Tracker.Server/Municipalities/IMunicipalityContactRepository.cs`
- `Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityContactRepository.cs`
- `Campaign_Tracker.Server/Municipalities/MunicipalityContact.cs`
- `Campaign_Tracker.Server/Municipalities/MunicipalityContactSaveResult.cs`
- `Campaign_Tracker.Server.Tests/MunicipalityContactControllerTests.cs`
- `Campaign_Tracker.Server.Tests/MunicipalityContactRepositoryTests.cs`
- `campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx`
- `campaign-tracker-client/src/municipalities/municipalityContracts.ts`
- `campaign-tracker-client/src/municipalities/municipalityContracts.test.ts`

### Change Log

- 2026-05-07: Implemented municipality service contacts and moved story to review.



+ 34
- 11
_bmad-output/implementation-artifacts/1-13-municipality-prior-cycle-service-defaults-view.md Datei anzeigen

@@ -1,6 +1,6 @@
# Story 1.13: Municipality Prior-Cycle Service Defaults View

Status: ready-for-dev
Status: done

## Story

@@ -16,16 +16,16 @@ so that I can reference proven configurations when setting up new election-cycle

## 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
- [ ] 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] Validate and document completion evidence
- [x] Verify build/tests for touched modules
- [x] Capture changed files and any migration/config implications

## Dev Notes

@@ -54,13 +54,36 @@ GPT-5 Codex
### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.
- 2026-05-07: Added failing backend repository/controller tests and frontend contract tests for prior-cycle defaults before implementation.
- 2026-05-07: Verified targeted prior-cycle tests, full backend test suite, frontend unit tests, frontend lint, and frontend production build.

### Completion Notes List

- Story context created and marked ready-for-dev.
- Implemented a read-only extension-layer prior-cycle defaults repository keyed by municipality JCode, with most-recent-first completed-cycle ordering.
- Added authenticated client-services GET endpoint for municipality prior-cycle defaults, resolving profile ID to JCode and returning empty-state guidance when no prior cycles exist.
- Added frontend prior-cycle defaults contract and a read-only defaults modal on the municipality profile table with cycle selector support for older cycles.
- Kept apply-to-new-job behavior out of scope; the view has no mutating API or UI action.
- Validation evidence: `dotnet test Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj /p:UseAppHost=false /p:BaseOutputPath="..\_bmad-output\test-bin\"` passed 155 tests; `npm test` passed 45 tests; `npm run lint` passed; `npm run build` passed.

### File List

- `_bmad-output/implementation-artifacts/1-13-municipality-prior-cycle-service-defaults-view.md`
- `_bmad-output/implementation-artifacts/sprint-status.yaml`
- `Campaign_Tracker.Server/Program.cs`
- `Campaign_Tracker.Server/Controllers/MunicipalityPriorCycleDefaultsController.cs`
- `Campaign_Tracker.Server/Municipalities/IMunicipalityPriorCycleDefaultsRepository.cs`
- `Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityPriorCycleDefaultsRepository.cs`
- `Campaign_Tracker.Server/Municipalities/MunicipalityPriorCycleDefaults.cs`
- `Campaign_Tracker.Server.Tests/MunicipalityPriorCycleDefaultsControllerTests.cs`
- `Campaign_Tracker.Server.Tests/MunicipalityPriorCycleDefaultsRepositoryTests.cs`
- `campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx`
- `campaign-tracker-client/src/municipalities/municipalityContracts.ts`
- `campaign-tracker-client/src/municipalities/municipalityContracts.test.ts`
- `campaign-tracker-client/src/workspace/WorkspaceShell.tsx`

### Change Log

- 2026-05-07: Implemented municipality prior-cycle service defaults view and moved story to review.



+ 77
- 0
_bmad-output/implementation-artifacts/2-1-municipality-to-cycle-kanban-entry-point.md Datei anzeigen

@@ -0,0 +1,77 @@
# Story 2.1: Municipality-to-Cycle Kanban Entry Point

Status: ready-for-dev

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a client services staff member,
I want a kanban board showing municipalities as cards organized by election cycle lanes,
so that I can see at a glance which municipalities are assigned to which cycles and initiate new cycle jobs from a familiar planning view.

## Acceptance Criteria

1. **Given** a client services user navigates to the election cycle workspace **When** the kanban loads **Then** municipalities with active cycle jobs appear as cards in their respective cycle lane columns, and municipalities with no active jobs appear in an "Unassigned" lane
2. **Given** a municipality has jobs in multiple concurrent election cycles **When** the kanban renders **Then** the municipality card appears in each relevant cycle lane independently (UX-DR16 multi-lane support)
3. **Given** a user views a municipality card **When** displayed in a cycle lane **Then** the card shows municipality name, jurisdiction code, cycle job status badge, and a quick-open action
4. **Given** a cycle lane contains many municipality cards **When** the user scrolls within the lane **Then** the lane column header remains visible and performance is maintained
5. **Given** a user interacts with the kanban via keyboard **When** navigating cards and lanes **Then** all card actions are reachable without a mouse and focus indicators are visible (UX-DR9)

## Tasks / Subtasks

- [ ] Backend: expose election-cycle kanban data (AC: #1, #2, #3)
- [ ] Add an extension-layer read model that returns municipalities grouped by active cycle assignments, including an "Unassigned" bucket for municipalities without an active cycle job
- [ ] Project per-card fields: municipality name, jurisdiction code (`JCode`), cycle name, cycle job status, legacy join key
- [ ] Support multi-lane rendering by returning one row per (municipality, active cycle) pair without mutating legacy Access tables
- [ ] Authorize endpoint for client services role using existing RBAC patterns from Epic 1
- [ ] Frontend: build kanban entry view (AC: #1, #2, #3, #4)
- [ ] Add kanban route to the workspace shell with cycle lane columns rendered from the read model and an Unassigned lane always present
- [ ] Render a municipality card component showing name, jurisdiction code, status badge, and a quick-open action that navigates to the cycle job detail (route stub acceptable until Story 2.2 lands)
- [ ] Keep lane headers sticky during column scroll and virtualize/window long lane lists to maintain interaction performance
- [ ] Accessibility & keyboard support (AC: #5)
- [ ] Provide keyboard navigation across lanes and cards with visible focus indicators per UX-DR9
- [ ] Ensure card actions (quick-open, future cycle assignment) are reachable via keyboard
- [ ] Tests & evidence (AC: #1–#5)
- [ ] Backend tests cover Unassigned bucket, multi-lane projection, RBAC, and confirm no writes hit legacy Access tables
- [ ] Frontend tests cover lane rendering, card content, sticky headers under scroll, and keyboard navigation
- [ ] Capture changed files and any config notes for the dev record

## Dev Notes

- Follow Epic 1 architecture constraints: ASP.NET Core + React separation, RBAC-aware patterns, and immutable legacy tables. Lane data must come from extension tables joined to legacy entities (read-only).
- Reuse the workspace shell, Ant Design tokens/components, and existing client services authorization patterns established in Epic 1; avoid bespoke styling or auth shims.
- This story is the entry point for Epic 2 — the quick-open / assignment actions are stubbed here and fully wired by Story 2.2 (Create Election-Cycle Job). Do not pull Story 2.2 behavior forward.
- Multi-lane support is a hard requirement (UX-DR16): a municipality with N concurrent cycle jobs must render N independent cards.
- Treat the lane data shape as the contract for Stories 2.2–2.5; keep field names stable.

### Project Structure Notes

- Backend: `Campaign_Tracker.Server/` — add cycle kanban read model under an election-cycle feature folder consistent with existing module conventions
- Frontend: `campaign-tracker-client/` — add kanban view under the workspace, sharing layout primitives with existing municipality views
- Story artifacts: `_bmad-output/implementation-artifacts/`

### References

- Story source: `_bmad-output/planning-artifacts/epics.md` (Epic 2 / Story 2.1)
- Architecture constraints: `_bmad-output/planning-artifacts/architecture.md` (extension-table write path, legacy read-only)
- UX patterns: `_bmad-output/planning-artifacts/ux-design-specification.md` (UX-DR9 keyboard/focus, UX-DR16 multi-lane kanban)
- Prior reference data: Story 1.13 prior-cycle defaults view (read-only municipality cycle history)

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.

### Completion Notes List

- Story context created and marked ready-for-dev.

### File List

### Change Log

+ 75
- 0
_bmad-output/implementation-artifacts/2-2-create-election-cycle-job.md Datei anzeigen

@@ -0,0 +1,75 @@
# Story 2.2: Create Election-Cycle Job

Status: ready-for-dev

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a client services staff member,
I want to create a new election-cycle job for a municipality from the kanban board,
so that the municipality is assigned to an election cycle without altering any legacy Access tables.

## Acceptance Criteria

1. **Given** a municipality card is in the Unassigned lane **When** a client services user initiates job creation **Then** they can select an existing cycle or name a new cycle, and a new election-cycle extension record is created linked to the municipality's legacy identifier (ID/JCode)
2. **Given** an election-cycle job is created **When** saved **Then** it is stored in the extension table with the municipality's legacy identifier as a required join key and the municipality card moves to the selected cycle lane on the kanban
3. **Given** an election-cycle job is created **When** the job record is inspected **Then** it includes municipality reference, cycle name, creation actor, creation timestamp, and a status of "In Setup"
4. **Given** a job creation is attempted without selecting or naming a cycle **When** the form is submitted **Then** the save is rejected with a clear inline validation message
5. **And** no INSERT, UPDATE, or DELETE operations are performed on legacy Access tables at any point

## Tasks / Subtasks

- [ ] Backend: election-cycle job creation endpoint (AC: #1, #2, #3, #4, #5)
- [ ] Add an extension-table entity for election-cycle jobs with required municipality legacy identifier (`ID`/`JCode`) join key, cycle name, status, created-by, and created-at
- [ ] POST endpoint accepts either an existing cycle reference or a new cycle name; rejects payloads missing a cycle selection with a structured validation error
- [ ] Persist new job with status `"In Setup"`, capturing actor identity from the authenticated principal and server-side timestamp
- [ ] Audit the create event using the shared audit logger established in Epic 1 (Story 1.5)
- [ ] Confirm via test that the operation never executes INSERT/UPDATE/DELETE against legacy Access tables — only extension storage
- [ ] Frontend: create job flow from kanban (AC: #1, #2, #4)
- [ ] Add a "Create cycle job" action on Unassigned lane cards that opens a modal/drawer with cycle selection (existing) and new-cycle-name input
- [ ] Wire form validation and surface inline server validation errors when cycle selection is missing
- [ ] On success, refresh kanban data so the card relocates to the selected cycle lane (multi-lane behavior from Story 2.1 must be preserved)
- [ ] Tests & evidence (AC: #1–#5)
- [ ] Backend tests for happy path, missing cycle, RBAC, audit emission, legacy table read-only invariant
- [ ] Frontend tests for the create flow, validation errors, and post-create kanban update
- [ ] Document changed files and any config notes

## Dev Notes

- Legacy Access tables are read-only — all writes must go to extension tables joined by `ID`/`JCode`. This invariant is enforced project-wide and is the primary regression risk for this story.
- Reuse the audit logger from Story 1.5 and the RBAC/authorization patterns from Stories 1.3/1.4. Do not introduce parallel auth or audit code.
- Status `"In Setup"` becomes the upstream input for Stories 2.3–2.5; treat the value as the canonical initial state and keep the status enum centralized.
- The kanban entry point and lane data shape are owned by Story 2.1 — extend the existing read model rather than building a parallel one.
- Keep changes scoped to this story; key dates, prior-cycle defaults, readiness, and publish behavior land in Stories 2.3–2.5.

### Project Structure Notes

- Backend: `Campaign_Tracker.Server/` — add election-cycle feature folder (entity, repository, controller, audit binding) following Epic 1 conventions
- Frontend: `campaign-tracker-client/` — add cycle creation UI alongside the kanban view from Story 2.1
- Story artifacts: `_bmad-output/implementation-artifacts/`

### References

- Story source: `_bmad-output/planning-artifacts/epics.md` (Epic 2 / Story 2.2)
- Architecture constraints: `_bmad-output/planning-artifacts/architecture.md` (extension-table write path, legacy read-only)
- UX patterns: `_bmad-output/planning-artifacts/ux-design-specification.md`
- Prior story: Story 2.1 — kanban entry point and lane data contract; Story 1.5 — shared audit logging; Story 1.6 — legacy anti-corruption data access layer

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.

### Completion Notes List

- Story context created and marked ready-for-dev.

### File List

### Change Log

+ 77
- 0
_bmad-output/implementation-artifacts/2-3-election-cycle-key-dates.md Datei anzeigen

@@ -0,0 +1,77 @@
# Story 2.3: Election-Cycle Key Dates

Status: ready-for-dev

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a client services staff member,
I want to define election-cycle key dates for data files, proofing, pickups, deliveries, and mail activities,
so that the election timeline is established and visible to all operational teams.

## Acceptance Criteria

1. **Given** an election-cycle job exists **When** a client services user opens the key dates section **Then** they can enter dates for: data file receipt, proofing, customer envelope pickup, customer envelope delivery, purple envelope pickup, purple envelope delivery, blue envelope pickup, blue envelope delivery, sort dates, mail dates, and tray delivery
2. **Given** a key date is entered and saved **When** the job is reloaded **Then** all entered dates persist and are visible in the job's date summary view
3. **Given** a key date entry would create a logical conflict (e.g., mail date before sort date) **When** the conflicting date is entered **Then** an inline warning identifies the conflict with specific field names — the save is not blocked but the warning is persistent until resolved
4. **Given** a key date is updated **When** saved **Then** the change is recorded in the audit log with actor identity and timestamp
5. **Given** a user enters dates via keyboard **When** using the date picker controls **Then** dates are enterable via keyboard without requiring mouse interaction (UX-DR9)

## Tasks / Subtasks

- [ ] Backend: persist election-cycle key dates (AC: #1, #2, #4)
- [ ] Extend the election-cycle job extension model with the full key date set: data file receipt, proofing, customer/purple/blue envelope pickup and delivery, sort date(s), mail date(s), tray delivery
- [ ] Add GET and PUT/PATCH endpoints for key dates scoped to a cycle job, authorized for client services
- [ ] Emit audit events on each saved date change including before/after values, actor, and timestamp via the shared audit logger
- [ ] Backend: conflict detection (AC: #3)
- [ ] Implement a non-blocking validator that returns structured conflict warnings naming each conflicting field pair (e.g., mail before sort)
- [ ] Persist warnings as derived state — saves succeed regardless; warnings remain visible until the underlying values are fixed
- [ ] Frontend: key dates editor (AC: #1, #2, #3, #5)
- [ ] Add the key dates section to the cycle job detail view with one date control per field, grouped logically (intake → production → mail → delivery)
- [ ] Render persistent inline warnings for each conflict returned by the backend, identifying both fields by name
- [ ] Use Ant Design DatePicker (or equivalent) configured for keyboard entry per UX-DR9
- [ ] Provide a date summary view that reads back all persisted values
- [ ] Tests & evidence (AC: #1–#5)
- [ ] Backend tests for persistence, conflict detection rules, audit emission, RBAC
- [ ] Frontend tests for save round-trip, conflict warning rendering, keyboard date entry
- [ ] Document changed files and any config notes

## Dev Notes

- Conflict detection is a warning, not a block. Resist the urge to gate saves — the rule is explicit: persistent warning until resolved.
- Use the existing audit logger from Story 1.5; do not introduce a parallel audit channel for date changes.
- Date storage must remain in extension tables — no writes to legacy Access. Surface dates to downstream stories (2.4 prior-cycle defaults, 2.5 readiness) via the cycle job read model.
- UX-DR9 keyboard support is a hard requirement — verify with a keyboard-only test path.
- Keep changes scoped to this story; readiness/publish behavior is Story 2.5.

### Project Structure Notes

- Backend: `Campaign_Tracker.Server/` — extend the election-cycle feature folder added in Story 2.2
- Frontend: `campaign-tracker-client/` — extend the cycle job detail view with the key dates editor
- Story artifacts: `_bmad-output/implementation-artifacts/`

### References

- Story source: `_bmad-output/planning-artifacts/epics.md` (Epic 2 / Story 2.3)
- Architecture constraints: `_bmad-output/planning-artifacts/architecture.md`
- UX patterns: `_bmad-output/planning-artifacts/ux-design-specification.md` (UX-DR9 keyboard accessibility)
- Prior stories: Story 2.2 — election-cycle job model; Story 1.5 — shared audit logging

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.

### Completion Notes List

- Story context created and marked ready-for-dev.

### File List

### Change Log

+ 78
- 0
_bmad-output/implementation-artifacts/2-4-prior-cycle-defaults-application.md Datei anzeigen

@@ -0,0 +1,78 @@
# Story 2.4: Prior-Cycle Defaults Application

Status: ready-for-dev

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a client services staff member,
I want to copy configurable planning defaults from a municipality's prior election-cycle job into a new cycle,
so that I can apply proven configurations without re-entering all details from scratch.

## Acceptance Criteria

1. **Given** an election-cycle job exists and the municipality has at least one prior completed cycle **When** a client services user selects "Apply Prior-Cycle Defaults" **Then** the prior cycle's service configurations and key date offsets are pre-populated into the new job's fields
2. **Given** prior-cycle defaults are applied **When** pre-populated fields are displayed **Then** each inherited field is visibly marked as "Inherited from [cycle name/year]" to distinguish it from manually entered data
3. **Given** a user applies defaults and then manually edits an inherited field **When** the edit is saved **Then** the "Inherited" marker is removed from that field and it is treated as a manually entered value going forward
4. **Given** a municipality has multiple prior cycles **When** applying defaults **Then** the user can select which prior cycle to use as the source from a list ordered most-recent first
5. **Given** a municipality has no prior cycles **When** the defaults action is viewed **Then** the option is disabled with a clear "No prior cycle available" message — no error state is triggered

## Tasks / Subtasks

- [ ] Backend: apply-defaults endpoint (AC: #1, #4, #5)
- [ ] Add an endpoint that, given a target cycle job and a chosen source prior-cycle id, copies service configuration values and key date offsets into the target job
- [ ] Compute key dates by applying the source cycle's offsets relative to the target cycle's anchor date(s) — do not copy absolute dates verbatim
- [ ] Reuse the read model from Story 1.13 (prior-cycle defaults view) for the source list, ordered most-recent first
- [ ] Return a clear, non-error empty state when the municipality has no completed prior cycles
- [ ] Backend: inheritance tracking (AC: #2, #3)
- [ ] Persist an inheritance marker per field indicating source cycle id/name/year
- [ ] On any subsequent save that changes the field value, clear the inheritance marker for that field atomically with the value update
- [ ] Audit the apply-defaults action and subsequent inheritance clears via the shared audit logger
- [ ] Frontend: apply-defaults UX (AC: #1–#5)
- [ ] Add an "Apply Prior-Cycle Defaults" action on the cycle job detail with a source cycle selector ordered most-recent first
- [ ] Disable the action with the empty-state message when no prior cycles exist (no error styling)
- [ ] Render the "Inherited from [cycle name/year]" marker on each inherited field; remove it visually as soon as the field is edited and saved
- [ ] Tests & evidence (AC: #1–#5)
- [ ] Backend tests cover offset-based date application, inheritance marker lifecycle, empty-state behavior, audit emission
- [ ] Frontend tests cover marker display, marker removal on edit, source cycle ordering, disabled empty state
- [ ] Document changed files and any config notes

## Dev Notes

- This story consumes the read-only prior-cycle defaults data delivered by Story 1.13 — extend that source rather than building a parallel one.
- Key dates are applied as **offsets** relative to the target cycle's anchor, not as absolute dates. Coordinate the offset model with the key dates work from Story 2.3 (the offset basis must reference fields already persisted there).
- "Inherited" is a per-field marker, not a job-level flag. A single manual edit clears only that field's marker and never the others.
- The empty state ("No prior cycle available") is informational, not an error. Do not page on it or surface it as a failed validation.
- All persistence remains in extension tables — no writes to legacy Access.

### Project Structure Notes

- Backend: `Campaign_Tracker.Server/` — extend the election-cycle feature folder; reuse `MunicipalityPriorCycleDefaultsRepository` and related contracts from Story 1.13
- Frontend: `campaign-tracker-client/` — extend the cycle job detail view added in Stories 2.2/2.3; reuse the prior-cycle defaults modal patterns established in 1.13
- Story artifacts: `_bmad-output/implementation-artifacts/`

### References

- Story source: `_bmad-output/planning-artifacts/epics.md` (Epic 2 / Story 2.4)
- Architecture constraints: `_bmad-output/planning-artifacts/architecture.md`
- UX patterns: `_bmad-output/planning-artifacts/ux-design-specification.md`
- Prior stories: Story 1.13 — prior-cycle defaults read model; Story 2.2 — cycle job entity; Story 2.3 — key dates fields and persistence

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.

### Completion Notes List

- Story context created and marked ready-for-dev.

### File List

### Change Log

+ 83
- 0
_bmad-output/implementation-artifacts/2-5-election-cycle-readiness-status-publication.md Datei anzeigen

@@ -0,0 +1,83 @@
# Story 2.5: Election-Cycle Readiness Status & Publication

Status: ready-for-dev

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a client services staff member,
I want to see required-field readiness status for my election-cycle job and publish it to production readiness through a pre-commit validation rail,
so that I can confirm the plan is complete and provide the team with a verified production-ready job.

## Acceptance Criteria

1. **Given** an election-cycle job has missing required fields (as defined by seeded rules from Story 1.9) **When** a client services user views readiness status **Then** each missing required field is identified by name with a jump-link that navigates directly to the field
2. **Given** all required fields are complete **When** readiness status is evaluated **Then** the job shows a "Ready to Publish" state and the Publish action becomes enabled
3. **Given** a user initiates the Publish action **When** the SafeCommitRail activates **Then** it runs dependency and policy checks and displays: overall pass/fail status, a list of any blocking reasons with one-click corrective action links, and a reason-code selector where audit policy requires it (UX-DR4)
4. **Given** the SafeCommitRail shows all checks passed **When** the user confirms the publish with a required reason code **Then** the job status transitions to "Production Ready" and actor, timestamp, and reason code are captured in the audit log
5. **Given** the SafeCommitRail shows one or more blocking issues **When** displayed **Then** the Publish confirm button remains disabled until all blocking issues are resolved
6. **Given** the publish action succeeds **When** the confirmation is shown **Then** a "Next Best Action" prompt guides the user to the recommended following step (e.g., configure services) (UX-DR11)
7. **And** the SafeCommitRail is built as a reusable shared module — it is independently consumable by Epic 3 service configuration commits and Epic 5 production status commits without duplication or modification

## Tasks / Subtasks

- [ ] Backend: readiness evaluation (AC: #1, #2)
- [ ] Implement a readiness evaluator that consumes the seeded required-field rules from Story 1.9 and returns the list of missing required fields with stable field identifiers usable for jump-links
- [ ] Expose readiness state and a derived `"Ready to Publish"` indicator on the cycle job read model
- [ ] Backend: SafeCommitRail shared module (AC: #3, #4, #5, #7)
- [ ] Build SafeCommitRail as a reusable module with a clearly defined extension point for pluggable check providers (dependency checks, policy checks, reason-code requirements)
- [ ] Provide a check provider for the election-cycle publish flow that registers required dependency/policy checks and the reason-code requirement
- [ ] Module API must be consumable as-is by Epic 3 (service configuration commits) and Epic 5 (production status commits) — no fork, no copy
- [ ] Publish endpoint runs SafeCommitRail; on full pass + reason code, transitions job status to `"Production Ready"` and writes an audit entry with actor, timestamp, and reason code via the shared audit logger
- [ ] Frontend: readiness panel and publish flow (AC: #1, #2, #3, #5, #6)
- [ ] Render readiness panel listing missing required fields as jump-links that scroll/focus the target field
- [ ] Enable the Publish action only when readiness is `"Ready to Publish"`
- [ ] Build a SafeCommitRail UI component (shared, not feature-specific) that displays overall status, blocking reasons with one-click corrective action links, and a reason-code selector when required (UX-DR4)
- [ ] Keep the confirm button disabled while any blocking issue remains
- [ ] On success, surface a Next Best Action prompt steering the user to service configuration (UX-DR11)
- [ ] Tests & evidence (AC: #1–#7)
- [ ] Backend tests for readiness evaluation across missing/complete states, SafeCommitRail check execution, reason-code requirement, audit emission, status transition
- [ ] Reusability proof: an integration test wires the SafeCommitRail module to a non-cycle dummy commit context to confirm Epic 3/5 can consume it without modification
- [ ] Frontend tests for jump-links, enabled/disabled Publish button, SafeCommitRail UI states, Next Best Action rendering
- [ ] Document changed files and any config notes

## Dev Notes

- SafeCommitRail must ship as a shared, reusable module from day one. Epics 3 and 5 will consume it; if it lands as election-cycle-specific code it will need to be rewritten.
- The reason-code selector is conditional — only required when audit policy demands it. Do not hardcode it as always-required.
- Jump-links must reference field identifiers stable enough to survive UI restructuring. Avoid CSS selectors as the contract.
- Required-field rules come from the Story 1.9 seed; do not duplicate the rule set inside this story's code.
- "Production Ready" is a status transition out of "In Setup" (Story 2.2). The audit log entry is mandatory for the transition.
- Next Best Action (UX-DR11) is an in-app prompt, not a redirect — keep it as part of the post-publish confirmation surface.

### Project Structure Notes

- Backend: `Campaign_Tracker.Server/` — place SafeCommitRail under a shared/cross-feature folder (e.g., `SafeCommit/`) rather than inside the election-cycle feature, to make Epic 3/5 reuse explicit
- Frontend: `campaign-tracker-client/` — SafeCommitRail UI lives in a shared component folder consumable by future commits; readiness panel lives with the cycle job detail view
- Story artifacts: `_bmad-output/implementation-artifacts/`

### References

- Story source: `_bmad-output/planning-artifacts/epics.md` (Epic 2 / Story 2.5)
- Architecture constraints: `_bmad-output/planning-artifacts/architecture.md` (SafeCommitRail invariants — no bypass for publish-sensitive updates)
- UX patterns: `_bmad-output/planning-artifacts/ux-design-specification.md` (UX-DR4 reason code, UX-DR11 next best action)
- Prior stories: Story 1.5 — shared audit logging; Story 1.9 — seed system reference values & rule defaults; Stories 2.2–2.4 — cycle job entity, key dates, prior-cycle defaults

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.

### Completion Notes List

- Story context created and marked ready-for-dev.

### File List

### Change Log

+ 82
- 0
_bmad-output/implementation-artifacts/2-6-spreadsheet-import-column-mapping.md Datei anzeigen

@@ -0,0 +1,82 @@
# Story 2.6: Spreadsheet Import & Column Mapping

Status: ready-for-dev

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a client services or operations user,
I want to import approved election-cycle spreadsheet data and map it to the extension model,
so that key schedule and service fields can be staged and reviewed without manual re-entry.

## Acceptance Criteria

1. **Given** an approved import file is provided **When** the import workflow starts **Then** the system parses required columns and validates expected template headers before staging data
2. **Given** staged rows include jurisdiction references **When** mapping runs **Then** each row is matched to legacy-linked identifiers (`ID`, `JCode`/`JurisCode`, `KitID` where applicable) or flagged as unresolved
3. **Given** a staged row fails validation **When** the review screen is displayed **Then** deterministic error output identifies the row, failing field, and corrective action needed before publish
4. **Given** a staged row passes validation **When** it is included in publish **Then** provenance metadata is recorded: source file identifier, source row reference, import timestamp, and importing user

## Tasks / Subtasks

- [ ] Backend: import parser & header validation (AC: #1)
- [ ] Add an import endpoint that accepts an approved spreadsheet file, parses required columns, and validates expected template headers up-front
- [ ] Reject malformed or unexpected templates with a structured error before any row staging occurs
- [ ] Backend: staging store & jurisdiction mapping (AC: #2)
- [ ] Persist parsed rows in a staging store separate from the live extension tables (per architecture: pre-publish review)
- [ ] Map each staged row to legacy-linked identifiers (`ID`, `JCode`/`JurisCode`, `KitID` where applicable) using the legacy anti-corruption layer from Story 1.6 / linker from Story 1.8
- [ ] Flag unresolved rows explicitly with the unresolved identifier(s) named
- [ ] Backend: validation & deterministic error output (AC: #3)
- [ ] Run row-level validation producing deterministic, stable error records identifying row index, failing field, and a specific corrective action message
- [ ] Same input must always produce the same error set in the same order (for reviewer trust and diffability)
- [ ] Backend: publish with provenance (AC: #4)
- [ ] On publish, write valid staged rows into the election-cycle extension model and record provenance per row: source file identifier, source row reference, import timestamp, importing user
- [ ] Audit the import publish event via the shared audit logger
- [ ] Frontend: import workflow UI (AC: #1, #2, #3, #4)
- [ ] File upload entry point with header-validation feedback before staging
- [ ] Review screen showing staged rows, jurisdiction mapping status (mapped vs. unresolved with the unmatched identifier visible), and validation errors with row/field/corrective-action columns
- [ ] Publish action runs only after all blocking validation errors are resolved or the offending rows are excluded
- [ ] Tests & evidence (AC: #1–#4)
- [ ] Backend tests for header validation, staging persistence, jurisdiction mapping (matched and unresolved), deterministic error output, provenance write, audit emission
- [ ] Frontend tests for upload feedback, review screen states, publish gating
- [ ] Document changed files and any config notes

## Dev Notes

- Staging store is **separate** from the live extension tables. Do not write parsed rows directly into election-cycle extension storage — staging exists so review can happen pre-publish (per architecture).
- Jurisdiction mapping must reuse the legacy anti-corruption data access layer (Story 1.6) and the legacy identifier linker (Story 1.8). Do not bypass them or build a parallel matcher.
- "Deterministic error output" is a hard property — sort/index error records by stable keys (row index, field name) so identical input yields identical output. Avoid set-iteration order or hash-randomized ordering.
- Provenance metadata (source file identifier, source row reference, import timestamp, importing user) is required per published row, not per import batch.
- Legacy Access tables remain read-only — the import path writes only to staging and to extension tables.
- This story does not consume SafeCommitRail (Story 2.5); the import publish path has its own validation gate scoped to import semantics. Coordinate naming so the two flows are visibly distinct.

### Project Structure Notes

- Backend: `Campaign_Tracker.Server/` — add an import feature folder (parser, staging repository, mapper, controller); reuse legacy ACL from Story 1.6 and identifier linker from Story 1.8
- Frontend: `campaign-tracker-client/` — add the import workflow under the election-cycle workspace
- Story artifacts: `_bmad-output/implementation-artifacts/`

### References

- Story source: `_bmad-output/planning-artifacts/epics.md` (Epic 2 / Story 2.6)
- Architecture constraints: `_bmad-output/planning-artifacts/architecture.md` (mapping registry, staging store for pre-publish review, provenance fields, publish service with audit)
- UX patterns: `_bmad-output/planning-artifacts/ux-design-specification.md`
- Prior stories: Story 1.5 — shared audit logging; Story 1.6 — legacy anti-corruption data access layer; Story 1.8 — legacy identifier linking for extension records

## Dev Agent Record

### Agent Model Used

{{agent_model_name_version}}

### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.

### Completion Notes List

- Story context created and marked ready-for-dev.

### File List

### Change Log

+ 10
- 10
_bmad-output/implementation-artifacts/sprint-status.yaml Datei anzeigen

@@ -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-06T18:00:00-04:00'
last_updated: '2026-05-07T11:14:04-04:00'
project: 'Campaign_Tracker App'
project_key: 'NOKEY'
tracking_system: 'file-system'
@@ -54,16 +54,16 @@ development_status:
1-9-seed-system-reference-values-rule-defaults: done
1-10-municipality-account-profile: done
1-11-municipality-operational-addresses: done
1-12-municipality-service-contacts: ready-for-dev
1-13-municipality-prior-cycle-service-defaults-view: ready-for-dev
1-12-municipality-service-contacts: done
1-13-municipality-prior-cycle-service-defaults-view: done
epic-1-retrospective: optional
epic-2: backlog
2-1-municipality-to-cycle-kanban-entry-point: backlog
2-2-create-election-cycle-job: backlog
2-3-election-cycle-key-dates: backlog
2-4-prior-cycle-defaults-application: backlog
2-5-election-cycle-readiness-status-publication: backlog
2-6-spreadsheet-import-column-mapping: backlog
epic-2: in-progress
2-1-municipality-to-cycle-kanban-entry-point: ready-for-dev
2-2-create-election-cycle-job: ready-for-dev
2-3-election-cycle-key-dates: ready-for-dev
2-4-prior-cycle-defaults-application: ready-for-dev
2-5-election-cycle-readiness-status-publication: ready-for-dev
2-6-spreadsheet-import-column-mapping: ready-for-dev
epic-2-retrospective: optional
epic-3: backlog
3-1-addressing-service-configuration: backlog


+ 487
- 51
campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx Datei anzeigen

@@ -1,3 +1,10 @@
import {
DeleteOutlined,
EditOutlined,
HistoryOutlined,
TeamOutlined,
UserAddOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
@@ -5,81 +12,68 @@ import {
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Spin,
Table,
Tag,
Typography,
} from 'antd'
import type { TableProps } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import {
createMunicipalityContact,
createMunicipalityProfile,
deleteMunicipalityContact,
fetchAvailableJurisdictions,
fetchMunicipalityContacts,
fetchMunicipalityProfiles,
fetchPriorCycleDefaults,
MunicipalityContactValidationError,
MunicipalityValidationError,
updateMunicipalityContact,
type LegacyJurisdiction,
type MunicipalityContact,
type MunicipalityContactInput,
type MunicipalityProfile,
type PriorCycleDefaults,
type PriorCycleServiceDefault,
} 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
}

type ContactFormValues = {
contactType: 'Primary' | 'Secondary'
name: string
roleTitle?: string
phone?: string
email?: string
}

export function MunicipalityProfilePanel({
load = fetchMunicipalityProfiles,
create = createMunicipalityProfile,
loadJurisdictions = fetchAvailableJurisdictions,
loadContacts = fetchMunicipalityContacts,
createContact = createMunicipalityContact,
updateContact = updateMunicipalityContact,
deleteContact = deleteMunicipalityContact,
loadPriorCycleDefaults = fetchPriorCycleDefaults,
}: {
load?: typeof fetchMunicipalityProfiles
create?: typeof createMunicipalityProfile
loadJurisdictions?: typeof fetchAvailableJurisdictions
loadContacts?: typeof fetchMunicipalityContacts
createContact?: typeof createMunicipalityContact
updateContact?: typeof updateMunicipalityContact
deleteContact?: typeof deleteMunicipalityContact
loadPriorCycleDefaults?: typeof fetchPriorCycleDefaults
} = {}) {
const [profiles, setProfiles] = useState<MunicipalityProfile[] | null>(null)
const [jurisdictions, setJurisdictions] = useState<LegacyJurisdiction[]>([])
@@ -89,6 +83,19 @@ export function MunicipalityProfilePanel({
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [form] = Form.useForm<CreateFormValues>()
const [contactModalOpen, setContactModalOpen] = useState(false)
const [selectedProfile, setSelectedProfile] = useState<MunicipalityProfile | null>(null)
const [contacts, setContacts] = useState<MunicipalityContact[] | null>(null)
const [contactsLoadError, setContactsLoadError] = useState<string | null>(null)
const [contactSaving, setContactSaving] = useState(false)
const [contactSaveError, setContactSaveError] = useState<string | null>(null)
const [editingContact, setEditingContact] = useState<MunicipalityContact | null>(null)
const [contactForm] = Form.useForm<ContactFormValues>()
const [defaultsModalOpen, setDefaultsModalOpen] = useState(false)
const [defaultsProfile, setDefaultsProfile] = useState<MunicipalityProfile | null>(null)
const [priorDefaults, setPriorDefaults] = useState<PriorCycleDefaults | null>(null)
const [defaultsLoadError, setDefaultsLoadError] = useState<string | null>(null)
const [selectedCycleId, setSelectedCycleId] = useState<string | null>(null)

const refresh = useCallback(async () => {
try {
@@ -101,21 +108,23 @@ export function MunicipalityProfilePanel({

useEffect(() => {
let cancelled = false
// P4: load profiles and jurisdictions independently so a jurisdiction failure
// does not prevent the profile table from rendering
// Load 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)
if (!cancelled) {
setLoadError(cause instanceof Error ? cause.message : 'Failed to load profiles')
}
})
loadJurisdictions()
.then((jList) => { if (!cancelled) setJurisdictions(jList) })
.catch((cause: unknown) => {
if (!cancelled)
if (!cancelled) {
setJurisdictionsLoadError(
cause instanceof Error ? cause.message : 'Failed to load available jurisdictions',
)
}
})
return () => { cancelled = true }
}, [load, loadJurisdictions])
@@ -141,6 +150,254 @@ export function MunicipalityProfilePanel({
}
}, [create, form, refresh])

const refreshContacts = useCallback(async (profileId: string) => {
setContactsLoadError(null)
try {
const items = await loadContacts(profileId)
setContacts(items)
} catch (cause) {
setContactsLoadError(
cause instanceof Error ? cause.message : 'Failed to load contacts',
)
}
}, [loadContacts])

const openContacts = useCallback(async (profile: MunicipalityProfile) => {
setSelectedProfile(profile)
setContacts(null)
setContactsLoadError(null)
setContactSaveError(null)
setEditingContact(null)
contactForm.resetFields()
setContactModalOpen(true)
await refreshContacts(profile.profileId)
}, [contactForm, refreshContacts])

const beginEditContact = useCallback((contact: MunicipalityContact) => {
setEditingContact(contact)
setContactSaveError(null)
contactForm.setFieldsValue({
contactType: contact.contactType,
name: contact.name,
roleTitle: contact.roleTitle ?? undefined,
phone: contact.phone ?? undefined,
email: contact.email ?? undefined,
})
}, [contactForm])

const resetContactForm = useCallback(() => {
setEditingContact(null)
setContactSaveError(null)
contactForm.resetFields()
}, [contactForm])

const handleSaveContact = useCallback(async (values: ContactFormValues) => {
if (!selectedProfile) return

setContactSaving(true)
setContactSaveError(null)
const payload: MunicipalityContactInput = {
contactType: values.contactType,
name: values.name,
roleTitle: values.roleTitle ?? null,
phone: values.phone ?? null,
email: values.email ?? null,
}

try {
if (editingContact) {
await updateContact(selectedProfile.profileId, editingContact.contactId, payload)
} else {
await createContact(selectedProfile.profileId, payload)
}
resetContactForm()
await refreshContacts(selectedProfile.profileId)
} catch (cause) {
setContactSaveError(
cause instanceof MunicipalityContactValidationError
? cause.message
: cause instanceof Error
? cause.message
: 'Save failed',
)
} finally {
setContactSaving(false)
}
}, [
createContact,
editingContact,
refreshContacts,
resetContactForm,
selectedProfile,
updateContact,
])

const handleDeleteContact = useCallback(async (contactId: string) => {
if (!selectedProfile) return

setContactSaveError(null)
try {
await deleteContact(selectedProfile.profileId, contactId)
if (editingContact?.contactId === contactId) {
resetContactForm()
}
await refreshContacts(selectedProfile.profileId)
} catch (cause) {
setContactSaveError(cause instanceof Error ? cause.message : 'Delete failed')
}
}, [deleteContact, editingContact, refreshContacts, resetContactForm, selectedProfile])

const openPriorDefaults = useCallback(async (profile: MunicipalityProfile) => {
setDefaultsProfile(profile)
setPriorDefaults(null)
setDefaultsLoadError(null)
setSelectedCycleId(null)
setDefaultsModalOpen(true)

try {
const defaults = await loadPriorCycleDefaults(profile.profileId)
setPriorDefaults(defaults)
setSelectedCycleId(defaults.selectedCycleId)
} catch (cause) {
setDefaultsLoadError(
cause instanceof Error ? cause.message : 'Failed to load prior cycle defaults',
)
}
}, [loadPriorCycleDefaults])

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,
},
{
title: 'Contacts',
key: 'contacts',
render: (_: unknown, record: MunicipalityProfile) => (
<Button
icon={<TeamOutlined />}
onClick={() => { void openContacts(record) }}
aria-label={`Manage contacts for ${record.displayName ?? record.legacyName ?? record.jCode}`}
>
Manage
</Button>
),
width: 130,
},
{
title: 'Prior Defaults',
key: 'priorDefaults',
render: (_: unknown, record: MunicipalityProfile) => (
<Button
icon={<HistoryOutlined />}
onClick={() => { void openPriorDefaults(record) }}
aria-label={`View prior cycle defaults for ${record.displayName ?? record.legacyName ?? record.jCode}`}
>
View
</Button>
),
width: 150,
},
]

const contactColumns: TableProps<MunicipalityContact>['columns'] = [
{
title: 'Designation',
dataIndex: 'contactType',
key: 'contactType',
render: (value: MunicipalityContact['contactType']) => (
<Tag color={value === 'Primary' ? 'blue' : 'default'}>{value}</Tag>
),
width: 130,
},
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Role/Title',
dataIndex: 'roleTitle',
key: 'roleTitle',
render: (value: string | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: 'Phone',
dataIndex: 'phone',
key: 'phone',
render: (value: string | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: 'Email',
dataIndex: 'email',
key: 'email',
render: (value: string | null) => value ?? <Text type="secondary">-</Text>,
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: MunicipalityContact) => (
<Space>
<Button
icon={<EditOutlined />}
onClick={() => beginEditContact(record)}
aria-label={`Edit ${record.name}`}
/>
<Popconfirm
title="Remove contact?"
description="This removes the contact from the current municipality profile."
okText="Remove"
okButtonProps={{ danger: true }}
onConfirm={() => { void handleDeleteContact(record.contactId) }}
>
<Button danger icon={<DeleteOutlined />} aria-label={`Remove ${record.name}`} />
</Popconfirm>
</Space>
),
width: 112,
},
]

const selectedCycle = priorDefaults?.cycles.find((cycle) => cycle.cycleId === selectedCycleId)
?? priorDefaults?.cycles[0]
?? null

return (
<section aria-label="Municipality account profiles" className="municipality-panel">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
@@ -162,7 +419,7 @@ export function MunicipalityProfilePanel({
type="warning"
showIcon
message="Jurisdictions unavailable"
description={`${jurisdictionsLoadError} creating new profiles is disabled until jurisdictions load.`}
description={`${jurisdictionsLoadError} - creating new profiles is disabled until jurisdictions load.`}
/>
) : null}

@@ -185,7 +442,7 @@ export function MunicipalityProfilePanel({
pagination={{ pageSize: 25 }}
columns={profileColumns}
dataSource={profiles}
scroll={{ x: 800 }}
scroll={{ x: 1060 }}
/>
)}

@@ -218,14 +475,14 @@ export function MunicipalityProfilePanel({
>
<Select
showSearch
placeholder="Search by JCode or name"
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,
label: j.name ? `${j.jCode} - ${j.name}` : j.jCode,
}))}
/>
</Form.Item>
@@ -249,6 +506,185 @@ export function MunicipalityProfilePanel({
</Form.Item>
</Form>
</Modal>

<Modal
title={selectedProfile
? `Service Contacts - ${selectedProfile.displayName ?? selectedProfile.legacyName ?? selectedProfile.jCode}`
: 'Service Contacts'}
open={contactModalOpen}
onCancel={() => {
setContactModalOpen(false)
setSelectedProfile(null)
setContacts(null)
resetContactForm()
}}
footer={null}
width={920}
destroyOnHidden
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{contactsLoadError ? (
<Alert type="error" showIcon message="Load error" description={contactsLoadError} />
) : null}

{contactSaveError ? (
<Alert type="error" showIcon message="Validation error" description={contactSaveError} />
) : null}

{contacts === null ? (
<Spin aria-label="Loading service contacts" />
) : contacts.length === 0 ? (
<Empty description="No service contacts yet." />
) : (
<Table<MunicipalityContact>
rowKey="contactId"
size="small"
pagination={false}
columns={contactColumns}
dataSource={contacts}
scroll={{ x: 760 }}
/>
)}

<Form<ContactFormValues>
form={contactForm}
layout="vertical"
initialValues={{ contactType: 'Primary' }}
onFinish={handleSaveContact}
>
<Space align="start" wrap>
<Form.Item
name="contactType"
label="Designation"
rules={[{ required: true, message: 'Contact type is required' }]}
>
<Select
aria-label="Contact designation"
style={{ width: 140 }}
options={[
{ value: 'Primary', label: 'Primary' },
{ value: 'Secondary', label: 'Secondary' },
]}
/>
</Form.Item>

<Form.Item
name="name"
label="Name"
rules={[{ required: true, whitespace: true, message: 'Name is required' }]}
>
<Input placeholder="Contact name" style={{ width: 200 }} />
</Form.Item>

<Form.Item name="roleTitle" label="Role/Title">
<Input placeholder="Clerk, manager..." style={{ width: 180 }} />
</Form.Item>

<Form.Item name="phone" label="Phone">
<Input placeholder="555-0100" style={{ width: 150 }} />
</Form.Item>

<Form.Item name="email" label="Email">
<Input placeholder="name@example.com" style={{ width: 220 }} />
</Form.Item>
</Space>

<Form.Item style={{ marginBottom: 0 }}>
<Space>
<Button type="primary" htmlType="submit" loading={contactSaving} icon={<UserAddOutlined />}>
{editingContact ? 'Update Contact' : 'Add Contact'}
</Button>
{editingContact ? (
<Button onClick={resetContactForm}>
Cancel Edit
</Button>
) : null}
</Space>
</Form.Item>
</Form>
</Space>
</Modal>

<Modal
title={defaultsProfile
? `Prior-Cycle Defaults - ${defaultsProfile.displayName ?? defaultsProfile.legacyName ?? defaultsProfile.jCode}`
: 'Prior-Cycle Defaults'}
open={defaultsModalOpen}
onCancel={() => {
setDefaultsModalOpen(false)
setDefaultsProfile(null)
setPriorDefaults(null)
setDefaultsLoadError(null)
setSelectedCycleId(null)
}}
footer={null}
width={840}
destroyOnHidden
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{defaultsLoadError ? (
<Alert type="error" showIcon message="Load error" description={defaultsLoadError} />
) : null}

{priorDefaults === null && defaultsLoadError === null ? (
<Spin aria-label="Loading prior cycle defaults" />
) : priorDefaults?.hasPriorCycles === false ? (
<Empty
description={priorDefaults.emptyStateMessage || 'No prior cycle defaults available.'}
>
<Text type="secondary">
Create the first election-cycle job before defaults can be referenced here.
</Text>
</Empty>
) : selectedCycle ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Select
aria-label="Prior cycle"
value={selectedCycle.cycleId}
onChange={setSelectedCycleId}
style={{ width: 280 }}
options={priorDefaults?.cycles.map((cycle) => ({
value: cycle.cycleId,
label: cycle.cycleName,
}))}
/>
<Text type="secondary">
Completed {new Date(selectedCycle.completedAt).toLocaleDateString()}
</Text>
<Table<PriorCycleServiceDefault>
rowKey="serviceType"
size="small"
pagination={false}
dataSource={selectedCycle.services}
columns={[
{
title: 'Service',
dataIndex: 'serviceType',
key: 'serviceType',
width: 160,
},
{
title: 'Summary',
dataIndex: 'summary',
key: 'summary',
},
{
title: 'Values',
key: 'values',
render: (_: unknown, service) => (
<Space size={4} wrap>
{Object.entries(service.values).map(([name, value]) => (
<Tag key={name}>{name}: {value}</Tag>
))}
</Space>
),
},
]}
/>
</Space>
) : null}
</Space>
</Modal>
</Space>
</section>
)


+ 145
- 0
campaign-tracker-client/src/municipalities/municipalityContracts.test.ts Datei anzeigen

@@ -1,11 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
createMunicipalityProfile,
createMunicipalityContact,
deleteMunicipalityContact,
fetchAvailableJurisdictions,
fetchMunicipalityContacts,
fetchMunicipalityProfiles,
fetchPriorCycleDefaults,
MunicipalityContactValidationError,
MunicipalityValidationError,
updateMunicipalityContact,
updateMunicipalityProfile,
type MunicipalityContact,
type MunicipalityProfile,
type PriorCycleDefaults,
} from './municipalityContracts'

const makeProfile = (overrides: Partial<MunicipalityProfile> = {}): MunicipalityProfile => ({
@@ -20,6 +28,43 @@ const makeProfile = (overrides: Partial<MunicipalityProfile> = {}): Municipality
...overrides,
})

const makeContact = (overrides: Partial<MunicipalityContact> = {}): MunicipalityContact => ({
contactId: 'contact-1',
profileId: 'abc123',
contactType: 'Primary',
name: 'Ada Clerk',
roleTitle: 'Town Clerk',
phone: '555-0100',
email: 'ada@example.test',
createdAt: '2026-05-07T12:00:00Z',
createdBy: 'creator@example.test',
updatedAt: '2026-05-07T12:00:00Z',
updatedBy: 'actor@example.test',
...overrides,
})

const makeDefaults = (overrides: Partial<PriorCycleDefaults> = {}): PriorCycleDefaults => ({
profileId: 'abc123',
hasPriorCycles: true,
selectedCycleId: 'cycle-2026-primary',
emptyStateMessage: '',
cycles: [
{
cycleId: 'cycle-2026-primary',
cycleName: '2026 Primary',
completedAt: '2026-05-01T00:00:00Z',
services: [
{
serviceType: 'Addressing',
summary: 'Standard addressing run',
values: { Quantity: '1200', Tracking: 'Yes' },
},
],
},
],
...overrides,
})

// ── fetchMunicipalityProfiles ─────────────────────────────────────────────────

describe('fetchMunicipalityProfiles', () => {
@@ -41,6 +86,106 @@ describe('fetchMunicipalityProfiles', () => {
})
})

// -- municipality contacts ---------------------------------------------------

describe('municipality contact contracts', () => {
it('fetchMunicipalityContacts returns primary and secondary contacts on 200', async () => {
const stub = async () =>
new Response(JSON.stringify([
makeContact({ contactType: 'Primary', name: 'Main Clerk' }),
makeContact({ contactId: 'contact-2', contactType: 'Secondary', name: 'Backup Clerk' }),
]), { status: 200 })

const result = await fetchMunicipalityContacts('abc123', stub)

expect(result.map((c) => c.contactType)).toEqual(['Primary', 'Secondary'])
expect(result[0].name).toBe('Main Clerk')
})

it('createMunicipalityContact posts required and optional fields', async () => {
let postedBody: unknown
const stub = async (_input: RequestInfo | URL, init?: RequestInit) => {
postedBody = JSON.parse(String(init?.body))
return new Response(JSON.stringify(makeContact()), { status: 201 })
}

const result = await createMunicipalityContact(
'abc123',
{
contactType: 'Primary',
name: 'Ada Clerk',
roleTitle: 'Town Clerk',
phone: '555-0100',
email: 'ada@example.test',
},
stub,
)

expect(result.contactType).toBe('Primary')
expect(postedBody).toEqual({
contactType: 'Primary',
name: 'Ada Clerk',
roleTitle: 'Town Clerk',
phone: '555-0100',
email: 'ada@example.test',
})
})

it('updateMunicipalityContact throws validation error for 422', async () => {
const stub = async () =>
new Response(JSON.stringify({ error: 'Name is required.' }), { status: 422 })

await expect(
updateMunicipalityContact('abc123', 'contact-1', { contactType: 'Primary', name: '' }, stub),
).rejects.toSatisfy(
(e) => e instanceof MunicipalityContactValidationError && e.message.includes('Name'),
)
})

it('deleteMunicipalityContact succeeds on 204 and throws on failure', async () => {
const okStub = async () => new Response(null, { status: 204 })
const failStub = async () => new Response('{}', { status: 500 })

await expect(deleteMunicipalityContact('abc123', 'contact-1', okStub)).resolves.toBeUndefined()
await expect(deleteMunicipalityContact('abc123', 'contact-1', failStub)).rejects.toThrow('500')
})
})

describe('fetchPriorCycleDefaults', () => {
it('returns read-only prior cycle defaults on 200', async () => {
const stub = async () =>
new Response(JSON.stringify(makeDefaults()), { status: 200 })

const result = await fetchPriorCycleDefaults('abc123', stub)

expect(result.hasPriorCycles).toBe(true)
expect(result.selectedCycleId).toBe('cycle-2026-primary')
expect(result.cycles[0].services[0].values.Quantity).toBe('1200')
})

it('returns empty state payload when no prior cycles exist', async () => {
const stub = async () =>
new Response(JSON.stringify(makeDefaults({
hasPriorCycles: false,
selectedCycleId: null,
emptyStateMessage: 'No prior cycle defaults available.',
cycles: [],
})), { status: 200 })

const result = await fetchPriorCycleDefaults('abc123', stub)

expect(result.hasPriorCycles).toBe(false)
expect(result.emptyStateMessage).toContain('No prior cycle')
expect(result.cycles).toEqual([])
})

it('throws on non-200', async () => {
const stub = async () => new Response('{}', { status: 404 })

await expect(fetchPriorCycleDefaults('missing', stub)).rejects.toThrow('404')
})
})

// ── createMunicipalityProfile ─────────────────────────────────────────────────

describe('createMunicipalityProfile', () => {


+ 152
- 0
campaign-tracker-client/src/municipalities/municipalityContracts.ts Datei anzeigen

@@ -13,6 +13,53 @@ export type MunicipalityProfileValidationError = {
error: string
}

export type MunicipalityContact = {
contactId: string
profileId: string
contactType: 'Primary' | 'Secondary'
name: string
roleTitle: string | null
phone: string | null
email: string | null
createdAt: string
createdBy: string
updatedAt: string
updatedBy: string
}

export type MunicipalityContactInput = {
contactType: 'Primary' | 'Secondary'
name: string
roleTitle?: string | null
phone?: string | null
email?: string | null
}

export type MunicipalityContactValidationErrorResponse = {
error: string
}

export type PriorCycleServiceDefault = {
serviceType: string
summary: string
values: Record<string, string>
}

export type PriorCycleDefault = {
cycleId: string
cycleName: string
completedAt: string
services: PriorCycleServiceDefault[]
}

export type PriorCycleDefaults = {
profileId: string
hasPriorCycles: boolean
selectedCycleId: string | null
emptyStateMessage: string
cycles: PriorCycleDefault[]
}

export async function fetchMunicipalityProfiles(
fetcher: typeof fetch = fetch,
): Promise<MunicipalityProfile[]> {
@@ -69,6 +116,89 @@ export async function updateMunicipalityProfile(
return (await response.json()) as MunicipalityProfile
}

export async function fetchMunicipalityContacts(
profileId: string,
fetcher: typeof fetch = fetch,
): Promise<MunicipalityContact[]> {
const response = await fetcher(`/api/municipalities/${profileId}/contacts`)
if (!response.ok) {
throw new Error(`Failed to load municipality contacts (${response.status})`)
}
return (await response.json()) as MunicipalityContact[]
}

export async function createMunicipalityContact(
profileId: string,
contact: MunicipalityContactInput,
fetcher: typeof fetch = fetch,
): Promise<MunicipalityContact> {
const response = await fetcher(`/api/municipalities/${profileId}/contacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(normalizeContactInput(contact)),
})

if (response.status === 422) {
const problem = (await response.json()) as MunicipalityContactValidationErrorResponse
throw new MunicipalityContactValidationError(problem.error ?? 'Validation failed.')
}

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

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

export async function updateMunicipalityContact(
profileId: string,
contactId: string,
contact: MunicipalityContactInput,
fetcher: typeof fetch = fetch,
): Promise<MunicipalityContact> {
const response = await fetcher(`/api/municipalities/${profileId}/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(normalizeContactInput(contact)),
})

if (response.status === 422) {
const problem = (await response.json()) as MunicipalityContactValidationErrorResponse
throw new MunicipalityContactValidationError(problem.error ?? 'Validation failed.')
}

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

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

export async function deleteMunicipalityContact(
profileId: string,
contactId: string,
fetcher: typeof fetch = fetch,
): Promise<void> {
const response = await fetcher(`/api/municipalities/${profileId}/contacts/${contactId}`, {
method: 'DELETE',
})

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

export async function fetchPriorCycleDefaults(
profileId: string,
fetcher: typeof fetch = fetch,
): Promise<PriorCycleDefaults> {
const response = await fetcher(`/api/municipalities/${profileId}/prior-cycle-defaults`)
if (!response.ok) {
throw new Error(`Failed to load prior cycle defaults (${response.status})`)
}
return (await response.json()) as PriorCycleDefaults
}

export type LegacyJurisdiction = {
jCode: string
name: string | null
@@ -90,3 +220,25 @@ export class MunicipalityValidationError extends Error {
this.name = 'MunicipalityValidationError'
}
}

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

function normalizeContactInput(contact: MunicipalityContactInput): MunicipalityContactInput {
return {
contactType: contact.contactType,
name: contact.name.trim(),
roleTitle: normalizeOptional(contact.roleTitle),
phone: normalizeOptional(contact.phone),
email: normalizeOptional(contact.email),
}
}

function normalizeOptional(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}

+ 14
- 0
campaign-tracker-client/src/workspace/WorkspaceShell.tsx Datei anzeigen

@@ -36,9 +36,14 @@ import type { AuthenticatedUser } from '../auth/authContracts'
import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel'
import { MunicipalityProfilePanel } from '../municipalities/MunicipalityProfilePanel'
import {
createMunicipalityContact,
createMunicipalityProfile,
deleteMunicipalityContact,
fetchAvailableJurisdictions,
fetchMunicipalityContacts,
fetchMunicipalityProfiles,
fetchPriorCycleDefaults,
updateMunicipalityContact,
} from '../municipalities/municipalityContracts'
import {
fetchLegacySchemaCheckHistory,
@@ -408,6 +413,15 @@ export function WorkspaceShell({
load={() => fetchMunicipalityProfiles(adminFetch)}
create={(jCode, displayName) => createMunicipalityProfile(jCode, displayName, adminFetch)}
loadJurisdictions={() => fetchAvailableJurisdictions(adminFetch)}
loadContacts={(profileId) => fetchMunicipalityContacts(profileId, adminFetch)}
createContact={(profileId, contact) =>
createMunicipalityContact(profileId, contact, adminFetch)}
updateContact={(profileId, contactId, contact) =>
updateMunicipalityContact(profileId, contactId, contact, adminFetch)}
deleteContact={(profileId, contactId) =>
deleteMunicipalityContact(profileId, contactId, adminFetch)}
loadPriorCycleDefaults={(profileId) =>
fetchPriorCycleDefaults(profileId, adminFetch)}
/>
) : (
<section


Laden…
Abbrechen
Speichern

Powered by TurnKey Linux.