Add CCS study tools and case reporting
This commit is contained in:
@@ -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 ?? "";
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user