소스 검색

Merge branch 'refs/heads/main' into 1-9

pull/16/head
Daniel Covington 2 일 전
부모
커밋
d3507202cf
35개의 변경된 파일1566개의 추가작업 그리고 37개의 파일을 삭제
  1. +24
    -0
      .gitea/workflows/release-gates.yml
  2. +2
    -0
      .gitignore
  3. +3
    -0
      Campaign_Tracker.Server.Tests/AuthEndpointTests.cs
  4. +63
    -0
      Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs
  5. +191
    -0
      Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs
  6. +193
    -0
      Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs
  7. +66
    -0
      Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs
  8. +91
    -0
      Campaign_Tracker.Server/Controllers/LegacyLinkController.cs
  9. +27
    -0
      Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs
  10. +10
    -0
      Campaign_Tracker.Server/ExtensionData/ILegacyLinkIntegrityCheck.cs
  11. +12
    -0
      Campaign_Tracker.Server/ExtensionData/ILegacyLinkValidator.cs
  12. +17
    -0
      Campaign_Tracker.Server/ExtensionData/ILegacyLinkedRecord.cs
  13. +11
    -0
      Campaign_Tracker.Server/ExtensionData/ILegacyLinkedRecordProvider.cs
  14. +90
    -0
      Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs
  15. +82
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs
  16. +25
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs
  17. +79
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs
  18. +27
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs
  19. +16
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkType.cs
  20. +16
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkValidationResult.cs
  21. +82
    -0
      Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs
  22. +55
    -0
      Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs
  23. +40
    -8
      Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs
  24. +1
    -0
      Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs
  25. +19
    -0
      Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs
  26. +4
    -1
      Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs
  27. +113
    -0
      Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs
  28. +38
    -3
      Campaign_Tracker.Server/Program.cs
  29. +61
    -0
      Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl
  30. +17
    -1
      _bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md
  31. +60
    -19
      _bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md
  32. +4
    -0
      _bmad-output/implementation-artifacts/deferred-work.md
  33. +3
    -3
      _bmad-output/implementation-artifacts/sprint-status.yaml
  34. +14
    -1
      campaign-tracker-client/src/App.tsx
  35. +10
    -1
      campaign-tracker-client/src/workspace/WorkspaceShell.tsx

+ 24
- 0
.gitea/workflows/release-gates.yml 파일 보기

@@ -0,0 +1,24 @@
name: release-gates

on:
push:
branches:
- main
pull_request:

jobs:
legacy-schema:
runs-on: windows-latest
env:
ASPNETCORE_ENVIRONMENT: Production
LegacyDatabase__ConnectionString: ${{ secrets.LEGACY_DATABASE_CONNECTION_STRING }}
LegacySchema__BaselineFile: Initial Documents/Access_Schema.txt
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Restore
run: dotnet restore campaign-tracker.sln
- name: Block release on legacy schema drift
run: dotnet run --project Campaign_Tracker.Server -- --check-legacy-schema

+ 2
- 0
.gitignore 파일 보기

@@ -21,3 +21,5 @@ Campaign_Tracker.Server/appsettings.Development.json
development-data/
Dockerfile
docker-compose.yml
Campaign_Tracker.Server/audit-logs/
Campaign_Tracker.Server/legacy-schema-history.jsonl

+ 3
- 0
Campaign_Tracker.Server.Tests/AuthEndpointTests.cs 파일 보기

@@ -35,6 +35,9 @@ public sealed class AuthIntegrationTestFactory : WebApplicationFactory<Program>
builder.UseSetting("Keycloak:ClientId", ClientId);
builder.UseSetting("Keycloak:DisableHttpsMetadata", "true");
builder.UseSetting("Keycloak:TestSigningKey", SigningKey);
builder.UseSetting("LegacySchema:HistoryFile",
Path.Combine(Path.GetTempPath(), $"campaign-tracker-schema-history-{Guid.NewGuid():N}.jsonl"));
builder.UseSetting("LegacyLinkIntegrity:Enabled", "false");

builder.ConfigureServices(services =>
{


+ 63
- 0
Campaign_Tracker.Server.Tests/ExtensionRecordControllerTests.cs 파일 보기

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

namespace Campaign_Tracker.Server.Tests;

public sealed class ExtensionRecordControllerTests
{
[Fact]
public async Task SaveExtensionRecord_StoresRequiredLegacyReference_AC1()
{
await using var factory = new AuthIntegrationTestFactory();
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", AuthIntegrationTestFactory.CreateToken("admin@example.test", "admin"));

var response = await client.PostAsJsonAsync("/api/admin/extension-records", new
{
recordType = "MunicipalityProfile",
recordId = "mp-001",
legacyLink = new { type = 0, value = "FAIR01" },
});

var body = await response.Content.ReadFromJsonAsync<ExtensionRecordResponse>();

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body);
Assert.Equal("MunicipalityProfile", body.RecordType);
Assert.Equal("mp-001", body.RecordId);
Assert.Equal("JurisdictionJCode", body.LinkType);
Assert.Equal("FAIR01", body.LinkValue);
}

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

var response = await client.PostAsJsonAsync("/api/admin/extension-records", new
{
recordType = "MunicipalityProfile",
recordId = "mp-ghost",
legacyLink = new { type = 0, value = "NOPE" },
});

var body = await response.Content.ReadFromJsonAsync<ExtensionRecordValidationProblem>();

Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.NotNull(body);
Assert.Contains("NOPE", body.Error);
}

private sealed record ExtensionRecordResponse(
string RecordType,
string RecordId,
string LinkType,
string LinkValue);

private sealed record ExtensionRecordValidationProblem(string Error);
}

+ 191
- 0
Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs 파일 보기

@@ -0,0 +1,191 @@
using Campaign_Tracker.Server.ExtensionData;
using Campaign_Tracker.Server.LegacyData;
using Campaign_Tracker.Server.LegacyData.Models;

namespace Campaign_Tracker.Server.Tests;

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

private static LegacyLinkIntegrityService BuildSut(
ILegacyDataAccess data,
params ILegacyLinkedRecordProvider[] providers)
{
var validator = new LegacyLinkValidator(data);
var time = new FakeTimeProvider(FixedNow);
return new LegacyLinkIntegrityService(providers, validator, time);
}

// ── AC #4 — nightly integrity check with no extension records ────────────

[Fact]
public async Task CheckAsync_NoProviders_ReturnsFullyConsistentReport_AC4()
{
var sut = BuildSut(new InMemoryLegacyDataAccess());

var report = await sut.CheckAsync();

Assert.False(report.IsConsistent);
Assert.Equal(0, report.ProviderCount);
Assert.Equal(0, report.TotalRecords);
Assert.Equal(0, report.FailedRecords);
Assert.Equal(100.0, report.ConsistencyPercentage);
Assert.Empty(report.Failures);
Assert.Equal(FixedNow, report.CheckedAt);
}

// ── AC #4 — consistent records produce clean report ──────────────────────

[Fact]
public async Task CheckAsync_AllValidLinks_Reports100PercentConsistency_AC4()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions:
[
new("FAIR01", "Fairview", null, null, null, null),
new("LAKE02", "Lake", null, null, null, null),
]);

var provider = new StubProvider(
[
new StubRecord("MunicipalityProfile", "mp-001", LegacyLinkReference.ForJurisdiction("FAIR01")),
new StubRecord("MunicipalityProfile", "mp-002", LegacyLinkReference.ForJurisdiction("LAKE02")),
]);

var sut = BuildSut(data, provider);
var report = await sut.CheckAsync();

Assert.True(report.IsConsistent);
Assert.Equal(1, report.ProviderCount);
Assert.Equal(2, report.TotalRecords);
Assert.Equal(2, report.ConsistentRecords);
Assert.Equal(0, report.FailedRecords);
Assert.Equal(100.0, report.ConsistencyPercentage);
Assert.Empty(report.Failures);
}

// ── AC #4 — failing links are flagged with descriptive reason ────────────

[Fact]
public async Task CheckAsync_InvalidLink_FlagsRecordWithReason_AC4()
{
var data = new InMemoryLegacyDataAccess(jurisdictions: []);

var provider = new StubProvider(
[
new StubRecord("MunicipalityProfile", "mp-ghost", LegacyLinkReference.ForJurisdiction("GONE01")),
]);

var sut = BuildSut(data, provider);
var report = await sut.CheckAsync();

Assert.False(report.IsConsistent);
Assert.Equal(1, report.ProviderCount);
Assert.Equal(1, report.TotalRecords);
Assert.Equal(0, report.ConsistentRecords);
Assert.Equal(1, report.FailedRecords);
Assert.Single(report.Failures);

var failure = report.Failures[0];
Assert.Equal("MunicipalityProfile", failure.RecordType);
Assert.Equal("mp-ghost", failure.RecordId);
Assert.Equal(LegacyLinkType.JurisdictionJCode, failure.Reference.Type);
Assert.Equal("GONE01", failure.Reference.Value);
Assert.Contains("GONE01", failure.Reason);
}

[Fact]
public async Task CheckAsync_MixedValidity_ReportsCorrectConsistencyPercentage_AC4()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions: [new("GOOD01", "Good", null, null, null, null)]);

var provider = new StubProvider(
[
new StubRecord("MunicipalityProfile", "mp-good", LegacyLinkReference.ForJurisdiction("GOOD01")),
new StubRecord("MunicipalityProfile", "mp-bad", LegacyLinkReference.ForJurisdiction("MISSING")),
]);

