using MongoDB.Driver; using MongoDB.Bson; using QuestionsApi.Models; namespace QuestionsApi.Services; public class QuestionStore { private readonly IMongoCollection _col; public QuestionStore(IConfiguration cfg) { var cs = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017"; var dbName = cfg["MongoDB:DatabaseName"] ?? "GameDb"; var client = new MongoClient(cs); var db = client.GetDatabase(dbName); _col = db.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 filter = filters.Count == 0 ? builder.Empty : builder.And(filters); var limit = Math.Clamp(req.Limit, 1, 80); var questions = await _col.Find(filter).Limit(limit).ToListAsync(); return questions.OrderBy(_ => Guid.NewGuid()).ToList(); } 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 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(); } 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(), IsPublished = req.IsPublished }; }