Implement responsive profile and account data controls
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/profile")]
|
||||
public sealed class ProfileController(UserProfileStore profiles, IConfiguration configuration) : ControllerBase
|
||||
{
|
||||
[HttpGet("me/preferences")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> GetPreferences()
|
||||
{
|
||||
var profile = await profiles.GetAsync(UserId());
|
||||
return Ok(profile ?? new UserStudyProfile { UserId = UserId() });
|
||||
}
|
||||
|
||||
[HttpPut("me/preferences")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> UpdatePreferences(UpdateUserStudyProfileRequest request)
|
||||
{
|
||||
if (UserStudyProfileValidator.Validate(request) is { } error) return BadRequest(error);
|
||||
return Ok(await profiles.UpsertAsync(UserId(), request));
|
||||
}
|
||||
|
||||
[HttpGet("me/export")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Export() => Ok(new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
exportedUtc = DateTime.UtcNow,
|
||||
userId = UserId(),
|
||||
records = await profiles.ExportAsync(UserId())
|
||||
});
|
||||
|
||||
[HttpDelete("internal/users/{userId}")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Purge(string userId)
|
||||
{
|
||||
var expected = configuration["InternalPurge:Secret"];
|
||||
var supplied = Request.Headers["X-MedMind-Internal-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(expected) || !CryptographicOperations.FixedTimeEquals(
|
||||
System.Text.Encoding.UTF8.GetBytes(expected),
|
||||
System.Text.Encoding.UTF8.GetBytes(supplied ?? "")))
|
||||
return Unauthorized();
|
||||
await profiles.PurgeAsync(userId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -6,6 +6,8 @@ public sealed class DashboardResponse
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public int CcsRating { get; set; } = 1000;
|
||||
public int RatedCcsCaseCount { get; set; }
|
||||
public int QuestionsCompleted { get; set; }
|
||||
public int CcsCasesCompleted { get; set; }
|
||||
public int FirstAttemptAccuracy { get; set; }
|
||||
public int AccuracySampleSize { get; set; }
|
||||
public int QuestionsThisWeek { get; set; }
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public sealed class UserStudyProfile
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public DateTime? TargetExamDate { get; set; }
|
||||
public string PreparationStage { get; set; } = "pre_dedicated";
|
||||
public int WeeklyGoalHours { get; set; } = 10;
|
||||
public string PreferredStudyMode { get; set; } = "mixed";
|
||||
public int Revision { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class UpdateUserStudyProfileRequest
|
||||
{
|
||||
public DateTime? TargetExamDate { get; set; }
|
||||
public string PreparationStage { get; set; } = "pre_dedicated";
|
||||
public int WeeklyGoalHours { get; set; } = 10;
|
||||
public string PreferredStudyMode { get; set; } = "mixed";
|
||||
}
|
||||
|
||||
public sealed class DeletedUserTombstone
|
||||
{
|
||||
[BsonId]
|
||||
public string UserId { get; set; } = "";
|
||||
public DateTime DeletedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static class UserStudyProfileValidator
|
||||
{
|
||||
private static readonly HashSet<string> Stages = ["pre_dedicated", "dedicated", "final_review"];
|
||||
private static readonly HashSet<int> Goals = [5, 10, 15, 20, 25];
|
||||
private static readonly HashSet<string> Modes = ["mixed", "question_bank", "ccs", "exam_simulation"];
|
||||
|
||||
public static string? Validate(UpdateUserStudyProfileRequest request)
|
||||
{
|
||||
if (!Stages.Contains(request.PreparationStage)) return "Invalid preparation stage.";
|
||||
if (!Goals.Contains(request.WeeklyGoalHours)) return "Weekly goal must be 5, 10, 15, 20, or 25 hours.";
|
||||
if (!Modes.Contains(request.PreferredStudyMode)) return "Invalid preferred study mode.";
|
||||
if (request.TargetExamDate?.Kind == DateTimeKind.Local)
|
||||
request.TargetExamDate = request.TargetExamDate.Value.ToUniversalTime();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ builder.Services.AddSingleton<UserNotepadStore>();
|
||||
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<UserProfileStore>();
|
||||
builder.Services.AddSingleton<ServiceMetrics>();
|
||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||
|
||||
@@ -71,6 +72,18 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
o.Events = new JwtBearerEvents
|
||||
{
|
||||
OnTokenValidated = async context =>
|
||||
{
|
||||
var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
var profiles = context.HttpContext.RequestServices.GetRequiredService<UserProfileStore>();
|
||||
if (await profiles.IsDeletedAsync(userId)) context.Fail("Account deleted");
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ spec:
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 5008
|
||||
env:
|
||||
- name: InternalPurge__Secret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: medmind-internal-api
|
||||
key: purge-secret
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
|
||||
Reference in New Issue
Block a user