using MongoDB.Driver; using MongoDB.Bson; using QuestionsApi.Models; namespace QuestionsApi.Services; public class QuestionStore { 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)); 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)); 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<(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; 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; 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; } else { question.Id = ObjectId.GenerateNewId().ToString(); question.CreatedUtc = DateTime.UtcNow; } 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); 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(); } 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; } 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(), 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(); }