Add synced question bookmarks
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user