Flagged questions disappear from every student-facing surface (search, exam simulation sampling, the missed-question review queue, bookmarks, direct fetch) while staying published - flagging and unpublishing are now separate, reversible actions with their own audit trail (who flagged it, when, why). Backend-only for now: SUPER-only flag/unflag/ list endpoints, no client UI. Includes a one-time CLI sweep (flag-media-questions) to flag every existing question with an image so they can be reviewed.
126 lines
5.0 KiB
C#
126 lines
5.0 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using QuestionsApi.Models;
|
|
using QuestionsApi.Services;
|
|
using System.Security.Claims;
|
|
|
|
namespace QuestionsApi.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/question-reports")]
|
|
public class QuestionReportsController : ControllerBase
|
|
{
|
|
private const int MinDetailsLength = 10;
|
|
private const int MaxDetailsLength = 2000;
|
|
private const int MaxReviewerNoteLength = 2000;
|
|
|
|
private readonly QuestionStore _questions;
|
|
private readonly QuestionReportStore _reports;
|
|
|
|
public QuestionReportsController(QuestionStore questions, QuestionReportStore reports)
|
|
{
|
|
_questions = questions;
|
|
_reports = reports;
|
|
}
|
|
|
|
[HttpPost]
|
|
[Authorize(Roles = "USER,SUPER")]
|
|
public async Task<IActionResult> Create([FromBody] CreateQuestionReportRequest request)
|
|
{
|
|
var questionExternalId = (request.QuestionExternalId ?? "").Trim();
|
|
var category = (request.Category ?? "").Trim().ToLowerInvariant();
|
|
var details = (request.Details ?? "").Trim();
|
|
|
|
if (string.IsNullOrWhiteSpace(questionExternalId))
|
|
return BadRequest("questionExternalId required");
|
|
if (!QuestionReportCategory.All.Contains(category))
|
|
return BadRequest("category is invalid");
|
|
if (details.Length < MinDetailsLength || details.Length > MaxDetailsLength)
|
|
return BadRequest($"details must be between {MinDetailsLength} and {MaxDetailsLength} characters");
|
|
|
|
var question = await _questions.GetByExternalIdAsync(questionExternalId);
|
|
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
|
return NotFound();
|
|
|
|
var userId = CurrentUserId();
|
|
if (string.IsNullOrWhiteSpace(userId))
|
|
return Unauthorized();
|
|
|
|
var report = await _reports.CreateAsync(question, userId, category, details);
|
|
return StatusCode(StatusCodes.Status201Created, new CreateQuestionReportResponse
|
|
{
|
|
Id = report.Id ?? "",
|
|
QuestionExternalId = report.QuestionExternalId,
|
|
Category = report.Category,
|
|
Status = report.Status,
|
|
CreatedUtc = report.CreatedUtc
|
|
});
|
|
}
|
|
|
|
[HttpGet]
|
|
[Authorize(Roles = "SUPER")]
|
|
public async Task<IActionResult> List(
|
|
[FromQuery] string status = QuestionReportStatus.Open,
|
|
[FromQuery] string category = "",
|
|
[FromQuery] string questionExternalId = "",
|
|
[FromQuery] string userId = "",
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int limit = 50)
|
|
{
|
|
status = status.Trim().ToLowerInvariant();
|
|
category = category.Trim().ToLowerInvariant();
|
|
questionExternalId = questionExternalId.Trim();
|
|
userId = userId.Trim();
|
|
|
|
if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status))
|
|
return BadRequest("status is invalid");
|
|
if (!string.IsNullOrWhiteSpace(category) && !QuestionReportCategory.All.Contains(category))
|
|
return BadRequest("category is invalid");
|
|
if (skip < 0)
|
|
return BadRequest("skip must be zero or greater");
|
|
if (limit is < 1 or > 100)
|
|
return BadRequest("limit must be between 1 and 100");
|
|
|
|
return Ok(await _reports.SearchAsync(status, category, questionExternalId, userId, skip, limit));
|
|
}
|
|
|
|
[HttpGet("{id}")]
|
|
[Authorize(Roles = "SUPER")]
|
|
public async Task<IActionResult> Get(string id)
|
|
{
|
|
var report = await _reports.GetByIdAsync(id.Trim());
|
|
return report is null ? NotFound() : Ok(report);
|
|
}
|
|
|
|
[HttpPatch("{id}/status")]
|
|
[Authorize(Roles = "SUPER")]
|
|
public async Task<IActionResult> UpdateStatus(string id, [FromBody] UpdateQuestionReportStatusRequest request)
|
|
{
|
|
var status = (request.Status ?? "").Trim().ToLowerInvariant();
|
|
var reviewerNote = (request.ReviewerNote ?? "").Trim();
|
|
if (!QuestionReportStatus.Terminal.Contains(status))
|
|
return BadRequest("status must be resolved or dismissed");
|
|
if (reviewerNote.Length > MaxReviewerNoteLength)
|
|
return BadRequest($"reviewerNote must be {MaxReviewerNoteLength} characters or fewer");
|
|
|
|
var existing = await _reports.GetByIdAsync(id.Trim());
|
|
if (existing is null)
|
|
return NotFound();
|
|
if (existing.Status != QuestionReportStatus.Open)
|
|
return Conflict("report is already closed");
|
|
|
|
var reviewerUserId = CurrentUserId();
|
|
if (string.IsNullOrWhiteSpace(reviewerUserId))
|
|
return Unauthorized();
|
|
|
|
var updated = await _reports.CloseAsync(id.Trim(), status, reviewerUserId, reviewerNote);
|
|
return updated is null ? Conflict("report is already closed") : Ok(updated);
|
|
}
|
|
|
|
private string CurrentUserId() =>
|
|
User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
?? User.FindFirstValue(ClaimTypes.Name)
|
|
?? User.Identity?.Name
|
|
?? "";
|
|
}
|