var sut = BuildSut(data, provider);
var report = await sut.CheckAsync();

Assert.False(report.IsConsistent);
Assert.Equal(2, report.TotalRecords);
Assert.Equal(1, report.ConsistentRecords);
Assert.Equal(1, report.FailedRecords);
Assert.Equal(50.0, report.ConsistencyPercentage);
}

// ── AC #4 — multiple providers aggregate correctly ───────────────────────

[Fact]
public async Task CheckAsync_MultipleProviders_AggregatesAllRecords_AC4()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions: [new("FAIR01", "Fairview", null, null, null, null)],
kits: [new LegacyKit(101, "FAIR01", null, null, null, null, false, false, null, null, null, null, null, null)]);

var providerA = new StubProvider(
[
new StubRecord("MunicipalityProfile", "mp-001", LegacyLinkReference.ForJurisdiction("FAIR01")),
]);
var providerB = new StubProvider(
[
new StubRecord("ElectionJob", "ej-001", LegacyLinkReference.ForKit(101)),
]);

var sut = BuildSut(data, providerA, providerB);
var report = await sut.CheckAsync();

Assert.True(report.IsConsistent);
Assert.Equal(2, report.ProviderCount);
Assert.Equal(2, report.TotalRecords);
Assert.Equal(0, report.FailedRecords);
}

[Fact]
public async Task CheckAsync_DuplicateActiveLinks_FlagsAmbiguousJoin_AC2_AC4()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions: [new("FAIR01", "Fairview", null, null, null, null)]);

var provider = new StubProvider(
[
new StubRecord("MunicipalityProfile", "mp-001", LegacyLinkReference.ForJurisdiction("FAIR01")),
new StubRecord("ElectionJob", "ej-001", LegacyLinkReference.ForJurisdiction("FAIR01")),
]);

var sut = BuildSut(data, provider);
var report = await sut.CheckAsync();

Assert.False(report.IsConsistent);
Assert.Equal(2, report.TotalRecords);
Assert.Equal(1, report.FailedRecords);
Assert.Contains(report.Failures, failure =>
failure.Reason.Contains("ambiguous", StringComparison.OrdinalIgnoreCase));
}

// ── Helpers ───────────────────────────────────────────────────────────────

private sealed class StubRecord(string recordType, string recordId, LegacyLinkReference link)
: ILegacyLinkedRecord
{
public string RecordType => recordType;
public string RecordId => recordId;
public LegacyLinkReference LegacyLink => link;
}

private sealed class StubProvider(IReadOnlyList<ILegacyLinkedRecord> records)
: ILegacyLinkedRecordProvider
{
public Task<IReadOnlyList<ILegacyLinkedRecord>> GetAllAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(records);
}

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

+ 193
- 0
Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs 파일 보기

@@ -0,0 +1,193 @@
using Campaign_Tracker.Server.ExtensionData;
using Campaign_Tracker.Server.LegacyData;
using Campaign_Tracker.Server.LegacyData.Models;

namespace Campaign_Tracker.Server.Tests;

public sealed class LegacyLinkValidatorTests
{
// ── AC #2 — ACL join returns correct record with no ambiguity ────────────

[Fact]
public async Task ValidateAsync_JurisdictionJCode_ExistingRecord_ReturnsSuccess_AC2()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions: [new("FAIR01", "Fairview Borough", null, null, null, null)]);
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(LegacyLinkReference.ForJurisdiction("FAIR01"));

Assert.True(result.IsValid);
Assert.Null(result.Error);
}

[Fact]
public async Task ValidateAsync_KitId_ExistingRecord_ReturnsSuccess_AC2()
{
var kit = new LegacyKit(101, "FAIR01", null, null, null, null, false, false,
null, null, null, null, null, null);
var data = new InMemoryLegacyDataAccess(kits: [kit]);
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(LegacyLinkReference.ForKit(101));

Assert.True(result.IsValid);
}

[Fact]
public async Task ValidateAsync_ContactId_ExistingRecord_ReturnsSuccess_AC2()
{
var contact = new LegacyContact(1, "FAIR01", "Jane Doe", null, null,
null, null, null, null, null, null, null, null, null, null);
var data = new InMemoryLegacyDataAccess(contacts: [contact]);
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(LegacyLinkReference.ForContact(1));

Assert.True(result.IsValid);
}

[Fact]
public async Task ValidateAsync_JurisdictionJCode_IsCaseInsensitive_AC2()
{
var data = new InMemoryLegacyDataAccess(
jurisdictions: [new("FAIR01", "Fairview Borough", null, null, null, null)]);
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(LegacyLinkReference.ForJurisdiction("fair01"));

Assert.True(result.IsValid);
}

// ── AC #3 — invalid/non-existent reference rejected with descriptive error ─

