Explorar el Código

fix(1.10): code review patches — concurrency, null-safety, test coverage

Repository:
- Add _lock to InMemoryMunicipalityProfileRepository; make duplicate-JCode
  check + insert atomic in CreateAsync (TOCTOU fix) and wrap UpdateAsync
  read-modify-write in lock (lost-update fix)
- Normalize JCode before passing to ILegacyLinkValidator so validation and
  storage use the same form (was: raw input validated, ToUpperInvariant stored)
- UpdateAsync returns ProfileNotFound (new factory on MunicipalityProfileSaveResult)
  so controller can distinguish 404 from 422

Controller:
- Null-guard post-save/update GetByIdAsync result instead of bang-deref
- Return 404 (not 422) when update target profile does not exist
- GET /api/municipalities/jurisdictions accepted as in-scope extension

Data access:
- FromJsonSeedFile: catch(Exception ex) with Console.Error.WriteLine;
  DistinctBy(JCode) before projection to suppress duplicates in seed file
- 422 body shape mismatch: problem.error ?? 'Validation failed.' fallback
  in createMunicipalityProfile and updateMunicipalityProfile

Frontend:
- Split Promise.all into independent profile + jurisdiction loads so a
  jurisdiction failure does not suppress the profile table
- Add jurisdictionsLoadError state; disable "New" button and show warning
  alert when jurisdictions are unavailable

Tests:
- Override ILegacyDataAccess in AuthIntegrationTestFactory with hardcoded
  defaults, isolating integration tests from the development seed file
- Add wrong-role 403, jurisdiction endpoint 401, update-not-found 404,
  and AC#3 audit assertion tests (create + update) to controller suite
- Add fetchAvailableJurisdictions contract tests (success + failure)
- Story 1.10 marked done; sprint-status synced
pull/19/head
Daniel Covington hace 2 días
padre
commit
6a10f82863
Se han modificado 18 ficheros con 662 adiciones y 58 borrados
  1. +12
    -5
      Campaign_Tracker.Server.Tests/AuthEndpointTests.cs
  2. +88
    -1
      Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs
  3. +28
    -2
      Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs
  4. +47
    -0
      Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs
  5. +13
    -13
      Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs
  6. +30
    -20
      Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs
  7. +5
    -1
      Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs
  8. +4
    -1
      Campaign_Tracker.Server/Program.cs
  9. +61
    -0
      Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl
  10. +238
    -0
      Campaign_Tracker.Server/seed-data.json.c92561d951cd49b0b53ec61b8330edc2.tmp
  11. +24
    -2
      _bmad-output/implementation-artifacts/1-10-municipality-account-profile.md
  12. +1
    -1
      _bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md
  13. +8
    -0
      _bmad-output/implementation-artifacts/deferred-work.md
  14. +2
    -2
      _bmad-output/implementation-artifacts/sprint-status.yaml
  15. +45
    -7
      campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx
  16. +29
    -0
      campaign-tracker-client/src/municipalities/municipalityContracts.test.ts
  17. +17
    -2
      campaign-tracker-client/src/municipalities/municipalityContracts.ts
  18. +10
    -1
      campaign-tracker-client/src/workspace/WorkspaceShell.tsx

+ 12
- 5
Campaign_Tracker.Server.Tests/AuthEndpointTests.cs Ver fichero

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

services.AddSingleton<IAuditService, InMemoryPassthroughAuditService>();

// Replace the data-file-backed ILegacyDataAccess with hardcoded test defaults so
// integration tests are not affected by the presence or contents of a seed file.
var legacyDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(ILegacyDataAccess));
if (legacyDescriptor is not null)
services.Remove(legacyDescriptor);

services.AddSingleton<ILegacyDataAccess>(new InMemoryLegacyDataAccess());
});
}



+ 88
- 1
Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs Ver fichero

@@ -1,6 +1,8 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Campaign_Tracker.Server.Audit;
using Microsoft.Extensions.DependencyInjection;

