Files
step3/api/QuestionsApi/Services/QuestionStore.cs
T

320 lines
14 KiB
C#

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<Question> _col;
public QuestionStore(MongoDatabaseProvider mongo)
{
_col = mongo.Database.GetCollection<Question>("Questions");
var externalIdIndex = Builders<Question>.IndexKeys.Ascending(q => q.ExternalId);
_col.Indexes.CreateOne(new CreateIndexModel<Question>(externalIdIndex, new CreateIndexOptions { Unique = true }));
var systemIndex = Builders<Question>.IndexKeys.Ascending(q => q.System).Ascending(q => q.IsPublished);
_col.Indexes.CreateOne(new CreateIndexModel<Question>(systemIndex));
var tagsIndex = Builders<Question>.IndexKeys.Ascending(q => q.Tags);
_col.Indexes.CreateOne(new CreateIndexModel<Question>(tagsIndex));
var step3SystemIndex = Builders<Question>.IndexKeys.Ascending(q => q.Step3System)
.Ascending(q => q.IsPublished).Ascending(q => q.FlaggedForReviewUtc).Ascending(q => q.Difficulty);
_col.Indexes.CreateOne(new CreateIndexModel<Question>(step3SystemIndex,
new CreateIndexOptions { Name = "step3_system_visibility_difficulty" }));
}
public async Task<List<Question>> SearchAsync(QuestionSearchRequest req, bool includeUnpublished)
{
var builder = Builders<Question>.Filter;
var filters = new List<FilterDefinition<Question>>();
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);
if (string.IsNullOrWhiteSpace(req.Step3System))
return await _col.Aggregate().Match(filter).Sample(limit).ToListAsync();
var requested = Step3SystemCatalog.Normalize(req.Step3System)
?? throw new ArgumentException("step3System must be an official Step 3 system");
// Include legacy records until the idempotent Step3System backfill has run.
var systemFilter = builder.Eq(question => question.Step3System, requested)
| builder.Exists(nameof(Question.Step3System), false)
| builder.Eq(question => question.Step3System, "");
var candidates = await _col.Find(filter & systemFilter).ToListAsync();
return candidates.Where(question => Step3SystemCatalog.Normalize(question) == requested)
.OrderBy(_ => Random.Shared.Next()).Take(limit).ToList();
}
public async Task<Question?> GetByIdAsync(string id) =>
await _col.Find(q => q.Id == id).FirstOrDefaultAsync();
public async Task<Question?> GetByExternalIdAsync(string externalId) =>
await _col.Find(q => q.ExternalId == externalId).FirstOrDefaultAsync();
public async Task<List<Question>> GetAllAsync() =>
await _col.Find(Builders<Question>.Filter.Empty).ToListAsync();
public async Task<List<Question>> GetPublishedAsync() =>
await _col.Find(question => question.IsPublished && question.FlaggedForReviewUtc == null).ToListAsync();
public async Task<Dictionary<string, Question>> GetByExternalIdsAsync(IEnumerable<string> 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<Question> Items, string? NextCursor, bool HasMore)> ExportPageAsync(
string? after,
int requestedLimit)
{
var limit = Math.Clamp(requestedLimit, 1, 200);
var filter = Builders<Question>.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<Question>.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<Question?> 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<Question?> 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<int> UpsertManyByExternalIdAsync(IEnumerable<UpsertQuestionRequest> requests)
{
var count = 0;
foreach (var req in requests)
{
var question = FromRequest(req);
question.UpdatedUtc = DateTime.UtcNow;
var filter = Builders<Question>.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<bool> DeleteAsync(string id)
{
var result = await _col.DeleteOneAsync(q => q.Id == id);
return result.DeletedCount > 0;
}
public async Task<List<string>> GetSystemsAsync(bool includeUnpublished)
{
var filter = includeUnpublished
? Builders<Question>.Filter.Empty
: Builders<Question>.Filter.Eq(q => q.IsPublished, true) & Builders<Question>.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<List<string>> GetMissingExternalIdsAsync(IEnumerable<string> externalIds)
{
var requested = externalIds.Distinct(StringComparer.Ordinal).ToList();
var existing = await _col.Find(Builders<Question>.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<long> FlagAllWithMediaAsync(string flaggedByUserId, string reason)
{
var filter = Builders<Question>.Filter.Exists("Media.0", true)
& Builders<Question>.Filter.Eq(q => q.FlaggedForReviewUtc, null);
var update = Builders<Question>.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<Question?> FlagAsync(string id, string reason, string flaggedByUserId)
{
var update = Builders<Question>.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<Question> { ReturnDocument = ReturnDocument.After });
}
public async Task<Question?> UnflagAsync(string id)
{
var update = Builders<Question>.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<Question> { ReturnDocument = ReturnDocument.After });
}
public async Task<List<Question>> GetFlaggedAsync() =>
await _col.Find(q => q.FlaggedForReviewUtc != null)
.SortByDescending(q => q.FlaggedForReviewUtc)
.ToListAsync();
public async Task<int> UpdateReferencesByExternalIdAsync(IEnumerable<QuestionReferencesUpdate> updates)
{
var now = DateTime.UtcNow;
var writes = updates.Select(update =>
new UpdateOneModel<Question>(
Builders<Question>.Filter.Eq(question => question.ExternalId, update.ExternalId.Trim()),
Builders<Question>.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(),
Step3System = Step3SystemCatalog.Derive(new Question
{
ExternalId = req.ExternalId.Trim(), System = req.System.Trim(),
PerformanceCategory = req.PerformanceCategory.Trim(), Discipline = req.Discipline.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<QuestionReference> NormalizeReferences(IEnumerable<QuestionReference>? 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();
}