using QuestionsApi.Models; using System.Globalization; namespace QuestionsApi.Services; public static class QuestionValidator { public static string? Validate(UpsertQuestionRequest req) { if (string.IsNullOrWhiteSpace(req.ExternalId)) return "externalId required"; if (string.IsNullOrWhiteSpace(req.Stem)) return "stem required"; if (string.IsNullOrWhiteSpace(req.LeadIn)) return "leadIn required"; if (req.Choices.Count < (req.IsPublished ? 5 : 2)) return req.IsPublished ? "published questions require at least five choices" : "at least two choices required"; if (req.Choices.Count > 20) return "questions support at most twenty choices"; if (req.Choices.Any(choice => string.IsNullOrWhiteSpace(choice.Id) || string.IsNullOrWhiteSpace(choice.Text))) return "choice ids and text are required"; var choiceIds = req.Choices.Select(choice => choice.Id.Trim()).ToList(); if (choiceIds.Distinct(StringComparer.Ordinal).Count() != choiceIds.Count) return "choice ids must be unique"; if (string.IsNullOrWhiteSpace(req.CorrectChoiceId)) return "correctChoiceId required"; if (!choiceIds.Contains(req.CorrectChoiceId.Trim(), StringComparer.Ordinal)) return "correctChoiceId must match a choice id"; if (string.IsNullOrWhiteSpace(req.Explanation)) return "explanation required"; var choiceIdSet = choiceIds.ToHashSet(StringComparer.Ordinal); if (!choiceIdSet.SetEquals(req.ChoiceExplanations.Keys)) return "choiceExplanations must contain exactly one entry for every choice"; var incorrectChoiceIds = choiceIdSet .Where(id => id != req.CorrectChoiceId.Trim()) .ToHashSet(StringComparer.Ordinal); if (!incorrectChoiceIds.SetEquals(req.WrongAnswerFollowups.Keys)) return "wrongAnswerFollowups must contain exactly one entry for every incorrect choice"; if (req.ChoiceSelectionPercentages.Keys.Any(id => !choiceIdSet.Contains(id))) return "choiceSelectionPercentages keys must match choice ids"; if (req.ChoiceSelectionPercentages.Any(entry => entry.Value is < 0 or > 100)) return "choiceSelectionPercentages values must be between 0 and 100"; foreach (var entry in req.WrongAnswerFollowups) { if (string.IsNullOrWhiteSpace(entry.Value.Prompt)) return "wrongAnswerFollowups prompt required"; if (string.IsNullOrWhiteSpace(entry.Value.TeachingPoint)) return "wrongAnswerFollowups teachingPoint required"; if (entry.Value.Choices.Count == 0) return "wrongAnswerFollowups choices required"; if (entry.Value.Choices.Any(choice => string.IsNullOrWhiteSpace(choice.Id) || string.IsNullOrWhiteSpace(choice.Text))) return "wrongAnswerFollowups choice ids and text are required"; if (entry.Value.Choices.Select(choice => choice.Id).Distinct(StringComparer.Ordinal).Count() != entry.Value.Choices.Count) return "wrongAnswerFollowups choice ids must be unique"; if (!entry.Value.Choices.Any(choice => choice.Id == entry.Value.CorrectChoiceId)) return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id"; } if (req.ReviewFollowups is null) return "reviewFollowups must be an array"; if (req.ReviewFollowups.Count > 10) return "questions support at most ten review follow-ups"; foreach (var followup in req.ReviewFollowups) { if (string.IsNullOrWhiteSpace(followup.Prompt)) return "reviewFollowups prompt required"; if (string.IsNullOrWhiteSpace(followup.TeachingPoint)) return "reviewFollowups teachingPoint required"; if (followup.Choices.Count < 2) return "reviewFollowups require at least two choices"; if (followup.Choices.Any(choice => string.IsNullOrWhiteSpace(choice.Id) || string.IsNullOrWhiteSpace(choice.Text))) return "reviewFollowups choice ids and text are required"; if (followup.Choices.Select(choice => choice.Id).Distinct(StringComparer.Ordinal).Count() != followup.Choices.Count) return "reviewFollowups choice ids must be unique"; if (!followup.Choices.Any(choice => choice.Id == followup.CorrectChoiceId)) return "reviewFollowups correctChoiceId must match a follow-up choice id"; } if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required"; if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5"; var labValidation = ValidateLabs(req.Labs, req.IsPublished); if (labValidation is not null) return labValidation; var referenceValidation = ValidateReferences(req.References, req.IsPublished); if (referenceValidation is not null) return referenceValidation; return null; } public static string? ValidateLabs(IReadOnlyCollection? labs, bool requireComplete) { if (labs is null) return "labs must be an array"; if (labs.Count > 50) return "questions support at most fifty lab results"; foreach (var lab in labs) { if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value)) return "lab name and value are required"; if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference)) return $"published question lab '{lab.Name.Trim()}' requires a reference range"; } return null; } public static string? ValidateReferences(IReadOnlyCollection? references, bool required) { if (references is null) return "verification references must be an array"; if (required && references.Count == 0) return "published questions require at least one verification reference"; if (references.Count > 20) return "questions support at most twenty verification references"; var urls = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var reference in references) { if (string.IsNullOrWhiteSpace(reference.Title) || string.IsNullOrWhiteSpace(reference.Publisher) || string.IsNullOrWhiteSpace(reference.Url) || string.IsNullOrWhiteSpace(reference.Supports) || string.IsNullOrWhiteSpace(reference.AccessedOn) || string.IsNullOrWhiteSpace(reference.Reviewer)) return "verification reference fields are required"; var url = reference.Url.Trim(); if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) return "verification reference urls must be absolute HTTPS urls"; if (!urls.Add(url)) return "verification reference urls must be unique within a question"; if (!DateOnly.TryParseExact( reference.AccessedOn.Trim(), "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) return "verification reference accessedOn must use YYYY-MM-DD"; } return null; } }