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> 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> 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> 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> 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; } }