瀏覽代碼

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.
pull/16/head
Daniel Covington 2 天之前
父節點
當前提交
3ee8082943
共有 18 個文件被更改,包括 894 次插入80 次删除
  1. +1
    -0
      .gitignore
  2. +151
    -1
      Campaign_Tracker.Server.Tests/SeedServiceTests.cs
  3. +11
    -0
      Campaign_Tracker.Server/Program.cs
  4. +178
    -0
      Campaign_Tracker.Server/Seed/FileSeedDataStore.cs
  5. +14
    -0
      Campaign_Tracker.Server/Seed/ISeedDataStore.cs
  6. +2
    -2
      Campaign_Tracker.Server/Seed/ISeedService.cs
  7. +195
    -0
      Campaign_Tracker.Server/Seed/InMemorySeedDataStore.cs
  8. +27
    -10
      Campaign_Tracker.Server/Seed/Models/EscalationRule.cs
  9. +20
    -8
      Campaign_Tracker.Server/Seed/Models/ReferenceValue.cs
  10. +23
    -9
      Campaign_Tracker.Server/Seed/Models/RequiredFieldRule.cs
  11. +7
    -0
      Campaign_Tracker.Server/Seed/Models/SeedRecordSource.cs
  12. +18
    -16
      Campaign_Tracker.Server/Seed/README.md
  13. +10
    -0
      Campaign_Tracker.Server/Seed/SeedDataSet.cs
  14. +25
    -0
      Campaign_Tracker.Server/Seed/SeedHostedService.cs
  15. +166
    -16
      Campaign_Tracker.Server/Seed/SeedService.cs
  16. +43
    -14
      _bmad-output/implementation-artifacts/1-9-seed-system-reference-values-rule-defaults.md
  17. +2
    -2
      _bmad-output/implementation-artifacts/sprint-status.yaml
  18. +1
    -2
      package-lock.json

+ 1
- 0
.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

+ 151
- 1
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
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;
}
}

+ 11
- 0
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<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>();



+ 178
- 0
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<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);
}
}

+ 14
- 0
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<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);
}

+ 2
- 2
Campaign_Tracker.Server/Seed/ISeedService.cs 查看文件

@@ -4,6 +4,6 @@ namespace Campaign_Tracker.Server.Seed;

public interface ISeedService
{
Task SeedAsync();
Task<bool> IsSeededAsync();
Task SeedAsync(CancellationToken cancellationToken = default);
Task<bool> IsSeededAsync(CancellationToken cancellationToken = default);
}

+ 195
- 0
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<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; } = [];
}
}

+ 27
- 10
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; }
}

+ 20
- 8
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; }
}

+ 23
- 9
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; }
}

+ 7
- 0
Campaign_Tracker.Server/Seed/Models/SeedRecordSource.cs 查看文件

@@ -0,0 +1,7 @@
namespace Campaign_Tracker.Server.Seed.Models;

public enum SeedRecordSource
{
SystemSeed = 0,
AdminManaged = 1,
}

+ 18
- 16
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.

+ 10
- 0
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<ReferenceValue> ReferenceValues { get; init; } = [];
public IReadOnlyList<RequiredFieldRule> RequiredFieldRules { get; init; } = [];
public IReadOnlyList<EscalationRule> EscalationRules { get; init; } = [];
}

+ 25
- 0
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<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;
}

+ 166
- 16
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<bool> IsSeededAsync(CancellationToken cancellationToken = default)
{
// This is a placeholder implementation for Story 1.9
// In a real implementation, this would:
// 1. Check if seeding is needed
// 2. Create default reference values in the database
// 3. Set up required-field rules for election-cycle jobs
// 4. Configure escalation rule defaults for overdue milestone alerts
// 5. Ensure idempotency - no duplicates on multiple runs
return Task.CompletedTask;
var referenceValues = await _store.GetReferenceValuesAsync(cancellationToken);
var requiredRules = await _store.GetRequiredFieldRulesAsync(cancellationToken);
var escalationRules = await _store.GetEscalationRulesAsync(cancellationToken);

return ContainsAll(referenceValues.Select(value => value.SeedKey), ExpectedReferenceSeedKeys)
&& ContainsAll(requiredRules.Select(rule => rule.SeedKey), ExpectedRequiredFieldSeedKeys)
&& ContainsAll(escalationRules.Select(rule => rule.SeedKey), ExpectedEscalationSeedKeys);
}

