Add synced question bookmarks

This commit is contained in:
hz
2026-07-18 23:07:22 -05:00
parent 819a108451
commit 2e52a7ea0a
20 changed files with 1387 additions and 18 deletions
@@ -0,0 +1,29 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class QuestionBookmarkTests
{
[Fact]
public void PublicResponsesDoNotExposeUserOrMongoIdentifiers()
{
Assert.Null(typeof(QuestionBookmarkSummary).GetProperty(nameof(QuestionBookmark.UserId)));
Assert.Null(typeof(QuestionBookmarkSummary).GetProperty(nameof(QuestionBookmark.Id)));
Assert.Null(typeof(QuestionBookmarkMutationResponse).GetProperty(nameof(QuestionBookmark.UserId)));
Assert.Null(typeof(QuestionBookmarkMutationResponse).GetProperty(nameof(QuestionBookmark.Id)));
}
[Fact]
public void QuestionResponseDefaultsToNotBookmarked()
{
var response = QuestionResponse.FromModel(new Question
{
ExternalId = "bookmark-test",
Choices = [new QuestionChoice { Id = "A", Text = "Answer" }],
CorrectChoiceId = "A"
});
Assert.False(response.IsBookmarked);
}
}
@@ -0,0 +1,117 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/bookmarks"), Authorize(Roles = "USER,SUPER")]
public sealed class BookmarksController(
QuestionBookmarkStore bookmarks,
QuestionStore questions,
AnswerStatisticsStore statistics,
RatingStore ratings) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<QuestionBookmarkPageResponse>> List(
[FromQuery] string query = "",
[FromQuery] string system = "",
[FromQuery] string discipline = "",
[FromQuery] string sort = "newest",
[FromQuery] string? cursor = null,
[FromQuery] int limit = 20)
{
if (query.Length > 200) return BadRequest("query must be 200 characters or fewer");
if (system.Length > 120 || discipline.Length > 120)
return BadRequest("filter values must be 120 characters or fewer");
try
{
var page = await bookmarks.SearchAsync(
UserId(), query, system, discipline, sort, cursor, limit, User.IsInRole("SUPER"));
var facets = await bookmarks.GetFacetsAsync(UserId(), User.IsInRole("SUPER"));
return Ok(new QuestionBookmarkPageResponse
{
Items = page.Items.Select(item => new QuestionBookmarkSummary
{
QuestionExternalId = item.Question.ExternalId,
Stem = item.Question.Stem,
LeadIn = item.Question.LeadIn,
System = item.Question.System,
Discipline = item.Question.Discipline,
Difficulty = item.Question.Difficulty,
HasMedia = item.Question.Media.Count > 0,
Tags = item.Question.Tags,
CreatedUtc = item.Bookmark.CreatedUtc
}).ToList(),
NextCursor = page.NextCursor,
HasMore = page.HasMore,
Systems = facets.Systems,
Disciplines = facets.Disciplines
});
}
catch (ArgumentException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut("{externalId}")]
public async Task<ActionResult<QuestionBookmarkMutationResponse>> Add(string externalId)
{
var normalized = NormalizeExternalId(externalId);
if (normalized is null) return BadRequest("externalId is invalid");
var question = await questions.GetByExternalIdAsync(normalized);
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) return NotFound();
await bookmarks.AddAsync(UserId(), question.ExternalId);
return Ok(new QuestionBookmarkMutationResponse
{
QuestionExternalId = question.ExternalId,
IsBookmarked = true
});
}
[HttpDelete("{externalId}")]
public async Task<ActionResult<QuestionBookmarkMutationResponse>> Remove(string externalId)
{
var normalized = NormalizeExternalId(externalId);
if (normalized is null) return BadRequest("externalId is invalid");
await bookmarks.RemoveAsync(UserId(), normalized);
return Ok(new QuestionBookmarkMutationResponse
{
QuestionExternalId = normalized,
IsBookmarked = false
});
}
[HttpGet("{externalId}/question")]
public async Task<ActionResult<QuestionResponse>> Question(string externalId)
{
var normalized = NormalizeExternalId(externalId);
if (normalized is null) return BadRequest("externalId is invalid");
if (!await bookmarks.IsBookmarkedAsync(UserId(), normalized)) return NotFound();
var question = await questions.GetByExternalIdAsync(normalized);
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
{
await bookmarks.DeleteStaleAsync(UserId(), normalized);
return NotFound();
}
var response = QuestionResponse.FromModel(
question,
await statistics.GetPercentagesForQuestionAsync(normalized),
await ratings.GetQuestionEloAsync(normalized));
response.IsBookmarked = true;
return Ok(response);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
private static string? NormalizeExternalId(string value)
{
var normalized = value.Trim();
return normalized.Length is > 0 and <= 200 ? normalized : null;
}
}
@@ -17,6 +17,7 @@ public class QuestionsController : ControllerBase
private readonly QuestionMediaStore _media;
private readonly AnswerStatisticsStore _statistics;
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
public QuestionsController(
QuestionStore questions,
@@ -24,7 +25,8 @@ public class QuestionsController : ControllerBase
RatingStore ratings,
QuestionMediaStore media,
AnswerStatisticsStore statistics,
ServiceMetrics metrics)
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks)
{
_questions = questions;
_attempts = attempts;
@@ -32,6 +34,7 @@ public class QuestionsController : ControllerBase
_media = media;
_statistics = statistics;
_metrics = metrics;
_bookmarks = bookmarks;
}
[HttpGet("systems")]
@@ -49,14 +52,17 @@ public class QuestionsController : ControllerBase
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
var bookmarkedIds = await _bookmarks.GetBookmarkedIdsAsync(UserId(), questions.Select(q => q.ExternalId));
return Ok(questions.Select(q =>
{
percentages.TryGetValue(q.ExternalId, out var questionPercentages);
return QuestionResponse.FromModel(
var response = QuestionResponse.FromModel(
q,
questionPercentages,
questionElos.GetValueOrDefault(q.ExternalId, 1000),
req.SupportsChoicePools);
response.IsBookmarked = bookmarkedIds.Contains(q.ExternalId);
return response;
}).ToList());
}
@@ -90,9 +96,16 @@ public class QuestionsController : ControllerBase
var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId);
var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId);
return Ok(QuestionResponse.FromModel(question, percentages, questionElo));
var response = QuestionResponse.FromModel(question, percentages, questionElo);
response.IsBookmarked = await _bookmarks.IsBookmarkedAsync(UserId(), question.ExternalId);
return Ok(response);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
[HttpPost("attempts/first")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> RecordFirstAttempt([FromBody] FirstQuestionAttemptRequest req)
@@ -0,0 +1,41 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public sealed class QuestionBookmark
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string QuestionExternalId { get; set; } = "";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class QuestionBookmarkSummary
{
public string QuestionExternalId { get; set; } = "";
public string Stem { get; set; } = "";
public string LeadIn { get; set; } = "";
public string System { get; set; } = "";
public string Discipline { get; set; } = "";
public int Difficulty { get; set; }
public bool HasMedia { get; set; }
public List<string> Tags { get; set; } = [];
public DateTime CreatedUtc { get; set; }
}
public sealed class QuestionBookmarkPageResponse
{
public List<QuestionBookmarkSummary> Items { get; set; } = [];
public string? NextCursor { get; set; }
public bool HasMore { get; set; }
public List<string> Systems { get; set; } = [];
public List<string> Disciplines { get; set; } = [];
}
public sealed class QuestionBookmarkMutationResponse
{
public string QuestionExternalId { get; set; } = "";
public bool IsBookmarked { get; set; }
}
@@ -25,6 +25,7 @@ public class QuestionResponse
public List<string> Media { get; set; } = [];
public List<string> Tags { get; set; } = [];
public List<QuestionReference> References { get; set; } = [];
public bool IsBookmarked { get; set; }
public static QuestionResponse FromModel(
Question question,
+1
View File
@@ -22,6 +22,7 @@ builder.Services.AddSingleton<CcsRatingStore>();
builder.Services.AddSingleton<CcsCaseReportStore>();
builder.Services.AddSingleton<AppFeedbackStore>();
builder.Services.AddSingleton<UserNotepadStore>();
builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
@@ -0,0 +1,215 @@
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;
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)));
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);
}