[Fact]
public async Task ValidateAsync_JurisdictionJCode_NotFound_ReturnsFailureWithDescription_AC3()
{
var data = new InMemoryLegacyDataAccess(jurisdictions: []);
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(LegacyLinkReference.ForJurisdiction("UNKNOWN"));

Assert.False(result.IsValid);
Assert.NotNull(result.Error);
Assert.Contains("UNKNOWN", result.Error);
Assert.Contains("jurisdiction", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ValidateAsync_KitId_NotFound_ReturnsFailureWithDescription_AC3()
{
var data = new InMemoryLegacyDataAccess(kits: []);
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(LegacyLinkReference.ForKit(9999));

Assert.False(result.IsValid);
Assert.NotNull(result.Error);
Assert.Contains("9999", result.Error);
Assert.Contains("kit", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ValidateAsync_ContactId_NotFound_ReturnsFailureWithDescription_AC3()
{
var data = new InMemoryLegacyDataAccess(contacts: []);
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(LegacyLinkReference.ForContact(9999));

Assert.False(result.IsValid);
Assert.NotNull(result.Error);
Assert.Contains("9999", result.Error);
Assert.Contains("contact", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ValidateAsync_BlankJCode_ReturnsFailureWithDescription_AC3()
{
var data = new InMemoryLegacyDataAccess();
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(new LegacyLinkReference(LegacyLinkType.JurisdictionJCode, " "));

Assert.False(result.IsValid);
Assert.NotNull(result.Error);
Assert.Contains("required", result.Error, StringComparison.OrdinalIgnoreCase);
}

[Fact]
public async Task ValidateAsync_KitId_NonIntegerValue_ReturnsFailureWithDescription_AC3()
{
var data = new InMemoryLegacyDataAccess();
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(new LegacyLinkReference(LegacyLinkType.KitId, "not-a-number"));

Assert.False(result.IsValid);
Assert.NotNull(result.Error);
Assert.Contains("not-a-number", result.Error);
}

[Fact]
public async Task ValidateAsync_ContactId_NonIntegerValue_ReturnsFailureWithDescription_AC3()
{
var data = new InMemoryLegacyDataAccess();
var sut = new LegacyLinkValidator(data);

var result = await sut.ValidateAsync(new LegacyLinkReference(LegacyLinkType.ContactId, "abc"));

Assert.False(result.IsValid);
Assert.NotNull(result.Error);
}

// ── AC #1 — factory methods produce correct link type and value ───────────

[Fact]
public void ForJurisdiction_SetsCorrectTypeAndValue_AC1()
{
var ref_ = LegacyLinkReference.ForJurisdiction("LAKE02");

Assert.Equal(LegacyLinkType.JurisdictionJCode, ref_.Type);
Assert.Equal("LAKE02", ref_.Value);
}

[Fact]
public void ForKit_SetsCorrectTypeAndValue_AC1()
{
var ref_ = LegacyLinkReference.ForKit(101);

Assert.Equal(LegacyLinkType.KitId, ref_.Type);
Assert.Equal("101", ref_.Value);
}

[Fact]
public void ForKit_NonPositiveId_Throws_AC1()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForKit(0));
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForKit(-1));
}

[Fact]
public void ForContact_SetsCorrectTypeAndValue_AC1()
{
var ref_ = LegacyLinkReference.ForContact(42);

Assert.Equal(LegacyLinkType.ContactId, ref_.Type);
Assert.Equal("42", ref_.Value);
}

[Fact]
public void ForContact_NonPositiveId_Throws_AC1()
{
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForContact(0));
Assert.Throws<ArgumentOutOfRangeException>(() => LegacyLinkReference.ForContact(-1));
}

[Fact]
public void ForJurisdiction_BlankJCode_Throws_AC1()
{
Assert.Throws<ArgumentException>(() => LegacyLinkReference.ForJurisdiction(""));
Assert.Throws<ArgumentException>(() => LegacyLinkReference.ForJurisdiction(" "));
}
}

+ 66
- 0
Campaign_Tracker.Server/Controllers/ExtensionRecordsController.cs 파일 보기

@@ -0,0 +1,66 @@
using Campaign_Tracker.Server.Authorization;
using Campaign_Tracker.Server.ExtensionData;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Campaign_Tracker.Server.Controllers;

[ApiController]
[Authorize(Policy = ApplicationPolicy.AdminAccess)]
[Route("api/admin/extension-records")]
public sealed class ExtensionRecordsController : ControllerBase
{
private readonly IExtensionRecordStore _store;

public ExtensionRecordsController(IExtensionRecordStore store)
{
_store = store;
}

[HttpPost]
public async Task<ActionResult<ExtensionRecordResponse>> Save(
ExtensionRecordRequest request,
CancellationToken cancellationToken)
{
if (request.LegacyLink is null)
{
return BadRequest(new ExtensionRecordValidationProblem("Legacy reference is required."));
}

var result = await _store.SaveAsync(
new ExtensionRecordDraft(
request.RecordType,
request.RecordId,
new LegacyLinkReference(request.LegacyLink.Type, request.LegacyLink.Value)),
cancellationToken);

if (!result.Saved || result.Record is null)
{
return BadRequest(new ExtensionRecordValidationProblem(
result.Error ?? "Extension record could not be saved."));
}

return Ok(ExtensionRecordResponse.From(result.Record));
}
}

public sealed record ExtensionRecordRequest(
string RecordType,
string RecordId,
LegacyLinkRequest? LegacyLink);

public sealed record LegacyLinkRequest(
LegacyLinkType Type,
string Value);

public sealed record ExtensionRecordResponse(
string RecordType,
string RecordId,
string LinkType,
string LinkValue)
{
public static ExtensionRecordResponse From(ILegacyLinkedRecord record) =>
new(record.RecordType, record.RecordId, record.LegacyLink.Type.ToString(), record.LegacyLink.Value);
}

public sealed record ExtensionRecordValidationProblem(string Error);

+ 91
- 0
Campaign_Tracker.Server/Controllers/LegacyLinkController.cs 파일 보기

@@ -0,0 +1,91 @@
using System.Security.Claims;
using Campaign_Tracker.Server.Audit;
using Campaign_Tracker.Server.Authorization;
using Campaign_Tracker.Server.ExtensionData;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Campaign_Tracker.Server.Controllers;

/// <summary>
/// Admin API for the extension-to-legacy referential integrity check (Story 1.8 AC #4).
/// </summary>
[ApiController]
[Authorize(Policy = ApplicationPolicy.AdminAccess)]
[Route("api/admin/legacy-link")]
public sealed class LegacyLinkController : ControllerBase
{
private readonly ILegacyLinkIntegrityCheck _integrityCheck;
private readonly IAuditService _audit;
private readonly TimeProvider _timeProvider;

public LegacyLinkController(
ILegacyLinkIntegrityCheck integrityCheck,
IAuditService audit,
TimeProvider timeProvider)
{
_integrityCheck = integrityCheck;
_audit = audit;
_timeProvider = timeProvider;
}

/// <summary>
/// Runs the extension-to-legacy link integrity check on demand and returns a report.
/// Intended to be called by a scheduler for nightly runs and by admins for manual runs.
/// </summary>
[HttpPost("integrity-check")]
public async Task<ActionResult<LegacyLinkIntegrityResponse>> RunIntegrityCheck(
CancellationToken cancellationToken)
{
var report = await _integrityCheck.CheckAsync(cancellationToken);

var actor = User.Identity?.Name
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? "unknown";

_audit.Record(new AuditEvent(
EventType: report.IsConsistent
? "LEGACY_LINK_INTEGRITY_PASSED"
: "LEGACY_LINK_INTEGRITY_FAILED",
ActorIdentity: actor,
Resource: "legacy-link/integrity-check",
Outcome: $"{report.ConsistentRecords}/{report.TotalRecords} consistent ({report.ConsistencyPercentage:F2}%)",
TraceIdentifier: HttpContext.TraceIdentifier,
RecordedAt: _timeProvider.GetUtcNow()));

return Ok(LegacyLinkIntegrityResponse.From(report));
}
}

public sealed record LegacyLinkIntegrityResponse(
bool IsConsistent,
DateTimeOffset CheckedAt,
int ProviderCount,
int TotalRecords,
int ConsistentRecords,
int FailedRecords,
double ConsistencyPercentage,
IReadOnlyList<LegacyLinkFailureResponse> Failures)
{
public static LegacyLinkIntegrityResponse From(LegacyLinkIntegrityReport report) =>
new(report.IsConsistent,
report.CheckedAt,
report.ProviderCount,
report.TotalRecords,
report.ConsistentRecords,
report.FailedRecords,
report.ConsistencyPercentage,
report.Failures
.Select(f => new LegacyLinkFailureResponse(
f.RecordType, f.RecordId,
f.Reference.Type.ToString(), f.Reference.Value,
f.Reason))
.ToArray());
}

public sealed record LegacyLinkFailureResponse(
string RecordType,
string RecordId,
string LinkType,
string LinkValue,
string Reason);

+ 27
- 0
Campaign_Tracker.Server/ExtensionData/IExtensionRecordStore.cs 파일 보기

@@ -0,0 +1,27 @@
namespace Campaign_Tracker.Server.ExtensionData;

public interface IExtensionRecordStore
{
Task<ExtensionRecordSaveResult> SaveAsync(
ExtensionRecordDraft draft,
CancellationToken cancellationToken = default);

Task<IReadOnlyList<ILegacyLinkedRecord>> GetAllAsync(CancellationToken cancellationToken = default);
}

public sealed record ExtensionRecordDraft(
string RecordType,
string RecordId,
LegacyLinkReference LegacyLink);

public sealed record ExtensionRecordSaveResult(
bool Saved,
string? Error,
ILegacyLinkedRecord? Record)
{
public static ExtensionRecordSaveResult Success(ILegacyLinkedRecord record) =>
new(true, null, record);

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

+ 10
- 0
Campaign_Tracker.Server/ExtensionData/ILegacyLinkIntegrityCheck.cs 파일 보기

@@ -0,0 +1,10 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Runs the extension-to-legacy referential integrity check across all registered
/// extension record types (AC #4, NFR13).
/// </summary>
public interface ILegacyLinkIntegrityCheck
{
Task<LegacyLinkIntegrityReport> CheckAsync(CancellationToken cancellationToken = default);
}

+ 12
- 0
Campaign_Tracker.Server/ExtensionData/ILegacyLinkValidator.cs 파일 보기

@@ -0,0 +1,12 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Validates that a <see cref="LegacyLinkReference"/> resolves to an existing legacy record
/// before an extension record is persisted (AC #3).
/// </summary>
public interface ILegacyLinkValidator
{
Task<LegacyLinkValidationResult> ValidateAsync(
LegacyLinkReference reference,
CancellationToken cancellationToken = default);
}

+ 17
- 0
Campaign_Tracker.Server/ExtensionData/ILegacyLinkedRecord.cs 파일 보기

@@ -0,0 +1,17 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Contract that every extension record type must satisfy to participate in
/// legacy-identifier linking (AC #1) and nightly integrity checks (AC #4).
/// </summary>
public interface ILegacyLinkedRecord
{
/// <summary>Domain type name used in integrity reports (e.g. "MunicipalityProfile").</summary>
string RecordType { get; }

/// <summary>Stable identifier for this specific record instance (e.g. its primary key).</summary>
string RecordId { get; }

/// <summary>The required legacy foreign reference stored on this record.</summary>
LegacyLinkReference LegacyLink { get; }
}

+ 11
- 0
Campaign_Tracker.Server/ExtensionData/ILegacyLinkedRecordProvider.cs 파일 보기

@@ -0,0 +1,11 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Supplies all persisted records of one extension record type to the integrity check.
/// Each extension record type (municipality profile, election job, etc.) registers its own
/// implementation so the <see cref="ILegacyLinkIntegrityCheck"/> can scan across all types.
/// </summary>
public interface ILegacyLinkedRecordProvider
{
Task<IReadOnlyList<ILegacyLinkedRecord>> GetAllAsync(CancellationToken cancellationToken = default);
}

+ 90
- 0
Campaign_Tracker.Server/ExtensionData/InMemoryExtensionRecordStore.cs 파일 보기

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

namespace Campaign_Tracker.Server.ExtensionData;

public sealed class InMemoryExtensionRecordStore : IExtensionRecordStore, ILegacyLinkedRecordProvider
{
private readonly ConcurrentDictionary<string, StoredExtensionRecord> _records = new();
private readonly ILegacyLinkValidator _validator;

public InMemoryExtensionRecordStore(ILegacyLinkValidator validator)
{
_validator = validator;
}

public async Task<ExtensionRecordSaveResult> SaveAsync(
ExtensionRecordDraft draft,
CancellationToken cancellationToken = default)
{
var validationError = ValidateDraft(draft);
if (validationError is not null)
{
return ExtensionRecordSaveResult.Failure(validationError);
}

var linkValidation = await _validator.ValidateAsync(draft.LegacyLink, cancellationToken);
if (!linkValidation.IsValid)
{
return ExtensionRecordSaveResult.Failure(linkValidation.Error ?? "Legacy link is invalid.");
}

var duplicate = _records.Values.FirstOrDefault(record =>
!string.Equals(record.RecordType, draft.RecordType, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(record.RecordId, draft.RecordId, StringComparison.OrdinalIgnoreCase)
? SameLink(record.LegacyLink, draft.LegacyLink)
: false);
if (duplicate is not null)
{
return ExtensionRecordSaveResult.Failure(
$"Legacy reference '{draft.LegacyLink}' is already linked by {duplicate.RecordType} '{duplicate.RecordId}'.");
}

var stored = new StoredExtensionRecord(draft.RecordType.Trim(), draft.RecordId.Trim(), draft.LegacyLink);
_records[BuildKey(stored.RecordType, stored.RecordId)] = stored;
return ExtensionRecordSaveResult.Success(stored);
}

public Task<IReadOnlyList<ILegacyLinkedRecord>> GetAllAsync(CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<ILegacyLinkedRecord>>(
_records.Values
.OrderBy(record => record.RecordType, StringComparer.OrdinalIgnoreCase)
.ThenBy(record => record.RecordId, StringComparer.OrdinalIgnoreCase)
.ToArray());

private static string? ValidateDraft(ExtensionRecordDraft draft)
{
if (string.IsNullOrWhiteSpace(draft.RecordType))
{
return "Extension record type is required.";
}

if (string.IsNullOrWhiteSpace(draft.RecordId))
{
return "Extension record ID is required.";
}

if (draft.LegacyLink is null)
{
return "Legacy reference is required.";
}

if (string.IsNullOrWhiteSpace(draft.LegacyLink.Value))
{
return $"{draft.LegacyLink.Type} legacy reference value is required.";
}

return null;
}

private static string BuildKey(string recordType, string recordId) =>
$"{recordType.Trim().ToUpperInvariant()}:{recordId.Trim().ToUpperInvariant()}";

private static bool SameLink(LegacyLinkReference left, LegacyLinkReference right) =>
left.Type == right.Type &&
string.Equals(left.Value, right.Value, StringComparison.OrdinalIgnoreCase);

private sealed record StoredExtensionRecord(
string RecordType,
string RecordId,
LegacyLinkReference LegacyLink) : ILegacyLinkedRecord;
}

+ 82
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityHostedService.cs 파일 보기

@@ -0,0 +1,82 @@
using Microsoft.Extensions.Options;

namespace Campaign_Tracker.Server.ExtensionData;

public sealed class LegacyLinkIntegrityOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan RunTimeLocal { get; init; } = new(2, 0, 0);
}

public sealed class LegacyLinkIntegrityHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<LegacyLinkIntegrityHostedService> _logger;
private readonly LegacyLinkIntegrityOptions _options;

public LegacyLinkIntegrityHostedService(
IServiceScopeFactory scopeFactory,
ILogger<LegacyLinkIntegrityHostedService> logger,
IOptions<LegacyLinkIntegrityOptions> options)
{
_scopeFactory = scopeFactory;
_logger = logger;
_options = options.Value;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_options.Enabled)
{
_logger.LogInformation("Legacy link nightly integrity check scheduler is disabled.");
return;
}

while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(GetDelayUntilNextRun(DateTimeOffset.Now, _options.RunTimeLocal), stoppingToken);
await RunOnceAsync(stoppingToken);
}
}

private async Task RunOnceAsync(CancellationToken cancellationToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
var check = scope.ServiceProvider.GetRequiredService<ILegacyLinkIntegrityCheck>();
var report = await check.CheckAsync(cancellationToken);

if (report.IsConsistent)
{
_logger.LogInformation(
"Legacy link nightly integrity check passed: {Consistent}/{Total} records ({Percentage:F2}%).",
report.ConsistentRecords, report.TotalRecords, report.ConsistencyPercentage);
}
else
{
_logger.LogError(
"Legacy link nightly integrity check failed: {Failures}/{Total} records failed ({Percentage:F2}%).",
report.FailedRecords, report.TotalRecords, report.ConsistencyPercentage);
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
_logger.LogError(ex, "Legacy link nightly integrity check could not complete.");
}
}

private static TimeSpan GetDelayUntilNextRun(DateTimeOffset now, TimeSpan runTimeLocal)
{
var next = new DateTimeOffset(now.Date + runTimeLocal, now.Offset);
if (next <= now)
{
next = next.AddDays(1);
}

return next - now;
}
}

