using System.Text.Json; using DysonNetwork.Shared.Cache; using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Poll; public class PollService(AppDatabase db, ICacheService cache) { public void ValidatePoll(Poll poll) { if (poll.Questions.Count == 0) throw new Exception("Poll must have at least one question"); foreach (var question in poll.Questions) { switch (question.Type) { case PollQuestionType.SingleChoice: case PollQuestionType.MultipleChoice: if (question.Options is null) throw new Exception("Poll question must have options"); if (question.Options.Count <= 1) throw new Exception("Poll question must have at least two options"); break; case PollQuestionType.YesNo: case PollQuestionType.Rating: case PollQuestionType.FreeText: default: continue; } } } private const string PollAnswerCachePrefix = "poll:answer:"; public async Task GetPollAnswer(Guid pollId, Guid accountId) { var cacheKey = $"{PollAnswerCachePrefix}{pollId}:{accountId}"; var cachedAnswer = await cache.GetAsync(cacheKey); if (cachedAnswer is not null) return cachedAnswer; var answer = await db.PollAnswers .Where(e => e.PollId == pollId && e.AccountId == accountId) .FirstOrDefaultAsync(); if (answer is not null) { await cache.SetAsync(cacheKey, answer, TimeSpan.FromMinutes(30)); } return answer; } private async Task ValidatePollAnswer(Guid pollId, Dictionary answer) { var questions = await db.PollQuestions .Where(e => e.PollId == pollId) .ToListAsync(); if (questions is null) throw new Exception("Poll has no questions"); foreach (var question in questions) { var questionId = question.Id.ToString(); if (question.IsRequired && !answer.ContainsKey(questionId)) throw new Exception($"Missing required field: {question.Title}"); switch (question.Type) { case PollQuestionType.Rating when answer[questionId].ValueKind != JsonValueKind.Number: throw new Exception($"Answer for question {question.Title} expected to be a number"); case PollQuestionType.FreeText when answer[questionId].ValueKind != JsonValueKind.String: throw new Exception($"Answer for question {question.Title} expected to be a string"); case PollQuestionType.SingleChoice when question.Options is not null: if (answer[questionId].ValueKind != JsonValueKind.String) throw new Exception($"Answer for question {question.Title} expected to be a string"); if (question.Options.All(e => e.Id.ToString() != answer[questionId].GetString())) throw new Exception($"Answer for question {question.Title} is invalid"); break; case PollQuestionType.MultipleChoice when question.Options is not null: if (answer[questionId].ValueKind != JsonValueKind.Array) throw new Exception($"Answer for question {question.Title} expected to be an array"); if (answer[questionId].EnumerateArray().Any(option => question.Options.All(e => e.Id.ToString() != option.GetString()))) throw new Exception($"Answer for question {question.Title} is invalid"); break; case PollQuestionType.YesNo when answer[questionId].ValueKind != JsonValueKind.True && answer[questionId].ValueKind != JsonValueKind.False: throw new Exception($"Answer for question {question.Title} expected to be a boolean"); default: throw new ArgumentOutOfRangeException(); } } } public async Task AnswerPoll(Guid pollId, Guid accountId, Dictionary answer) { // Validation var poll = await db.Polls .Where(e => e.Id == pollId) .FirstOrDefaultAsync(); if (poll is null) throw new Exception("Poll not found"); if (poll.EndedAt < SystemClock.Instance.GetCurrentInstant()) throw new Exception("Poll has ended"); await ValidatePollAnswer(pollId, answer); // Remove the existing answer var existingAnswer = await db.PollAnswers .Where(e => e.PollId == pollId && e.AccountId == accountId) .FirstOrDefaultAsync(); if (existingAnswer is not null) await UnAnswerPoll(pollId, accountId); // Save the new answer var answerRecord = new PollAnswer { PollId = pollId, AccountId = accountId, Answer = answer }; await db.PollAnswers.AddAsync(answerRecord); await db.SaveChangesAsync(); // Update cache for this poll answer and invalidate stats cache var answerCacheKey = $"poll:answer:{pollId}:{accountId}"; await cache.SetAsync(answerCacheKey, answerRecord, TimeSpan.FromMinutes(30)); // Invalidate all stats cache for this poll since answers have changed await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId); return answerRecord; } public async Task UnAnswerPoll(Guid pollId, Guid accountId) { var now = SystemClock.Instance.GetCurrentInstant(); var result = await db.PollAnswers .Where(e => e.PollId == pollId && e.AccountId == accountId) .ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, now)) > 0; if (!result) return result; // Remove the cached answer if it exists var answerCacheKey = $"poll:answer:{pollId}:{accountId}"; await cache.RemoveAsync(answerCacheKey); // Invalidate all stats cache for this poll since answers have changed await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId); return result; } private const string PollStatsCachePrefix = "poll:stats:"; private const string PollCacheGroupPrefix = "poll:"; // Returns stats for a single question (option id -> count) public async Task> GetPollQuestionStats(Guid questionId) { var cacheKey = $"{PollStatsCachePrefix}{questionId}"; // Try to get from cache first var (found, cachedStats) = await cache.GetAsyncWithStatus>(cacheKey); if (found && cachedStats != null) { return cachedStats; } var question = await db.PollQuestions .Where(q => q.Id == questionId) .FirstOrDefaultAsync(); if (question == null) throw new Exception("Question not found"); var answers = await db.PollAnswers .Where(a => a.PollId == question.PollId && a.DeletedAt == null) .ToListAsync(); var stats = new Dictionary(); foreach (var answer in answers) { if (!answer.Answer.TryGetValue(questionId.ToString(), out var value)) continue; switch (question.Type) { case PollQuestionType.SingleChoice: if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var selected)) { stats.TryGetValue(selected.ToString(), out var count); stats[selected.ToString()] = count + 1; } break; case PollQuestionType.MultipleChoice: if (value.ValueKind == JsonValueKind.Array) { foreach (var element in value.EnumerateArray()) { if (element.ValueKind != JsonValueKind.String || !Guid.TryParse(element.GetString(), out var opt)) continue; stats.TryGetValue(opt.ToString(), out var count); stats[opt.ToString()] = count + 1; } } break; case PollQuestionType.YesNo: var id = value.ValueKind switch { JsonValueKind.True => "true", JsonValueKind.False => "false", _ => "neither" }; stats.TryGetValue(id, out var ynCount); stats[id] = ynCount + 1; break; case PollQuestionType.Rating: double sum = 0; var countRating = 0; foreach (var rating in answers) { if (!rating.Answer.TryGetValue(questionId.ToString(), out var ratingValue)) continue; if (ratingValue.ValueKind == JsonValueKind.Number && ratingValue.TryGetDouble(out var ratingNumber)) { sum += ratingNumber; countRating++; } } if (countRating > 0) { var avgRating = sum / countRating; stats["rating"] = (int)Math.Round(avgRating); } break; case PollQuestionType.FreeText: break; default: throw new ArgumentOutOfRangeException(); } } // Cache the result with a 1-hour expiration and add to the poll cache group await cache.SetWithGroupsAsync( cacheKey, stats, new[] { PollCacheGroupPrefix + question.PollId }, TimeSpan.FromHours(1)); return stats; } // Returns stats for all questions in a poll (question id -> (option id -> count)) public async Task>> GetPollStats(Guid pollId) { var questions = await db.PollQuestions .Where(q => q.PollId == pollId) .ToListAsync(); var result = new Dictionary>(); foreach (var question in questions) { var stats = await GetPollQuestionStats(question.Id); result[question.Id] = stats; } return result; } }