Add a review-flag state for questions, independent of publish status

Flagged questions disappear from every student-facing surface (search,
exam simulation sampling, the missed-question review queue, bookmarks,
direct fetch) while staying published - flagging and unpublishing are
now separate, reversible actions with their own audit trail (who
flagged it, when, why). Backend-only for now: SUPER-only flag/unflag/
list endpoints, no client UI. Includes a one-time CLI sweep
(flag-media-questions) to flag every existing question with an image
so they can be reviewed.
This commit is contained in:
hz
2026-07-22 22:42:19 -05:00
parent e8d615985b
commit 9f62444a63
14 changed files with 187 additions and 9 deletions
@@ -0,0 +1,21 @@
namespace QuestionsApi.Services;
// One-time sweep: flags every question that currently has at least one image so it can be
// pulled from student rotation for review, without touching IsPublished. Idempotent (only
// touches questions not already flagged, via QuestionStore.FlagAllWithMediaAsync's filter) -
// safe to re-run.
public static class FlagMediaQuestionsCommand
{
private const string Reason = "Bulk-flagged for image review";
private const string FlaggedBy = "system";
public static async Task<int> RunAsync(IConfiguration configuration, TextWriter output)
{
var mongo = new MongoDatabaseProvider(configuration);
var questions = new QuestionStore(mongo);
var flagged = await questions.FlagAllWithMediaAsync(FlaggedBy, Reason);
await output.WriteLineAsync($"Flagged {flagged} question(s) with images for review.");
return 0;
}
}
@@ -105,7 +105,11 @@ public sealed class QuestionBookmarkStore
}
var questionMatch = new BsonDocument();
if (!includeUnpublished) questionMatch["Question.IsPublished"] = true;
if (!includeUnpublished)
{
questionMatch["Question.IsPublished"] = true;
questionMatch["Question.FlaggedForReviewUtc"] = BsonNull.Value;
}
if (!string.IsNullOrWhiteSpace(system) && system != "All Systems") questionMatch["Question.System"] = system.Trim();
if (!string.IsNullOrWhiteSpace(discipline)) questionMatch["Question.Discipline"] = discipline.Trim();
if (!string.IsNullOrWhiteSpace(query))
@@ -172,7 +176,11 @@ public sealed class QuestionBookmarkStore
new("$unwind", "$Question")
};
if (!includeUnpublished)
pipeline.Add(new BsonDocument("$match", new BsonDocument("Question.IsPublished", true)));
pipeline.Add(new BsonDocument("$match", new BsonDocument
{
{ "Question.IsPublished", true },
{ "Question.FlaggedForReviewUtc", BsonNull.Value }
}));
pipeline.Add(new BsonDocument("$group", new BsonDocument
{
{ "_id", BsonNull.Value },
+50 -2
View File
@@ -26,7 +26,10 @@ public class QuestionStore
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))
@@ -65,7 +68,7 @@ public class QuestionStore
await _col.Find(Builders<Question>.Filter.Empty).ToListAsync();
public async Task<List<Question>> GetPublishedAsync() =>
await _col.Find(question => question.IsPublished).ToListAsync();
await _col.Find(question => question.IsPublished && question.FlaggedForReviewUtc == null).ToListAsync();
public async Task<Dictionary<string, Question>> GetByExternalIdsAsync(IEnumerable<string> externalIds)
{
@@ -125,6 +128,9 @@ public class QuestionStore
return null;
question.CreatedUtc = existing.CreatedUtc;
question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc;
question.FlagReason = existing.FlagReason;
question.FlaggedByUserId = existing.FlaggedByUserId;
var result = await _col.ReplaceOneAsync(q => q.Id == id, question);
return result.MatchedCount == 0 ? null : question;
}
@@ -143,6 +149,9 @@ public class QuestionStore
{
question.Id = existing.Id;
question.CreatedUtc = existing.CreatedUtc;
question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc;
question.FlagReason = existing.FlagReason;
question.FlaggedByUserId = existing.FlaggedByUserId;
}
else
{
@@ -167,7 +176,7 @@ public class QuestionStore
{
var filter = includeUnpublished
? Builders<Question>.Filter.Empty
: Builders<Question>.Filter.Eq(q => q.IsPublished, true);
: 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();
}
@@ -181,6 +190,45 @@ public class QuestionStore
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;
@@ -0,0 +1,9 @@
namespace QuestionsApi.Services;
// Pure student-visibility decision for a question, kept dependency-free (like FlashcardScheduling)
// so it's unit testable without a controller or Mongo. Consolidates what used to be duplicated
// inline across QuestionsController/QuestionReportsController.
public static class QuestionVisibility
{
public static bool IsVisibleToStudent(bool isPublished, bool isFlagged) => isPublished && !isFlagged;
}
@@ -129,7 +129,7 @@ public sealed class ReviewQueueStore
foreach (var candidate in candidates)
{
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
if (question is { IsPublished: true }) return candidate;
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate;
await _items.UpdateOneAsync(
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
Builders<ReviewQueueItem>.Update