|
|
|
@@ -1,5 +1,7 @@
|
|
|
|
|
using MongoDB.Bson;
|
|
|
|
|
using MongoDB.Driver;
|
|
|
|
|
using QuestionsApi.Models;
|
|
|
|
|
using System.Text;
|
|
|
|
|
|
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
|
|
|
|
|
@@ -29,6 +31,9 @@ public sealed class CcsStore
|
|
|
|
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc)));
|
|
|
|
|
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
|
|
|
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)));
|
|
|
|
|
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
|
|
|
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.ActiveSlot),
|
|
|
|
|
new CreateIndexOptions { Unique = true, Sparse = true, Name = "unique_active_ccs_attempt" }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<CcsImportResponse> ImportAsync(CcsImportRequest request)
|
|
|
|
@@ -86,6 +91,8 @@ public sealed class CcsStore
|
|
|
|
|
{
|
|
|
|
|
var validation = CcsCaseValidator.ValidateCreate(request);
|
|
|
|
|
if (validation is not null) throw new ArgumentException(validation);
|
|
|
|
|
if (await FindActiveAsync(userId) is not null)
|
|
|
|
|
throw new InvalidOperationException("An active CCS case already exists.");
|
|
|
|
|
CcsCaseRevision? definition;
|
|
|
|
|
if (string.IsNullOrWhiteSpace(request.CaseExternalId))
|
|
|
|
|
{
|
|
|
|
@@ -104,6 +111,7 @@ public sealed class CcsStore
|
|
|
|
|
var attempt = new CcsAttempt
|
|
|
|
|
{
|
|
|
|
|
UserId = userId,
|
|
|
|
|
ActiveSlot = userId,
|
|
|
|
|
CaseExternalId = definition.ExternalId,
|
|
|
|
|
CaseRevisionId = definition.Id ?? "",
|
|
|
|
|
CaseVersion = definition.Version,
|
|
|
|
@@ -119,10 +127,45 @@ public sealed class CcsStore
|
|
|
|
|
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
|
|
|
|
|
]
|
|
|
|
|
};
|
|
|
|
|
await _attempts.InsertOneAsync(attempt);
|
|
|
|
|
try { await _attempts.InsertOneAsync(attempt); }
|
|
|
|
|
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
|
|
|
{
|
|
|
|
|
throw new InvalidOperationException("An active CCS case already exists.", exception);
|
|
|
|
|
}
|
|
|
|
|
return attempt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<CcsAttempt?> FindActiveAsync(string userId)
|
|
|
|
|
{
|
|
|
|
|
var unfinished = await _attempts.Find(value => value.UserId == userId &&
|
|
|
|
|
(value.State == CcsAttemptStates.Active || value.State == CcsAttemptStates.FinalOrders))
|
|
|
|
|
.SortByDescending(value => value.UpdatedUtc).ThenByDescending(value => value.Id).ToListAsync();
|
|
|
|
|
if (unfinished.Count == 0) return null;
|
|
|
|
|
|
|
|
|
|
var retained = unfinished[0];
|
|
|
|
|
foreach (var older in unfinished.Skip(1))
|
|
|
|
|
{
|
|
|
|
|
older.State = CcsAttemptStates.Abandoned;
|
|
|
|
|
older.ActiveSlot = null;
|
|
|
|
|
older.UpdatedUtc = DateTime.UtcNow;
|
|
|
|
|
await SaveAsync(older);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
retained = await GetOwnedAsync(retained.Id ?? "", userId) ?? retained;
|
|
|
|
|
if (retained.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned) return null;
|
|
|
|
|
if (retained.ActiveSlot != userId)
|
|
|
|
|
{
|
|
|
|
|
retained.ActiveSlot = userId;
|
|
|
|
|
retained.UpdatedUtc = DateTime.UtcNow;
|
|
|
|
|
try { await SaveAsync(retained); }
|
|
|
|
|
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
|
|
|
|
{
|
|
|
|
|
return await _attempts.Find(value => value.ActiveSlot == userId).FirstOrDefaultAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return retained;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<CcsAttempt?> GetOwnedAsync(string id, string userId)
|
|
|
|
|
{
|
|
|
|
|
var attempt = await _attempts.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
|
|
|
@@ -177,19 +220,71 @@ public sealed class CcsStore
|
|
|
|
|
return attempt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task<List<CcsHistoryItem>> HistoryAsync(string userId, int limit)
|
|
|
|
|
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
|
|
|
|
|
{
|
|
|
|
|
var attempts = await _attempts.Find(value => value.UserId == userId).SortByDescending(value => value.CreatedUtc).Limit(Math.Clamp(limit, 1, 100)).ToListAsync();
|
|
|
|
|
limit = Math.Clamp(limit, 1, 50);
|
|
|
|
|
status = status.Trim().ToLowerInvariant();
|
|
|
|
|
sort = sort.Trim().ToLowerInvariant();
|
|
|
|
|
if (status is not ("all" or "completed" or "abandoned")) throw new ArgumentException("The CCS history status is invalid.");
|
|
|
|
|
if (sort is not ("newest" or "oldest")) throw new ArgumentException("The CCS history sort order is invalid.");
|
|
|
|
|
|
|
|
|
|
var filters = Builders<CcsAttempt>.Filter;
|
|
|
|
|
var filter = filters.Eq(value => value.UserId, userId);
|
|
|
|
|
filter &= status switch
|
|
|
|
|
{
|
|
|
|
|
"completed" => filters.Eq(value => value.State, CcsAttemptStates.Completed),
|
|
|
|
|
"abandoned" => filters.Eq(value => value.State, CcsAttemptStates.Abandoned),
|
|
|
|
|
_ => filters.In(value => value.State, [CcsAttemptStates.Completed, CcsAttemptStates.Abandoned])
|
|
|
|
|
};
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(cursor))
|
|
|
|
|
{
|
|
|
|
|
if (!TryDecodeHistoryCursor(cursor, out var decoded)) throw new ArgumentException("The CCS history cursor is invalid.");
|
|
|
|
|
var dateComparison = sort == "newest" ? filters.Lt(value => value.CreatedUtc, decoded!.CreatedUtc) : filters.Gt(value => value.CreatedUtc, decoded!.CreatedUtc);
|
|
|
|
|
var idComparison = sort == "newest" ? filters.Lt(value => value.Id, decoded!.Id) : filters.Gt(value => value.Id, decoded!.Id);
|
|
|
|
|
filter &= dateComparison | (filters.Eq(value => value.CreatedUtc, decoded!.CreatedUtc) & idComparison);
|
|
|
|
|
}
|
|
|
|
|
var ordering = sort == "newest"
|
|
|
|
|
? Builders<CcsAttempt>.Sort.Descending(value => value.CreatedUtc).Descending(value => value.Id)
|
|
|
|
|
: Builders<CcsAttempt>.Sort.Ascending(value => value.CreatedUtc).Ascending(value => value.Id);
|
|
|
|
|
var attempts = await _attempts.Find(filter).Sort(ordering).Limit(limit + 1).ToListAsync();
|
|
|
|
|
var hasMore = attempts.Count > limit;
|
|
|
|
|
if (hasMore) attempts.RemoveAt(attempts.Count - 1);
|
|
|
|
|
var revisionIds = attempts.Select(value => value.CaseRevisionId).Distinct().ToList();
|
|
|
|
|
var definitions = (await _cases.Find(value => revisionIds.Contains(value.Id!)).ToListAsync()).ToDictionary(value => value.Id ?? "");
|
|
|
|
|
return attempts.Select(value => new CcsHistoryItem
|
|
|
|
|
var items = attempts.Select(value => new CcsHistoryItem
|
|
|
|
|
{
|
|
|
|
|
Id = value.Id ?? "", CaseExternalId = value.CaseExternalId,
|
|
|
|
|
CaseTitle = definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId,
|
|
|
|
|
CaseTitle = value.State == CcsAttemptStates.Completed
|
|
|
|
|
? definitions.GetValueOrDefault(value.CaseRevisionId)?.Title ?? value.CaseExternalId
|
|
|
|
|
: "Timed CCS Case",
|
|
|
|
|
Mode = value.Mode, State = value.State, Score = value.Score?.Percent, Rating = value.Rating, CreatedUtc = value.CreatedUtc
|
|
|
|
|
}).ToList();
|
|
|
|
|
var last = attempts.LastOrDefault();
|
|
|
|
|
return new CcsHistoryPage
|
|
|
|
|
{
|
|
|
|
|
Items = items,
|
|
|
|
|
NextCursor = hasMore && last is not null ? EncodeHistoryCursor(last.CreatedUtc, last.Id ?? "") : null
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string EncodeHistoryCursor(DateTime createdUtc, string id) =>
|
|
|
|
|
Convert.ToBase64String(Encoding.UTF8.GetBytes($"{createdUtc.ToUniversalTime().Ticks}|{id}"));
|
|
|
|
|
|
|
|
|
|
public static bool TryDecodeHistoryCursor(string value, out HistoryCursor? cursor)
|
|
|
|
|
{
|
|
|
|
|
cursor = null;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var parts = Encoding.UTF8.GetString(Convert.FromBase64String(value)).Split('|', 2);
|
|
|
|
|
if (parts.Length != 2 || !long.TryParse(parts[0], out var ticks) || !ObjectId.TryParse(parts[1], out _)) return false;
|
|
|
|
|
cursor = new HistoryCursor(new DateTime(ticks, DateTimeKind.Utc), parts[1]);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
catch (Exception exception) when (exception is FormatException or ArgumentOutOfRangeException) { return false; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed record HistoryCursor(DateTime CreatedUtc, string Id);
|
|
|
|
|
|
|
|
|
|
public async Task<CcsPredictionResponse> PredictionAsync(string userId)
|
|
|
|
|
{
|
|
|
|
|
var timed = await _attempts.Find(value => value.UserId == userId && value.Mode == CcsAttemptModes.Timed && value.State == CcsAttemptStates.Completed && value.Score != null)
|
|
|
|
@@ -226,6 +321,7 @@ public sealed class CcsStore
|
|
|
|
|
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
|
|
|
|
|
SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location,
|
|
|
|
|
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
|
|
|
|
|
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
|
|
|
|
|
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
|
|
|
|
|
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
|
|
|
|
|
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
|
|
|
|
@@ -241,6 +337,7 @@ public sealed class CcsStore
|
|
|
|
|
public async Task AbandonAsync(CcsAttempt attempt)
|
|
|
|
|
{
|
|
|
|
|
attempt.State = CcsAttemptStates.Abandoned;
|
|
|
|
|
attempt.ActiveSlot = null;
|
|
|
|
|
attempt.UpdatedUtc = DateTime.UtcNow;
|
|
|
|
|
await SaveAsync(attempt);
|
|
|
|
|
}
|
|
|
|
@@ -346,6 +443,7 @@ public sealed class CcsStore
|
|
|
|
|
if (attempt.State == CcsAttemptStates.Completed) return;
|
|
|
|
|
var definition = await DefinitionAsync(attempt);
|
|
|
|
|
attempt.State = CcsAttemptStates.Completed;
|
|
|
|
|
attempt.ActiveSlot = null;
|
|
|
|
|
attempt.CompletedUtc = DateTime.UtcNow;
|
|
|
|
|
attempt.Score = CalculateScore(attempt, definition);
|
|
|
|
|
attempt.Rating = await _ratings.ApplyAsync(attempt);
|
|
|
|
|