public Task<bool> IsSeededAsync()
private static SeedDataSet CreateDefaults(DateTimeOffset now) => new()
{
ReferenceValues =
[
Reference("operational-status.not-started", "OperationalStatus", "Not Started", "not-started",
"Election-cycle job work has not started.", now),
Reference("operational-status.in-progress", "OperationalStatus", "In Progress", "in-progress",
"Election-cycle job work is actively in progress.", now),
Reference("operational-status.blocked", "OperationalStatus", "Blocked", "blocked",
"Election-cycle job work is blocked and needs intervention.", now),
Reference("operational-status.complete", "OperationalStatus", "Complete", "complete",
"Election-cycle job work is complete.", now),
Reference("service-template.addressing", "ServiceTemplate", "Addressing", "addressing",
"Default service template for addressing work.", now),
Reference("service-template.sorting", "ServiceTemplate", "Sorting", "sorting",
"Default service template for sorting work.", now),
Reference("service-template.transportation", "ServiceTemplate", "Transportation", "transportation",
"Default service template for transportation work.", now),
Reference("service-template.office-copy", "ServiceTemplate", "Office Copy", "office-copy",
"Default service template for office-copy work.", now),
Reference("extension-reference.election-cycle.primary", "ElectionCycleType", "Primary", "primary",
"Extension-layer election-cycle reference value for primary elections.", now),
Reference("extension-reference.election-cycle.general", "ElectionCycleType", "General", "general",
"Extension-layer election-cycle reference value for general elections.", now),
Reference("extension-reference.mail-class.first-class", "MailClass", "First Class", "first-class",
"Extension-layer mail-class reference value.", now),
Reference("extension-reference.mail-class.standard", "MailClass", "Standard", "standard",
"Extension-layer mail-class reference value.", now),
],
RequiredFieldRules =
[
RequiredField("required-field.election-cycle-job.municipality-profile-id",
"Municipality Profile", "municipalityProfileId",
"Election-cycle jobs must be linked to a municipality profile.", now),
RequiredField("required-field.election-cycle-job.legacy-jurisdiction-j-code",
"Legacy Jurisdiction Code", "legacyJurisdictionJCode",
"Election-cycle jobs must keep the legacy jurisdiction bridge required by Story 1.8.", now),
RequiredField("required-field.election-cycle-job.election-date",
"Election Date", "electionDate",
"Election-cycle jobs need an election date before readiness can pass.", now),
RequiredField("required-field.election-cycle-job.mail-date",
"Mail Date", "mailDate",
"Election-cycle jobs need a planned mail date before readiness can pass.", now),
RequiredField("required-field.election-cycle-job.service-template",
"Service Template", "serviceTemplate",
"Election-cycle jobs need a selected service template before readiness can pass.", now),
],
EscalationRules =
[
new()
{
SeedKey = "escalation.overdue-milestone.operations-lead",
Name = "Overdue Milestone Operations Lead Alert",
Description = "Escalates election-cycle jobs whose active milestone is overdue.",
Scenario = OverdueMilestoneScenario,
TriggerCondition = "activeMilestone.dueDate < today && job.status != 'complete'",
Action = "NotifyOperationsLead",
MilestoneBasis = "activeMilestone.dueDate",
AlertWindow = TimeSpan.Zero,
Priority = 1,
Source = SeedRecordSource.SystemSeed,
IsActive = true,
CreatedAt = now,
UpdatedAt = now,
},
],
};

private static ReferenceValue Reference(
string seedKey,
string category,
string name,
string value,
string description,
DateTimeOffset now) => new()
{
SeedKey = seedKey,
Category = category,
Name = name,
Value = value,
Description = description,
Source = SeedRecordSource.SystemSeed,
IsActive = true,
CreatedAt = now,
UpdatedAt = now,
};

private static RequiredFieldRule RequiredField(
string seedKey,
string name,
string fieldPath,
string description,
DateTimeOffset now) => new()
{
SeedKey = seedKey,
Name = name,
Description = description,
EntityType = ElectionCycleJobEntityType,
FieldPath = fieldPath,
ReadinessFeatureKey = ReadinessFeatureKey,
IsRequired = true,
Source = SeedRecordSource.SystemSeed,
IsActive = true,
CreatedAt = now,
UpdatedAt = now,
};

private static bool ContainsAll(IEnumerable<string> actual, IEnumerable<string> expected)
{
// Placeholder - in a real implementation, this would check
// if the database has been seeded with reference values
return Task.FromResult(true);
var actualSet = actual.ToHashSet(StringComparer.OrdinalIgnoreCase);
return expected.All(actualSet.Contains);
}
}

+ 43
- 14
_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`



+ 2
- 2
_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


+ 1
- 2
package-lock.json 查看文件

@@ -1,7 +1,6 @@
{
"name": "Campaign_Tracker App",
"name": "Campaign_Tracker",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}


Loading…
取消
儲存

Powered by TurnKey Linux.