Add CCS study tools and case reporting

This commit is contained in:
hz
2026-07-15 10:43:03 -05:00
parent 7256f93002
commit 9f379cda5c
13 changed files with 542 additions and 56 deletions
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/ccs/reports")]
public sealed class CcsCaseReportsController(CcsStore cases, CcsCaseReportStore reports) : ControllerBase
{
private const int MinDetails = 10;
private const int MaxDetails = 2000;
[HttpPost, Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Create(CreateCcsCaseReportRequest request)
{
var category = request.Category.Trim().ToLowerInvariant();
var details = request.Details.Trim();
if (!CcsCaseReportCategory.All.Contains(category)) return BadRequest("category is invalid");
if (details.Length is < MinDetails or > MaxDetails) return BadRequest($"details must be between {MinDetails} and {MaxDetails} characters");
var attempt = await cases.GetOwnedAsync(request.AttemptId.Trim(), UserId());
if (attempt is null) return NotFound();
var report = await reports.CreateAsync(attempt, await cases.DefinitionAsync(attempt), category, details);
return StatusCode(StatusCodes.Status201Created, new { id = report.Id, report.AttemptId, report.Category, report.Status, report.CreatedUtc });
}
[HttpGet, Authorize(Roles = "SUPER")]
public async Task<IActionResult> List([FromQuery] string status = "open", [FromQuery] string category = "", [FromQuery] string caseExternalId = "", [FromQuery] string userId = "", [FromQuery] int skip = 0, [FromQuery] int limit = 50)
{
status = status.Trim().ToLowerInvariant(); category = category.Trim().ToLowerInvariant();
if (status != "all" && status != QuestionReportStatus.Open && !QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status is invalid");
if (category.Length > 0 && !CcsCaseReportCategory.All.Contains(category)) return BadRequest("category is invalid");
if (skip < 0 || limit is < 1 or > 100) return BadRequest("pagination is invalid");
return Ok(await reports.SearchAsync(status, category, caseExternalId.Trim(), userId.Trim(), skip, limit));
}
[HttpGet("{id}"), Authorize(Roles = "SUPER")]
public async Task<IActionResult> Get(string id) => await reports.GetAsync(id.Trim()) is { } report ? Ok(report) : NotFound();
[HttpPatch("{id}/status"), Authorize(Roles = "SUPER")]
public async Task<IActionResult> Close(string id, UpdateQuestionReportStatusRequest request)
{
var status = request.Status.Trim().ToLowerInvariant();
if (!QuestionReportStatus.Terminal.Contains(status)) return BadRequest("status must be resolved or dismissed");
if (request.ReviewerNote.Length > MaxDetails) return BadRequest("reviewerNote must be 2000 characters or fewer");
var updated = await reports.CloseAsync(id.Trim(), status, UserId(), request.ReviewerNote.Trim());
return updated is null ? Conflict("report is missing or already closed") : Ok(updated);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? "";
}
+63
View File
@@ -0,0 +1,63 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CcsCaseReportCategory
{
public const string Clarity = "clarity";
public const string MedicalAccuracy = "medical_accuracy";
public const string CaseProgressionScoring = "case_progression_scoring";
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, CaseProgressionScoring, FormattingMedia, TechnicalIssue, Other];
}
public sealed class CcsCaseReport
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string CaseExternalId { get; set; } = "";
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
public int CaseVersion { get; set; }
[BsonRepresentation(BsonType.ObjectId)] public string AttemptId { 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 CcsCaseRevision CaseSnapshot { get; set; } = new();
public CcsAttempt AttemptSnapshot { 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 sealed class CreateCcsCaseReportRequest
{
public string AttemptId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
}
public sealed class CcsCaseReportListResponse
{
public List<CcsCaseReportSummary> Items { get; set; } = [];
public long Total { get; set; }
public int Skip { get; set; }
public int Limit { get; set; }
}
public sealed class CcsCaseReportSummary
{
public string Id { get; set; } = "";
public string CaseExternalId { get; set; } = "";
public int CaseVersion { get; set; }
public string AttemptId { get; set; } = "";
public string UserId { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
public string Status { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
+1
View File
@@ -18,6 +18,7 @@ builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddSingleton<ExamSimulationStore>();
builder.Services.AddSingleton<CcsStore>();
builder.Services.AddSingleton<CcsCaseReportStore>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
@@ -0,0 +1,67 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CcsCaseReportStore
{
private readonly IMongoCollection<CcsCaseReport> _reports;
public CcsCaseReportStore(MongoDatabaseProvider mongo)
{
_reports = mongo.Database.GetCollection<CcsCaseReport>("CcsCaseReports");
_reports.Indexes.CreateMany([
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.Status).Descending(value => value.CreatedUtc)),
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.CaseExternalId)),
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.AttemptId)),
new CreateIndexModel<CcsCaseReport>(Builders<CcsCaseReport>.IndexKeys.Ascending(value => value.UserId))
]);
}
public async Task<CcsCaseReport> CreateAsync(CcsAttempt attempt, CcsCaseRevision definition, string category, string details)
{
var now = DateTime.UtcNow;
var report = new CcsCaseReport
{
CaseExternalId = attempt.CaseExternalId, CaseRevisionId = attempt.CaseRevisionId,
CaseVersion = attempt.CaseVersion, AttemptId = attempt.Id ?? "", UserId = attempt.UserId,
Category = category, Details = details, CaseSnapshot = definition, AttemptSnapshot = attempt,
CreatedUtc = now, UpdatedUtc = now
};
await _reports.InsertOneAsync(report);
return report;
}
public async Task<CcsCaseReport?> GetAsync(string id) => ObjectId.TryParse(id, out _)
? await _reports.Find(value => value.Id == id).FirstOrDefaultAsync() : null;
public async Task<CcsCaseReportListResponse> SearchAsync(string status, string category, string caseId, string userId, int skip, int limit)
{
var filter = Builders<CcsCaseReport>.Filter.Empty;
if (status != "all") filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.Status, status);
if (category.Length > 0) filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.Category, category);
if (caseId.Length > 0) filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.CaseExternalId, caseId);
if (userId.Length > 0) filter &= Builders<CcsCaseReport>.Filter.Eq(value => value.UserId, userId);
var reports = await _reports.Find(filter).SortByDescending(value => value.CreatedUtc).Skip(skip).Limit(limit).ToListAsync();
return new CcsCaseReportListResponse {
Items = reports.Select(value => new CcsCaseReportSummary {
Id = value.Id ?? "", CaseExternalId = value.CaseExternalId, CaseVersion = value.CaseVersion,
AttemptId = value.AttemptId, UserId = value.UserId, Category = value.Category,
Details = value.Details, Status = value.Status, CreatedUtc = value.CreatedUtc
}).ToList(),
Total = await _reports.CountDocumentsAsync(filter), Skip = skip, Limit = limit
};
}
public async Task<CcsCaseReport?> CloseAsync(string id, string status, string reviewer, string note)
{
if (!ObjectId.TryParse(id, out _)) return null;
var now = DateTime.UtcNow;
return await _reports.FindOneAndUpdateAsync(
value => value.Id == id && value.Status == QuestionReportStatus.Open,
Builders<CcsCaseReport>.Update.Set(value => value.Status, status).Set(value => value.ReviewerNote, note)
.Set(value => value.ReviewedByUserId, reviewer).Set(value => value.ReviewedUtc, now).Set(value => value.UpdatedUtc, now),
new FindOneAndUpdateOptions<CcsCaseReport> { ReturnDocument = ReturnDocument.After });
}
}
+1 -1
View File
@@ -378,7 +378,7 @@ public sealed class CcsStore
};
}
private async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
?? throw new InvalidOperationException("The case revision is unavailable.");