229 lines
16 KiB
C#
229 lines
16 KiB
C#
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public static class CcsCaseValidator
|
|
{
|
|
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)
|
|
{
|
|
var errors = new List<string>();
|
|
var orderCodes = existingOrderCodes.Concat(request.Orders.Select(order => order.Code.Trim())).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var order in request.Orders)
|
|
{
|
|
var prefix = $"order {order.Code}: ";
|
|
if (string.IsNullOrWhiteSpace(order.Code)) errors.Add(prefix + "code required");
|
|
if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required");
|
|
if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required");
|
|
if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative");
|
|
if (!string.IsNullOrWhiteSpace(order.ResultSection) && !CcsChartCategories.ResultSections.Contains(order.ResultSection))
|
|
errors.Add(prefix + "resultSection is invalid");
|
|
}
|
|
if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count)
|
|
errors.Add("order codes must be unique within an import");
|
|
if (request.Cases.Select(item => item.ExternalId).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Cases.Count)
|
|
errors.Add("case externalIds must be unique within an import");
|
|
|
|
foreach (var item in request.Cases)
|
|
{
|
|
var prefix = $"case {item.ExternalId}: ";
|
|
if (string.IsNullOrWhiteSpace(item.ExternalId)) errors.Add(prefix + "externalId required");
|
|
if (string.IsNullOrWhiteSpace(item.Title)) errors.Add(prefix + "title required");
|
|
if (string.IsNullOrWhiteSpace(item.System)) errors.Add(prefix + "system required");
|
|
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
|
|
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
|
|
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
|
|
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
|
|
errors.Add(prefix + "experienceMode is invalid");
|
|
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
|
|
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|
|
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
|
|
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
|
|
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
|
|
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);
|
|
if (referenceError is not null) errors.Add(prefix + referenceError);
|
|
if (item.ScoringCriteria.Count == 0) errors.Add(prefix + "published cases require scoringCriteria");
|
|
}
|
|
var examCodes = item.Exams.Select(exam => exam.Code).ToList();
|
|
if (examCodes.Any(string.IsNullOrWhiteSpace) || examCodes.Distinct(StringComparer.OrdinalIgnoreCase).Count() != examCodes.Count)
|
|
errors.Add(prefix + "exam codes must be present and unique");
|
|
var eventIds = item.Events.Select(value => value.Id).ToList();
|
|
if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count)
|
|
errors.Add(prefix + "event ids must be present and unique");
|
|
foreach (var response in item.OrderResponses)
|
|
{
|
|
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
|
|
if (response.TurnaroundMinutes < 0) errors.Add(prefix + $"order response {response.OrderCode} turnaroundMinutes cannot be negative");
|
|
if (response.RequiresPatientVisit && string.IsNullOrWhiteSpace(response.PerformanceLocation))
|
|
errors.Add(prefix + $"order response {response.OrderCode} requires a performanceLocation");
|
|
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
|
|
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
|
|
}
|
|
foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes))))
|
|
if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}");
|
|
var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList();
|
|
if (criteriaIds.Any(string.IsNullOrWhiteSpace) || criteriaIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != criteriaIds.Count)
|
|
errors.Add(prefix + "scoring criterion ids must be present and unique");
|
|
foreach (var criterion in item.ScoringCriteria)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(criterion.Title)) errors.Add(prefix + $"criterion {criterion.Id} requires a title");
|
|
var targets = criterion.TargetCodes.Count > 0 ? criterion.TargetCodes : [criterion.TargetCode];
|
|
if (criterion.Points == 0) errors.Add(prefix + $"criterion {criterion.Id} points cannot be zero");
|
|
if (targets.Any(string.IsNullOrWhiteSpace)) errors.Add(prefix + $"criterion {criterion.Id} requires a target code");
|
|
foreach (var target in targets)
|
|
{
|
|
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
|
|
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
|
|
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
|
|
if (criterion.ActionType == "dialogue_choice"
|
|
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
|
|
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
|
|
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
|
|
}
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
public static string? ValidateCreate(CreateCcsAttemptRequest request)
|
|
{
|
|
if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase))
|
|
return "new CCS attempts must be timed";
|
|
return null;
|
|
}
|
|
|
|
public static string? ValidateCommand(CcsCommandRequest request)
|
|
{
|
|
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" 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;
|
|
}
|
|
|
|
public static string? ValidateBatch(CcsBatchCommandRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
|
|
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
|
|
if (request.Commands.Count is < 1 or > 50) return "commands must contain between 1 and 50 items";
|
|
foreach (var item in request.Commands)
|
|
{
|
|
var error = ValidateCommand(new CcsCommandRequest
|
|
{
|
|
CommandId = request.CommandId,
|
|
ExpectedRevision = request.ExpectedRevision,
|
|
Type = item.Type,
|
|
Code = item.Code,
|
|
Minutes = item.Minutes
|
|
});
|
|
if (error is not null) return error;
|
|
if (item.Type is not ("exam" or "interval_history" or "order" or "cancel_order"))
|
|
return "batch commands support history/exams, orders, and order cancellations only";
|
|
}
|
|
var hasClinicalAssessment = request.Commands.Any(value => value.Type is "exam" or "interval_history");
|
|
if (hasClinicalAssessment && request.Commands.Any(value => value.Type is not ("exam" or "interval_history")))
|
|
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 (interview.Topics.Count > 0)
|
|
{
|
|
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
|
|
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
|
|
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
|
|
errors.Add(prefix + "topic interview requires openingPatientText");
|
|
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
|
|
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
|
|
var topicIds = interview.Topics.Select(value => value.Id).ToList();
|
|
if (topicIds.Any(string.IsNullOrWhiteSpace)
|
|
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
|
|
errors.Add(prefix + "interview topic ids must be present and unique");
|
|
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
|
|
errors.Add(prefix + "finish_interview is a reserved interview topic id");
|
|
if (!interview.Topics.Any(value => value.Required))
|
|
errors.Add(prefix + "topic interview requires at least one required topic");
|
|
foreach (var topic in interview.Topics)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
|
|
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
|
|
if (string.IsNullOrWhiteSpace(topic.PatientText))
|
|
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
|
|
foreach (var prerequisite in topic.PrerequisiteTopicIds)
|
|
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|
|
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
|
|
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
|
|
}
|
|
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
|
|
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
bool ResolveTopic(string topicId)
|
|
{
|
|
if (resolving.Contains(topicId))
|
|
{
|
|
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
|
|
return false;
|
|
}
|
|
if (resolved.Contains(topicId)) return true;
|
|
resolving.Add(topicId);
|
|
if (topicMap.TryGetValue(topicId, out var topic))
|
|
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
|
|
ResolveTopic(prerequisite);
|
|
resolving.Remove(topicId);
|
|
resolved.Add(topicId);
|
|
return true;
|
|
}
|
|
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
|
|
return;
|
|
}
|
|
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");
|
|
}
|
|
}
|