using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class DailyMissionStore { private readonly IMongoCollection _sets; private readonly CreditStore _credits; private readonly TimeProvider _clock; private readonly ServiceMetrics _metrics; public DailyMissionStore(MongoDatabaseProvider mongo, CreditStore credits, TimeProvider clock, ServiceMetrics metrics) { _sets = mongo.Database.GetCollection("DailyMissionSets"); _credits = credits; _clock = clock; _metrics = metrics; _sets.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc), new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" })); } public async Task GetOrCreateAsync( string userId, DashboardResponse dashboard, IReadOnlyCollection published) { var now = _clock.GetUtcNow().UtcDateTime; await RecordExpiredAsync(userId, now); var key = DateKey(now); var existing = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync(); if (existing is not null) return Envelope(existing); var candidates = BuildCandidates(dashboard, published, now); if (candidates.Count < 3) throw new InvalidOperationException("Not enough eligible question-bank coverage to create daily missions."); var set = new DailyMissionSet { Id = $"{userId}|{key}", UserId = userId, DateUtc = key, Missions = candidates.Take(3).ToList(), ReplacementCandidates = candidates.Skip(3).ToList(), CreatedUtc = now, ExpiresUtc = now.Date.AddDays(1) }; try { await _sets.InsertOneAsync(set); foreach (var mission in set.Missions) _metrics.DailyMissionAssigned(mission.Type); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstAsync(); } return Envelope(set); } public async Task ReplaceAsync(string userId, string missionId) { var key = DateKey(_clock.GetUtcNow().UtcDateTime); var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync(); if (set is null) return null; if (set.ReplacementUsed) throw new InvalidOperationException("Today's mission replacement has already been used."); var index = set.Missions.FindIndex(value => value.Id == missionId); if (index < 0) throw new ArgumentException("Mission not found."); if (set.Missions[index].CompletedUtc.HasValue) throw new InvalidOperationException("Completed missions cannot be replaced."); if (set.ReplacementCandidates.Count == 0) throw new InvalidOperationException("No replacement mission is available."); var replacement = set.ReplacementCandidates[0]; replacement.StartsUtc = _clock.GetUtcNow().UtcDateTime; set.Missions[index] = replacement; set.ReplacementCandidates.RemoveAt(0); set.ReplacementUsed = true; var changed = await _sets.ReplaceOneAsync(value => value.Id == set.Id && !value.ReplacementUsed, set); if (changed.ModifiedCount != 1) throw new InvalidOperationException("Today's mission replacement has already been used."); _metrics.DailyMissionReplaced(); return Envelope(set); } public Task RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) => !attempt.IsCorrect || attempt.IsSkip ? Task.FromResult(null) : RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc, mission => QualifiesQuestion(mission, attempt, question)); public Task RecordReviewAsync(string userId, QuestionReviewAttempt attempt) => !attempt.IsCorrect ? Task.FromResult(null) : RecordAsync(userId, $"review:{attempt.Id}", attempt.CreatedUtc, mission => QualifiesReview(mission, attempt)); public static bool QualifiesQuestion(DailyMission mission, QuestionAttempt attempt, Question question) { if (!attempt.IsCorrect || attempt.IsSkip || attempt.CreatedUtc < mission.StartsUtc) return false; var system = Step3SystemCatalog.Normalize(question); return mission.Type == DailyMissionTypes.WeakSystemCorrect && mission.Step3System == system || mission.Type == DailyMissionTypes.DifficultyFourCorrect && mission.Step3System == system && question.Difficulty >= 4; } public static bool QualifiesReview(DailyMission mission, QuestionReviewAttempt attempt) => attempt.IsCorrect && attempt.CreatedUtc >= mission.StartsUtc && mission.Type == DailyMissionTypes.DueReviews; private async Task RecordAsync( string userId, string eventId, DateTime eventUtc, Func qualifies) { var key = DateKey(_clock.GetUtcNow().UtcDateTime); var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync(); if (set is null) return null; foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.StartsUtc <= eventUtc && qualifies(value))) { var missionFilter = Builders.Filter.Eq(value => value.Id, mission.Id) & Builders.Filter.Eq(value => value.CompletedUtc, null) & Builders.Filter.AnyNin(value => value.AppliedEventIds, [eventId]); var filter = Builders.Filter.Eq(value => value.Id, set.Id) & Builders.Filter.ElemMatch(value => value.Missions, missionFilter); await _sets.UpdateOneAsync(filter, Builders.Update .Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId)); } set = await _sets.Find(value => value.Id == set.Id).FirstAsync(); var awards = new List(); foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target)) { var now = _clock.GetUtcNow().UtcDateTime; var filter = Builders.Filter.Eq(value => value.Id, set.Id) & Builders.Filter.ElemMatch(value => value.Missions, item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target); var completed = await _sets.UpdateOneAsync(filter, Builders.Update.Set("Missions.$.CompletedUtc", now)); if (completed.ModifiedCount != 1) continue; var award = await _credits.AwardAsync(userId, new CreditAwardRequest( $"mission:{mission.Id}:complete", 3, CreditReasonCodes.DailyMissionComplete, $"Completed daily mission: {mission.Title}", "daily_mission", mission.Id, true)); if (award.Award is not null) awards.Add(award.Award); _metrics.DailyMissionCompleted(mission.Type); } set = await _sets.Find(value => value.Id == set.Id).FirstAsync(); return new DailyMissionEnvelope { State = Response(set), CreditAwards = awards }; } public static List BuildCandidates( DashboardResponse dashboard, IReadOnlyCollection published, DateTime now) { var availability = published.GroupBy(Step3SystemCatalog.Normalize) .Where(group => !string.IsNullOrWhiteSpace(group.Key)) .ToDictionary(group => group.Key!, group => (All: group.Count(), Hard: group.Count(q => q.Difficulty >= 4))); var systems = dashboard.Systems.Where(value => availability.ContainsKey(value.System)) .OrderBy(value => MasteryRank(value.MasteryState)).ThenBy(value => value.MasteryScore) .ThenBy(value => value.DistinctQuestions).ThenBy(value => value.System).ToList(); var result = new List(); if (dashboard.MissedQuestionCount > 0) result.Add(New(DailyMissionTypes.DueReviews, $"Complete {Math.Min(10, dashboard.MissedQuestionCount)} scheduled reviews", "", 0, Math.Min(10, dashboard.MissedQuestionCount), now)); var weak = systems.Where(value => availability[value.System].All >= 3).Select(system => New(DailyMissionTypes.WeakSystemCorrect, $"Get 3 first-attempt answers correct in {system.System}", system.System, 0, 3, now)); var hard = systems.Where(value => availability[value.System].Hard >= 3) .OrderBy(value => value.Difficulty4PlusSuccessful).ThenBy(value => value.MasteryScore) .Select(system => New(DailyMissionTypes.DifficultyFourCorrect, $"Get 3 difficulty 4+ answers correct in {system.System}", system.System, 4, 3, now)); var weakList = weak.ToList(); var hardList = hard.ToList(); if (weakList.Count > 0) result.Add(weakList[0]); var firstHard = hardList.FirstOrDefault(value => value.Step3System != weakList.FirstOrDefault()?.Step3System) ?? hardList.FirstOrDefault(); if (firstHard is not null) result.Add(firstHard); result.AddRange(weakList.Skip(1)); result.AddRange(hardList.Where(value => value.Id != firstHard?.Id)); return result.GroupBy(value => $"{value.Type}|{value.Step3System}").Select(group => group.First()).ToList(); } private static DailyMission New(string type, string title, string system, int difficulty, int target, DateTime now) => new() { Type = type, Title = title, Step3System = system, DifficultyMin = difficulty, Target = target, StartsUtc = now }; private static int MasteryRank(string state) => state switch { MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2, MasteryStates.Strong => 3, _ => 4 }; private static string DateKey(DateTime now) => now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); private async Task RecordExpiredAsync(string userId, DateTime now) { var expired = await _sets.Find(value => value.UserId == userId && value.ExpiresUtc <= now && !value.ExpirationRecorded).ToListAsync(); foreach (var set in expired) { var changed = await _sets.UpdateOneAsync(value => value.Id == set.Id && !value.ExpirationRecorded, Builders.Update.Set(value => value.ExpirationRecorded, true)); if (changed.ModifiedCount != 1) continue; foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null)) _metrics.DailyMissionExpired(mission.Type); } } private static DailyMissionEnvelope Envelope(DailyMissionSet set) => new() { State = Response(set) }; private static DailyMissionSetResponse Response(DailyMissionSet set) => new() { DateUtc = set.DateUtc, ExpiresUtc = set.ExpiresUtc, ReplacementAvailable = !set.ReplacementUsed && set.ReplacementCandidates.Count > 0 && set.Missions.Any(value => value.CompletedUtc is null), Missions = set.Missions.Select(value => new DailyMissionResponse { Id = value.Id, Type = value.Type, Title = value.Title, Step3System = value.Step3System, DifficultyMin = value.DifficultyMin, Target = value.Target, Progress = Math.Min(value.Progress, value.Target), Completed = value.CompletedUtc.HasValue }).ToList() }; }