- wire legacy schema checks to live OleDb inspection and durable history - harden schema baseline parsing and add constraint drift detection - authenticate admin schema API calls from the client - add release-gate workflow for legacy schema validation - add extension-record save path with pre-save legacy link validation - add link ambiguity detection, provider coverage reporting, and nightly integrity scheduler - mark stories 1.7 and 1.8 done after review fixespull/17/head
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 => | |||
| { | |||
| @@ -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); | |||
| } | |||
| @@ -27,7 +27,8 @@ public sealed class LegacyLinkIntegrityServiceTests | |||
| var report = await sut.CheckAsync(); | |||
| Assert.True(report.IsConsistent); | |||
| 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); | |||
| @@ -41,18 +42,23 @@ public sealed class LegacyLinkIntegrityServiceTests | |||
| public async Task CheckAsync_AllValidLinks_Reports100PercentConsistency_AC4() | |||
| { | |||
| var data = new InMemoryLegacyDataAccess( | |||
| jurisdictions: [new("FAIR01", "Fairview", null, null, null, null)]); | |||
| 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("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); | |||
| @@ -76,6 +82,7 @@ public sealed class LegacyLinkIntegrityServiceTests | |||
| 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); | |||
| @@ -133,10 +140,33 @@ public sealed class LegacyLinkIntegrityServiceTests | |||
| 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) | |||
| @@ -161,6 +161,13 @@ public sealed class LegacyLinkValidatorTests | |||
| 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() | |||
| { | |||
| @@ -170,6 +177,13 @@ public sealed class LegacyLinkValidatorTests | |||
| 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() | |||
| { | |||
| @@ -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); | |||
| @@ -60,6 +60,7 @@ public sealed class LegacyLinkController : ControllerBase | |||
| public sealed record LegacyLinkIntegrityResponse( | |||
| bool IsConsistent, | |||
| DateTimeOffset CheckedAt, | |||
| int ProviderCount, | |||
| int TotalRecords, | |||
| int ConsistentRecords, | |||
| int FailedRecords, | |||
| @@ -69,6 +70,7 @@ public sealed record LegacyLinkIntegrityResponse( | |||
| public static LegacyLinkIntegrityResponse From(LegacyLinkIntegrityReport report) => | |||
| new(report.IsConsistent, | |||
| report.CheckedAt, | |||
| report.ProviderCount, | |||
| report.TotalRecords, | |||
| report.ConsistentRecords, | |||
| report.FailedRecords, | |||
| @@ -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); | |||
| } | |||
| @@ -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; | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -6,6 +6,7 @@ namespace Campaign_Tracker.Server.ExtensionData; | |||
| /// </summary> | |||
| public sealed record LegacyLinkIntegrityReport( | |||
| DateTimeOffset CheckedAt, | |||
| int ProviderCount, | |||
| int TotalRecords, | |||
| int ConsistentRecords, | |||
| int FailedRecords, | |||
| @@ -13,7 +14,7 @@ public sealed record LegacyLinkIntegrityReport( | |||
| IReadOnlyList<LegacyLinkIntegrityFailure> Failures) | |||
| { | |||
| /// <summary>True when no failures were detected (or no records exist to check).</summary> | |||
| public bool IsConsistent => FailedRecords == 0; | |||
| public bool IsConsistent => ProviderCount > 0 && FailedRecords == 0; | |||
| } | |||
| /// <summary>Describes one extension record that failed to resolve its legacy link.</summary> | |||
| @@ -26,9 +26,11 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck | |||
| 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) | |||
| foreach (var provider in providers) | |||
| { | |||
| var records = await provider.GetAllAsync(cancellationToken); | |||
| foreach (var record in records) | |||
| @@ -43,6 +45,22 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck | |||
| 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; | |||
| } | |||
| } | |||
| } | |||
| @@ -51,6 +69,7 @@ public sealed class LegacyLinkIntegrityService : ILegacyLinkIntegrityCheck | |||
| return new LegacyLinkIntegrityReport( | |||
| CheckedAt: _timeProvider.GetUtcNow(), | |||
| ProviderCount: providers.Length, | |||
| TotalRecords: total, | |||
| ConsistentRecords: consistent, | |||
| FailedRecords: failures.Count, | |||
| @@ -14,10 +14,14 @@ public sealed record LegacyLinkReference(LegacyLinkType Type, string Value) | |||
| } | |||
| public static LegacyLinkReference ForKit(int id) => | |||
| new(LegacyLinkType.KitId, id.ToString(System.Globalization.CultureInfo.InvariantCulture)); | |||
| 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) => | |||
| new(LegacyLinkType.ContactId, id.ToString(System.Globalization.CultureInfo.InvariantCulture)); | |||
| 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}"; | |||
| } | |||
| @@ -50,6 +50,10 @@ public sealed class LegacyLinkValidator : ILegacyLinkValidator | |||
| 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( | |||
| @@ -65,6 +69,10 @@ public sealed class LegacyLinkValidator : ILegacyLinkValidator | |||
| 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( | |||
| @@ -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(); | |||
| } | |||
| } | |||
| } | |||
| @@ -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(); | |||
| } | |||
| } | |||
| @@ -13,6 +13,7 @@ public enum LegacySchemaChangeType | |||
| ColumnTypeChanged, | |||
| ColumnSizeChanged, | |||
| ColumnNullabilityChanged, | |||
| ColumnConstraintsChanged, | |||
| } | |||
| /// <summary> | |||
| @@ -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); | |||
| } | |||
| @@ -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 | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -72,17 +72,36 @@ 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>(); | |||
| @@ -91,8 +110,16 @@ builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, Authorizati | |||
| // 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.AddScoped<ILegacyLinkValidator, LegacyLinkValidator>(); | |||
| 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 => | |||
| @@ -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"} | |||
| @@ -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 | | |||
| @@ -1,6 +1,6 @@ | |||
| # Story 1.8: Legacy Identifier Linking for Extension Records | |||
| Status: review | |||
| Status: done | |||
| ## Story | |||
| @@ -30,6 +30,15 @@ so that all new capabilities join deterministically to legacy Access records in | |||
| - [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 | |||
| - Follow Epic 1 architecture constraints: ASP.NET Core + React separation, RBAC-aware patterns, and immutable legacy tables. | |||
| @@ -73,6 +82,8 @@ claude-sonnet-4-6 | |||
| - 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 | |||
| - `Campaign_Tracker.Server/ExtensionData/LegacyLinkType.cs` (new) | |||
| @@ -94,4 +105,6 @@ claude-sonnet-4-6 | |||
| ## 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) | |||
| @@ -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. | |||
| @@ -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-06T15:00: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: review | |||
| 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 | |||
| @@ -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} /> | |||
| ) : ( | |||
| @@ -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.