From 3ee8082943a4064ed3796262b93917e827380ae5 Mon Sep 17 00:00:00 2001 From: Daniel Covington Date: Wed, 6 May 2026 15:04:30 -0400 Subject: [PATCH] fix: complete story 1.9 seed defaults 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. --- .gitignore | 1 + .../SeedServiceTests.cs | 152 +++++++++++++- Campaign_Tracker.Server/Program.cs | 11 + .../Seed/FileSeedDataStore.cs | 178 ++++++++++++++++ .../Seed/ISeedDataStore.cs | 14 ++ Campaign_Tracker.Server/Seed/ISeedService.cs | 4 +- .../Seed/InMemorySeedDataStore.cs | 195 ++++++++++++++++++ .../Seed/Models/EscalationRule.cs | 37 +++- .../Seed/Models/ReferenceValue.cs | 28 ++- .../Seed/Models/RequiredFieldRule.cs | 32 ++- .../Seed/Models/SeedRecordSource.cs | 7 + Campaign_Tracker.Server/Seed/README.md | 34 +-- Campaign_Tracker.Server/Seed/SeedDataSet.cs | 10 + .../Seed/SeedHostedService.cs | 25 +++ Campaign_Tracker.Server/Seed/SeedService.cs | 182 ++++++++++++++-- ...d-system-reference-values-rule-defaults.md | 57 +++-- .../sprint-status.yaml | 4 +- package-lock.json | 3 +- 18 files changed, 894 insertions(+), 80 deletions(-) create mode 100644 Campaign_Tracker.Server/Seed/FileSeedDataStore.cs create mode 100644 Campaign_Tracker.Server/Seed/ISeedDataStore.cs create mode 100644 Campaign_Tracker.Server/Seed/InMemorySeedDataStore.cs create mode 100644 Campaign_Tracker.Server/Seed/Models/SeedRecordSource.cs create mode 100644 Campaign_Tracker.Server/Seed/SeedDataSet.cs create mode 100644 Campaign_Tracker.Server/Seed/SeedHostedService.cs diff --git a/.gitignore b/.gitignore index 944c2c5..091bf47 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ Dockerfile docker-compose.yml Campaign_Tracker.Server/audit-logs/ Campaign_Tracker.Server/legacy-schema-history.jsonl +Campaign_Tracker.Server/seed-data.json diff --git a/Campaign_Tracker.Server.Tests/SeedServiceTests.cs b/Campaign_Tracker.Server.Tests/SeedServiceTests.cs index 0d4a416..a760121 100644 --- a/Campaign_Tracker.Server.Tests/SeedServiceTests.cs +++ b/Campaign_Tracker.Server.Tests/SeedServiceTests.cs @@ -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 \ No newline at end of file +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; + } +} diff --git a/Campaign_Tracker.Server/Program.cs b/Campaign_Tracker.Server/Program.cs index dd1277f..91a5b5b 100644 --- a/Campaign_Tracker.Server/Program.cs +++ b/Campaign_Tracker.Server/Program.cs @@ -7,6 +7,7 @@ using Campaign_Tracker.Server.Configuration; using Campaign_Tracker.Server.ExtensionData; using Campaign_Tracker.Server.LegacyData; using Campaign_Tracker.Server.LegacyData.Schema; +using Campaign_Tracker.Server.Seed; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization.Policy; @@ -103,6 +104,16 @@ builder.Services.AddSingleton(sp => builder.Services.AddSingleton(_ => 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(_ => + new FileSeedDataStore(Path.GetFullPath(seedDataPath))); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); diff --git a/Campaign_Tracker.Server/Seed/FileSeedDataStore.cs b/Campaign_Tracker.Server/Seed/FileSeedDataStore.cs new file mode 100644 index 0000000..5f6f659 --- /dev/null +++ b/Campaign_Tracker.Server/Seed/FileSeedDataStore.cs @@ -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 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> 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> 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> 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 LoadAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_path)) + { + return new InMemorySeedDataStore.SeedDataSnapshot(); + } + + await using var stream = File.OpenRead(_path); + var snapshot = await JsonSerializer.DeserializeAsync( + 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); + } +} diff --git a/Campaign_Tracker.Server/Seed/ISeedDataStore.cs b/Campaign_Tracker.Server/Seed/ISeedDataStore.cs new file mode 100644 index 0000000..eb8e8cb --- /dev/null +++ b/Campaign_Tracker.Server/Seed/ISeedDataStore.cs @@ -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> GetReferenceValuesAsync(CancellationToken cancellationToken = default); + Task> GetRequiredFieldRulesAsync(CancellationToken cancellationToken = default); + Task> 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); +} diff --git a/Campaign_Tracker.Server/Seed/ISeedService.cs b/Campaign_Tracker.Server/Seed/ISeedService.cs index 528d1c2..8bcb3be 100644 --- a/Campaign_Tracker.Server/Seed/ISeedService.cs +++ b/Campaign_Tracker.Server/Seed/ISeedService.cs @@ -4,6 +4,6 @@ namespace Campaign_Tracker.Server.Seed; public interface ISeedService { - Task SeedAsync(); - Task IsSeededAsync(); + Task SeedAsync(CancellationToken cancellationToken = default); + Task IsSeededAsync(CancellationToken cancellationToken = default); } diff --git a/Campaign_Tracker.Server/Seed/InMemorySeedDataStore.cs b/Campaign_Tracker.Server/Seed/InMemorySeedDataStore.cs new file mode 100644 index 0000000..0f1e591 --- /dev/null +++ b/Campaign_Tracker.Server/Seed/InMemorySeedDataStore.cs @@ -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> GetReferenceValuesAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + lock (_sync) + { + return Task.FromResult>( + _snapshot.ReferenceValues.Select(Clone).ToArray()); + } + } + + public Task> GetRequiredFieldRulesAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + lock (_sync) + { + return Task.FromResult>( + _snapshot.RequiredFieldRules.Select(Clone).ToArray()); + } + } + + public Task> GetEscalationRulesAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + lock (_sync) + { + return Task.FromResult>( + _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( + List target, + IEnumerable defaults, + Func 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( + List target, + T item, + Func 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 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 ReferenceValues { get; init; } = []; + public List RequiredFieldRules { get; init; } = []; + public List EscalationRules { get; init; } = []; + } +} diff --git a/Campaign_Tracker.Server/Seed/Models/EscalationRule.cs b/Campaign_Tracker.Server/Seed/Models/EscalationRule.cs index 39d17e3..3f56b48 100644 --- a/Campaign_Tracker.Server/Seed/Models/EscalationRule.cs +++ b/Campaign_Tracker.Server/Seed/Models/EscalationRule.cs @@ -5,27 +5,44 @@ namespace Campaign_Tracker.Server.Seed.Models; public class EscalationRule { public int Id { get; set; } - + + [Required] + [StringLength(160)] + public string SeedKey { get; set; } = string.Empty; + [Required] [StringLength(100)] public string Name { get; set; } = string.Empty; - + [StringLength(500)] public string Description { get; set; } = string.Empty; - + + [Required] + [StringLength(100)] + public string Scenario { get; set; } = string.Empty; + [Required] [StringLength(200)] public string TriggerCondition { get; set; } = string.Empty; - + [Required] [StringLength(200)] 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 SeedRecordSource Source { get; set; } = SeedRecordSource.SystemSeed; + 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; } } diff --git a/Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs b/Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs index d071a84..b3ce546 100644 --- a/Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs +++ b/Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs @@ -5,19 +5,31 @@ namespace Campaign_Tracker.Server.Seed.Models; public class ReferenceValue { 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] [StringLength(100)] public string Name { get; set; } = string.Empty; - + [StringLength(500)] public string Description { get; set; } = string.Empty; - + + [Required] + [StringLength(200)] public string Value { get; set; } = string.Empty; - + + public SeedRecordSource Source { get; set; } = SeedRecordSource.SystemSeed; + 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; } } diff --git a/Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs b/Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs index 641f7de..345337c 100644 --- a/Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs +++ b/Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs @@ -5,23 +5,37 @@ namespace Campaign_Tracker.Server.Seed.Models; public class RequiredFieldRule { public int Id { get; set; } - + + [Required] + [StringLength(160)] + public string SeedKey { get; set; } = string.Empty; + [Required] [StringLength(100)] public string Name { get; set; } = string.Empty; - + [StringLength(500)] public string Description { get; set; } = string.Empty; - + + [Required] + [StringLength(100)] + public string EntityType { get; set; } = string.Empty; + [Required] [StringLength(200)] public string FieldPath { get; set; } = string.Empty; - + + [Required] + [StringLength(120)] + public string ReadinessFeatureKey { get; set; } = string.Empty; + public bool IsRequired { get; set; } = true; - + + public SeedRecordSource Source { get; set; } = SeedRecordSource.SystemSeed; + 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; } } diff --git a/Campaign_Tracker.Server/Seed/Models/SeedRecordSource.cs b/Campaign_Tracker.Server/Seed/Models/SeedRecordSource.cs new file mode 100644 index 0000000..5f3cc29 --- /dev/null +++ b/Campaign_Tracker.Server/Seed/Models/SeedRecordSource.cs @@ -0,0 +1,7 @@ +namespace Campaign_Tracker.Server.Seed.Models; + +public enum SeedRecordSource +{ + SystemSeed = 0, + AdminManaged = 1, +} diff --git a/Campaign_Tracker.Server/Seed/README.md b/Campaign_Tracker.Server/Seed/README.md index d9d1128..716c7c6 100644 --- a/Campaign_Tracker.Server/Seed/README.md +++ b/Campaign_Tracker.Server/Seed/README.md @@ -2,28 +2,28 @@ 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 - Service template defaults - Extension-layer reference values -2. **Configure Required-Field Rules** +2. **Configures Required-Field Rules** - 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 - 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 - Seed does not overwrite admin changes @@ -31,12 +31,14 @@ The seed service will: - `ISeedService.cs` - Interface defining seed service contract - `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 -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. diff --git a/Campaign_Tracker.Server/Seed/SeedDataSet.cs b/Campaign_Tracker.Server/Seed/SeedDataSet.cs new file mode 100644 index 0000000..a0e7b0d --- /dev/null +++ b/Campaign_Tracker.Server/Seed/SeedDataSet.cs @@ -0,0 +1,10 @@ +using Campaign_Tracker.Server.Seed.Models; + +namespace Campaign_Tracker.Server.Seed; + +public sealed class SeedDataSet +{ + public IReadOnlyList ReferenceValues { get; init; } = []; + public IReadOnlyList RequiredFieldRules { get; init; } = []; + public IReadOnlyList EscalationRules { get; init; } = []; +} diff --git a/Campaign_Tracker.Server/Seed/SeedHostedService.cs b/Campaign_Tracker.Server/Seed/SeedHostedService.cs new file mode 100644 index 0000000..2d1a1cb --- /dev/null +++ b/Campaign_Tracker.Server/Seed/SeedHostedService.cs @@ -0,0 +1,25 @@ +namespace Campaign_Tracker.Server.Seed; + +public sealed class SeedHostedService : IHostedService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + public SeedHostedService( + IServiceScopeFactory scopeFactory, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var seedService = scope.ServiceProvider.GetRequiredService(); + await seedService.SeedAsync(cancellationToken); + _logger.LogInformation("System reference values and rule defaults are seeded."); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/Campaign_Tracker.Server/Seed/SeedService.cs b/Campaign_Tracker.Server/Seed/SeedService.cs index e3f8051..6e76977 100644 --- a/Campaign_Tracker.Server/Seed/SeedService.cs +++ b/Campaign_Tracker.Server/Seed/SeedService.cs @@ -1,26 +1,176 @@ -using System.Threading.Tasks; +using Campaign_Tracker.Server.Seed.Models; 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 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 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 actual, IEnumerable 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); } } diff --git a/_bmad-output/implementation-artifacts/1-9-seed-system-reference-values-rule-defaults.md b/_bmad-output/implementation-artifacts/1-9-seed-system-reference-values-rule-defaults.md index 3db9060..0af943f 100644 --- a/_bmad-output/implementation-artifacts/1-9-seed-system-reference-values-rule-defaults.md +++ b/_bmad-output/implementation-artifacts/1-9-seed-system-reference-values-rule-defaults.md @@ -1,6 +1,6 @@ # Story 1.9: Seed System Reference Values & Rule Defaults -Status: ready-for-dev +Status: done ## Story @@ -18,18 +18,27 @@ so that Epics 2–5 are immediately functional without requiring administrator c ## 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 @@ -58,13 +67,33 @@ GPT-5 Codex ### Debug Log References - 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 -- 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 - `_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` diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 5d26c52..6c72689 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: '2026-05-05T12:00:44-04:00' -last_updated: '2026-05-06T13:52:00-04:00' +last_updated: '2026-05-06T14:41:40-04:00' project: 'Campaign_Tracker App' project_key: 'NOKEY' tracking_system: 'file-system' @@ -51,7 +51,7 @@ development_status: 1-6-legacy-anti-corruption-data-access-layer: done 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-9-seed-system-reference-values-rule-defaults: done 1-10-municipality-account-profile: ready-for-dev 1-11-municipality-operational-addresses: ready-for-dev 1-12-municipality-service-contacts: ready-for-dev diff --git a/package-lock.json b/package-lock.json index d1f0eb5..442a15d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,7 +1,6 @@ { - "name": "Campaign_Tracker App", + "name": "Campaign_Tracker", "lockfileVersion": 3, "requires": true, "packages": {} } -