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 new file mode 100644 index 0000000..a760121 --- /dev/null +++ b/Campaign_Tracker.Server.Tests/SeedServiceTests.cs @@ -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; + } +} 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 new file mode 100644 index 0000000..8bcb3be --- /dev/null +++ b/Campaign_Tracker.Server/Seed/ISeedService.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +namespace Campaign_Tracker.Server.Seed; + +public interface ISeedService +{ + 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 new file mode 100644 index 0000000..3f56b48 --- /dev/null +++ b/Campaign_Tracker.Server/Seed/Models/EscalationRule.cs @@ -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; } +} diff --git a/Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs b/Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs new file mode 100644 index 0000000..b3ce546 --- /dev/null +++ b/Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs @@ -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; } +} diff --git a/Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs b/Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs new file mode 100644 index 0000000..345337c --- /dev/null +++ b/Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs @@ -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; } +} 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 new file mode 100644 index 0000000..716c7c6 --- /dev/null +++ b/Campaign_Tracker.Server/Seed/README.md @@ -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. 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 new file mode 100644 index 0000000..6e76977 --- /dev/null +++ b/Campaign_Tracker.Server/Seed/SeedService.cs @@ -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 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 actual, IEnumerable expected) + { + 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": {} } -