Files
step3/api/QuestionsApi/Services/QuestionBookmarkStore.cs
T
hz 9f62444a63 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.
2026-07-22 22:42:19 -05:00

224 lines
9.4 KiB
C#

using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using MongoDB.Bson;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class QuestionBookmarkStore
{
private readonly IMongoCollection<QuestionBookmark> _bookmarks;
public QuestionBookmarkStore(MongoDatabaseProvider mongo)
{
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
_bookmarks.Indexes.CreateMany([
new CreateIndexModel<QuestionBookmark>(
Builders<QuestionBookmark>.IndexKeys
.Ascending(item => item.UserId)
.Ascending(item => item.QuestionExternalId),
new CreateIndexOptions { Unique = true, Name = "bookmark_unique_user_question" }),
new CreateIndexModel<QuestionBookmark>(
Builders<QuestionBookmark>.IndexKeys
.Ascending(item => item.UserId)
.Descending(item => item.CreatedUtc)
.Descending(item => item.Id),
new CreateIndexOptions { Name = "bookmark_user_created_desc" })
]);
}
public async Task<QuestionBookmark> AddAsync(string userId, string externalId)
{
var now = DateTime.UtcNow;
try
{
return await _bookmarks.FindOneAndUpdateAsync(
item => item.UserId == userId && item.QuestionExternalId == externalId,
Builders<QuestionBookmark>.Update
.SetOnInsert(item => item.UserId, userId)
.SetOnInsert(item => item.QuestionExternalId, externalId)
.SetOnInsert(item => item.CreatedUtc, now)
.Set(item => item.UpdatedUtc, now),
new FindOneAndUpdateOptions<QuestionBookmark>
{
IsUpsert = true,
ReturnDocument = ReturnDocument.After
});
}
catch (MongoCommandException exception) when (exception.Code == 11000)
{
return await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId)
.FirstAsync();
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId)
.FirstAsync();
}
}
public async Task RemoveAsync(string userId, string externalId) =>
await _bookmarks.DeleteOneAsync(item => item.UserId == userId && item.QuestionExternalId == externalId);
public async Task<bool> IsBookmarkedAsync(string userId, string externalId) =>
await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId).AnyAsync();
public async Task<HashSet<string>> GetBookmarkedIdsAsync(string userId, IEnumerable<string> externalIds)
{
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
if (ids.Count == 0) return [];
var found = await _bookmarks.Find(item => item.UserId == userId && ids.Contains(item.QuestionExternalId))
.Project(item => item.QuestionExternalId)
.ToListAsync();
return found.ToHashSet(StringComparer.Ordinal);
}
public async Task<(List<(QuestionBookmark Bookmark, Question Question)> Items, string? NextCursor, bool HasMore)> SearchAsync(
string userId,
string query,
string system,
string discipline,
string sort,
string? cursor,
int requestedLimit,
bool includeUnpublished)
{
var descending = !string.Equals(sort, "oldest", StringComparison.OrdinalIgnoreCase);
var limit = Math.Clamp(requestedLimit, 1, 50);
var match = new BsonDocument("UserId", userId);
if (!string.IsNullOrWhiteSpace(cursor))
{
var decoded = DecodeCursor(cursor);
var comparison = descending ? "$lt" : "$gt";
match["$or"] = new BsonArray
{
new BsonDocument("CreatedUtc", new BsonDocument(comparison, decoded.CreatedUtc)),
new BsonDocument
{
{ "CreatedUtc", decoded.CreatedUtc },
{ "_id", new BsonDocument(comparison, decoded.Id) }
}
};
}
var questionMatch = new BsonDocument();
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))
{
var regex = new BsonRegularExpression(Regex.Escape(query.Trim()), "i");
questionMatch["$or"] = new BsonArray
{
new BsonDocument("Question.Stem", regex),
new BsonDocument("Question.LeadIn", regex),
new BsonDocument("Question.Tags", regex)
};
}
var direction = descending ? -1 : 1;
var pipeline = new List<BsonDocument>
{
new("$match", match),
new("$lookup", new BsonDocument
{
{ "from", "Questions" },
{ "localField", "QuestionExternalId" },
{ "foreignField", "ExternalId" },
{ "as", "Question" }
}),
new("$unwind", "$Question")
};
if (questionMatch.ElementCount > 0) pipeline.Add(new BsonDocument("$match", questionMatch));
pipeline.Add(new BsonDocument("$sort", new BsonDocument { { "CreatedUtc", direction }, { "_id", direction } }));
pipeline.Add(new BsonDocument("$limit", limit + 1));
var docs = await _bookmarks.Aggregate<BsonDocument>(pipeline).ToListAsync();
var hasMore = docs.Count > limit;
if (hasMore) docs.RemoveAt(docs.Count - 1);
var items = new List<(QuestionBookmark, Question)>();
foreach (var doc in docs)
{
var bookmarkDoc = doc.DeepClone().AsBsonDocument;
bookmarkDoc.Remove("Question");
items.Add((
BsonSerializer.Deserialize<QuestionBookmark>(bookmarkDoc),
BsonSerializer.Deserialize<Question>(doc["Question"].AsBsonDocument)));
}
var nextCursor = hasMore && items.Count > 0 ? EncodeCursor(items[^1].Item1) : null;
return (items, nextCursor, hasMore);
}
public async Task DeleteStaleAsync(string userId, string externalId) =>
await RemoveAsync(userId, externalId);
public async Task<(List<string> Systems, List<string> Disciplines)> GetFacetsAsync(
string userId,
bool includeUnpublished)
{
var pipeline = new List<BsonDocument>
{
new("$match", new BsonDocument("UserId", userId)),
new("$lookup", new BsonDocument
{
{ "from", "Questions" },
{ "localField", "QuestionExternalId" },
{ "foreignField", "ExternalId" },
{ "as", "Question" }
}),
new("$unwind", "$Question")
};
if (!includeUnpublished)
pipeline.Add(new BsonDocument("$match", new BsonDocument
{
{ "Question.IsPublished", true },
{ "Question.FlaggedForReviewUtc", BsonNull.Value }
}));
pipeline.Add(new BsonDocument("$group", new BsonDocument
{
{ "_id", BsonNull.Value },
{ "Systems", new BsonDocument("$addToSet", "$Question.System") },
{ "Disciplines", new BsonDocument("$addToSet", "$Question.Discipline") }
}));
var result = await _bookmarks.Aggregate<BsonDocument>(pipeline).FirstOrDefaultAsync();
if (result is null) return ([], []);
static List<string> Values(BsonDocument source, string field) => source[field].AsBsonArray
.Where(value => value.IsString && !string.IsNullOrWhiteSpace(value.AsString))
.Select(value => value.AsString)
.OrderBy(value => value)
.ToList();
return (Values(result, "Systems"), Values(result, "Disciplines"));
}
private static string EncodeCursor(QuestionBookmark bookmark)
{
var payload = JsonSerializer.Serialize(new CursorPayload(bookmark.CreatedUtc, bookmark.Id ?? ""));
return Convert.ToBase64String(Encoding.UTF8.GetBytes(payload));
}
private static (DateTime CreatedUtc, ObjectId Id) DecodeCursor(string cursor)
{
try
{
var json = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
var payload = JsonSerializer.Deserialize<CursorPayload>(json)
?? throw new ArgumentException("cursor is invalid");
if (!ObjectId.TryParse(payload.Id, out var id)) throw new ArgumentException("cursor is invalid");
return (payload.CreatedUtc, id);
}
catch (Exception exception) when (exception is FormatException or JsonException)
{
throw new ArgumentException("cursor is invalid", nameof(cursor));
}
}
private sealed record CursorPayload(DateTime CreatedUtc, string Id);
}