+ 25
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs 파일 보기

@@ -0,0 +1,25 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Result of a nightly (or on-demand) extension-to-legacy referential integrity check (AC #4).
/// Targeting at least 99.9% <see cref="ConsistencyPercentage"/> (NFR13).
/// </summary>
public sealed record LegacyLinkIntegrityReport(
DateTimeOffset CheckedAt,
int ProviderCount,
int TotalRecords,
int ConsistentRecords,
int FailedRecords,
double ConsistencyPercentage,
IReadOnlyList<LegacyLinkIntegrityFailure> Failures)
{
/// <summary>True when no failures were detected (or no records exist to check).</summary>
public bool IsConsistent => ProviderCount > 0 && FailedRecords == 0;
}

/// <summary>Describes one extension record that failed to resolve its legacy link.</summary>
public sealed record LegacyLinkIntegrityFailure(
string RecordType,
string RecordId,
LegacyLinkReference Reference,
string Reason);

+ 79
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs 파일 보기

@@ -0,0 +1,79 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Implements <see cref="ILegacyLinkIntegrityCheck"/> by scanning every record supplied
/// by the registered <see cref="ILegacyLinkedRecordProvider"/> implementations and
/// validating each one's legacy link through <see cref="ILegacyLinkValidator"/> (AC #4).
/// When no providers are registered (i.e. no extension records exist yet), the report
/// reflects zero records and 100% consistency.
/// </summary>
public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck
{
private readonly IEnumerable<ILegacyLinkedRecordProvider> _providers;
private readonly ILegacyLinkValidator _validator;
private readonly TimeProvider _timeProvider;

public LegacyLinkIntegrityService(
IEnumerable<ILegacyLinkedRecordProvider> providers,
ILegacyLinkValidator validator,
TimeProvider timeProvider)
{
_providers = providers;
_validator = validator;
_timeProvider = timeProvider;
}

public async Task<LegacyLinkIntegrityReport> CheckAsync(CancellationToken cancellationToken = default)
{
var failures = new List<LegacyLinkIntegrityFailure>();
var seenLinks = new Dictionary<string, ILegacyLinkedRecord>(StringComparer.OrdinalIgnoreCase);
var providers = _providers.ToArray();
int total = 0;

foreach (var provider in providers)
{
var records = await provider.GetAllAsync(cancellationToken);
foreach (var record in records)
{
total++;
var result = await _validator.ValidateAsync(record.LegacyLink, cancellationToken);
if (!result.IsValid)
{
failures.Add(new LegacyLinkIntegrityFailure(
record.RecordType,
record.RecordId,
record.LegacyLink,
result.Error ?? "Unknown validation error"));
}

var linkKey = $"{record.LegacyLink.Type}:{record.LegacyLink.Value}";
if (seenLinks.TryGetValue(linkKey, out var existing) &&
(!string.Equals(existing.RecordType, record.RecordType, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(existing.RecordId, record.RecordId, StringComparison.OrdinalIgnoreCase)))
{
failures.Add(new LegacyLinkIntegrityFailure(
record.RecordType,
record.RecordId,
record.LegacyLink,
$"Legacy reference is also linked by {existing.RecordType} '{existing.RecordId}', creating an ambiguous active-record join."));
}
else
{
seenLinks[linkKey] = record;
}
}
}

int consistent = total - failures.Count;
double pct = total == 0 ? 100.0 : (consistent / (double)total) * 100.0;

return new LegacyLinkIntegrityReport(
CheckedAt: _timeProvider.GetUtcNow(),
ProviderCount: providers.Length,
TotalRecords: total,
ConsistentRecords: consistent,
FailedRecords: failures.Count,
ConsistencyPercentage: Math.Round(pct, 4),
Failures: failures);
}
}

+ 27
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs 파일 보기

@@ -0,0 +1,27 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Immutable value object that an extension record stores as its required legacy foreign reference.
/// Carry the <see cref="LegacyLinkType"/> and the string representation of the join key so
/// the anti-corruption layer can resolve it unambiguously (AC #2).
/// </summary>
public sealed record LegacyLinkReference(LegacyLinkType Type, string Value)
{
public static LegacyLinkReference ForJurisdiction(string jCode)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jCode);
return new(LegacyLinkType.JurisdictionJCode, jCode);
}

public static LegacyLinkReference ForKit(int id) =>
id <= 0
? throw new ArgumentOutOfRangeException(nameof(id), "Kit ID must be greater than zero.")
: new(LegacyLinkType.KitId, id.ToString(System.Globalization.CultureInfo.InvariantCulture));

public static LegacyLinkReference ForContact(int id) =>
id <= 0
? throw new ArgumentOutOfRangeException(nameof(id), "Contact ID must be greater than zero.")
: new(LegacyLinkType.ContactId, id.ToString(System.Globalization.CultureInfo.InvariantCulture));

public override string ToString() => $"{Type}:{Value}";
}

+ 16
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkType.cs 파일 보기

@@ -0,0 +1,16 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Identifies which legacy join key an extension record's link resolves through.
/// </summary>
public enum LegacyLinkType
{
/// <summary>JCode / JurisCode — links to the legacy Jurisdiction table.</summary>
JurisdictionJCode,

/// <summary>ID — links to the legacy Kit table (integer primary key).</summary>
KitId,

/// <summary>ID — links to the legacy Contacts table (integer primary key).</summary>
ContactId,
}

+ 16
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkValidationResult.cs 파일 보기

