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.
224 lines
9.4 KiB
C#
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);
|
|
}
|