Connect graphical CCS interviews to cases

This commit is contained in:
hz
2026-08-05 20:14:27 -05:00
parent 31093ab80d
commit af2bbb673d
16 changed files with 547 additions and 28 deletions
+46 -2
View File
@@ -4,7 +4,7 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator
{
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{
@@ -38,6 +38,7 @@ public static class CcsCaseValidator
errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "allowedLocations must include presentingLocation");
ValidateInterview(item.InitialInterview, prefix, errors);
if (item.IsPublished)
{
var referenceError = QuestionValidator.ValidateReferences(item.References, true);
@@ -90,7 +91,7 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (!CommandTypes.Contains(request.Type)) return "unsupported command type";
if ((request.Type is "exam" or "order" or "cancel_order" or "location") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
if ((request.Type is "exam" or "order" or "cancel_order" or "location" or "dialogue_choice") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null;
}
@@ -119,4 +120,47 @@ public static class CcsCaseValidator
return "history and examinations cannot be mixed with order changes in one batch";
return null;
}
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
var nodeIds = interview.Nodes.Select(value => value.Id).ToList();
if (nodeIds.Any(string.IsNullOrWhiteSpace) || nodeIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != nodeIds.Count)
errors.Add(prefix + "interview node ids must be present and unique");
var nodeMap = interview.Nodes.Where(value => !string.IsNullOrWhiteSpace(value.Id))
.GroupBy(value => value.Id, StringComparer.OrdinalIgnoreCase).ToDictionary(value => value.Key, value => value.First(), StringComparer.OrdinalIgnoreCase);
if (!nodeMap.ContainsKey(interview.StartNodeId)) errors.Add(prefix + "interview start node does not exist");
var choiceIds = interview.Nodes.SelectMany(value => value.Choices).Select(value => value.Id).ToList();
if (choiceIds.Any(string.IsNullOrWhiteSpace) || choiceIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != choiceIds.Count)
errors.Add(prefix + "interview choice ids must be present and globally unique");
foreach (var node in interview.Nodes)
{
if (string.IsNullOrWhiteSpace(node.PatientText)) errors.Add(prefix + $"interview node {node.Id} requires patientText");
if (node.IsTerminal && node.Choices.Count > 0) errors.Add(prefix + $"terminal interview node {node.Id} cannot have choices");
if (!node.IsTerminal && node.Choices.Count == 0) errors.Add(prefix + $"nonterminal interview node {node.Id} requires choices");
foreach (var choice in node.Choices)
{
if (string.IsNullOrWhiteSpace(choice.Text)) errors.Add(prefix + $"interview choice {choice.Id} requires text");
if (string.IsNullOrWhiteSpace(choice.NextNodeId) || !nodeMap.ContainsKey(choice.NextNodeId))
errors.Add(prefix + $"interview choice {choice.Id} references an unknown next node");
}
}
if (!nodeMap.ContainsKey(interview.StartNodeId)) return;
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var visiting = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool Walk(string nodeId)
{
if (visiting.Contains(nodeId)) { errors.Add(prefix + "interview graph cannot contain cycles"); return false; }
if (visited.Contains(nodeId)) return true;
visiting.Add(nodeId);
if (nodeMap.TryGetValue(nodeId, out var node))
foreach (var choice in node.Choices.Where(value => nodeMap.ContainsKey(value.NextNodeId))) Walk(choice.NextNodeId);
visiting.Remove(nodeId); visited.Add(nodeId); return true;
}
Walk(interview.StartNodeId);
if (visited.Count != nodeMap.Count) errors.Add(prefix + "every interview node must be reachable from startNodeId");
if (!interview.Nodes.Any(value => value.IsTerminal)) errors.Add(prefix + "interview requires a terminal node");
}
}