@@ -0,0 +1,16 @@
namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Outcome of a pre-save legacy link validation (AC #3).
/// On failure, <see cref="Error"/> contains a descriptive message identifying the invalid reference.
/// </summary>
public sealed record LegacyLinkValidationResult(bool IsValid, string? Error = null)
{
public static LegacyLinkValidationResult Success() => new(true);

public static LegacyLinkValidationResult Failure(string error)
{
ArgumentException.ThrowIfNullOrWhiteSpace(error);
return new(false, error);
}
}

+ 82
- 0
Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs 파일 보기

@@ -0,0 +1,82 @@
using Campaign_Tracker.Server.LegacyData;

namespace Campaign_Tracker.Server.ExtensionData;

/// <summary>
/// Validates a <see cref="LegacyLinkReference"/> by resolving it through the
/// anti-corruption layer (<see cref="ILegacyDataAccess"/>).
/// Each link type maps to a unique primary-key lookup, guaranteeing no ambiguity (AC #2, AC #3).
/// </summary>
public sealed class LegacyLinkValidator : ILegacyLinkValidator
{
private readonly ILegacyDataAccess _legacyData;

public LegacyLinkValidator(ILegacyDataAccess legacyData)
{
_legacyData = legacyData;
}

public async Task<LegacyLinkValidationResult> ValidateAsync(
LegacyLinkReference reference,
CancellationToken cancellationToken = default)
{
return reference.Type switch
{
LegacyLinkType.JurisdictionJCode => await ValidateJurisdictionAsync(reference.Value, cancellationToken),
LegacyLinkType.KitId => await ValidateKitAsync(reference.Value, cancellationToken),
LegacyLinkType.ContactId => await ValidateContactAsync(reference.Value, cancellationToken),
_ => LegacyLinkValidationResult.Failure($"Unknown legacy link type '{reference.Type}'."),
};
}

private async Task<LegacyLinkValidationResult> ValidateJurisdictionAsync(
string jCode, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(jCode))
return LegacyLinkValidationResult.Failure("JCode is required and cannot be blank.");

var record = await _legacyData.GetJurisdictionAsync(jCode, cancellationToken);
return record is null
? LegacyLinkValidationResult.Failure(
$"No legacy jurisdiction found for JCode '{jCode}'. Verify the identifier and try again.")
: LegacyLinkValidationResult.Success();
}

private async Task<LegacyLinkValidationResult> ValidateKitAsync(
string rawId, CancellationToken cancellationToken)
{
if (!int.TryParse(rawId, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var id))
return LegacyLinkValidationResult.Failure(
$"Kit identifier '{rawId}' is not a valid integer ID.");

if (id <= 0)
return LegacyLinkValidationResult.Failure(
$"Kit identifier '{rawId}' must be greater than zero.");

var record = await _legacyData.GetKitByIdAsync(id, cancellationToken);
return record is null
? LegacyLinkValidationResult.Failure(
$"No legacy kit found for ID {id}. Verify the identifier and try again.")
: LegacyLinkValidationResult.Success();
}

private async Task<LegacyLinkValidationResult> ValidateContactAsync(
string rawId, CancellationToken cancellationToken)
{
if (!int.TryParse(rawId, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var id))
return LegacyLinkValidationResult.Failure(
$"Contact identifier '{rawId}' is not a valid integer ID.");

if (id <= 0)
return LegacyLinkValidationResult.Failure(
$"Contact identifier '{rawId}' must be greater than zero.");

var record = await _legacyData.GetContactByIdAsync(id, cancellationToken);
return record is null
? LegacyLinkValidationResult.Failure(
$"No legacy contact found for ID {id}. Verify the identifier and try again.")
: LegacyLinkValidationResult.Success();
}
}

+ 55
- 0
Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs 파일 보기

@@ -0,0 +1,55 @@
using System.Text.Json;

namespace Campaign_Tracker.Server.LegacyData.Schema;

public sealed class FileLegacySchemaCheckHistory : ILegacySchemaCheckHistory
{
private readonly string _filePath;
private readonly object _fileLock = new();

private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};

public FileLegacySchemaCheckHistory(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("History file path is required.", nameof(filePath));
}

_filePath = filePath;
Directory.CreateDirectory(Path.GetDirectoryName(_filePath) ?? ".");
}

public void Record(LegacySchemaCheckResult result)
{
ArgumentNullException.ThrowIfNull(result);
var line = JsonSerializer.Serialize(result, JsonOptions) + Environment.NewLine;
lock (_fileLock)
{
File.AppendAllText(_filePath, line);
}
}

public IReadOnlyList<LegacySchemaCheckResult> GetRecent(int maxCount = 50)
{
if (maxCount <= 0 || !File.Exists(_filePath))
{
return [];
}

lock (_fileLock)
{
return File.ReadLines(_filePath)
.Where(line => !string.IsNullOrWhiteSpace(line))
.Select(line => JsonSerializer.Deserialize<LegacySchemaCheckResult>(line, JsonOptions))
.Where(result => result is not null)
.Cast<LegacySchemaCheckResult>()
.OrderByDescending(result => result.CheckedAt)
.Take(maxCount)
.ToArray();
}
}
}

+ 40
- 8
Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs 파일 보기

@@ -50,12 +50,20 @@ public static class LegacySchemaBaselineParser
}

var trimmed = line.TrimStart();
if (!trimmed.StartsWith("Column:", StringComparison.Ordinal)) continue;
if (!trimmed.StartsWith("Column:", StringComparison.Ordinal))
{
throw new FormatException($"Unrecognized legacy schema line: {line}");
}

currentColumns.Add(ParseColumn(trimmed));
}

FlushTable(tables, currentTable, currentColumns);
if (tables.Count == 0)
{
throw new FormatException("Legacy schema baseline did not contain any table definitions.");
}

return new LegacySchemaBaseline(tables, source, capturedAt);
}

@@ -72,9 +80,10 @@ public static class LegacySchemaBaselineParser
{
// "Column: NAME Type: 130 Size: 255 Nullable: True"
var name = ReadField(line, "Column:", ["Type:"]);
var typeRaw = ReadField(line, "Type:", ["Size:", "Nullable:"]);
var sizeRaw = ReadField(line, "Size:", ["Nullable:"]);
var nullableRaw = ReadField(line, "Nullable:", []);
var typeRaw = ReadField(line, "Type:", ["Size:", "Nullable:", "Constraints:"]);
var sizeRaw = ReadField(line, "Size:", ["Nullable:", "Constraints:"]);
var nullableRaw = ReadField(line, "Nullable:", ["Constraints:"]);
var constraintsRaw = ReadField(line, "Constraints:", []);

if (!int.TryParse(typeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var typeCode))
{
@@ -82,14 +91,25 @@ public static class LegacySchemaBaselineParser
}

int? size = null;
if (!string.IsNullOrWhiteSpace(sizeRaw) &&
int.TryParse(sizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize))
if (!string.IsNullOrWhiteSpace(sizeRaw))
{
if (!int.TryParse(sizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedSize))
{
throw new FormatException($"Invalid Size value in column line: {line}");
}

size = parsedSize;
}

var nullable = string.Equals(nullableRaw, "True", StringComparison.OrdinalIgnoreCase);
return new LegacyColumnDefinition(name, typeCode, size, nullable);
if (!bool.TryParse(nullableRaw, out var nullable))
{
throw new FormatException($"Invalid Nullable value in column line: {line}");
}

return new LegacyColumnDefinition(name, typeCode, size, nullable)
{
Constraints = ParseConstraints(constraintsRaw),
};
}

private static string ReadField(string line, string label, string[] terminators)
@@ -107,4 +127,16 @@ public static class LegacySchemaBaselineParser

return line[start..end].Trim();
}

private static IReadOnlyList<string> ParseConstraints(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return [];
}

return value
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.ToArray();
}
}

+ 1
- 0
Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs 파일 보기

@@ -13,6 +13,7 @@ public enum LegacySchemaChangeType
ColumnTypeChanged,
ColumnSizeChanged,
ColumnNullabilityChanged,
ColumnConstraintsChanged,
}

/// <summary>


+ 19
- 0
Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs 파일 보기

@@ -110,6 +110,15 @@ public sealed class LegacySchemaCompatibilityCheck : ILegacySchemaCompatibilityC
tableName, name, LegacySchemaChangeType.ColumnNullabilityChanged,
$"Column '{tableName}.{name}' nullability changed: baseline={baseCol.Nullable}, live={liveCol.Nullable}."));
}

var baselineConstraints = NormalizeConstraints(baseCol.Constraints);
var liveConstraints = NormalizeConstraints(liveCol.Constraints);
if (!baselineConstraints.SequenceEqual(liveConstraints, StringComparer.OrdinalIgnoreCase))
{
sink.Add(new LegacySchemaDrift(
tableName, name, LegacySchemaChangeType.ColumnConstraintsChanged,
$"Column '{tableName}.{name}' constraints changed: baseline={FormatConstraints(baselineConstraints)}, live={FormatConstraints(liveConstraints)}."));
}
}

foreach (var (name, _) in liveByName)
@@ -124,4 +133,14 @@ public sealed class LegacySchemaCompatibilityCheck : ILegacySchemaCompatibilityC
}

private static string Format(int? value) => value?.ToString() ?? "(none)";

private static IReadOnlyList<string> NormalizeConstraints(IReadOnlyList<string> constraints) =>
constraints
.Where(c => !string.IsNullOrWhiteSpace(c))
.Select(c => c.Trim())
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();

private static string FormatConstraints(IReadOnlyList<string> constraints) =>
constraints.Count == 0 ? "(none)" : string.Join(",", constraints);
}

+ 4
- 1
Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs 파일 보기

