Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 239 additions & 0 deletions DotNet/Automation.UI/Controllers/NormalizationsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
using Automation.UI.Models;
using Automation.UI.Models;
using Automation.UI.Services.Persistence;
using Microsoft.AspNetCore.Mvc;

namespace Automation.UI.Controllers;

public class NormalizationsController(INormalizationStore store) : Controller
{
[HttpGet]
public async Task<IActionResult> Index(CancellationToken ct)
{
var operations = await store.GetAllOperationsAsync(ct);
var sequences = await store.GetAllSequencesAsync(ct);
var suites = await store.GetAllSuitesAsync(ct);

ViewBag.Operations = operations;
ViewBag.Sequences = sequences;
ViewBag.Suites = suites;

return View(operations);
}

// ===== Operations =====

[HttpGet]
public async Task<IActionResult> GetOperationJson(Guid id, CancellationToken ct)
{
var op = await store.GetOperationByIdAsync(id, ct);
if (op == null) return NotFound();
return Json(op);
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveOperation([FromBody] NormalizationOperationDefinition model, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(model.Name))
return BadRequest("Operation name is required.");
if (string.IsNullOrWhiteSpace(model.OperationType))
return BadRequest("Operation type is required.");
if (model.ResourceTypes.Count == 0)
return BadRequest("At least one resource type is required.");

var existing = await store.GetOperationByIdAsync(model.Id, ct);
if (existing is { IsSystem: true })
return StatusCode(StatusCodes.Status403Forbidden, "System operations cannot be modified.");

model.IsSystem = false;
model.UpdatedAt = DateTimeOffset.UtcNow;
await store.UpsertOperationAsync(model, ct);
return Json(new { id = model.Id });
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteOperation([FromBody] IdRequest request, CancellationToken ct)
{
var op = await store.GetOperationByIdAsync(request.Id, ct);
if (op == null) return NotFound();
if (op.IsSystem)
return StatusCode(StatusCodes.Status403Forbidden, "System operations cannot be deleted.");

await store.DeleteOperationAsync(request.Id, ct);
return Ok();
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CloneOperation([FromBody] IdRequest request, CancellationToken ct)
{
var source = await store.GetOperationByIdAsync(request.Id, ct);
if (source == null) return NotFound();

var clone = new NormalizationOperationDefinition
{
Id = Guid.NewGuid(),
Name = $"{source.Name} (Copy)",
Description = source.Description,
OperationType = source.OperationType,
ResourceTypes = [..source.ResourceTypes],
SourceFhirPath = source.SourceFhirPath,
TargetFhirPath = source.TargetFhirPath,
ConditionTargetFhirPath = source.ConditionTargetFhirPath,
ConditionTargetValue = source.ConditionTargetValue,
Conditions = [..source.Conditions],
CodeMapFhirPath = source.CodeMapFhirPath,
CodeSystemMaps = [..source.CodeSystemMaps],
ExtensionUrls = [..source.ExtensionUrls],
IsSystem = false,
UpdatedAt = DateTimeOffset.UtcNow
};

await store.UpsertOperationAsync(clone, ct);
return Json(new { id = clone.Id });
}

// ===== Sequences =====

[HttpGet]
public async Task<IActionResult> GetSequenceJson(Guid id, CancellationToken ct)
{
var seq = await store.GetSequenceByIdAsync(id, ct);
if (seq == null) return NotFound();
return Json(seq);
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSequence([FromBody] NormalizationSequenceDefinition model, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(model.Name))
return BadRequest("Sequence name is required.");
if (model.Entries.Count == 0)
return BadRequest("At least one operation entry is required.");

var existing = await store.GetSequenceByIdAsync(model.Id, ct);
if (existing is { IsSystem: true })
return StatusCode(StatusCodes.Status403Forbidden, "System sequences cannot be modified.");

model.IsSystem = false;
model.UpdatedAt = DateTimeOffset.UtcNow;
await store.UpsertSequenceAsync(model, ct);
return Json(new { id = model.Id });
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteSequence([FromBody] IdRequest request, CancellationToken ct)
{
var seq = await store.GetSequenceByIdAsync(request.Id, ct);
if (seq == null) return NotFound();
if (seq.IsSystem)
return StatusCode(StatusCodes.Status403Forbidden, "System sequences cannot be deleted.");

await store.DeleteSequenceAsync(request.Id, ct);
return Ok();
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CloneSequence([FromBody] IdRequest request, CancellationToken ct)
{
var source = await store.GetSequenceByIdAsync(request.Id, ct);
if (source == null) return NotFound();

var clone = new NormalizationSequenceDefinition
{
Id = Guid.NewGuid(),
Name = $"{source.Name} (Copy)",
Description = source.Description,
Entries = source.Entries.Select(e => new NormalizationSequenceEntry { OperationId = e.OperationId, Sequence = e.Sequence }).ToList(),
IsSystem = false,
UpdatedAt = DateTimeOffset.UtcNow
};

await store.UpsertSequenceAsync(clone, ct);
return Json(new { id = clone.Id });
}

// ===== Suites =====

[HttpGet]
public async Task<IActionResult> GetSuiteJson(Guid id, CancellationToken ct)
{
var suite = await store.GetSuiteByIdAsync(id, ct);
if (suite == null) return NotFound();
return Json(suite);
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveSuite([FromBody] NormalizationSuiteDefinition model, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(model.Name))
return BadRequest("Suite name is required.");

var existing = await store.GetSuiteByIdAsync(model.Id, ct);
if (existing is { IsSystem: true })
return StatusCode(StatusCodes.Status403Forbidden, "System suites cannot be modified.");

model.IsSystem = false;
model.UpdatedAt = DateTimeOffset.UtcNow;
await store.UpsertSuiteAsync(model, ct);
return Json(new { id = model.Id });
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteSuite([FromBody] IdRequest request, CancellationToken ct)
{
var suite = await store.GetSuiteByIdAsync(request.Id, ct);
if (suite == null) return NotFound();
if (suite.IsSystem)
return StatusCode(StatusCodes.Status403Forbidden, "System suites cannot be deleted.");

await store.DeleteSuiteAsync(request.Id, ct);
return Ok();
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> CloneSuite([FromBody] IdRequest request, CancellationToken ct)
{
var source = await store.GetSuiteByIdAsync(request.Id, ct);
if (source == null) return NotFound();

var clone = new NormalizationSuiteDefinition
{
Id = Guid.NewGuid(),
Name = $"{source.Name} (Copy)",
Description = source.Description,
OperationIds = [..source.OperationIds],
SequenceIds = [..source.SequenceIds],
IsSystem = false,
IsDefault = false,
UpdatedAt = DateTimeOffset.UtcNow
};

await store.UpsertSuiteAsync(clone, ct);
return Json(new { id = clone.Id });
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SetDefaultSuite([FromBody] IdRequest request, CancellationToken ct)
{
var suite = await store.GetSuiteByIdAsync(request.Id, ct);
if (suite == null) return NotFound();

await store.SetDefaultSuiteAsync(request.Id, ct);
return Ok();
}

public sealed class IdRequest
{
public Guid Id { get; set; }
}
}
2 changes: 2 additions & 0 deletions DotNet/Automation.UI/Controllers/ScenariosController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ namespace Automation.UI.Controllers;
public class ScenariosController(
IScenarioStore scenarioStore,
IQueryPlanTemplateStore queryPlanTemplateStore,
INormalizationStore normalizationStore,
IOptions<AutomationConfig> automationConfig,
IMongoDatabase database) : Controller
{
Expand All @@ -25,6 +26,7 @@ public async Task<IActionResult> Index(CancellationToken ct)
{
var scenarios = await scenarioStore.GetAllAsync(ct);
ViewBag.QueryPlanTemplates = await queryPlanTemplateStore.GetAllAsync(ct);
ViewBag.NormalizationSuites = await normalizationStore.GetAllSuitesAsync(ct);
return View(scenarios);
}

Expand Down
104 changes: 104 additions & 0 deletions DotNet/Automation.UI/Models/NormalizationModels.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
namespace Automation.UI.Models;

/// <summary>
/// A reusable normalization operation definition stored locally in MongoDB.
/// This captures the operation type and its configuration parameters so they can
/// be composed into sequences and suites without hitting the remote Normalization API.
/// </summary>
public class NormalizationOperationDefinition
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OperationType { get; set; } = string.Empty;

/// <summary>FHIR resource types this operation applies to (e.g. "Location", "Encounter").</summary>
public List<string> ResourceTypes { get; set; } = [];

// --- CopyProperty fields ---
public string? SourceFhirPath { get; set; }
public string? TargetFhirPath { get; set; }

// --- ConditionalTransform fields ---
public string? ConditionTargetFhirPath { get; set; }
public object? ConditionTargetValue { get; set; }
public List<NormalizationCondition> Conditions { get; set; } = [];

// --- CodeMap fields ---
public string? CodeMapFhirPath { get; set; }
public List<NormalizationCodeSystemMap> CodeSystemMaps { get; set; } = [];

// --- RemoveExtensions fields ---
public List<string> ExtensionUrls { get; set; } = [];

// --- CopyLocation has no extra fields ---

public bool IsSystem { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

public class NormalizationCondition
{
public string FhirPathSource { get; set; } = string.Empty;
public string Operator { get; set; } = "Equal";
public object? Value { get; set; }
}

public class NormalizationCodeSystemMap
{
public string SourceSystem { get; set; } = string.Empty;
public string TargetSystem { get; set; } = string.Empty;
public Dictionary<string, NormalizationCodeMapEntry> CodeMaps { get; set; } = new();
}

public class NormalizationCodeMapEntry
{
public string Code { get; set; } = string.Empty;
public string Display { get; set; } = string.Empty;
}

/// <summary>
/// An ordered series of normalization operations grouped by resource type.
/// </summary>
public class NormalizationSequenceDefinition
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }

/// <summary>Ordered list of operation IDs in this sequence.</summary>
public List<NormalizationSequenceEntry> Entries { get; set; } = [];

public bool IsSystem { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}

public class NormalizationSequenceEntry
{
/// <summary>Reference to a <see cref="NormalizationOperationDefinition.Id"/>.</summary>
public Guid OperationId { get; set; }

/// <summary>Order in the sequence (1-based).</summary>
public int Sequence { get; set; }
}

/// <summary>
/// A bundle of operations and sequences that together represent a complete normalization
/// configuration selectable on a scenario.
/// </summary>
public class NormalizationSuiteDefinition
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }

/// <summary>Operation IDs included directly (not part of a sequence).</summary>
public List<Guid> OperationIds { get; set; } = [];

/// <summary>Sequence IDs included in this suite.</summary>
public List<Guid> SequenceIds { get; set; } = [];

public bool IsSystem { get; set; }
public bool IsDefault { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
7 changes: 7 additions & 0 deletions DotNet/Automation.UI/Models/StartScenarioRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ public class StartScenarioRequest : IValidatableObject
/// </summary>
public Guid? QueryPlanTemplateId { get; set; }

/// <summary>
/// Optional normalization suite ID. When set, the run uses this suite's
/// operations for normalization configuration. When null, the system default suite is used.
/// </summary>
public Guid? NormalizationSuiteId { get; set; }

/// <summary>
/// Cross-field validation. Rejects inverted report windows
/// (<see cref="ReportPeriodStart"/> &gt; <see cref="ReportPeriodEnd"/>) at the request
Expand Down Expand Up @@ -112,6 +118,7 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
ReportPeriodStart = scenario.ReportPeriodStart,
ReportPeriodEnd = scenario.ReportPeriodEnd,
QueryPlanTemplateId = scenario.QueryPlanTemplateId,
NormalizationSuiteId = scenario.NormalizationSuiteId,
};

private static string SerializeScenarioConfiguration(TestScenarioDefinition scenario)
Expand Down
7 changes: 7 additions & 0 deletions DotNet/Automation.UI/Models/TestScenarioDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ public class TestScenarioDefinition
/// </summary>
public Guid? QueryPlanTemplateId { get; set; }

/// <summary>
/// Optional normalization suite ID. When set, the scenario uses this suite's
/// operations instead of creating a simple default normalization.
/// When null, the system default normalization suite is used.
/// </summary>
public Guid? NormalizationSuiteId { get; set; }

/// <summary>
/// Remove facility config, soft-delete reports, DA logs, and query dispatch config after the run.
/// </summary>
Expand Down
Loading
Loading