Seed system reference values, readiness required-field rules, and overdue milestone escalation defaults during application startup. Add idempotent seed-key storage with source metadata so reruns avoid duplicates and preserve admin-managed values. Replace the corrupted seed test file with coverage for first seed, persistence, idempotency, FR29 readiness scope, FR30 escalation defaults, and admin override preservation.pull/16/head
| @@ -23,3 +23,4 @@ Dockerfile | |||||
| docker-compose.yml | docker-compose.yml | ||||
| Campaign_Tracker.Server/audit-logs/ | Campaign_Tracker.Server/audit-logs/ | ||||
| Campaign_Tracker.Server/legacy-schema-history.jsonl | Campaign_Tracker.Server/legacy-schema-history.jsonl | ||||
| Campaign_Tracker.Server/seed-data.json | |||||
| @@ -1 +1,151 @@ | |||||
| [<0;40;40M[<0;40;40m[<64;67;37M[<64;67;37M[<64;67;37M[<64;67;37M[<65;67;37M[<65;67;37M[<65;67;37M[<65;67;37M[<65;67;37M[<65;67;37M[<65;68;37M[<65;76;38M[<65;107;40M[<65;107;40M[<65;107;40M[<0;108;41M[<0;108;41m[<65;118;35M[<65;118;35M[<65;118;35M[<65;118;35M[<64;118;35M[<64;118;35M[<64;118;35M[<64;118;35M[<64;118;35M | |||||
| using Campaign_Tracker.Server.Seed; | |||||
| using Campaign_Tracker.Server.Seed.Models; | |||||
| namespace Campaign_Tracker.Server.Tests; | |||||
| public sealed class SeedServiceTests | |||||
| { | |||||
| private static readonly DateTimeOffset FixedNow = | |||||
| new(2026, 5, 6, 12, 0, 0, TimeSpan.Zero); | |||||
| [Fact] | |||||
| public async Task IsSeededAsync_ReportsFalseBeforeSeedAndTrueAfterSeed_AC1_AC4() | |||||
| { | |||||
| var store = new InMemorySeedDataStore(); | |||||
| var sut = BuildSut(store); | |||||
| Assert.False(await sut.IsSeededAsync()); | |||||
| await sut.SeedAsync(); | |||||
| Assert.True(await sut.IsSeededAsync()); | |||||
| } | |||||
| [Fact] | |||||
| public async Task SeedAsync_PopulatesReferenceValuesRequiredRulesAndEscalationDefaults_AC1_AC2_AC3() | |||||
| { | |||||
| var store = new InMemorySeedDataStore(); | |||||
| var sut = BuildSut(store); | |||||
| await sut.SeedAsync(); | |||||
| var referenceValues = await store.GetReferenceValuesAsync(); | |||||
| var requiredRules = await store.GetRequiredFieldRulesAsync(); | |||||
| var escalationRules = await store.GetEscalationRulesAsync(); | |||||
| Assert.Contains(referenceValues, value => | |||||
| value.Category == "OperationalStatus" && value.Value == "blocked"); | |||||
| Assert.Contains(referenceValues, value => | |||||
| value.Category == "ServiceTemplate" && value.Value == "transportation"); | |||||
| Assert.Contains(referenceValues, value => | |||||
| value.Category == "ElectionCycleType" && value.Value == "general"); | |||||
| Assert.Contains(requiredRules, rule => | |||||
| rule.EntityType == "ElectionCycleJob" && | |||||
| rule.FieldPath == "electionDate" && | |||||
| rule.ReadinessFeatureKey == "FR29.ReadinessStatus" && | |||||
| rule.IsRequired); | |||||
| Assert.Contains(escalationRules, rule => | |||||
| rule.Scenario == "OverdueMilestoneAlert" && | |||||
| rule.Action == "NotifyOperationsLead" && | |||||
| rule.TriggerCondition.Contains("dueDate", StringComparison.OrdinalIgnoreCase)); | |||||
| } | |||||
| [Fact] | |||||
| public async Task SeedAsync_IsFullyIdempotentAndCreatesNoDuplicateSeedKeys_AC4() | |||||
| { | |||||
| var store = new InMemorySeedDataStore(); | |||||
| var sut = BuildSut(store); | |||||
| await sut.SeedAsync(); | |||||
| await sut.SeedAsync(); | |||||
| var referenceValues = await store.GetReferenceValuesAsync(); | |||||
| var requiredRules = await store.GetRequiredFieldRulesAsync(); | |||||
| var escalationRules = await store.GetEscalationRulesAsync(); | |||||
| Assert.Equal(referenceValues.Count, referenceValues.Select(value => value.SeedKey).Distinct(StringComparer.OrdinalIgnoreCase).Count()); | |||||
| Assert.Equal(requiredRules.Count, requiredRules.Select(rule => rule.SeedKey).Distinct(StringComparer.OrdinalIgnoreCase).Count()); | |||||
| Assert.Equal(escalationRules.Count, escalationRules.Select(rule => rule.SeedKey).Distinct(StringComparer.OrdinalIgnoreCase).Count()); | |||||
| } | |||||
| [Fact] | |||||
| public async Task SeedAsync_DoesNotOverwriteAdminManagedValuesOnRerun_AC5() | |||||
| { | |||||
| var store = new InMemorySeedDataStore(); | |||||
| var sut = BuildSut(store); | |||||
| await sut.SeedAsync(); | |||||
| var template = (await store.GetReferenceValuesAsync()) | |||||
| .Single(value => value.SeedKey == "service-template.addressing"); | |||||
| template.Name = "Custom Addressing Template"; | |||||
| template.Value = "custom-addressing"; | |||||
| template.Source = SeedRecordSource.AdminManaged; | |||||
| template.UpdatedAt = FixedNow.AddHours(1); | |||||
| await store.SaveReferenceValueAsync(template); | |||||
| await sut.SeedAsync(); | |||||
| var persisted = (await store.GetReferenceValuesAsync()) | |||||
| .Single(value => value.SeedKey == "service-template.addressing"); | |||||
| Assert.Equal("Custom Addressing Template", persisted.Name); | |||||
| Assert.Equal("custom-addressing", persisted.Value); | |||||
| Assert.Equal(SeedRecordSource.AdminManaged, persisted.Source); | |||||
| Assert.Equal(FixedNow.AddHours(1), persisted.UpdatedAt); | |||||
| } | |||||
| [Fact] | |||||
| public async Task RequiredFieldRules_AreScopedForEpicTwoReadinessEvaluation_AC2() | |||||
| { | |||||
| var store = new InMemorySeedDataStore(); | |||||
| var sut = BuildSut(store); | |||||
| await sut.SeedAsync(); | |||||
| var rules = await store.GetRequiredFieldRulesAsync(); | |||||
| Assert.All(rules, rule => | |||||
| { | |||||
| Assert.Equal("ElectionCycleJob", rule.EntityType); | |||||
| Assert.Equal("FR29.ReadinessStatus", rule.ReadinessFeatureKey); | |||||
| Assert.False(string.IsNullOrWhiteSpace(rule.FieldPath)); | |||||
| }); | |||||
| Assert.Contains(rules, rule => rule.FieldPath == "mailDate"); | |||||
| Assert.Contains(rules, rule => rule.FieldPath == "legacyJurisdictionJCode"); | |||||
| } | |||||
| [Fact] | |||||
| public async Task FileSeedDataStore_PersistsSeededDefaultsAcrossInstances_AC1() | |||||
| { | |||||
| var path = Path.Combine(Path.GetTempPath(), $"campaign-tracker-seed-{Guid.NewGuid():N}.json"); | |||||
| try | |||||
| { | |||||
| var firstStore = new FileSeedDataStore(path); | |||||
| var firstService = BuildSut(firstStore); | |||||
| await firstService.SeedAsync(); | |||||
| var secondStore = new FileSeedDataStore(path); | |||||
| var secondService = BuildSut(secondStore); | |||||
| Assert.True(await secondService.IsSeededAsync()); | |||||
| Assert.Contains(await secondStore.GetEscalationRulesAsync(), rule => | |||||
| rule.SeedKey == "escalation.overdue-milestone.operations-lead"); | |||||
| } | |||||
| finally | |||||
| { | |||||
| if (File.Exists(path)) | |||||
| { | |||||
| File.Delete(path); | |||||
| } | |||||
| } | |||||
| } | |||||
| private static SeedService BuildSut(ISeedDataStore store) => | |||||
| new(store, new FakeTimeProvider(FixedNow)); | |||||
| private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider | |||||
| { | |||||
| public override DateTimeOffset GetUtcNow() => utcNow; | |||||
| } | |||||
| } | |||||
| @@ -7,6 +7,7 @@ using Campaign_Tracker.Server.Configuration; | |||||
| using Campaign_Tracker.Server.ExtensionData; | using Campaign_Tracker.Server.ExtensionData; | ||||
| using Campaign_Tracker.Server.LegacyData; | using Campaign_Tracker.Server.LegacyData; | ||||
| using Campaign_Tracker.Server.LegacyData.Schema; | using Campaign_Tracker.Server.LegacyData.Schema; | ||||
| using Campaign_Tracker.Server.Seed; | |||||
| using Microsoft.AspNetCore.Authentication.JwtBearer; | using Microsoft.AspNetCore.Authentication.JwtBearer; | ||||
| using Microsoft.AspNetCore.Authorization; | using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Authorization.Policy; | using Microsoft.AspNetCore.Authorization.Policy; | ||||
| @@ -103,6 +104,16 @@ builder.Services.AddSingleton<ILegacySchemaCompatibilityCheck>(sp => | |||||
| builder.Services.AddSingleton<ILegacySchemaCheckHistory>(_ => | builder.Services.AddSingleton<ILegacySchemaCheckHistory>(_ => | ||||
| new FileLegacySchemaCheckHistory(Path.GetFullPath(schemaHistoryPath))); | new FileLegacySchemaCheckHistory(Path.GetFullPath(schemaHistoryPath))); | ||||
| // System reference data and rule defaults (Story 1.9). | |||||
| // Seed keys are stable idempotency boundaries; reruns insert missing defaults | |||||
| // without overwriting admin-managed changes to existing values. | |||||
| var seedDataPath = builder.Configuration["Seed:DataFile"] | |||||
| ?? Path.Combine(builder.Environment.ContentRootPath, "seed-data.json"); | |||||
| builder.Services.AddSingleton<ISeedDataStore>(_ => | |||||
| new FileSeedDataStore(Path.GetFullPath(seedDataPath))); | |||||
| builder.Services.AddSingleton<ISeedService, SeedService>(); | |||||
| builder.Services.AddHostedService<SeedHostedService>(); | |||||
| builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>(); | builder.Services.AddHttpClient<IKeycloakTokenClient, KeycloakTokenClient>(); | ||||
| builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationAuditResultHandler>(); | builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationAuditResultHandler>(); | ||||
| @@ -0,0 +1,178 @@ | |||||
| using System.Collections.Concurrent; | |||||
| using System.Text.Json; | |||||
| using System.Text.Json.Serialization; | |||||
| using Campaign_Tracker.Server.Seed.Models; | |||||
| namespace Campaign_Tracker.Server.Seed; | |||||
| public sealed class FileSeedDataStore : ISeedDataStore | |||||
| { | |||||
| private static readonly ConcurrentDictionary<string, SemaphoreSlim> FileLocks = new( | |||||
| StringComparer.OrdinalIgnoreCase); | |||||
| private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | |||||
| { | |||||
| WriteIndented = true, | |||||
| Converters = { new JsonStringEnumConverter() }, | |||||
| }; | |||||
| private readonly string _path; | |||||
| private readonly SemaphoreSlim _sync; | |||||
| public FileSeedDataStore(string path) | |||||
| { | |||||
| ArgumentException.ThrowIfNullOrWhiteSpace(path); | |||||
| _path = Path.GetFullPath(path); | |||||
| _sync = FileLocks.GetOrAdd(_path, _ => new SemaphoreSlim(1, 1)); | |||||
| } | |||||
| public async Task UpsertSeedDataAsync(SeedDataSet seedData, CancellationToken cancellationToken = default) | |||||
| { | |||||
| await _sync.WaitAsync(cancellationToken); | |||||
| try | |||||
| { | |||||
| var snapshot = await LoadAsync(cancellationToken); | |||||
| InMemorySeedDataStore.UpsertMissing( | |||||
| snapshot.ReferenceValues, seedData.ReferenceValues, InMemorySeedDataStore.Clone); | |||||
| InMemorySeedDataStore.UpsertMissing( | |||||
| snapshot.RequiredFieldRules, seedData.RequiredFieldRules, InMemorySeedDataStore.Clone); | |||||
| InMemorySeedDataStore.UpsertMissing( | |||||
| snapshot.EscalationRules, seedData.EscalationRules, InMemorySeedDataStore.Clone); | |||||
| await SaveAsync(snapshot, cancellationToken); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _sync.Release(); | |||||
| } | |||||
| } | |||||
| public async Task<IReadOnlyList<ReferenceValue>> GetReferenceValuesAsync(CancellationToken cancellationToken = default) | |||||
| { | |||||
| await _sync.WaitAsync(cancellationToken); | |||||
| try | |||||
| { | |||||
| var snapshot = await LoadAsync(cancellationToken); | |||||
| return snapshot.ReferenceValues.Select(InMemorySeedDataStore.Clone).ToArray(); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _sync.Release(); | |||||
| } | |||||
| } | |||||
| public async Task<IReadOnlyList<RequiredFieldRule>> GetRequiredFieldRulesAsync(CancellationToken cancellationToken = default) | |||||
| { | |||||
| await _sync.WaitAsync(cancellationToken); | |||||
| try | |||||
| { | |||||
| var snapshot = await LoadAsync(cancellationToken); | |||||
| return snapshot.RequiredFieldRules.Select(InMemorySeedDataStore.Clone).ToArray(); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _sync.Release(); | |||||
| } | |||||
| } | |||||
| public async Task<IReadOnlyList<EscalationRule>> GetEscalationRulesAsync(CancellationToken cancellationToken = default) | |||||
| { | |||||
| await _sync.WaitAsync(cancellationToken); | |||||
| try | |||||
| { | |||||
| var snapshot = await LoadAsync(cancellationToken); | |||||
| return snapshot.EscalationRules.Select(InMemorySeedDataStore.Clone).ToArray(); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _sync.Release(); | |||||
| } | |||||
| } | |||||
| public async Task SaveReferenceValueAsync( | |||||
| ReferenceValue referenceValue, | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| await _sync.WaitAsync(cancellationToken); | |||||
| try | |||||
| { | |||||
| var snapshot = await LoadAsync(cancellationToken); | |||||
| InMemorySeedDataStore.ReplaceBySeedKey( | |||||
| snapshot.ReferenceValues, referenceValue, InMemorySeedDataStore.Clone); | |||||
| await SaveAsync(snapshot, cancellationToken); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _sync.Release(); | |||||
| } | |||||
| } | |||||
| public async Task SaveRequiredFieldRuleAsync( | |||||
| RequiredFieldRule rule, | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| await _sync.WaitAsync(cancellationToken); | |||||
| try | |||||
| { | |||||
| var snapshot = await LoadAsync(cancellationToken); | |||||
| InMemorySeedDataStore.ReplaceBySeedKey( | |||||
| snapshot.RequiredFieldRules, rule, InMemorySeedDataStore.Clone); | |||||
| await SaveAsync(snapshot, cancellationToken); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _sync.Release(); | |||||
| } | |||||
| } | |||||
| public async Task SaveEscalationRuleAsync( | |||||
| EscalationRule rule, | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| await _sync.WaitAsync(cancellationToken); | |||||
| try | |||||
| { | |||||
| var snapshot = await LoadAsync(cancellationToken); | |||||
| InMemorySeedDataStore.ReplaceBySeedKey( | |||||
| snapshot.EscalationRules, rule, InMemorySeedDataStore.Clone); | |||||
| await SaveAsync(snapshot, cancellationToken); | |||||
| } | |||||
| finally | |||||
| { | |||||
| _sync.Release(); | |||||
| } | |||||
| } | |||||
| private async Task<InMemorySeedDataStore.SeedDataSnapshot> LoadAsync(CancellationToken cancellationToken) | |||||
| { | |||||
| if (!File.Exists(_path)) | |||||
| { | |||||
| return new InMemorySeedDataStore.SeedDataSnapshot(); | |||||
| } | |||||
| await using var stream = File.OpenRead(_path); | |||||
| var snapshot = await JsonSerializer.DeserializeAsync<InMemorySeedDataStore.SeedDataSnapshot>( | |||||
| stream, | |||||
| SerializerOptions, | |||||
| cancellationToken); | |||||
| return snapshot ?? new InMemorySeedDataStore.SeedDataSnapshot(); | |||||
| } | |||||
| private async Task SaveAsync( | |||||
| InMemorySeedDataStore.SeedDataSnapshot snapshot, | |||||
| CancellationToken cancellationToken) | |||||
| { | |||||
| var directory = Path.GetDirectoryName(_path); | |||||
| if (!string.IsNullOrWhiteSpace(directory)) | |||||
| { | |||||
| Directory.CreateDirectory(directory); | |||||
| } | |||||
| var tempPath = $"{_path}.{Guid.NewGuid():N}.tmp"; | |||||
| await using (var stream = File.Create(tempPath)) | |||||
| { | |||||
| await JsonSerializer.SerializeAsync(stream, snapshot, SerializerOptions, cancellationToken); | |||||
| } | |||||
| File.Move(tempPath, _path, overwrite: true); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,14 @@ | |||||
| using Campaign_Tracker.Server.Seed.Models; | |||||
| namespace Campaign_Tracker.Server.Seed; | |||||
| public interface ISeedDataStore | |||||
| { | |||||
| Task UpsertSeedDataAsync(SeedDataSet seedData, CancellationToken cancellationToken = default); | |||||
| Task<IReadOnlyList<ReferenceValue>> GetReferenceValuesAsync(CancellationToken cancellationToken = default); | |||||
| Task<IReadOnlyList<RequiredFieldRule>> GetRequiredFieldRulesAsync(CancellationToken cancellationToken = default); | |||||
| Task<IReadOnlyList<EscalationRule>> GetEscalationRulesAsync(CancellationToken cancellationToken = default); | |||||
| Task SaveReferenceValueAsync(ReferenceValue referenceValue, CancellationToken cancellationToken = default); | |||||
| Task SaveRequiredFieldRuleAsync(RequiredFieldRule rule, CancellationToken cancellationToken = default); | |||||
| Task SaveEscalationRuleAsync(EscalationRule rule, CancellationToken cancellationToken = default); | |||||
| } | |||||
| @@ -4,6 +4,6 @@ namespace Campaign_Tracker.Server.Seed; | |||||
| public interface ISeedService | public interface ISeedService | ||||
| { | { | ||||
| Task SeedAsync(); | |||||
| Task<bool> IsSeededAsync(); | |||||
| Task SeedAsync(CancellationToken cancellationToken = default); | |||||
| Task<bool> IsSeededAsync(CancellationToken cancellationToken = default); | |||||
| } | } | ||||
| @@ -0,0 +1,195 @@ | |||||
| using Campaign_Tracker.Server.Seed.Models; | |||||
| namespace Campaign_Tracker.Server.Seed; | |||||
| public sealed class InMemorySeedDataStore : ISeedDataStore | |||||
| { | |||||
| private readonly object _sync = new(); | |||||
| private readonly SeedDataSnapshot _snapshot = new(); | |||||
| public Task UpsertSeedDataAsync(SeedDataSet seedData, CancellationToken cancellationToken = default) | |||||
| { | |||||
| cancellationToken.ThrowIfCancellationRequested(); | |||||
| lock (_sync) | |||||
| { | |||||
| UpsertMissing(_snapshot.ReferenceValues, seedData.ReferenceValues, Clone); | |||||
| UpsertMissing(_snapshot.RequiredFieldRules, seedData.RequiredFieldRules, Clone); | |||||
| UpsertMissing(_snapshot.EscalationRules, seedData.EscalationRules, Clone); | |||||
| } | |||||
| return Task.CompletedTask; | |||||
| } | |||||
| public Task<IReadOnlyList<ReferenceValue>> GetReferenceValuesAsync(CancellationToken cancellationToken = default) | |||||
| { | |||||
| cancellationToken.ThrowIfCancellationRequested(); | |||||
| lock (_sync) | |||||
| { | |||||
| return Task.FromResult<IReadOnlyList<ReferenceValue>>( | |||||
| _snapshot.ReferenceValues.Select(Clone).ToArray()); | |||||
| } | |||||
| } | |||||
| public Task<IReadOnlyList<RequiredFieldRule>> GetRequiredFieldRulesAsync(CancellationToken cancellationToken = default) | |||||
| { | |||||
| cancellationToken.ThrowIfCancellationRequested(); | |||||
| lock (_sync) | |||||
| { | |||||
| return Task.FromResult<IReadOnlyList<RequiredFieldRule>>( | |||||
| _snapshot.RequiredFieldRules.Select(Clone).ToArray()); | |||||
| } | |||||
| } | |||||
| public Task<IReadOnlyList<EscalationRule>> GetEscalationRulesAsync(CancellationToken cancellationToken = default) | |||||
| { | |||||
| cancellationToken.ThrowIfCancellationRequested(); | |||||
| lock (_sync) | |||||
| { | |||||
| return Task.FromResult<IReadOnlyList<EscalationRule>>( | |||||
| _snapshot.EscalationRules.Select(Clone).ToArray()); | |||||
| } | |||||
| } | |||||
| public Task SaveReferenceValueAsync(ReferenceValue referenceValue, CancellationToken cancellationToken = default) | |||||
| { | |||||
| cancellationToken.ThrowIfCancellationRequested(); | |||||
| lock (_sync) | |||||
| { | |||||
| ReplaceBySeedKey(_snapshot.ReferenceValues, referenceValue, Clone); | |||||
| } | |||||
| return Task.CompletedTask; | |||||
| } | |||||
| public Task SaveRequiredFieldRuleAsync(RequiredFieldRule rule, CancellationToken cancellationToken = default) | |||||
| { | |||||
| cancellationToken.ThrowIfCancellationRequested(); | |||||
| lock (_sync) | |||||
| { | |||||
| ReplaceBySeedKey(_snapshot.RequiredFieldRules, rule, Clone); | |||||
| } | |||||
| return Task.CompletedTask; | |||||
| } | |||||
| public Task SaveEscalationRuleAsync(EscalationRule rule, CancellationToken cancellationToken = default) | |||||
| { | |||||
| cancellationToken.ThrowIfCancellationRequested(); | |||||
| lock (_sync) | |||||
| { | |||||
| ReplaceBySeedKey(_snapshot.EscalationRules, rule, Clone); | |||||
| } | |||||
| return Task.CompletedTask; | |||||
| } | |||||
| internal static void UpsertMissing<T>( | |||||
| List<T> target, | |||||
| IEnumerable<T> defaults, | |||||
| Func<T, T> clone) | |||||
| where T : class | |||||
| { | |||||
| foreach (var item in defaults) | |||||
| { | |||||
| var key = GetSeedKey(item); | |||||
| if (target.Any(existing => SameSeedKey(GetSeedKey(existing), key))) | |||||
| { | |||||
| continue; | |||||
| } | |||||
| target.Add(clone(item)); | |||||
| } | |||||
| } | |||||
| internal static void ReplaceBySeedKey<T>( | |||||
| List<T> target, | |||||
| T item, | |||||
| Func<T, T> clone) | |||||
| where T : class | |||||
| { | |||||
| var key = GetSeedKey(item); | |||||
| var existingIndex = target.FindIndex(existing => SameSeedKey(GetSeedKey(existing), key)); | |||||
| if (existingIndex >= 0) | |||||
| { | |||||
| target[existingIndex] = clone(item); | |||||
| } | |||||
| else | |||||
| { | |||||
| target.Add(clone(item)); | |||||
| } | |||||
| } | |||||
| internal static ReferenceValue Clone(ReferenceValue value) => new() | |||||
| { | |||||
| Id = value.Id, | |||||
| SeedKey = value.SeedKey, | |||||
| Category = value.Category, | |||||
| Name = value.Name, | |||||
| Description = value.Description, | |||||
| Value = value.Value, | |||||
| Source = value.Source, | |||||
| IsActive = value.IsActive, | |||||
| CreatedAt = value.CreatedAt, | |||||
| UpdatedAt = value.UpdatedAt, | |||||
| }; | |||||
| internal static RequiredFieldRule Clone(RequiredFieldRule rule) => new() | |||||
| { | |||||
| Id = rule.Id, | |||||
| SeedKey = rule.SeedKey, | |||||
| Name = rule.Name, | |||||
| Description = rule.Description, | |||||
| EntityType = rule.EntityType, | |||||
| FieldPath = rule.FieldPath, | |||||
| ReadinessFeatureKey = rule.ReadinessFeatureKey, | |||||
| IsRequired = rule.IsRequired, | |||||
| Source = rule.Source, | |||||
| IsActive = rule.IsActive, | |||||
| CreatedAt = rule.CreatedAt, | |||||
| UpdatedAt = rule.UpdatedAt, | |||||
| }; | |||||
| internal static EscalationRule Clone(EscalationRule rule) => new() | |||||
| { | |||||
| Id = rule.Id, | |||||
| SeedKey = rule.SeedKey, | |||||
| Name = rule.Name, | |||||
| Description = rule.Description, | |||||
| Scenario = rule.Scenario, | |||||
| TriggerCondition = rule.TriggerCondition, | |||||
| Action = rule.Action, | |||||
| MilestoneBasis = rule.MilestoneBasis, | |||||
| AlertWindow = rule.AlertWindow, | |||||
| Priority = rule.Priority, | |||||
| Source = rule.Source, | |||||
| IsActive = rule.IsActive, | |||||
| CreatedAt = rule.CreatedAt, | |||||
| UpdatedAt = rule.UpdatedAt, | |||||
| }; | |||||
| private static string GetSeedKey<T>(T item) where T : class => | |||||
| item switch | |||||
| { | |||||
| ReferenceValue value => value.SeedKey, | |||||
| RequiredFieldRule rule => rule.SeedKey, | |||||
| EscalationRule rule => rule.SeedKey, | |||||
| _ => throw new InvalidOperationException($"Unsupported seed item type {typeof(T).Name}."), | |||||
| }; | |||||
| private static bool SameSeedKey(string left, string right) => | |||||
| string.Equals(left, right, StringComparison.OrdinalIgnoreCase); | |||||
| internal sealed class SeedDataSnapshot | |||||
| { | |||||
| public List<ReferenceValue> ReferenceValues { get; init; } = []; | |||||
| public List<RequiredFieldRule> RequiredFieldRules { get; init; } = []; | |||||
| public List<EscalationRule> EscalationRules { get; init; } = []; | |||||
| } | |||||
| } | |||||
| @@ -5,27 +5,44 @@ namespace Campaign_Tracker.Server.Seed.Models; | |||||
| public class EscalationRule | public class EscalationRule | ||||
| { | { | ||||
| public int Id { get; set; } | public int Id { get; set; } | ||||
| [Required] | |||||
| [StringLength(160)] | |||||
| public string SeedKey { get; set; } = string.Empty; | |||||
| [Required] | [Required] | ||||
| [StringLength(100)] | [StringLength(100)] | ||||
| public string Name { get; set; } = string.Empty; | public string Name { get; set; } = string.Empty; | ||||
| [StringLength(500)] | [StringLength(500)] | ||||
| public string Description { get; set; } = string.Empty; | public string Description { get; set; } = string.Empty; | ||||
| [Required] | |||||
| [StringLength(100)] | |||||
| public string Scenario { get; set; } = string.Empty; | |||||
| [Required] | [Required] | ||||
| [StringLength(200)] | [StringLength(200)] | ||||
| public string TriggerCondition { get; set; } = string.Empty; | public string TriggerCondition { get; set; } = string.Empty; | ||||
| [Required] | [Required] | ||||
| [StringLength(200)] | [StringLength(200)] | ||||
| public string Action { get; set; } = string.Empty; | public string Action { get; set; } = string.Empty; | ||||
| [Required] | |||||
| [StringLength(100)] | |||||
| public string MilestoneBasis { get; set; } = string.Empty; | |||||
| public TimeSpan AlertWindow { get; set; } | |||||
| [Range(1, int.MaxValue)] | |||||
| public int Priority { get; set; } = 1; | public int Priority { get; set; } = 1; | ||||
| public SeedRecordSource Source { get; set; } = SeedRecordSource.SystemSeed; | |||||
| public bool IsActive { get; set; } = true; | public bool IsActive { get; set; } = true; | ||||
| public DateTime CreatedAt { get; set; } = DateTime.UtcNow; | |||||
| public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; | |||||
| public DateTimeOffset CreatedAt { get; set; } | |||||
| public DateTimeOffset UpdatedAt { get; set; } | |||||
| } | } | ||||
| @@ -5,19 +5,31 @@ namespace Campaign_Tracker.Server.Seed.Models; | |||||
| public class ReferenceValue | public class ReferenceValue | ||||
| { | { | ||||
| public int Id { get; set; } | public int Id { get; set; } | ||||
| [Required] | |||||
| [StringLength(160)] | |||||
| public string SeedKey { get; set; } = string.Empty; | |||||
| [Required] | |||||
| [StringLength(100)] | |||||
| public string Category { get; set; } = string.Empty; | |||||
| [Required] | [Required] | ||||
| [StringLength(100)] | [StringLength(100)] | ||||
| public string Name { get; set; } = string.Empty; | public string Name { get; set; } = string.Empty; | ||||
| [StringLength(500)] | [StringLength(500)] | ||||
| public string Description { get; set; } = string.Empty; | public string Description { get; set; } = string.Empty; | ||||
| [Required] | |||||
| [StringLength(200)] | |||||
| public string Value { get; set; } = string.Empty; | public string Value { get; set; } = string.Empty; | ||||
| public SeedRecordSource Source { get; set; } = SeedRecordSource.SystemSeed; | |||||
| public bool IsActive { get; set; } = true; | public bool IsActive { get; set; } = true; | ||||
| public DateTime CreatedAt { get; set; } = DateTime.UtcNow; | |||||
| public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; | |||||
| public DateTimeOffset CreatedAt { get; set; } | |||||
| public DateTimeOffset UpdatedAt { get; set; } | |||||
| } | } | ||||
| @@ -5,23 +5,37 @@ namespace Campaign_Tracker.Server.Seed.Models; | |||||
| public class RequiredFieldRule | public class RequiredFieldRule | ||||
| { | { | ||||
| public int Id { get; set; } | public int Id { get; set; } | ||||
| [Required] | |||||
| [StringLength(160)] | |||||
| public string SeedKey { get; set; } = string.Empty; | |||||
| [Required] | [Required] | ||||
| [StringLength(100)] | [StringLength(100)] | ||||
| public string Name { get; set; } = string.Empty; | public string Name { get; set; } = string.Empty; | ||||
| [StringLength(500)] | [StringLength(500)] | ||||
| public string Description { get; set; } = string.Empty; | public string Description { get; set; } = string.Empty; | ||||
| [Required] | |||||
| [StringLength(100)] | |||||
| public string EntityType { get; set; } = string.Empty; | |||||
| [Required] | [Required] | ||||
| [StringLength(200)] | [StringLength(200)] | ||||
| public string FieldPath { get; set; } = string.Empty; | public string FieldPath { get; set; } = string.Empty; | ||||
| [Required] | |||||
| [StringLength(120)] | |||||
| public string ReadinessFeatureKey { get; set; } = string.Empty; | |||||
| public bool IsRequired { get; set; } = true; | public bool IsRequired { get; set; } = true; | ||||
| public SeedRecordSource Source { get; set; } = SeedRecordSource.SystemSeed; | |||||
| public bool IsActive { get; set; } = true; | public bool IsActive { get; set; } = true; | ||||
| public DateTime CreatedAt { get; set; } = DateTime.UtcNow; | |||||
| public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; | |||||
| public DateTimeOffset CreatedAt { get; set; } | |||||
| public DateTimeOffset UpdatedAt { get; set; } | |||||
| } | } | ||||
| @@ -0,0 +1,7 @@ | |||||
| namespace Campaign_Tracker.Server.Seed.Models; | |||||
| public enum SeedRecordSource | |||||
| { | |||||
| SystemSeed = 0, | |||||
| AdminManaged = 1, | |||||
| } | |||||
| @@ -2,28 +2,28 @@ | |||||
| This directory contains the seed service implementation for Story 1.9: "Seed System Reference Values & Rule Defaults". | This directory contains the seed service implementation for Story 1.9: "Seed System Reference Values & Rule Defaults". | ||||
| ## Implementation Plan | |||||
| ## Implementation | |||||
| The seed service will: | |||||
| The seed service: | |||||
| 1. **Seed Reference Values** | |||||
| 1. **Seeds Reference Values** | |||||
| - Operational status values | - Operational status values | ||||
| - Service template defaults | - Service template defaults | ||||
| - Extension-layer reference values | - Extension-layer reference values | ||||
| 2. **Configure Required-Field Rules** | |||||
| 2. **Configures Required-Field Rules** | |||||
| - Default readiness fields for election-cycle jobs | - Default readiness fields for election-cycle jobs | ||||
| - Evaluation capability for Epic 2's readiness status feature | |||||
| - Evaluator-facing entity scope and FR29 readiness feature keys | |||||
| 3. **Set Up Escalation Rule Defaults** | |||||
| 3. **Sets Up Escalation Rule Defaults** | |||||
| - Default rules covering overdue milestone alert scenarios | - Default rules covering overdue milestone alert scenarios | ||||
| - FR30 compliance | - FR30 compliance | ||||
| 4. **Ensure Idempotency** | |||||
| - Seed operation is fully idempotent | |||||
| - No duplicate records on multiple runs | |||||
| 4. **Ensures Idempotency** | |||||
| - Seed keys are stable idempotency boundaries | |||||
| - Existing records are not overwritten on rerun | |||||
| 5. **Maintain Separation** | |||||
| 5. **Maintains Separation** | |||||
| - Admin-managed values persist independently | - Admin-managed values persist independently | ||||
| - Seed does not overwrite admin changes | - Seed does not overwrite admin changes | ||||
| @@ -31,12 +31,14 @@ The seed service will: | |||||
| - `ISeedService.cs` - Interface defining seed service contract | - `ISeedService.cs` - Interface defining seed service contract | ||||
| - `SeedService.cs` - Implementation of seed service | - `SeedService.cs` - Implementation of seed service | ||||
| - `ISeedDataStore.cs` - Store abstraction for reference values and rule defaults | |||||
| - `FileSeedDataStore.cs` - Durable JSON-backed store used by the application | |||||
| - `InMemorySeedDataStore.cs` - Test-friendly in-memory store with the same idempotency behavior | |||||
| - `SeedHostedService.cs` - Runs seeding during application startup | |||||
| ## Implementation Notes | ## Implementation Notes | ||||
| In a complete implementation, this service would: | |||||
| - Interact with a database context | |||||
| - Check if seeding has already occurred | |||||
| - Create seed data with proper constraints | |||||
| - Handle idempotent operations | |||||
| - Be registered in the DI container | |||||
| The current application does not yet have a relational extension database context, | |||||
| so Story 1.9 persists seed data through the same application-layer pattern used by | |||||
| earlier Epic 1 infrastructure: a DI-registered durable store. When a database | |||||
| context is introduced, `ISeedDataStore` is the replacement boundary. | |||||
| @@ -0,0 +1,10 @@ | |||||
| using Campaign_Tracker.Server.Seed.Models; | |||||
| namespace Campaign_Tracker.Server.Seed; | |||||
| public sealed class SeedDataSet | |||||
| { | |||||
| public IReadOnlyList<ReferenceValue> ReferenceValues { get; init; } = []; | |||||
| public IReadOnlyList<RequiredFieldRule> RequiredFieldRules { get; init; } = []; | |||||
| public IReadOnlyList<EscalationRule> EscalationRules { get; init; } = []; | |||||
| } | |||||
| @@ -0,0 +1,25 @@ | |||||
| namespace Campaign_Tracker.Server.Seed; | |||||
| public sealed class SeedHostedService : IHostedService | |||||
| { | |||||
| private readonly IServiceScopeFactory _scopeFactory; | |||||
| private readonly ILogger<SeedHostedService> _logger; | |||||
| public SeedHostedService( | |||||
| IServiceScopeFactory scopeFactory, | |||||
| ILogger<SeedHostedService> logger) | |||||
| { | |||||
| _scopeFactory = scopeFactory; | |||||
| _logger = logger; | |||||
| } | |||||
| public async Task StartAsync(CancellationToken cancellationToken) | |||||
| { | |||||
| using var scope = _scopeFactory.CreateScope(); | |||||
| var seedService = scope.ServiceProvider.GetRequiredService<ISeedService>(); | |||||
| await seedService.SeedAsync(cancellationToken); | |||||
| _logger.LogInformation("System reference values and rule defaults are seeded."); | |||||
| } | |||||
| public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; | |||||
| } | |||||
| @@ -1,26 +1,176 @@ | |||||
| using System.Threading.Tasks; | |||||
| using Campaign_Tracker.Server.Seed.Models; | |||||
| namespace Campaign_Tracker.Server.Seed; | namespace Campaign_Tracker.Server.Seed; | ||||
| public class SeedService : ISeedService | |||||
| public sealed class SeedService : ISeedService | |||||
| { | { | ||||
| public Task SeedAsync() | |||||
| internal const string ElectionCycleJobEntityType = "ElectionCycleJob"; | |||||
| internal const string ReadinessFeatureKey = "FR29.ReadinessStatus"; | |||||
| internal const string OverdueMilestoneScenario = "OverdueMilestoneAlert"; | |||||
| private static readonly string[] ExpectedReferenceSeedKeys = | |||||
| [ | |||||
| "operational-status.not-started", | |||||
| "operational-status.in-progress", | |||||
| "operational-status.blocked", | |||||
| "operational-status.complete", | |||||
| "service-template.addressing", | |||||
| "service-template.sorting", | |||||
| "service-template.transportation", | |||||
| "service-template.office-copy", | |||||
| "extension-reference.election-cycle.primary", | |||||
| "extension-reference.election-cycle.general", | |||||
| "extension-reference.mail-class.first-class", | |||||
| "extension-reference.mail-class.standard", | |||||
| ]; | |||||
| private static readonly string[] ExpectedRequiredFieldSeedKeys = | |||||
| [ | |||||
| "required-field.election-cycle-job.municipality-profile-id", | |||||
| "required-field.election-cycle-job.legacy-jurisdiction-j-code", | |||||
| "required-field.election-cycle-job.election-date", | |||||
| "required-field.election-cycle-job.mail-date", | |||||
| "required-field.election-cycle-job.service-template", | |||||
| ]; | |||||
| private static readonly string[] ExpectedEscalationSeedKeys = | |||||
| [ | |||||
| "escalation.overdue-milestone.operations-lead", | |||||
| ]; | |||||
| private readonly ISeedDataStore _store; | |||||
| private readonly TimeProvider _timeProvider; | |||||
| public SeedService(ISeedDataStore store, TimeProvider timeProvider) | |||||
| { | |||||
| _store = store; | |||||
| _timeProvider = timeProvider; | |||||
| } | |||||
| public Task SeedAsync(CancellationToken cancellationToken = default) => | |||||
| _store.UpsertSeedDataAsync(CreateDefaults(_timeProvider.GetUtcNow()), cancellationToken); | |||||
| public async Task<bool> IsSeededAsync(CancellationToken cancellationToken = default) | |||||
| { | { | ||||
| // This is a placeholder implementation for Story 1.9 | |||||
| // In a real implementation, this would: | |||||
| // 1. Check if seeding is needed | |||||
| // 2. Create default reference values in the database | |||||
| // 3. Set up required-field rules for election-cycle jobs | |||||
| // 4. Configure escalation rule defaults for overdue milestone alerts | |||||
| // 5. Ensure idempotency - no duplicates on multiple runs | |||||
| return Task.CompletedTask; | |||||
| var referenceValues = await _store.GetReferenceValuesAsync(cancellationToken); | |||||
| var requiredRules = await _store.GetRequiredFieldRulesAsync(cancellationToken); | |||||
| var escalationRules = await _store.GetEscalationRulesAsync(cancellationToken); | |||||
| return ContainsAll(referenceValues.Select(value => value.SeedKey), ExpectedReferenceSeedKeys) | |||||
| && ContainsAll(requiredRules.Select(rule => rule.SeedKey), ExpectedRequiredFieldSeedKeys) | |||||
| && ContainsAll(escalationRules.Select(rule => rule.SeedKey), ExpectedEscalationSeedKeys); | |||||
| } | } | ||||
| public Task<bool> IsSeededAsync() | |||||
| private static SeedDataSet CreateDefaults(DateTimeOffset now) => new() | |||||
| { | |||||
| ReferenceValues = | |||||
| [ | |||||
| Reference("operational-status.not-started", "OperationalStatus", "Not Started", "not-started", | |||||
| "Election-cycle job work has not started.", now), | |||||
| Reference("operational-status.in-progress", "OperationalStatus", "In Progress", "in-progress", | |||||
| "Election-cycle job work is actively in progress.", now), | |||||
| Reference("operational-status.blocked", "OperationalStatus", "Blocked", "blocked", | |||||
| "Election-cycle job work is blocked and needs intervention.", now), | |||||
| Reference("operational-status.complete", "OperationalStatus", "Complete", "complete", | |||||
| "Election-cycle job work is complete.", now), | |||||
| Reference("service-template.addressing", "ServiceTemplate", "Addressing", "addressing", | |||||
| "Default service template for addressing work.", now), | |||||
| Reference("service-template.sorting", "ServiceTemplate", "Sorting", "sorting", | |||||
| "Default service template for sorting work.", now), | |||||
| Reference("service-template.transportation", "ServiceTemplate", "Transportation", "transportation", | |||||
| "Default service template for transportation work.", now), | |||||
| Reference("service-template.office-copy", "ServiceTemplate", "Office Copy", "office-copy", | |||||
| "Default service template for office-copy work.", now), | |||||
| Reference("extension-reference.election-cycle.primary", "ElectionCycleType", "Primary", "primary", | |||||
| "Extension-layer election-cycle reference value for primary elections.", now), | |||||
| Reference("extension-reference.election-cycle.general", "ElectionCycleType", "General", "general", | |||||
| "Extension-layer election-cycle reference value for general elections.", now), | |||||
| Reference("extension-reference.mail-class.first-class", "MailClass", "First Class", "first-class", | |||||
| "Extension-layer mail-class reference value.", now), | |||||
| Reference("extension-reference.mail-class.standard", "MailClass", "Standard", "standard", | |||||
| "Extension-layer mail-class reference value.", now), | |||||
| ], | |||||
| RequiredFieldRules = | |||||
| [ | |||||
| RequiredField("required-field.election-cycle-job.municipality-profile-id", | |||||
| "Municipality Profile", "municipalityProfileId", | |||||
| "Election-cycle jobs must be linked to a municipality profile.", now), | |||||
| RequiredField("required-field.election-cycle-job.legacy-jurisdiction-j-code", | |||||
| "Legacy Jurisdiction Code", "legacyJurisdictionJCode", | |||||
| "Election-cycle jobs must keep the legacy jurisdiction bridge required by Story 1.8.", now), | |||||
| RequiredField("required-field.election-cycle-job.election-date", | |||||
| "Election Date", "electionDate", | |||||
| "Election-cycle jobs need an election date before readiness can pass.", now), | |||||
| RequiredField("required-field.election-cycle-job.mail-date", | |||||
| "Mail Date", "mailDate", | |||||
| "Election-cycle jobs need a planned mail date before readiness can pass.", now), | |||||
| RequiredField("required-field.election-cycle-job.service-template", | |||||
| "Service Template", "serviceTemplate", | |||||
| "Election-cycle jobs need a selected service template before readiness can pass.", now), | |||||
| ], | |||||
| EscalationRules = | |||||
| [ | |||||
| new() | |||||
| { | |||||
| SeedKey = "escalation.overdue-milestone.operations-lead", | |||||
| Name = "Overdue Milestone Operations Lead Alert", | |||||
| Description = "Escalates election-cycle jobs whose active milestone is overdue.", | |||||
| Scenario = OverdueMilestoneScenario, | |||||
| TriggerCondition = "activeMilestone.dueDate < today && job.status != 'complete'", | |||||
| Action = "NotifyOperationsLead", | |||||
| MilestoneBasis = "activeMilestone.dueDate", | |||||
| AlertWindow = TimeSpan.Zero, | |||||
| Priority = 1, | |||||
| Source = SeedRecordSource.SystemSeed, | |||||
| IsActive = true, | |||||
| CreatedAt = now, | |||||
| UpdatedAt = now, | |||||
| }, | |||||
| ], | |||||
| }; | |||||
| private static ReferenceValue Reference( | |||||
| string seedKey, | |||||
| string category, | |||||
| string name, | |||||
| string value, | |||||
| string description, | |||||
| DateTimeOffset now) => new() | |||||
| { | |||||
| SeedKey = seedKey, | |||||
| Category = category, | |||||
| Name = name, | |||||
| Value = value, | |||||
| Description = description, | |||||
| Source = SeedRecordSource.SystemSeed, | |||||
| IsActive = true, | |||||
| CreatedAt = now, | |||||
| UpdatedAt = now, | |||||
| }; | |||||
| private static RequiredFieldRule RequiredField( | |||||
| string seedKey, | |||||
| string name, | |||||
| string fieldPath, | |||||
| string description, | |||||
| DateTimeOffset now) => new() | |||||
| { | |||||
| SeedKey = seedKey, | |||||
| Name = name, | |||||
| Description = description, | |||||
| EntityType = ElectionCycleJobEntityType, | |||||
| FieldPath = fieldPath, | |||||
| ReadinessFeatureKey = ReadinessFeatureKey, | |||||
| IsRequired = true, | |||||
| Source = SeedRecordSource.SystemSeed, | |||||
| IsActive = true, | |||||
| CreatedAt = now, | |||||
| UpdatedAt = now, | |||||
| }; | |||||
| private static bool ContainsAll(IEnumerable<string> actual, IEnumerable<string> expected) | |||||
| { | { | ||||
| // Placeholder - in a real implementation, this would check | |||||
| // if the database has been seeded with reference values | |||||
| return Task.FromResult(true); | |||||
| var actualSet = actual.ToHashSet(StringComparer.OrdinalIgnoreCase); | |||||
| return expected.All(actualSet.Contains); | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,6 +1,6 @@ | |||||
| # Story 1.9: Seed System Reference Values & Rule Defaults | # Story 1.9: Seed System Reference Values & Rule Defaults | ||||
| Status: ready-for-dev | |||||
| Status: done | |||||
| ## Story | ## Story | ||||
| @@ -18,18 +18,27 @@ so that Epics 2–5 are immediately functional without requiring administrator c | |||||
| ## Tasks / Subtasks | ## 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] `SeedServiceTests.cs` contains terminal escape/control bytes instead of C# and blocks the backend test project from compiling [Campaign_Tracker.Server.Tests/SeedServiceTests.cs:1] | |||||
| - [x] [Review][Patch] `SeedAsync()` is a no-op placeholder and does not populate operational statuses, service template defaults, reference values, required-field rules, or overdue escalation defaults [Campaign_Tracker.Server/Seed/SeedService.cs:7] | |||||
| - [x] [Review][Patch] `IsSeededAsync()` always returns `true`, so a fresh system can be treated as already seeded [Campaign_Tracker.Server/Seed/SeedService.cs:20] | |||||
| - [x] [Review][Patch] Seed service is not registered or invoked during application initialization, so even a completed seed implementation would not run on first startup [Campaign_Tracker.Server/Program.cs:106] | |||||
| - [x] [Review][Patch] Seed models lack immutable seed keys/source metadata needed for idempotency and for preserving admin-managed values on rerun [Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs:7] | |||||
| - [x] [Review][Patch] Required-field and escalation rules are raw strings with no evaluator-facing scope or validated metadata, so Epic 2 readiness and overdue milestone alerts cannot evaluate them deterministically [Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs:16] | |||||
| ## Dev Notes | ## Dev Notes | ||||
| @@ -58,13 +67,33 @@ GPT-5 Codex | |||||
| ### Debug Log References | ### Debug Log References | ||||
| - Story generated from epic source and architecture/UX planning artifacts. | - Story generated from epic source and architecture/UX planning artifacts. | ||||
| - Code review run for Story 1.9. | |||||
| - Fixed review findings and verified with `dotnet test .\Campaign_Tracker.Server.Tests\Campaign_Tracker.Server.Tests.csproj /p:UseAppHost=false` (117 passed). | |||||
| ### Completion Notes List | ### Completion Notes List | ||||
| - Story context created and marked ready-for-dev. | |||||
| - Implemented startup seeding for system reference values, election-cycle readiness required-field rules, and overdue milestone escalation defaults. | |||||
| - Added stable seed keys and source metadata to support idempotency and admin-managed persistence on rerun. | |||||
| - Added durable JSON-backed seed store plus in-memory test store; JSON seed output is ignored from source control. | |||||
| - Replaced corrupted seed tests with AC-focused tests covering first seed, idempotency, persistence, FR29 rule scope, FR30 escalation defaults, and admin override preservation. | |||||
| ### File List | ### File List | ||||
| - `_bmad-output/implementation-artifacts/1-9-seed-system-reference-values-rule-defaults.md` | - `_bmad-output/implementation-artifacts/1-9-seed-system-reference-values-rule-defaults.md` | ||||
| - `.gitignore` | |||||
| - `Campaign_Tracker.Server/Program.cs` | |||||
| - `Campaign_Tracker.Server/Seed/ISeedService.cs` | |||||
| - `Campaign_Tracker.Server/Seed/ISeedDataStore.cs` | |||||
| - `Campaign_Tracker.Server/Seed/SeedDataSet.cs` | |||||
| - `Campaign_Tracker.Server/Seed/SeedService.cs` | |||||
| - `Campaign_Tracker.Server/Seed/SeedHostedService.cs` | |||||
| - `Campaign_Tracker.Server/Seed/FileSeedDataStore.cs` | |||||
| - `Campaign_Tracker.Server/Seed/InMemorySeedDataStore.cs` | |||||
| - `Campaign_Tracker.Server/Seed/README.md` | |||||
| - `Campaign_Tracker.Server/Seed/Models/EscalationRule.cs` | |||||
| - `Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs` | |||||
| - `Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs` | |||||
| - `Campaign_Tracker.Server/Seed/Models/SeedRecordSource.cs` | |||||
| - `Campaign_Tracker.Server.Tests/SeedServiceTests.cs` | |||||
| @@ -35,7 +35,7 @@ | |||||
| # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) | ||||
| generated: '2026-05-05T12:00:44-04:00' | generated: '2026-05-05T12:00:44-04:00' | ||||
| last_updated: '2026-05-06T13:52:00-04:00' | |||||
| last_updated: '2026-05-06T14:41:40-04:00' | |||||
| project: 'Campaign_Tracker App' | project: 'Campaign_Tracker App' | ||||
| project_key: 'NOKEY' | project_key: 'NOKEY' | ||||
| tracking_system: 'file-system' | tracking_system: 'file-system' | ||||
| @@ -51,7 +51,7 @@ development_status: | |||||
| 1-6-legacy-anti-corruption-data-access-layer: done | 1-6-legacy-anti-corruption-data-access-layer: done | ||||
| 1-7-legacy-schema-compatibility-validation-gate: done | 1-7-legacy-schema-compatibility-validation-gate: done | ||||
| 1-8-legacy-identifier-linking-for-extension-records: done | 1-8-legacy-identifier-linking-for-extension-records: done | ||||
| 1-9-seed-system-reference-values-rule-defaults: ready-for-dev | |||||
| 1-9-seed-system-reference-values-rule-defaults: done | |||||
| 1-10-municipality-account-profile: ready-for-dev | 1-10-municipality-account-profile: ready-for-dev | ||||
| 1-11-municipality-operational-addresses: ready-for-dev | 1-11-municipality-operational-addresses: ready-for-dev | ||||
| 1-12-municipality-service-contacts: ready-for-dev | 1-12-municipality-service-contacts: ready-for-dev | ||||
| @@ -1,7 +1,6 @@ | |||||
| { | { | ||||
| "name": "Campaign_Tracker App", | |||||
| "name": "Campaign_Tracker", | |||||
| "lockfileVersion": 3, | "lockfileVersion": 3, | ||||
| "requires": true, | "requires": true, | ||||
| "packages": {} | "packages": {} | ||||
| } | } | ||||
Powered by TurnKey Linux.