@@ -9,7 +9,10 @@ public sealed record LegacyColumnDefinition(
string Name,
int TypeCode,
int? Size,
bool Nullable);
bool Nullable)
{
public IReadOnlyList<string> Constraints { get; init; } = [];
}

/// <summary>
/// Represents a legacy table's full structural definition: the table name plus


+ 113
- 0
Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs 파일 보기

@@ -0,0 +1,113 @@
using System.Data;
using System.Data.OleDb;
using System.Runtime.Versioning;

namespace Campaign_Tracker.Server.LegacyData.Schema;

[SupportedOSPlatform("windows")]
public sealed class OleDbLegacySchemaInspector : ILegacySchemaInspector
{
private readonly string _connectionString;

public OleDbLegacySchemaInspector(string connectionString)
{
if (string.IsNullOrWhiteSpace(connectionString))
{
throw new ArgumentException("Legacy database connection string is required.", nameof(connectionString));
}

_connectionString = connectionString;
}

public async Task<IReadOnlyList<LegacyTableDefinition>> GetCurrentSchemaAsync(
CancellationToken cancellationToken = default)
{
await using var connection = new OleDbConnection(_connectionString);
await connection.OpenAsync(cancellationToken);

var schema = connection.GetOleDbSchemaTable(OleDbSchemaGuid.Columns, null)
?? throw new InvalidOperationException("OleDb provider did not return column schema metadata.");

var tables = schema.Rows
.Cast<DataRow>()
.GroupBy(row => GetRequiredString(row, "TABLE_NAME"), StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => new LegacyTableDefinition(
group.Key,
group
.OrderBy(row => GetOrdinal(row))
.Select(row => new LegacyColumnDefinition(
GetRequiredString(row, "COLUMN_NAME"),
GetRequiredInt(row, "DATA_TYPE"),
GetNullableInt(row, "CHARACTER_MAXIMUM_LENGTH"),
GetNullableBool(row, "IS_NULLABLE") ?? true)
{
Constraints = GetColumnConstraints(row),
})
.ToArray()))
.ToArray();

return tables;
}

private static int GetOrdinal(DataRow row) =>
GetNullableInt(row, "ORDINAL_POSITION") ?? int.MaxValue;

private static IReadOnlyList<string> GetColumnConstraints(DataRow row)
{
var constraints = new List<string>();
return constraints;
}

private static string GetRequiredString(DataRow row, string columnName)
{
var value = row.Table.Columns.Contains(columnName) ? row[columnName] : null;
var text = value is null or DBNull ? null : Convert.ToString(value);
return string.IsNullOrWhiteSpace(text)
? throw new InvalidOperationException($"OleDb schema row is missing required field {columnName}.")
: text;
}

private static int GetRequiredInt(DataRow row, string columnName) =>
GetNullableInt(row, columnName)
?? throw new InvalidOperationException($"OleDb schema row is missing required field {columnName}.");

private static int? GetNullableInt(DataRow row, string columnName)
{
if (!row.Table.Columns.Contains(columnName) || row[columnName] is DBNull)
{
return null;
}

return Convert.ToInt32(row[columnName]);
}

private static bool? GetNullableBool(DataRow row, string columnName)
{
if (!row.Table.Columns.Contains(columnName) || row[columnName] is DBNull)
{
return null;
}

var value = row[columnName];
if (value is bool flag)
{
return flag;
}

var text = Convert.ToString(value);
if (string.Equals(text, "YES", StringComparison.OrdinalIgnoreCase) ||
string.Equals(text, "TRUE", StringComparison.OrdinalIgnoreCase))
{
return true;
}

if (string.Equals(text, "NO", StringComparison.OrdinalIgnoreCase) ||
string.Equals(text, "FALSE", StringComparison.OrdinalIgnoreCase))
{
return false;
}

return null;
}
}

+ 38
- 3
Campaign_Tracker.Server/Program.cs 파일 보기

@@ -4,6 +4,7 @@ using Campaign_Tracker.Server.Audit;
using Campaign_Tracker.Server.Authentication;
using Campaign_Tracker.Server.Authorization;
using Campaign_Tracker.Server.Configuration;
using Campaign_Tracker.Server.ExtensionData;
using Campaign_Tracker.Server.LegacyData;
using Campaign_Tracker.Server.LegacyData.Schema;
using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -71,21 +72,55 @@ else
// when running against a live Access database.
var schemaBaselinePath = builder.Configuration["LegacySchema:BaselineFile"]
?? Path.Combine(builder.Environment.ContentRootPath, "..", "Initial Documents", "Access_Schema.txt");
var schemaHistoryPath = builder.Configuration["LegacySchema:HistoryFile"]
?? Path.Combine(builder.Environment.ContentRootPath, "legacy-schema-history.jsonl");
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.AddSingleton(_ =>
LegacySchemaBaselineParser.ParseFile(Path.GetFullPath(schemaBaselinePath), DateTimeOffset.UtcNow));
builder.Services.AddSingleton<ILegacySchemaInspector>(sp =>
new InMemoryLegacySchemaInspector(sp.GetRequiredService<LegacySchemaBaseline>().Tables));
if (!string.IsNullOrWhiteSpace(legacyConnectionString))
{
if (!OperatingSystem.IsWindows())
{
throw new PlatformNotSupportedException(
"OleDb legacy schema inspection is supported only on Windows.");
}

builder.Services.AddSingleton<ILegacySchemaInspector>(_ =>
#pragma warning disable CA1416
new OleDbLegacySchemaInspector(legacyConnectionString));
#pragma warning restore CA1416
}
else
{
builder.Services.AddSingleton<ILegacySchemaInspector>(sp =>
new InMemoryLegacySchemaInspector(sp.GetRequiredService<LegacySchemaBaseline>().Tables));
}
builder.Services.AddSingleton<ILegacySchemaCompatibilityCheck>(sp =>
new LegacySchemaCompatibilityCheck(
sp.GetRequiredService<LegacySchemaBaseline>(),
sp.GetRequiredService<ILegacySchemaInspector>(),
sp.GetRequiredService<TimeProvider>()));
builder.Services.AddSingleton<ILegacySchemaCheckHistory, InMemoryLegacySchemaCheckHistory>();
builder.Services.AddSingleton<ILegacySchemaCheckHistory>(_ =>
new FileLegacySchemaCheckHistory(Path.GetFullPath(schemaHistoryPath)));

builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationAuditResultHandler>();

// Legacy identifier linking for extension records (Story 1.8).
// ILegacyLinkValidator resolves references through the anti-corruption layer (AC #2, AC #3).
// ILegacyLinkIntegrityCheck scans all registered extension record providers (AC #4, NFR13).
// Additional ILegacyLinkedRecordProvider registrations are added by each extension record story.
builder.Services.Configure<LegacyLinkIntegrityOptions>(
builder.Configuration.GetSection("LegacyLinkIntegrity"));
builder.Services.AddSingleton<ILegacyLinkValidator, LegacyLinkValidator>();
builder.Services.AddScoped<ILegacyLinkIntegrityCheck, LegacyLinkIntegrityService>();
builder.Services.AddSingleton<InMemoryExtensionRecordStore>();
builder.Services.AddSingleton<IExtensionRecordStore>(sp =>
sp.GetRequiredService<InMemoryExtensionRecordStore>());
builder.Services.AddSingleton<ILegacyLinkedRecordProvider>(sp =>
sp.GetRequiredService<InMemoryExtensionRecordStore>());
builder.Services.AddHostedService<LegacyLinkIntegrityHostedService>();

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


+ 61
- 0
Campaign_Tracker.Server/audit-logs/audit-2026-05-06.jsonl 파일 보기

