using MongoDB.Bson; using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class UserProfileStore { private readonly IMongoDatabase _database; private readonly IMongoCollection _profiles; private readonly IMongoCollection _deletedUsers; private static readonly string[] OwnedCollections = [ "QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads", "ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates", "DynamicQuestionAttempts", "DynamicObjectiveMastery", "DynamicReviewItems", "CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations", "Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles", "ExternalIdentities", "ExternalAuthTransactions", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", "UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals" ]; public UserProfileStore(MongoDatabaseProvider mongo) { _database = mongo.Database; _profiles = _database.GetCollection("UserStudyProfiles"); _deletedUsers = _database.GetCollection("DeletedUserTombstones"); _profiles.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true })); } public async Task GetAsync(string userId) => await _profiles.Find(value => value.UserId == userId).FirstOrDefaultAsync(); public async Task UpsertAsync(string userId, UpdateUserStudyProfileRequest request) { var update = Builders.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 { IsUpsert = true, ReturnDocument = ReturnDocument.After }); } public async Task UpdateTextScaleAsync(string userId, UpdateTextScaleRequest request) { var update = Builders.Update.Set(value => value.UpdatedUtc, DateTime.UtcNow); if (request.QuestionTextScale is { } questionScale) update = update.Set(value => value.QuestionTextScale, questionScale); if (request.CcsTextScale is { } ccsScale) update = update.Set(value => value.CcsTextScale, ccsScale); update = update.SetOnInsert(value => value.UserId, userId); return await _profiles.FindOneAndUpdateAsync( value => value.UserId == userId, update, new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }); } public Task IsDeletedAsync(string userId) => _deletedUsers.Find(value => value.UserId == userId).AnyAsync(); public async Task>> ExportAsync(string userId) { var result = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var collectionName in OwnedCollections) { var documents = await _database.GetCollection(collectionName) .Find(Builders.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(collectionName) .DeleteManyAsync(Builders.Filter.Eq("UserId", userId)); } foreach (var collectionName in new[] { "QuestionReports", "CcsCaseReports", "AppFeedback" }) { await _database.GetCollection(collectionName).UpdateManyAsync( Builders.Filter.Eq("ReviewedByUserId", userId), Builders.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); } }