Implement responsive profile and account data controls

This commit is contained in:
hz
2026-07-19 13:39:15 -05:00
parent 7508d4fe1d
commit dacf2b89b2
22 changed files with 1099 additions and 290 deletions
+5 -1
View File
@@ -39,11 +39,13 @@ public sealed class DashboardStore
var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask,
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
var attempts = attemptsTask.Result;
@@ -62,6 +64,8 @@ public sealed class DashboardStore
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
QuestionsCompleted = answeredAttempts.Count,
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
AccuracySampleSize = recentAnswered.Count,
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
@@ -0,0 +1,99 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class UserProfileStore
{
private readonly IMongoDatabase _database;
private readonly IMongoCollection<UserStudyProfile> _profiles;
private readonly IMongoCollection<DeletedUserTombstone> _deletedUsers;
private static readonly string[] OwnedCollections =
[
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles"
];
public UserProfileStore(MongoDatabaseProvider mongo)
{
_database = mongo.Database;
_profiles = _database.GetCollection<UserStudyProfile>("UserStudyProfiles");
_deletedUsers = _database.GetCollection<DeletedUserTombstone>("DeletedUserTombstones");
_profiles.Indexes.CreateOne(new CreateIndexModel<UserStudyProfile>(
Builders<UserStudyProfile>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true }));
}
public async Task<UserStudyProfile?> GetAsync(string userId) =>
await _profiles.Find(value => value.UserId == userId).FirstOrDefaultAsync();
public async Task<UserStudyProfile> UpsertAsync(string userId, UpdateUserStudyProfileRequest request)
{
var update = Builders<UserStudyProfile>.Update
.Set(value => value.TargetExamDate, request.TargetExamDate)
.Set(value => value.PreparationStage, request.PreparationStage)
.Set(value => value.WeeklyGoalHours, request.WeeklyGoalHours)
.Set(value => value.PreferredStudyMode, request.PreferredStudyMode)
.Set(value => value.UpdatedUtc, DateTime.UtcNow)
.Inc(value => value.Revision, 1)
.SetOnInsert(value => value.UserId, userId);
return await _profiles.FindOneAndUpdateAsync(
value => value.UserId == userId,
update,
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
public Task<bool> IsDeletedAsync(string userId) =>
_deletedUsers.Find(value => value.UserId == userId).AnyAsync();
public async Task<Dictionary<string, List<object?>>> ExportAsync(string userId)
{
var result = new Dictionary<string, List<object?>>(StringComparer.OrdinalIgnoreCase);
foreach (var collectionName in OwnedCollections)
{
var documents = await _database.GetCollection<BsonDocument>(collectionName)
.Find(Builders<BsonDocument>.Filter.Eq("UserId", userId)).ToListAsync();
result[collectionName] = documents.Select(document => Map(document)).ToList();
}
return result;
}
public async Task PurgeAsync(string userId)
{
await _deletedUsers.ReplaceOneAsync(
value => value.UserId == userId,
new DeletedUserTombstone { UserId = userId, DeletedUtc = DateTime.UtcNow },
new ReplaceOptions { IsUpsert = true });
foreach (var collectionName in OwnedCollections)
{
await _database.GetCollection<BsonDocument>(collectionName)
.DeleteManyAsync(Builders<BsonDocument>.Filter.Eq("UserId", userId));
}
foreach (var collectionName in new[] { "QuestionReports", "CcsCaseReports", "AppFeedback" })
{
await _database.GetCollection<BsonDocument>(collectionName).UpdateManyAsync(
Builders<BsonDocument>.Filter.Eq("ReviewedByUserId", userId),
Builders<BsonDocument>.Update.Unset("ReviewedByUserId"));
}
}
private static object? Map(BsonValue value)
{
if (value.IsBsonNull) return null;
if (value.IsBsonDocument)
return value.AsBsonDocument.Elements.ToDictionary(element => element.Name, element => Map(element.Value));
if (value.IsBsonArray) return value.AsBsonArray.Select(Map).ToList();
if (value.IsObjectId) return value.AsObjectId.ToString();
if (value.IsValidDateTime) return value.ToUniversalTime();
if (value.IsDecimal128) return Decimal128.ToDecimal(value.AsDecimal128);
if (value.IsBsonBinaryData) return Convert.ToBase64String(value.AsBsonBinaryData.Bytes);
if (value.IsBsonRegularExpression) return value.AsBsonRegularExpression.ToString();
return BsonTypeMapper.MapToDotNetValue(value);
}
}