@@ -327,3 +327,64 @@
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.1841212+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.1851939+00:00"}
{"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBH0QQ3G1Q:00000001","recordedAt":"2026-05-06T15:58:09.2199773+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/token/exchange","outcome":"success","traceIdentifier":"0HNLBISPGHVT4:00000001","recordedAt":"2026-05-06T17:45:02.1990176+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVT5:00000001","recordedAt":"2026-05-06T17:45:02.40076+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBISPGHVT5:00000001","recordedAt":"2026-05-06T17:45:02.4130954+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/history","outcome":"denied","traceIdentifier":"0HNLBISPGHVT6:00000001","recordedAt":"2026-05-06T17:45:06.3269769+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/history","outcome":"denied","traceIdentifier":"0HNLBISPGHVT7:00000001","recordedAt":"2026-05-06T17:45:06.3492109+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/admin/legacy-schema/check","outcome":"denied","traceIdentifier":"0HNLBISPGHVT8:00000001","recordedAt":"2026-05-06T17:45:07.434817+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVT9:00000001","recordedAt":"2026-05-06T17:45:17.01555+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/session","outcome":"allowed","traceIdentifier":"0HNLBISPGHVT9:00000001","recordedAt":"2026-05-06T17:45:17.0168423+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.8083727+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"/api/auth/logout","outcome":"allowed","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.809983+00:00"}
{"eventType":"SESSION_LOGOUT","actorIdentity":"37382e5e-5730-4306-a4f8-711e3de27178","resource":"authentication/logout","outcome":"success","traceIdentifier":"0HNLBISPGHVTA:00000001","recordedAt":"2026-05-06T17:45:20.8508567+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C6","recordedAt":"2026-05-06T17:48:37.5725213+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C6","recordedAt":"2026-05-06T17:48:37.5789618+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C8","recordedAt":"2026-05-06T17:48:37.5873879+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C8","recordedAt":"2026-05-06T17:48:37.5882285+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7C9","recordedAt":"2026-05-06T17:48:37.5928807+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7C9","recordedAt":"2026-05-06T17:48:37.59443+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CA","recordedAt":"2026-05-06T17:48:37.5970639+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CA","recordedAt":"2026-05-06T17:48:37.5980842+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CB","recordedAt":"2026-05-06T17:48:37.6022739+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CB","recordedAt":"2026-05-06T17:48:37.6031774+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CE","recordedAt":"2026-05-06T17:48:37.6052188+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CE","recordedAt":"2026-05-06T17:48:37.6055721+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CF","recordedAt":"2026-05-06T17:48:37.6079186+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CF","recordedAt":"2026-05-06T17:48:37.6086937+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CG","recordedAt":"2026-05-06T17:48:37.6096937+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CG","recordedAt":"2026-05-06T17:48:37.6101421+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CH","recordedAt":"2026-05-06T17:48:37.614982+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CH","recordedAt":"2026-05-06T17:48:37.6154427+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CI","recordedAt":"2026-05-06T17:48:37.6208276+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CI","recordedAt":"2026-05-06T17:48:37.6212823+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CJ","recordedAt":"2026-05-06T17:48:37.6397775+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CJ","recordedAt":"2026-05-06T17:48:37.640385+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIUPPM7CM","recordedAt":"2026-05-06T17:48:37.6460037+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIUPPM7CM","recordedAt":"2026-05-06T17:48:37.6464905+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIUPPM7CO","recordedAt":"2026-05-06T17:48:37.6509621+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671H7","recordedAt":"2026-05-06T17:49:02.3645376+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671H7","recordedAt":"2026-05-06T17:49:02.369804+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671H9","recordedAt":"2026-05-06T17:49:02.3762871+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIV1671H9","recordedAt":"2026-05-06T17:49:02.3769022+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HA","recordedAt":"2026-05-06T17:49:02.3803493+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"allowed","traceIdentifier":"0HNLBIV1671HA","recordedAt":"2026-05-06T17:49:02.3813788+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HB","recordedAt":"2026-05-06T17:49:02.3827131+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"allowed","traceIdentifier":"0HNLBIV1671HB","recordedAt":"2026-05-06T17:49:02.3833535+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HC","recordedAt":"2026-05-06T17:49:02.3870859+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HC","recordedAt":"2026-05-06T17:49:02.3876718+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HD","recordedAt":"2026-05-06T17:49:02.3901251+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/election-cycles","outcome":"allowed","traceIdentifier":"0HNLBIV1671HD","recordedAt":"2026-05-06T17:49:02.3905976+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HE","recordedAt":"2026-05-06T17:49:02.3924979+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/admin/settings","outcome":"denied","traceIdentifier":"0HNLBIV1671HE","recordedAt":"2026-05-06T17:49:02.3929208+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HH","recordedAt":"2026-05-06T17:49:02.3944284+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"daniel@example.test","resource":"/api/production/work-queue","outcome":"denied","traceIdentifier":"0HNLBIV1671HH","recordedAt":"2026-05-06T17:49:02.3952695+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"admin@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HI","recordedAt":"2026-05-06T17:49:02.401648+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"admin@example.test","resource":"/api/admin/privileged-operation","outcome":"allowed","traceIdentifier":"0HNLBIV1671HI","recordedAt":"2026-05-06T17:49:02.4022759+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"unknown@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HJ","recordedAt":"2026-05-06T17:49:02.4081559+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"unknown@example.test","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HJ","recordedAt":"2026-05-06T17:49:02.4090346+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"client@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HK","recordedAt":"2026-05-06T17:49:02.4255111+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"client@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HK","recordedAt":"2026-05-06T17:49:02.4259259+00:00"}
{"eventType":"SESSION_LOGIN","actorIdentity":"daniel@example.test","resource":"authentication","outcome":"success","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.4317157+00:00"}
{"eventType":"AUTHORIZATION_ALLOWED","actorIdentity":"daniel@example.test","resource":"/api/municipalities/profile","outcome":"allowed","traceIdentifier":"0HNLBIV1671HM","recordedAt":"2026-05-06T17:49:02.432207+00:00"}
{"eventType":"AUTHORIZATION_DENIED","actorIdentity":"anonymous","resource":"/api/municipalities/profile","outcome":"denied","traceIdentifier":"0HNLBIV1671HO","recordedAt":"2026-05-06T17:49:02.4360749+00:00"}

+ 17
- 1
_bmad-output/implementation-artifacts/1-7-legacy-schema-compatibility-validation-gate.md 파일 보기

@@ -1,6 +1,6 @@
# Story 1.7: Legacy Schema Compatibility Validation Gate

Status: review
Status: done

## Story

@@ -41,6 +41,16 @@ so that every release can be gated on legacy integrity before deployment.
- [x] Frontend: 11 new vitest specs; full vitest run green (28/28); typecheck and lint clean; production build clean
- [x] Manual smoke: `dotnet run --check-legacy-schema` in Development prints `PASS — 9 tables verified` and exits 0

### Review Findings

- [x] [Review][Patch] Schema check compares the approved baseline to itself instead of the live Access schema [Campaign_Tracker.Server/Program.cs:77]
- [x] [Review][Patch] Constraint drift is not represented or compared despite AC #1 requiring constraint comparison [Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs:8]
- [x] [Review][Patch] Admin schema panel calls protected endpoints without the authenticated bearer-token fetch path [campaign-tracker-client/src/admin/legacySchemaContracts.ts:20]
- [x] [Review][Patch] Release gate is only an optional CLI hook; no repository release pipeline invokes `--check-legacy-schema` automatically [Campaign_Tracker.Server/Program.cs:245]
- [x] [Review][Patch] Release-gate run history is recorded only in a short-lived in-memory CLI process, so gate failures are not surfaced through admin history [Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaReleaseGate.cs:28]
- [x] [Review][Patch] Baseline parser can silently accept malformed or incomplete schema input, producing a false approved baseline [Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs:52]
- [x] [Review][Defer] Runtime audit log JSONL files are tracked and contain actor identities, resources, trace IDs, and timestamps [Campaign_Tracker.Server/audit-logs/audit-2026-05-05.jsonl:1] - deferred, pre-existing

## Dev Notes

- Follow Epic 1 architecture constraints: ASP.NET Core + React separation, RBAC-aware patterns, and immutable legacy tables.
@@ -84,6 +94,8 @@ claude-sonnet-4-6
- **Auditing** — Manual runs flow through the existing shared `IAuditService` from Story 1.5; no parallel audit pipeline was introduced.
- **Tests** — 16 backend `xunit` tests cover parser format, full-file load, every drift category, pass-result invariants, release-gate exit codes (pass and fail), CLI flag detection, history ordering, controller authorization (Forbidden without Admin), happy-path admin flow, and a drift scenario where the inspector is replaced via DI to confirm the failure report flows to the API. 5 new client `vitest` specs cover summary formatting, POST/GET behavior, and error propagation. All 86 backend tests and 28 client tests pass.

- **Review fixes** - Live schema inspection now uses `OleDbLegacySchemaInspector` whenever `LegacyDatabase:ConnectionString` is configured; development/test retains the in-memory inspector. Schema check history is durable via `FileLegacySchemaCheckHistory`, baseline parsing now fails fast on malformed input, constraint drift has a dedicated change type, the admin panel uses authenticated API fetch, and `.gitea/workflows/release-gates.yml` invokes `--check-legacy-schema`.

### File List

- `Campaign_Tracker.Server/LegacyData/Schema/LegacyTableDefinition.cs`
@@ -91,12 +103,15 @@ claude-sonnet-4-6
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaBaselineParser.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCheckResult.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaInspector.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/OleDbLegacySchemaInspector.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaCompatibilityCheck.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaCompatibilityCheck.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/ILegacySchemaCheckHistory.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/FileLegacySchemaCheckHistory.cs`
- `Campaign_Tracker.Server/LegacyData/Schema/LegacySchemaReleaseGate.cs`
- `Campaign_Tracker.Server/Controllers/LegacySchemaController.cs`
- `Campaign_Tracker.Server/Program.cs`
- `.gitea/workflows/release-gates.yml`
- `Campaign_Tracker.Server.Tests/LegacySchemaCompatibilityTests.cs`
- `campaign-tracker-client/src/admin/legacySchemaContracts.ts`
- `campaign-tracker-client/src/admin/legacySchemaContracts.test.ts`
@@ -110,3 +125,4 @@ claude-sonnet-4-6
| Date | Version | Description | Author |
| --- | --- | --- | --- |
| 2026-05-06 | 1.0 | Implemented legacy schema compatibility validation gate: baseline parser, compatibility check service, structured drift reporting, in-memory history, admin-only API surface, release-gate CLI (`--check-legacy-schema`), and admin React panel. 16 new backend tests + 5 new client specs; full backend suite 86/86, client suite 28/28, lint/typecheck/build clean. Story moved to review. | claude-sonnet-4-6 |
| 2026-05-06 | 1.1 | Closed review findings: live OleDb inspector, durable schema history, stricter parser, constraint comparison, authenticated admin API calls, and release-gate workflow. Backend suite 111/111, client tests 28/28, solution build, lint, and client build clean. | Codex |

+ 60
- 19
_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md 파일 보기

@@ -1,6 +1,6 @@
# Story 1.8: Legacy Identifier Linking for Extension Records

Status: ready-for-dev
Status: done

## Story

@@ -17,18 +17,27 @@ so that all new capabilities join deterministically to legacy Access records in

## 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] No concrete extension record create/save path stores a required legacy reference for municipality profiles, election jobs, or service configs [Campaign_Tracker.Server/Program.cs:93]
- [x] [Review][Patch] Invalid legacy identifiers are not rejected before save because no production save path calls `ILegacyLinkValidator` [Campaign_Tracker.Server/Controllers/LegacyLinkController.cs:36]
- [x] [Review][Patch] Legacy-link validation treats any non-null lookup as unambiguous and does not detect duplicate or active-record ambiguity [Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs:38]
- [x] [Review][Patch] Nightly integrity checking is not scheduled; only a manual/admin POST endpoint exists [Campaign_Tracker.Server/Controllers/LegacyLinkController.cs:32]
- [x] [Review][Patch] Integrity check reports 100 percent consistency when no record providers are registered, which can mask missing provider coverage [Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs:50]
- [x] [Review][Patch] Kit and contact link factories accept zero or negative IDs as valid-looking references [Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs:16]

## Dev Notes

@@ -52,18 +61,50 @@ so that all new capabilities join deterministically to legacy Access records in

### Agent Model Used

GPT-5 Codex
claude-sonnet-4-6

### Debug Log References

- Story generated from epic source and architecture/UX planning artifacts.
- All 19 new unit tests pass; all 87 existing server tests pass; all 28 frontend tests pass.

### Completion Notes List

- Story context created and marked ready-for-dev.
- Introduced `Campaign_Tracker.Server/ExtensionData/` namespace with the full linking infrastructure.
- `LegacyLinkType` enum defines the three join-key domains: JurisdictionJCode, KitId, ContactId.
- `LegacyLinkReference` immutable record stores the type+value pair; factory methods enforce non-blank JCode.
- `LegacyLinkValidationResult` captures success/failure with a descriptive error string.
- `ILegacyLinkValidator` / `LegacyLinkValidator`: resolves each link type through the anti-corruption layer (`ILegacyDataAccess`), guaranteeing unambiguous resolution per AC #2; rejects unknown/missing identifiers per AC #3.
- `ILegacyLinkedRecord` / `ILegacyLinkedRecordProvider`: contracts that every future extension record type implements to participate in AC #1 and AC #4.
- `LegacyLinkIntegrityReport` + `LegacyLinkIntegrityFailure`: report model for AC #4.
- `ILegacyLinkIntegrityCheck` / `LegacyLinkIntegrityService`: scans all registered providers, validates each record's link, computes consistency percentage. Empty provider set → 100% consistent (no records to fail).
- `LegacyLinkController` (POST `/api/admin/legacy-link/integrity-check`, Admin-only): on-demand integrity check with audit event.
- `Program.cs`: registered `ILegacyLinkValidator` and `ILegacyLinkIntegrityCheck` as scoped services.
- 19 unit tests cover all four ACs across validator and integrity service.
- Stories 1.10–1.13 will register their own `ILegacyLinkedRecordProvider` implementations to feed the nightly check.

- Review fixes introduced a concrete admin extension-record save API backed by an in-memory extension record store/provider, validation-before-save, duplicate active-link detection, positive ID guards, provider coverage reporting, and a nightly hosted integrity scheduler.

### File List

- `_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md`


- `Campaign_Tracker.Server/ExtensionData/LegacyLinkType.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/LegacyLinkReference.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/LegacyLinkValidationResult.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/ILegacyLinkValidator.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/LegacyLinkValidator.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/ILegacyLinkedRecord.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/ILegacyLinkedRecordProvider.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityReport.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/ILegacyLinkIntegrityCheck.cs` (new)
- `Campaign_Tracker.Server/ExtensionData/LegacyLinkIntegrityService.cs` (new)
- `Campaign_Tracker.Server/Controllers/LegacyLinkController.cs` (new)
- `Campaign_Tracker.Server/Program.cs` (modified — added ExtensionData using + service registrations)
- `Campaign_Tracker.Server.Tests/LegacyLinkValidatorTests.cs` (new — 12 tests)
- `Campaign_Tracker.Server.Tests/LegacyLinkIntegrityServiceTests.cs` (new — 7 tests)
- `_bmad-output/implementation-artifacts/1-8-legacy-identifier-linking-for-extension-records.md` (this file)
- `_bmad-output/implementation-artifacts/sprint-status.yaml` (modified — status updated)

## Change Log

- 2026-05-06: Review fixes completed - concrete extension-record save path, pre-save validation, ambiguity checks, provider coverage guard, nightly scheduler, and ID domain validation added. Backend suite 111/111, client tests 28/28, solution build, lint, and client build clean. (Codex)

- 2026-05-06: Story 1.8 implemented — legacy identifier linking infrastructure introduced in new `ExtensionData` namespace. Validator, integrity check service, admin API endpoint, and 19 unit tests added. All 4 ACs satisfied. (claude-sonnet-4-6)

+ 4
- 0
_bmad-output/implementation-artifacts/deferred-work.md 파일 보기

@@ -6,3 +6,7 @@
## Deferred from: code review of 1-4-keycloak-role-mapping-application-authorization.md (2026-05-06)

- AuthorizationProbeController ships canned operational routes in the production controller surface. Evidence: Campaign_Tracker.Server/Controllers/AuthorizationProbeController.cs:8. Reason: deferred by user choice during review.

## Deferred from: code review of 1-7-legacy-schema-compatibility-validation-gate.md (2026-05-06)

- Runtime audit log JSONL files are tracked and contain actor identities, resources, trace IDs, and timestamps. Evidence: Campaign_Tracker.Server/audit-logs/audit-2026-05-05.jsonl:1. Reason: pre-existing hygiene issue from the broader prior commit, not specific to Story 1.7 or 1.8 behavior.

+ 3
- 3
_bmad-output/implementation-artifacts/sprint-status.yaml 파일 보기

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

generated: '2026-05-05T12:00:44-04:00'
last_updated: '2026-05-06T14:05:00-04:00'
last_updated: '2026-05-06T13:52:00-04:00'
project: 'Campaign_Tracker App'
project_key: 'NOKEY'
tracking_system: 'file-system'
@@ -49,8 +49,8 @@ development_status:
1-4-keycloak-role-mapping-application-authorization: done
1-5-shared-audit-logging-infrastructure: done
1-6-legacy-anti-corruption-data-access-layer: done
1-7-legacy-schema-compatibility-validation-gate: review
1-8-legacy-identifier-linking-for-extension-records: ready-for-dev
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: ready-for-dev
1-10-municipality-account-profile: ready-for-dev
1-11-municipality-operational-addresses: ready-for-dev


+ 14
- 1
campaign-tracker-client/src/App.tsx 파일 보기

@@ -4,9 +4,11 @@ import './App.css'
import {
buildKeycloakAuthorizationUrl,
getKeycloakClientConfig,
authenticatedFetch,
logout,
oidcStateStorageKey,
submitKeycloakLogout,
storeAuthTokenSet,
} from './auth/authContracts'
import { useOidcSession } from './auth/useOidcSession'
import { WorkspaceShell } from './workspace/WorkspaceShell'
@@ -32,9 +34,20 @@ function App() {
)
}, [session, config])

const adminFetch = useCallback(
async (input: RequestInfo | URL, init: RequestInit = {}) => {
if (session.status !== 'authenticated') {
throw new Error('Admin request requires an authenticated session')
}

return authenticatedFetch(input, init, config, session.tokens, storeAuthTokenSet)
},
[config, session],
)

const content =
session.status === 'authenticated' ? (
<WorkspaceShell user={session.user} onLogout={handleLogout} />
<WorkspaceShell user={session.user} onLogout={handleLogout} adminFetch={adminFetch} />
) : session.status === 'error' ? (
<Result status="warning" title={session.error} />
) : (


+ 10
- 1
campaign-tracker-client/src/workspace/WorkspaceShell.tsx 파일 보기

@@ -34,6 +34,10 @@ import {
} from './workspaceContracts'
import type { AuthenticatedUser } from '../auth/authContracts'
import { LegacySchemaCheckPanel } from '../admin/LegacySchemaCheckPanel'
import {
fetchLegacySchemaCheckHistory,
runLegacySchemaCheck,
} from '../admin/legacySchemaContracts'
import './WorkspaceShell.css'

const { Header, Sider, Content } = Layout
@@ -257,9 +261,11 @@ function RiskPanel({
export function WorkspaceShell({
user,
onLogout,
adminFetch,
}: {
user: AuthenticatedUser
onLogout: () => Promise<void>
adminFetch: typeof fetch
}) {
const width = useViewportWidth()
const editingAvailable = isEditingAvailable(width)
@@ -387,7 +393,10 @@ export function WorkspaceShell({
/>
) : null}
{selectedView === 'admin' && user.permissions.canAccessAdmin ? (
<LegacySchemaCheckPanel />
<LegacySchemaCheckPanel
loadHistory={() => fetchLegacySchemaCheckHistory(adminFetch)}
runCheck={() => runLegacySchemaCheck(adminFetch)}
/>
) : (
<section
className="workspace-board"


불러오는 중...
취소
저장

Powered by TurnKey Linux.