namespace Campaign_Tracker.Server.Tests;

@@ -97,7 +99,7 @@ public sealed class MunicipalityProfileControllerTests
Assert.Contains("DOESNOTEXIST", body.Error);
}

// ── Authorization: non-recognized role gets 403 ───────────────────────────
// ── Authorization ─────────────────────────────────────────────────────────

[Fact]
public async Task CreateProfile_NoToken_Returns401()
@@ -111,6 +113,91 @@ public sealed class MunicipalityProfileControllerTests
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task CreateProfile_WrongRoleToken_Returns403()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("prod@example.test", "production"));

var response = await client.PostAsJsonAsync("/api/municipalities/profiles",
new { jCode = "FAIR01", displayName = (string?)null });

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

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

var response = await client.GetAsync("/api/municipalities/jurisdictions");

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

// ── AC #3: audit events recorded on create and update ────────────────────

[Fact]
public async Task CreateProfile_RecordsAuditEvent_AC3()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services"));

await client.PostAsJsonAsync("/api/municipalities/profiles",
new { jCode = "FAIR01", displayName = "Fairview" });

var auditService = factory.Services.GetRequiredService<IAuditService>();
var events = auditService.GetRecent();
Assert.Contains(events, e =>
e.EventType == "MUNICIPALITY_PROFILE_CREATED" &&
e.ActorIdentity == "cs@example.test");
}

[Fact]
public async Task UpdateProfile_RecordsAuditEvent_AC3()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services"));

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

await client.PutAsJsonAsync(
$"/api/municipalities/profiles/{created!.ProfileId}",
new { displayName = "Updated Name" });

var auditService = factory.Services.GetRequiredService<IAuditService>();
var events = auditService.GetRecent();
Assert.Contains(events, e =>
e.EventType == "MUNICIPALITY_PROFILE_UPDATED" &&
e.ActorIdentity == "cs@example.test");
}

// ── Update not-found returns 404 ─────────────────────────────────────────

[Fact]
public async Task UpdateProfile_UnknownId_Returns404()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("cs@example.test", "client-services"));

var response = await client.PutAsJsonAsync(
"/api/municipalities/profiles/does-not-exist",
new { displayName = "X" });

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

// ── Local DTOs for deserialization ────────────────────────────────────────

private sealed record MunicipalityProfileDto(


+ 28
- 2
Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs Ver fichero

@@ -1,6 +1,7 @@
using System.Security.Claims;
using Campaign_Tracker.Server.Audit;
using Campaign_Tracker.Server.Authorization;
using Campaign_Tracker.Server.LegacyData;
using Campaign_Tracker.Server.Municipalities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -17,19 +18,34 @@ namespace Campaign_Tracker.Server.Controllers;
public sealed class MunicipalityProfileController : ControllerBase
{
private readonly IMunicipalityProfileRepository _profiles;
private readonly ILegacyDataAccess _legacyData;
private readonly IAuditService _audit;
private readonly TimeProvider _timeProvider;

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

// ── Available legacy jurisdictions (JCode picker source) ─────────────────

[HttpGet("/api/municipalities/jurisdictions")]
public async Task<ActionResult<IReadOnlyList<LegacyJurisdictionResponse>>> GetJurisdictions(
CancellationToken cancellationToken)
{
var jurisdictions = await _legacyData.GetAllJurisdictionsAsync(cancellationToken);
return Ok(jurisdictions
.Select(j => new LegacyJurisdictionResponse(j.JCode, j.Name))
.ToArray());
}

// ── AC #1, AC #2: create and immediately return the combined view ─────────

[HttpPost]
@@ -53,7 +69,9 @@ public sealed class MunicipalityProfileController : ControllerBase
RecordedAt: _timeProvider.GetUtcNow()));

