| @@ -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 | |||
| @@ -0,0 +1,151 @@ | |||
| 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.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<ILegacySchemaCompatibilityCheck>(sp => | |||
| builder.Services.AddSingleton<ILegacySchemaCheckHistory>(_ => | |||
| 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.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); | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| using System.Threading.Tasks; | |||
| namespace Campaign_Tracker.Server.Seed; | |||
| public interface ISeedService | |||
| { | |||
| 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; } = []; | |||
| } | |||
| } | |||
| @@ -0,0 +1,48 @@ | |||
| using System.ComponentModel.DataAnnotations; | |||
| 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 DateTimeOffset CreatedAt { get; set; } | |||
| public DateTimeOffset UpdatedAt { get; set; } | |||
| } | |||
| @@ -0,0 +1,35 @@ | |||
| using System.ComponentModel.DataAnnotations; | |||
| 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 DateTimeOffset CreatedAt { get; set; } | |||
| public DateTimeOffset UpdatedAt { get; set; } | |||
| } | |||
| @@ -0,0 +1,41 @@ | |||
| using System.ComponentModel.DataAnnotations; | |||
| 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 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, | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| # Seed Service Implementation | |||
| This directory contains the seed service implementation for Story 1.9: "Seed System Reference Values & Rule Defaults". | |||
| ## Implementation | |||
| The seed service: | |||
| 1. **Seeds Reference Values** | |||
| - Operational status values | |||
| - Service template defaults | |||
| - Extension-layer reference values | |||
| 2. **Configures Required-Field Rules** | |||
| - Default readiness fields for election-cycle jobs | |||
| - Evaluator-facing entity scope and FR29 readiness feature keys | |||
| 3. **Sets Up Escalation Rule Defaults** | |||
| - Default rules covering overdue milestone alert scenarios | |||
| - FR30 compliance | |||
| 4. **Ensures Idempotency** | |||
| - Seed keys are stable idempotency boundaries | |||
| - Existing records are not overwritten on rerun | |||
| 5. **Maintains Separation** | |||
| - Admin-managed values persist independently | |||
| - Seed does not overwrite admin changes | |||
| ## Files | |||
| - `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 | |||
| 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; | |||
| } | |||
| @@ -0,0 +1,176 @@ | |||
| using Campaign_Tracker.Server.Seed.Models; | |||
| namespace Campaign_Tracker.Server.Seed; | |||
| public sealed class SeedService : ISeedService | |||
| { | |||
| 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) | |||
| { | |||
| 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); | |||
| } | |||
| 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) | |||
| { | |||
| var actualSet = actual.ToHashSet(StringComparer.OrdinalIgnoreCase); | |||
| return expected.All(actualSet.Contains); | |||
| } | |||
| } | |||
| @@ -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` | |||
| @@ -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 | |||
| @@ -1,7 +1,6 @@ | |||
| { | |||
| "name": "Campaign_Tracker App", | |||
| "name": "Campaign_Tracker", | |||
| "lockfileVersion": 3, | |||
| "requires": true, | |||
| "packages": {} | |||
| } | |||
Powered by TurnKey Linux.