using MongoDB.Driver; using MongoDB.Bson; using QuestionsApi.Models; namespace QuestionsApi.Services; public class QuestionStore { private const string MediaReviewReason = "Contains media - pending review"; private const string MediaReviewFlaggedBy = "system"; private readonly IMongoCollection _col; public QuestionStore(MongoDatabaseProvider mongo) { _col = mongo.Database.GetCollection("Questions"); var externalIdIndex = Builders.IndexKeys.Ascending(q => q.ExternalId); _col.Indexes.CreateOne(new CreateIndexModel(externalIdIndex, new CreateIndexOptions { Unique = true })); var systemIndex = Builders.IndexKeys.Ascending(q => q.System).Ascending(q => q.IsPublished); _col.Indexes.CreateOne(new CreateIndexModel(systemIndex)); var tagsIndex = Builders.IndexKeys.Ascending(q => q.Tags); _col.Indexes.CreateOne(new CreateIndexModel(tagsIndex)); } public async Task> SearchAsync(QuestionSearchRequest req, bool includeUnpublished) { var builder = Builders.Filter; var filters = new List>(); if (!includeUnpublished) { filters.Add(builder.Eq(q => q.IsPublished, true)); filters.Add(builder.Eq(q => q.FlaggedForReviewUtc, null)); } if (!string.IsNullOrWhiteSpace(req.System) && req.System != "All Systems") filters.Add(builder.Eq(q => q.System, req.System.Trim())); if (!string.IsNullOrWhiteSpace(req.Discipline)) filters.Add(builder.Eq(q => q.Discipline, req.Discipline.Trim())); if (req.Tags.Count > 0) filters.Add(builder.All(q => q.Tags, req.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()))); if (req.DifficultyMin is not null) filters.Add(builder.Gte(q => q.Difficulty, req.DifficultyMin.Value)); if (req.DifficultyMax is not null) filters.Add(builder.Lte(q => q.Difficulty, req.DifficultyMax.Value)); if (req.RequireMedia) filters.Add(builder.Exists("Media.0", true)); var excludedIds = req.ExcludeExternalIds .Where(id => !string.IsNullOrWhiteSpace(id)) .Select(id => id.Trim()) .Distinct(StringComparer.Ordinal) .ToList(); if (excludedIds.Count > 0) filters.Add(builder.Nin(q => q.ExternalId, excludedIds)); var filter = filters.Count == 0 ? builder.Empty : builder.And(filters); var limit = Math.Clamp(req.Limit, 1, 80); return await _col.Aggregate() .Match(filter) .Sample(limit) .ToListAsync(); } public async Task GetByIdAsync(string id) => await _col.Find(q => q.Id == id).FirstOrDefaultAsync(); public async Task GetByExternalIdAsync(string externalId) => await _col.Find(q => q.ExternalId == externalId).FirstOrDefaultAsync(); public async Task> GetAllAsync() => await _col.Find(Builders.Filter.Empty).ToListAsync(); public async Task> GetPublishedAsync() => await _col.Find(question => question.IsPublished && question.FlaggedForReviewUtc == null).ToListAsync(); public async Task> GetByExternalIdsAsync(IEnumerable externalIds) { var ids = externalIds.Distinct(StringComparer.Ordinal).ToList(); var questions = await _col.Find(question => ids.Contains(question.ExternalId)).ToListAsync(); return questions.ToDictionary(question => question.ExternalId, StringComparer.Ordinal); } public async Task<(List Items, string? NextCursor, bool HasMore)> ExportPageAsync( string? after, int requestedLimit) { var limit = Math.Clamp(requestedLimit, 1, 200); var filter = Builders.Filter.Empty; if (!string.IsNullOrWhiteSpace(after)) { if (!ObjectId.TryParse(after, out var cursor)) throw new ArgumentException("after must be a valid question cursor", nameof(after)); filter = new BsonDocument("_id", new BsonDocument("$gt", cursor)); } var page = await _col.Find(filter) .Sort(Builders.Sort.Ascending("_id")) .Limit(limit + 1) .ToListAsync(); var hasMore = page.Count > limit; if (hasMore) page.RemoveAt(page.Count - 1); return (page, hasMore ? page[^1].Id : null, hasMore); } public async Task CreateAsync(UpsertQuestionRequest req) { var question = FromRequest(req); question.CreatedUtc = DateTime.UtcNow; question.UpdatedUtc = DateTime.UtcNow; ApplyMediaReviewGate(question); try { await _col.InsertOneAsync(question); return question; } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { return null; } } public async Task UpdateAsync(string id, UpsertQuestionRequest req) { var question = FromRequest(req); question.Id = id; question.UpdatedUtc = DateTime.UtcNow; var existing = await GetByIdAsync(id); if (existing is null) return null; question.CreatedUtc = existing.CreatedUtc; question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc; question.FlagReason = existing.FlagReason; question.FlaggedByUserId = existing.FlaggedByUserId; ApplyMediaReviewGate(question); var result = await _col.ReplaceOneAsync(q => q.Id == id, question); return result.MatchedCount == 0 ? null : question; } public async Task UpsertManyByExternalIdAsync(IEnumerable requests) { var count = 0; foreach (var req in requests) { var question = FromRequest(req); question.UpdatedUtc = DateTime.UtcNow; var filter = Builders.Filter.Eq(q => q.ExternalId, question.ExternalId); var existing = await _col.Find(filter).FirstOrDefaultAsync(); if (existing is not null) { question.Id = existing.Id; question.CreatedUtc = existing.CreatedUtc; question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc; question.FlagReason = existing.FlagReason; question.FlaggedByUserId = existing.FlaggedByUserId; } else { question.Id = ObjectId.GenerateNewId().ToString(); question.CreatedUtc = DateTime.UtcNow; } ApplyMediaReviewGate(question); await _col.ReplaceOneAsync(filter, question, new ReplaceOptions { IsUpsert = true }); count++; } return count; } public async Task DeleteAsync(string id) { var result = await _col.DeleteOneAsync(q => q.Id == id); return result.DeletedCount > 0; } public async Task> GetSystemsAsync(bool includeUnpublished) { var filter = includeUnpublished ? Builders.Filter.Empty : Builders.Filter.Eq(q => q.IsPublished, true) & Builders.Filter.Eq(q => q.FlaggedForReviewUtc, null); var systems = await _col.Distinct(q => q.System, filter).ToListAsync(); return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList(); } public async Task> GetMissingExternalIdsAsync(IEnumerable externalIds) { var requested = externalIds.Distinct(StringComparer.Ordinal).ToList(); var existing = await _col.Find(Builders.Filter.In(q => q.ExternalId, requested)) .Project(q => q.ExternalId) .ToListAsync(); return requested.Except(existing, StringComparer.Ordinal).OrderBy(id => id).ToList(); } // One-time bulk sweep: flags every question that has at least one image and isn't already // flagged, in a single update rather than fetching everything into memory. public async Task FlagAllWithMediaAsync(string flaggedByUserId, string reason) { var filter = Builders.Filter.Exists("Media.0", true) & Builders.Filter.Eq(q => q.FlaggedForReviewUtc, null); var update = Builders.Update .Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow) .Set(q => q.FlagReason, reason) .Set(q => q.FlaggedByUserId, flaggedByUserId); var result = await _col.UpdateManyAsync(filter, update); return result.ModifiedCount; } public async Task FlagAsync(string id, string reason, string flaggedByUserId) { var update = Builders.Update .Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow) .Set(q => q.FlagReason, reason) .Set(q => q.FlaggedByUserId, flaggedByUserId); return await _col.FindOneAndUpdateAsync(q => q.Id == id, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); } public async Task UnflagAsync(string id) { var update = Builders.Update .Set(q => q.FlaggedForReviewUtc, null) .Set(q => q.FlagReason, "") .Set(q => q.FlaggedByUserId, ""); return await _col.FindOneAndUpdateAsync(q => q.Id == id, update, new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); } public async Task> GetFlaggedAsync() => await _col.Find(q => q.FlaggedForReviewUtc != null) .SortByDescending(q => q.FlaggedForReviewUtc) .ToListAsync(); public async Task UpdateReferencesByExternalIdAsync(IEnumerable updates) { var now = DateTime.UtcNow; var writes = updates.Select(update => new UpdateOneModel( Builders.Filter.Eq(question => question.ExternalId, update.ExternalId.Trim()), Builders.Update .Set(question => question.References, NormalizeReferences(update.References)) .Set(question => question.UpdatedUtc, now))) .ToList(); if (writes.Count == 0) return 0; var result = await _col.BulkWriteAsync(writes, new BulkWriteOptions { IsOrdered = true }); return (int)result.MatchedCount; } // Questions with media must be reviewed before reaching students - gate any question that // gains media and isn't already flagged, so newly created/imported/edited questions can't // slip past the media-review requirement the way a one-time sweep alone would miss. internal static void ApplyMediaReviewGate(Question question) { if (question.Media.Count == 0 || question.FlaggedForReviewUtc is not null) return; question.FlaggedForReviewUtc = DateTime.UtcNow; question.FlagReason = MediaReviewReason; question.FlaggedByUserId = MediaReviewFlaggedBy; } private static Question FromRequest(UpsertQuestionRequest req) => new() { ExternalId = req.ExternalId.Trim(), Stem = req.Stem.Trim(), LeadIn = req.LeadIn.Trim(), Choices = req.Choices, CorrectChoiceId = req.CorrectChoiceId.Trim(), Explanation = req.Explanation.Trim(), KeyTakeaway = req.KeyTakeaway.Trim(), UsmlePearl = req.UsmlePearl.Trim(), ReviewFollowups = req.ReviewFollowups, ChoiceExplanations = req.ChoiceExplanations, WrongAnswerFollowups = req.WrongAnswerFollowups, ChoiceSelectionPercentages = req.ChoiceSelectionPercentages, Discipline = req.Discipline.Trim(), System = req.System.Trim(), PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline), Difficulty = req.Difficulty, Labs = req.Labs, Media = req.Media, Tags = req.Tags.Where(t => !string.IsNullOrWhiteSpace(t)).Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList(), References = NormalizeReferences(req.References), IsPublished = req.IsPublished }; private static List NormalizeReferences(IEnumerable? references) => (references ?? []).Select(reference => new QuestionReference { Title = reference.Title.Trim(), Publisher = reference.Publisher.Trim(), Url = reference.Url.Trim(), Supports = reference.Supports.Trim(), AccessedOn = reference.AccessedOn.Trim(), Reviewer = reference.Reviewer.Trim() }).ToList(); }