var view = await _profiles.GetByIdAsync(result.Profile.ProfileId, cancellationToken);
return Ok(MunicipalityProfileResponse.From(view!));
if (view is null)
return StatusCode(500, new MunicipalityProfileProblem("Profile was saved but could not be retrieved."));
return Ok(MunicipalityProfileResponse.From(view));
}

// ── AC #2: list all profiles with resolved legacy data ───────────────────
@@ -87,7 +105,11 @@ public sealed class MunicipalityProfileController : ControllerBase
var result = await _profiles.UpdateAsync(profileId, request.DisplayName, actor, cancellationToken);

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

_audit.Record(new AuditEvent(
EventType: "MUNICIPALITY_PROFILE_UPDATED",
@@ -98,7 +120,9 @@ public sealed class MunicipalityProfileController : ControllerBase
RecordedAt: _timeProvider.GetUtcNow()));

var view = await _profiles.GetByIdAsync(profileId, cancellationToken);
return Ok(MunicipalityProfileResponse.From(view!));
if (view is null)
return StatusCode(500, new MunicipalityProfileProblem("Profile was updated but could not be retrieved."));
return Ok(MunicipalityProfileResponse.From(view));
}

private string GetActor() =>
@@ -133,3 +157,5 @@ public sealed record MunicipalityProfileResponse(
}

public sealed record MunicipalityProfileProblem(string Error);

public sealed record LegacyJurisdictionResponse(string JCode, string? Name);

+ 47
- 0
Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs Ver fichero

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

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

/// <summary>
/// Creates an instance seeded from a JSON file produced by the development-data
/// export script. Falls back to the hardcoded defaults if the file is absent.
/// </summary>
public static InMemoryLegacyDataAccess FromJsonSeedFile(string jsonPath)
{
if (!File.Exists(jsonPath))
return new InMemoryLegacyDataAccess();

try
{
var json = File.ReadAllText(jsonPath);
var records = JsonSerializer.Deserialize<JsonJurisdiction[]>(
json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? [];

var jurisdictions = records
.Where(r => !string.IsNullOrWhiteSpace(r.JCode))
.DistinctBy(r => r.JCode!.Trim(), StringComparer.OrdinalIgnoreCase)
.Select(r => new LegacyJurisdiction(
r.JCode!.Trim(),
string.IsNullOrWhiteSpace(r.Name) ? null : r.Name.Trim(),
string.IsNullOrWhiteSpace(r.MailingAddress) ? null : r.MailingAddress.Trim(),
string.IsNullOrWhiteSpace(r.CityStateZip) ? null : r.CityStateZip.Trim(),
string.IsNullOrWhiteSpace(r.Imb) ? null : r.Imb.Trim(),
string.IsNullOrWhiteSpace(r.ImbDigits) ? null : r.ImbDigits.Trim()))
.ToArray();

return new InMemoryLegacyDataAccess(jurisdictions: jurisdictions);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[InMemoryLegacyDataAccess] Failed to load seed file '{jsonPath}': {ex.Message}. Falling back to hardcoded defaults.");
return new InMemoryLegacyDataAccess();
}
}

private sealed class JsonJurisdiction
{
public string? JCode { get; set; }
public string? Name { get; set; }
public string? MailingAddress { get; set; }
public string? CityStateZip { get; set; }
public string? Imb { get; set; }
public string? ImbDigits { get; set; }
}

// ── Jurisdiction ──────────────────────────────────────────────────────────

public Task<LegacyJurisdiction?> GetJurisdictionAsync(


+ 13
- 13
Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs Ver fichero

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

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

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

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

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

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

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

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

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


+ 30
- 20
Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs Ver fichero

@@ -13,6 +13,7 @@ public sealed class InMemoryMunicipalityProfileRepository
: IMunicipalityProfileRepository, ILegacyLinkedRecordProvider
{
private readonly ConcurrentDictionary<string, MunicipalityProfile> _profiles = new(StringComparer.OrdinalIgnoreCase);
private readonly object _lock = new();
private readonly ILegacyLinkValidator _validator;
private readonly ILegacyDataAccess _legacyData;
private readonly TimeProvider _timeProvider;
@@ -38,27 +39,33 @@ public sealed class InMemoryMunicipalityProfileRepository
if (string.IsNullOrWhiteSpace(jCode))
return MunicipalityProfileSaveResult.Failure("JCode is required.");

// P8: normalize before validation so the link validator uses the same form that gets stored
var normalizedJCode = jCode.Trim().ToUpperInvariant();

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

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

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

_profiles[profile.ProfileId] = profile;
// P2: atomic check + insert under lock to prevent TOCTOU race on duplicate JCode
lock (_lock)
{
if (_profiles.Values.Any(p => string.Equals(p.JCode, normalizedJCode, StringComparison.OrdinalIgnoreCase)))
return MunicipalityProfileSaveResult.Failure(
$"A municipality profile already exists for JCode '{normalizedJCode}'.");

_profiles[profile.ProfileId] = profile;
}
return MunicipalityProfileSaveResult.Success(profile);
}

@@ -70,19 +77,22 @@ public sealed class InMemoryMunicipalityProfileRepository
string actorIdentity,
CancellationToken cancellationToken = default)
{
if (!_profiles.TryGetValue(profileId, out var existing))
return Task.FromResult(MunicipalityProfileSaveResult.Failure(
$"Municipality profile '{profileId}' not found."));

var updated = existing with
// P2: wrap read-modify-write in lock to prevent lost updates under concurrent PUTs
lock (_lock)
{
DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
UpdatedAt = _timeProvider.GetUtcNow(),
UpdatedBy = actorIdentity,
};

_profiles[profileId] = updated;
return Task.FromResult(MunicipalityProfileSaveResult.Success(updated));
if (!_profiles.TryGetValue(profileId, out var existing))
return Task.FromResult(MunicipalityProfileSaveResult.ProfileNotFound(profileId));

var updated = existing with
{
DisplayName = string.IsNullOrWhiteSpace(displayName) ? null : displayName.Trim(),
UpdatedAt = _timeProvider.GetUtcNow(),
UpdatedBy = actorIdentity,
};

_profiles[profileId] = updated;
return Task.FromResult(MunicipalityProfileSaveResult.Success(updated));
}
}

