using QuestionsApi.Models; namespace QuestionsApi.Services; public static class CcsCaseValidator { private static readonly HashSet CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"]; public static List ValidateImport(CcsImportRequest request, IReadOnlySet existingOrderCodes) { var errors = new List(); 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 (!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 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(StringComparer.OrdinalIgnoreCase); var resolving = new HashSet(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(StringComparer.OrdinalIgnoreCase); var visiting = new HashSet(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"); } }