Add question reporting and reference reviews

This commit is contained in:
hz
2026-07-13 01:30:01 -05:00
parent 6eae6c9690
commit 700da4e415
32 changed files with 2684 additions and 6 deletions
@@ -0,0 +1,125 @@
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 || (!question.IsPublished && !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
?? "";
}
+96
View File
@@ -0,0 +1,96 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class QuestionReportCategory
{
public const string Clarity = "clarity";
public const string MedicalAccuracy = "medical_accuracy";
public const string AnswerExplanation = "answer_explanation";
public const string FormattingMedia = "formatting_media";
public const string TechnicalIssue = "technical_issue";
public const string Other = "other";
public static readonly HashSet<string> All =
[
Clarity,
MedicalAccuracy,
AnswerExplanation,
FormattingMedia,
TechnicalIssue,
Other
];
}
public static class QuestionReportStatus
{
public const string Open = "open";
public const string Resolved = "resolved";
public const string Dismissed = "dismissed";
public static readonly HashSet<string> Terminal = [Resolved, Dismissed];
}
public class QuestionReport
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string QuestionExternalId { get; set; } = "";
public string QuestionMongoId { get; set; } = "";
public string UserId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
public string Status { get; set; } = QuestionReportStatus.Open;
public QuestionReportSnapshot QuestionSnapshot { get; set; } = new();
public string ReviewedByUserId { get; set; } = "";
public string ReviewerNote { get; set; } = "";
public DateTime? ReviewedUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public class QuestionReportSnapshot
{
public string Stem { get; set; } = "";
public string LeadIn { get; set; } = "";
public List<QuestionChoice> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string Explanation { get; set; } = "";
public Dictionary<string, string> ChoiceExplanations { get; set; } = [];
public Dictionary<string, WrongAnswerFollowup> WrongAnswerFollowups { get; set; } = [];
public List<QuestionLab> Labs { get; set; } = [];
public List<string> Media { get; set; } = [];
}
public class CreateQuestionReportRequest
{
public string QuestionExternalId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
}
public class CreateQuestionReportResponse
{
public string Id { get; set; } = "";
public string QuestionExternalId { get; set; } = "";
public string Category { get; set; } = "";
public string Status { get; set; } = QuestionReportStatus.Open;
public DateTime CreatedUtc { get; set; }
}
public class UpdateQuestionReportStatusRequest
{
public string Status { get; set; } = "";
public string ReviewerNote { get; set; } = "";
}
public class QuestionReportListResponse
{
public List<QuestionReport> Items { get; set; } = [];
public long Total { get; set; }
public int Skip { get; set; }
public int Limit { get; set; }
}
+1
View File
@@ -12,6 +12,7 @@ builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<RatingStore>();
builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
+10
View File
@@ -12,5 +12,15 @@ STEP-style question bank service for study games and clients.
- `POST /api/questions/bulk` Upsert questions by `externalId` (SUPER only).
- `PUT /api/questions/{id}` Update question (SUPER only).
- `DELETE /api/questions/{id}` Delete question (SUPER only).
- `POST /api/question-reports` Submit a categorized question report (USER or SUPER).
- `GET /api/question-reports` List and filter reports, newest first (SUPER only).
- `GET /api/question-reports/{id}` Get a report and its question snapshot (SUPER only).
- `PATCH /api/question-reports/{id}/status` Resolve or dismiss an open report (SUPER only).
Player reads require `USER` or `SUPER` JWT auth. Authoring endpoints require `SUPER`.
Questions submitted through authoring endpoints must have authoritative direct
reference links recorded as described in the repository's **Question
Verification References** policy. The references must verify the keyed answer
and the material claims in the explanation; search-result links are not
acceptable.
@@ -0,0 +1,146 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public class QuestionReportStore
{
private readonly IMongoCollection<QuestionReport> _reports;
public QuestionReportStore(IConfiguration cfg)
{
var connectionString = cfg["MongoDB:ConnectionString"] ?? "mongodb://127.0.0.1:27017";
var databaseName = cfg["MongoDB:DatabaseName"] ?? "GameDb";
var client = new MongoClient(connectionString);
var database = client.GetDatabase(databaseName);
_reports = database.GetCollection<QuestionReport>("QuestionReports");
_reports.Indexes.CreateMany(
[
new CreateIndexModel<QuestionReport>(
Builders<QuestionReport>.IndexKeys
.Ascending(r => r.Status)
.Descending(r => r.CreatedUtc)),
new CreateIndexModel<QuestionReport>(
Builders<QuestionReport>.IndexKeys.Ascending(r => r.QuestionExternalId)),
new CreateIndexModel<QuestionReport>(
Builders<QuestionReport>.IndexKeys.Ascending(r => r.UserId))
]);
}
public async Task<QuestionReport> CreateAsync(Question question, string userId, string category, string details)
{
var now = DateTime.UtcNow;
var report = new QuestionReport
{
QuestionExternalId = question.ExternalId,
QuestionMongoId = question.Id ?? "",
UserId = userId,
Category = category,
Details = details,
Status = QuestionReportStatus.Open,
QuestionSnapshot = new QuestionReportSnapshot
{
Stem = question.Stem,
LeadIn = question.LeadIn,
Choices = question.Choices
.Select(choice => new QuestionChoice { Id = choice.Id, Text = choice.Text })
.ToList(),
CorrectChoiceId = question.CorrectChoiceId,
Explanation = question.Explanation,
ChoiceExplanations = new Dictionary<string, string>(question.ChoiceExplanations),
WrongAnswerFollowups = question.WrongAnswerFollowups.ToDictionary(
item => item.Key,
item => new WrongAnswerFollowup
{
Prompt = item.Value.Prompt,
Choices = item.Value.Choices
.Select(choice => new QuestionChoice { Id = choice.Id, Text = choice.Text })
.ToList(),
CorrectChoiceId = item.Value.CorrectChoiceId,
TeachingPoint = item.Value.TeachingPoint
}),
Labs = question.Labs
.Select(lab => new QuestionLab
{
Name = lab.Name,
Value = lab.Value,
Unit = lab.Unit,
Reference = lab.Reference
})
.ToList(),
Media = [.. question.Media]
},
CreatedUtc = now,
UpdatedUtc = now
};
await _reports.InsertOneAsync(report);
return report;
}
public async Task<QuestionReport?> GetByIdAsync(string id)
{
if (!ObjectId.TryParse(id, out _))
return null;
return await _reports.Find(r => r.Id == id).FirstOrDefaultAsync();
}
public async Task<QuestionReportListResponse> SearchAsync(
string status,
string category,
string questionExternalId,
string userId,
int skip,
int limit)
{
var filter = Builders<QuestionReport>.Filter.Empty;
if (!string.Equals(status, "all", StringComparison.OrdinalIgnoreCase))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.Status, status);
if (!string.IsNullOrWhiteSpace(category))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.Category, category);
if (!string.IsNullOrWhiteSpace(questionExternalId))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.QuestionExternalId, questionExternalId);
if (!string.IsNullOrWhiteSpace(userId))
filter &= Builders<QuestionReport>.Filter.Eq(r => r.UserId, userId);
var total = await _reports.CountDocumentsAsync(filter);
var items = await _reports
.Find(filter)
.SortByDescending(r => r.CreatedUtc)
.Skip(skip)
.Limit(limit)
.ToListAsync();
return new QuestionReportListResponse
{
Items = items,
Total = total,
Skip = skip,
Limit = limit
};
}
public async Task<QuestionReport?> CloseAsync(string id, string status, string reviewerUserId, string reviewerNote)
{
if (!ObjectId.TryParse(id, out _))
return null;
var now = DateTime.UtcNow;
var filter = Builders<QuestionReport>.Filter.And(
Builders<QuestionReport>.Filter.Eq(r => r.Id, id),
Builders<QuestionReport>.Filter.Eq(r => r.Status, QuestionReportStatus.Open));
var update = Builders<QuestionReport>.Update
.Set(r => r.Status, status)
.Set(r => r.ReviewerNote, reviewerNote)
.Set(r => r.ReviewedByUserId, reviewerUserId)
.Set(r => r.ReviewedUtc, now)
.Set(r => r.UpdatedUtc, now);
return await _reports.FindOneAndUpdateAsync(
filter,
update,
new FindOneAndUpdateOptions<QuestionReport> { ReturnDocument = ReturnDocument.After });
}
}