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,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)