Add question reporting and reference reviews
This commit is contained in:
@@ -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
|
||||
?? "";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user