using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class WeeklyGoalStore { private readonly IMongoCollection _goals; private readonly IMongoCollection _attempts; private readonly IMongoCollection _reviews; private readonly IMongoCollection _queue; private readonly IMongoCollection _flashReviews; private readonly IMongoCollection _flashSchedules; private readonly IMongoCollection _flashNotes; private readonly IMongoCollection _ccsAttempts; private readonly IMongoCollection _studySessions; private readonly IMongoCollection _simulations; private readonly QuestionStore _questions; private readonly CreditStore _credits; private readonly TimeProvider _clock; private readonly ServiceMetrics _metrics; public WeeklyGoalStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock, ServiceMetrics metrics) { var db = mongo.Database; _goals = db.GetCollection("WeeklyGoals"); _attempts = db.GetCollection("QuestionAttempts"); _reviews = db.GetCollection("QuestionReviewAttempts"); _queue = db.GetCollection("ReviewQueueItems"); _flashReviews = db.GetCollection("FlashcardReviewLogs"); _flashSchedules = db.GetCollection("FlashcardSchedules"); _flashNotes = db.GetCollection("FlashcardNotes"); _ccsAttempts = db.GetCollection("CcsAttempts"); _studySessions = db.GetCollection("StudySessions"); _simulations = db.GetCollection("ExamSimulations"); _questions = questions; _credits = credits; _clock = clock; _metrics = metrics; _goals.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.WeekStartUtc), new CreateIndexOptions { Unique = true, Name = "uq_weekly_goal_user_start" })); } public async Task GetAsync( string userId, DashboardResponse dashboard, IReadOnlyCollection published) { var now = _clock.GetUtcNow().UtcDateTime; var awards = await FinalizeExpiredAsync(userId, now); var start = WeekStart(now); var goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start) .FirstOrDefaultAsync(); if (goal is null) { var reviewed = await _reviews.CountDocumentsAsync(value => value.UserId == userId && value.IsCorrect && value.CreatedUtc >= start && value.CreatedUtc < start.AddDays(7)); var pending = await _queue.CountDocumentsAsync(value => value.UserId == userId && value.State == ReviewQueueStates.Pending); var availableReviews = checked((int)Math.Min(int.MaxValue, reviewed + pending)); var remediation = ChooseRemediation(availableReviews); var eligible = SelectHardSystems(dashboard, published); goal = new WeeklyGoal { Id = $"{userId}|{start:yyyy-MM-dd}", UserId = userId, WeekStartUtc = start, ExpiresUtc = start.AddDays(7), CreatedUtc = now, RemediationType = remediation.Type, RemediationTarget = remediation.Target, EligibleSystems = eligible }; try { await _goals.InsertOneAsync(goal); _metrics.WeeklyGoalAssigned(goal.RemediationType); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start).FirstAsync(); } } var evaluated = await EvaluateAsync(goal); awards.AddRange(evaluated.Awards); return new WeeklyGoalEnvelope { State = evaluated.Response, CreditAwards = awards, Credits = await _credits.GetStatusAsync(userId) }; } public static DateTime WeekStart(DateTime value) { var date = value.ToUniversalTime().Date; var offset = ((int)date.DayOfWeek + 6) % 7; return date.AddDays(-offset); } public static List SelectHardSystems( DashboardResponse dashboard, IReadOnlyCollection published) { var counts = published.Where(question => question.Difficulty >= 4) .GroupBy(Step3SystemCatalog.Normalize).Where(group => group.Key is not null) .ToDictionary(group => group.Key!, group => group.Count()); var ordered = dashboard.Systems.Where(system => counts.GetValueOrDefault(system.System) > 0) .OrderBy(system => MasteryRank(system.MasteryState)).ThenBy(system => system.MasteryScore) .ThenBy(system => system.Difficulty4PlusSuccessful).Select(system => system.System).ToList(); var result = new List(); var available = 0; foreach (var system in ordered) { result.Add(system); available += counts[system]; if (available >= 5 || result.Count == 3) break; } return result; } public static (string Type, int Target) ChooseRemediation(int availableReviews) => availableReviews >= 5 ? (WeeklyRemediationTypes.Reviews, Math.Min(15, availableReviews)) : (WeeklyRemediationTypes.HardCorrect, 5); public static int CountActiveDays(IEnumerable timestamps) => timestamps.Select(value => value.ToUniversalTime().Date).Distinct().Count(); public static bool QualifiesHardAttempt(WeeklyGoal goal, QuestionAttempt attempt, Question question) => !attempt.IsSkip && attempt.IsCorrect && attempt.CreatedUtc >= goal.WeekStartUtc && attempt.CreatedUtc < goal.ExpiresUtc && question.Difficulty >= 4 && goal.EligibleSystems.Contains(Step3SystemCatalog.Normalize(question) ?? ""); private async Task> FinalizeExpiredAsync(string userId, DateTime now) { var awards = new List(); var expired = await _goals.Find(value => value.UserId == userId && value.ExpiresUtc <= now && !value.ExpirationRecorded).ToListAsync(); foreach (var goal in expired) { var wasCompleted = goal.CompletedUtc.HasValue; var result = await EvaluateAsync(goal); awards.AddRange(result.Awards); var latest = await _goals.Find(value => value.Id == goal.Id).FirstAsync(); var recorded = await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.ExpirationRecorded, Builders.Update.Set(value => value.ExpirationRecorded, true)); if (recorded.ModifiedCount == 1 && !latest.CompletedUtc.HasValue) _metrics.WeeklyGoalExpiredIncomplete(); if (recorded.ModifiedCount == 1 && !wasCompleted && latest.CompletedUtc.HasValue) _metrics.WeeklyGoalLateFinalized(); } return awards; } private async Task<(WeeklyGoalResponse Response, List Awards)> EvaluateAsync(WeeklyGoal goal) { var evidence = await EvidenceAsync(goal); var complete = evidence.ActiveDays >= 4 && evidence.Systems.Count >= 3 && evidence.Remediation >= goal.RemediationTarget; if (complete && !goal.CompletedUtc.HasValue) { var completedUtc = _clock.GetUtcNow().UtcDateTime; var changed = await _goals.UpdateOneAsync(value => value.Id == goal.Id && value.CompletedUtc == null, Builders.Update.Set(value => value.CompletedUtc, completedUtc)); goal.CompletedUtc = completedUtc; if (changed.ModifiedCount == 1) _metrics.WeeklyGoalCompleted(goal.RemediationType); } var awards = new List(); if (complete && !goal.RewardIssued) { var operationId = $"weekly-goal:{goal.Id}:complete"; var result = await _credits.AwardAsync(goal.UserId, new CreditAwardRequest( operationId, 10, CreditReasonCodes.WeeklyGoalComplete, "Completed the balanced weekly study goal", "weekly_goal", goal.Id, true)); if (result.Award is not null) awards.Add(result.Award); var applied = result.Award ?? await _credits.GetAppliedAwardAsync(goal.UserId, operationId); var amount = applied?.Amount ?? 0; await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.RewardIssued, Builders.Update.Set(value => value.RewardIssued, true) .Set(value => value.RewardAmount, amount)); goal.RewardIssued = true; goal.RewardAmount = amount; } return (Response(goal, evidence), awards); } private async Task EvidenceAsync(WeeklyGoal goal) { var start = goal.WeekStartUtc; var end = goal.ExpiresUtc; var attemptsTask = _attempts.Find(value => value.UserId == goal.UserId && !value.IsSkip && value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync(); var reviewsTask = _reviews.Find(value => value.UserId == goal.UserId && value.IsCorrect && value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync(); var flashTask = _flashReviews.Find(value => value.UserId == goal.UserId && value.UndoneUtc == null && value.ReviewedUtc >= start && value.ReviewedUtc < end).ToListAsync(); var ccsTask = _ccsAttempts.Find(value => value.UserId == goal.UserId && value.State == CcsAttemptStates.Completed && value.CompletedUtc >= start && value.CompletedUtc < end) .ToListAsync(); var blocksTask = _studySessions.Find(value => value.UserId == goal.UserId && value.GoalType == StudyGoalTypes.SimulationBlock && value.State == StudySessionStates.Completed && value.CompletedUtc >= start && value.CompletedUtc < end).ToListAsync(); var simulationsTask = _simulations.Find(value => value.UserId == goal.UserId && value.Responses.Any(response => response.UpdatedUtc >= start && response.UpdatedUtc < end)).ToListAsync(); await Task.WhenAll(attemptsTask, reviewsTask, flashTask, ccsTask, blocksTask, simulationsTask); var attempts = attemptsTask.Result; var reviews = reviewsTask.Result; var flash = flashTask.Result; var dates = attempts.Select(value => value.CreatedUtc) .Concat(reviews.Select(value => value.CreatedUtc.Date)) .Concat(flash.Select(value => value.ReviewedUtc)) .Concat(ccsTask.Result.Select(value => value.CompletedUtc!.Value)) .Concat(blocksTask.Result.Select(value => value.CompletedUtc!.Value)).ToList(); var questionIds = attempts.Select(value => value.QuestionExternalId) .Concat(reviews.Select(value => value.QuestionExternalId)) .Concat(simulationsTask.Result.SelectMany(value => value.Responses) .Where(value => value.UpdatedUtc >= start && value.UpdatedUtc < end && !string.IsNullOrWhiteSpace(value.ChoiceId)) .Select(value => value.QuestionExternalId)).ToHashSet(StringComparer.Ordinal); if (flash.Count > 0) { var cardIds = flash.Select(value => value.CardId).Distinct().ToList(); var schedules = await _flashSchedules.Find(value => cardIds.Contains(value.Id!)).ToListAsync(); var noteIds = schedules.Select(value => value.NoteId).Distinct().ToList(); var notes = await _flashNotes.Find(value => noteIds.Contains(value.Id!) && value.SourceQuestionExternalId != "").ToListAsync(); foreach (var note in notes) questionIds.Add(note.SourceQuestionExternalId); } var questionMap = await _questions.GetByExternalIdsAsync(questionIds); var systems = questionMap.Values.Select(Step3SystemCatalog.Normalize) .Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).Distinct().ToHashSet(); var hardCorrect = attempts.Count(attempt => questionMap.TryGetValue(attempt.QuestionExternalId, out var question) && QualifiesHardAttempt(goal, attempt, question)); return new WeeklyEvidence { ActiveDays = CountActiveDays(dates), Systems = systems, Remediation = goal.RemediationType == WeeklyRemediationTypes.Reviews ? reviews.Count : hardCorrect }; } private static WeeklyGoalResponse Response(WeeklyGoal goal, WeeklyEvidence evidence) { var components = new List { Component("active_days", "Study on 4 different days", evidence.ActiveDays, 4), Component("system_breadth", "Study 3 Step 3 systems", evidence.Systems.Count, 3), Component(goal.RemediationType, goal.RemediationType == WeeklyRemediationTypes.Reviews ? $"Complete {goal.RemediationTarget} scheduled reviews" : "Get 5 difficulty 4+ answers correct in coverage gaps", evidence.Remediation, goal.RemediationTarget) }; return new WeeklyGoalResponse { WeekStartUtc = goal.WeekStartUtc, ExpiresUtc = goal.ExpiresUtc, Completed = components.All(value => value.Completed), RemediationType = goal.RemediationType, EligibleSystems = goal.EligibleSystems, SuggestedSystem = goal.EligibleSystems.FirstOrDefault(value => !evidence.Systems.Contains(value)) ?? goal.EligibleSystems.FirstOrDefault() ?? "", RewardAmount = goal.RewardAmount, Components = components }; } private static WeeklyGoalComponentResponse Component(string type, string label, int progress, int target) => new() { Type = type, Label = label, Progress = Math.Min(progress, target), Target = target, Completed = progress >= target }; private static int MasteryRank(string state) => state switch { MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2, MasteryStates.Strong => 3, _ => 4 }; private sealed class WeeklyEvidence { public int ActiveDays { get; set; } public HashSet Systems { get; set; } = []; public int Remediation { get; set; } } }