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 _bookmarks; public QuestionBookmarkStore(MongoDatabaseProvider mongo) { _bookmarks = mongo.Database.GetCollection("QuestionBookmarks"); _bookmarks.Indexes.CreateMany([ new CreateIndexModel( Builders.IndexKeys .Ascending(item => item.UserId) .Ascending(item => item.QuestionExternalId), new CreateIndexOptions { Unique = true, Name = "bookmark_unique_user_question" }), new CreateIndexModel( Builders.IndexKeys .Ascending(item => item.UserId) .Descending(item => item.CreatedUtc) .Descending(item => item.Id), new CreateIndexOptions { Name = "bookmark_user_created_desc" }) ]); } public async Task AddAsync(string userId, string externalId) { var now = DateTime.UtcNow; try { return await _bookmarks.FindOneAndUpdateAsync( item => item.UserId == userId && item.QuestionExternalId == externalId, Builders.Update .SetOnInsert(item => item.UserId, userId) .SetOnInsert(item => item.QuestionExternalId, externalId) .SetOnInsert(item => item.CreatedUtc, now) .Set(item => item.UpdatedUtc, now), new FindOneAndUpdateOptions { 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 IsBookmarkedAsync(string userId, string externalId) => await _bookmarks.Find(item => item.UserId == userId && item.QuestionExternalId == externalId).AnyAsync(); public async Task> GetBookmarkedIdsAsync(string userId, IEnumerable 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 { 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(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(bookmarkDoc), BsonSerializer.Deserialize(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 Systems, List Disciplines)> GetFacetsAsync( string userId, bool includeUnpublished) { var pipeline = new List { 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(pipeline).FirstOrDefaultAsync(); if (result is null) return ([], []); static List 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(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); }