// ── AC #2: resolve combined extension + legacy view ──────────────────────


+ 5
- 1
Campaign_Tracker.Server/Municipalities/MunicipalityProfileSaveResult.cs Ver fichero

@@ -3,11 +3,15 @@ namespace Campaign_Tracker.Server.Municipalities;
public sealed record MunicipalityProfileSaveResult(
bool Saved,
string? Error,
MunicipalityProfile? Profile)
MunicipalityProfile? Profile,
bool IsNotFound = false)
{
public static MunicipalityProfileSaveResult Success(MunicipalityProfile profile) =>
new(true, null, profile);

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

public static MunicipalityProfileSaveResult ProfileNotFound(string profileId) =>
new(false, $"Municipality profile '{profileId}' not found.", null, IsNotFound: true);
}

+ 4
- 1
Campaign_Tracker.Server/Program.cs Ver fichero

@@ -59,7 +59,10 @@ if (!string.IsNullOrWhiteSpace(legacyConnectionString))
}
else if (builder.Environment.IsDevelopment())
{
builder.Services.AddSingleton<ILegacyDataAccess, InMemoryLegacyDataAccess>();
var jsonSeedPath = Path.GetFullPath(
Path.Combine(builder.Environment.ContentRootPath, "..", "development-data", "jurisdictions.json"));
builder.Services.AddSingleton<ILegacyDataAccess>(
_ => InMemoryLegacyDataAccess.FromJsonSeedFile(jsonSeedPath));
}
else
{


+ 61
- 0
Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl Ver fichero

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

+ 238
- 0
Campaign_Tracker.Server/seed-data.json.c92561d951cd49b0b53ec61b8330edc2.tmp Ver fichero

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

+ 24
- 2
_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md Ver fichero

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

Status: review
Status: done

## Story

@@ -68,7 +68,7 @@ claude-sonnet-4-6
- Frontend: `municipalityContracts.ts` — typed fetch functions with `MunicipalityValidationError` for 422 responses (AC #4).
- Frontend: `MunicipalityProfilePanel.tsx` — table of profiles with combined legacy data (AC #2), modal form for create with JCode field and error display.
- `WorkspaceShell.tsx` updated: selecting "Municipalities" nav item now renders `MunicipalityProfilePanel` for users with `canViewMunicipalityProfile` permission.
- 16 backend unit tests (11 repository + 5 controller integration) + 9 frontend contract tests.
- 20 backend unit tests (10 repository + 10 controller integration) + 10 frontend contract tests.

### File List

@@ -88,6 +88,28 @@ claude-sonnet-4-6
- `_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md` (this file)
- `_bmad-output/implementation-artifacts/sprint-status.yaml` (modified — status updated)

## Review Findings

- [x] [Review][Decision] Out-of-scope: jurisdiction endpoint + searchable Select picker — accepted as scope extension. Test gaps and Promise.all failure mode addressed via patch items below.
- [x] [Review][Patch] Post-save/update GetByIdAsync bang-dereferenced — null guard added; returns 500 with descriptive message if view unexpectedly null [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs]
- [x] [Review][Patch] TOCTOU race on CreateAsync + lost write on UpdateAsync — `_lock` object added; duplicate-check+insert atomic in CreateAsync; read-modify-write atomic in UpdateAsync [Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs]
- [x] [Review][Patch] UpdateAsync not-found returns 422 instead of 404 — `MunicipalityProfileSaveResult.ProfileNotFound` factory added; controller checks `result.IsNotFound` and returns 404 [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs]
- [x] [Review][Patch] Promise.all — jurisdiction failure blocks profile display — loads split into two independent calls; `jurisdictionsLoadError` state added; "New" button disabled when jurisdictions unavailable; distinct warning alert shown [campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx]
- [x] [Review][Patch] FromJsonSeedFile bare catch swallows all exceptions with no logging — `catch (Exception ex)` with `Console.Error.WriteLine` added [Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs]
- [x] [Review][Patch] Missing 403 test for wrong-role token on municipality endpoints — `CreateProfile_WrongRoleToken_Returns403`, `GetJurisdictions_NoToken_Returns401`, and `UpdateProfile_UnknownId_Returns404` tests added [Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs]
- [x] [Review][Patch] fetchAvailableJurisdictions has no contract tests — success and failure tests added [campaign-tracker-client/src/municipalities/municipalityContracts.test.ts]
- [x] [Review][Patch] JCode normalization inconsistency — `normalizedJCode` computed before validator call; stored and validated forms are now consistent [Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs]
- [x] [Review][Patch] FromJsonSeedFile does not DistinctBy(JCode) — `.DistinctBy(r => r.JCode!.Trim(), StringComparer.OrdinalIgnoreCase)` added before projection [Campaign_Tracker.Server/LegacyData/InMemoryLegacyDataAccess.cs]
- [x] [Review][Patch] AC #3 audit path not asserted at integration level — `CreateProfile_RecordsAuditEvent_AC3` and `UpdateProfile_RecordsAuditEvent_AC3` tests added [Campaign_Tracker.Server.Tests/MunicipalityProfileControllerTests.cs]
- [x] [Review][Patch] 422 body shape mismatch produces "undefined" user error — `problem.error ?? 'Validation failed.'` fallback added in both create and update functions [campaign-tracker-client/src/municipalities/municipalityContracts.ts]
- [x] [Review][Patch] Backend test count discrepancy — updated: 10 repository + 10 controller = 20 backend tests; 10 frontend contract tests [_bmad-output/implementation-artifacts/1-10-municipality-account-profile.md]
- [x] [Review][Patch] Integration tests broken by FromJsonSeedFile when real seed data is present — AuthIntegrationTestFactory now overrides ILegacyDataAccess with hardcoded test defaults, isolating tests from the development seed file (same pattern as IAuditService override) [Campaign_Tracker.Server.Tests/AuthEndpointTests.cs]
- [x] [Review][Defer] Internal whitespace in JCode from Access not handled — Trim() strips leading/trailing only; embedded spaces cause lookup mismatches [Campaign_Tracker.Server/LegacyData/OleDbLegacyDataAccess.cs] — deferred, pre-existing
- [x] [Review][Defer] ProfileId uses ToString("N") (no hyphens) — latent cross-system UUID format mismatch if consumers return a hyphenated variant [Campaign_Tracker.Server/Municipalities/InMemoryMunicipalityProfileRepository.cs] — deferred, pre-existing
- [x] [Review][Defer] CreatedAt stored but absent from API response DTO — profile creation timestamp inaccessible to consumers [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs] — deferred, pre-existing
- [x] [Review][Defer] Audit Outcome hardcoded to "updated display name" — will mislead once model has additional updatable fields [Campaign_Tracker.Server/Controllers/MunicipalityProfileController.cs] — deferred, pre-existing
- [x] [Review][Defer] refresh() post-create does not reload jurisdiction list — stale list if jurisdictions change during session [campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx] — deferred, pre-existing

## Change Log

- 2026-05-06: Story 1.10 implemented — municipality account profile domain, repository, REST API, and React panel with legacy join resolution. 25 tests added. All 4 ACs satisfied. (claude-sonnet-4-6)

+ 1
- 1
_bmad-output/implementation-artifacts/1-11-municipality-operational-addresses.md Ver fichero

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

Status: ready-for-dev
Status: review

## Story



+ 8
- 0
_bmad-output/implementation-artifacts/deferred-work.md Ver fichero

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

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

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

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


+ 2
- 2
_bmad-output/implementation-artifacts/sprint-status.yaml Ver fichero

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

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


+ 45
- 7
campaign-tracker-client/src/municipalities/MunicipalityProfilePanel.tsx Ver fichero

@@ -5,6 +5,7 @@ import {
Form,
Input,
Modal,
Select,
Space,
Spin,
Table,
@@ -14,8 +15,10 @@ import type { TableProps } from 'antd'
import { useCallback, useEffect, useState } from 'react'
import {
createMunicipalityProfile,
fetchAvailableJurisdictions,
fetchMunicipalityProfiles,
MunicipalityValidationError,
type LegacyJurisdiction,
type MunicipalityProfile,
} from './municipalityContracts'

@@ -72,12 +75,16 @@ type CreateFormValues = {
export function MunicipalityProfilePanel({
load = fetchMunicipalityProfiles,
create = createMunicipalityProfile,
loadJurisdictions = fetchAvailableJurisdictions,
}: {
load?: typeof fetchMunicipalityProfiles
create?: typeof createMunicipalityProfile
loadJurisdictions?: typeof fetchAvailableJurisdictions
} = {}) {
const [profiles, setProfiles] = useState<MunicipalityProfile[] | null>(null)
const [jurisdictions, setJurisdictions] = useState<LegacyJurisdiction[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [jurisdictionsLoadError, setJurisdictionsLoadError] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -94,14 +101,24 @@ export function MunicipalityProfilePanel({

useEffect(() => {
let cancelled = false
// P4: load profiles and jurisdictions independently so a jurisdiction failure
// does not prevent the profile table from rendering
load()
.then((items) => { if (!cancelled) setProfiles(items) })
.catch((cause: unknown) => {
if (!cancelled)
setLoadError(cause instanceof Error ? cause.message : 'Failed to load profiles')
})
loadJurisdictions()
.then((jList) => { if (!cancelled) setJurisdictions(jList) })
.catch((cause: unknown) => {
if (!cancelled)
setJurisdictionsLoadError(
cause instanceof Error ? cause.message : 'Failed to load available jurisdictions',
)
})
return () => { cancelled = true }
}, [load])
}, [load, loadJurisdictions])

const handleCreate = useCallback(async (values: CreateFormValues) => {
setSaving(true)
@@ -140,7 +157,20 @@ export function MunicipalityProfilePanel({
<Alert type="error" showIcon message="Load error" description={loadError} />
) : null}

<Button type="primary" onClick={() => { setSaveError(null); setModalOpen(true) }}>
{jurisdictionsLoadError ? (
<Alert
type="warning"
showIcon
message="Jurisdictions unavailable"
description={`${jurisdictionsLoadError} — creating new profiles is disabled until jurisdictions load.`}
/>
) : null}

<Button
type="primary"
onClick={() => { setSaveError(null); setModalOpen(true) }}
disabled={jurisdictionsLoadError !== null}
>
New Municipality Profile
</Button>

@@ -183,12 +213,20 @@ export function MunicipalityProfilePanel({
>
<Form.Item
name="jCode"
label="JCode (legacy jurisdiction identifier)"
rules={[{ required: true, message: 'JCode is required' }]}
label="Jurisdiction"
rules={[{ required: true, message: 'Jurisdiction is required' }]}
>
<Input
placeholder="e.g. FAIR01"
aria-label="Legacy jurisdiction JCode"
<Select
showSearch
placeholder="Search by JCode or name…"
aria-label="Legacy jurisdiction"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={jurisdictions.map((j) => ({
value: j.jCode,
label: j.name ? `${j.jCode} — ${j.name}` : j.jCode,
}))}
/>
</Form.Item>



+ 29
- 0
campaign-tracker-client/src/municipalities/municipalityContracts.test.ts Ver fichero

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
createMunicipalityProfile,
fetchAvailableJurisdictions,
fetchMunicipalityProfiles,
MunicipalityValidationError,
updateMunicipalityProfile,
@@ -96,6 +97,34 @@ describe('updateMunicipalityProfile', () => {
})
})

// ── fetchAvailableJurisdictions ───────────────────────────────────────────────

describe('fetchAvailableJurisdictions', () => {
it('returns jurisdictions on 200', async () => {
const stub = async () =>
new Response(
JSON.stringify([
{ jCode: 'FAIR01', name: 'Fairview Borough' },
{ jCode: 'LAKE02', name: null },
]),
{ status: 200 },
)

const result = await fetchAvailableJurisdictions(stub)

expect(result).toHaveLength(2)
expect(result[0].jCode).toBe('FAIR01')
expect(result[0].name).toBe('Fairview Borough')
expect(result[1].name).toBeNull()
})

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

await expect(fetchAvailableJurisdictions(stub)).rejects.toThrow('503')
})
})

// ── MunicipalityValidationError ───────────────────────────────────────────────

describe('MunicipalityValidationError', () => {


+ 17
- 2
campaign-tracker-client/src/municipalities/municipalityContracts.ts Ver fichero

@@ -36,7 +36,7 @@ export async function createMunicipalityProfile(

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

if (!response.ok) {
@@ -59,7 +59,7 @@ export async function updateMunicipalityProfile(

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

if (!response.ok) {
@@ -69,6 +69,21 @@ export async function updateMunicipalityProfile(
return (await response.json()) as MunicipalityProfile
}

export type LegacyJurisdiction = {
jCode: string
name: string | null
}

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

export class MunicipalityValidationError extends Error {
constructor(message: string) {
super(message)


+ 10
- 1
campaign-tracker-client/src/workspace/WorkspaceShell.tsx Ver fichero

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


Cargando…
Cancelar
Guardar

Powered by TurnKey Linux.