318 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			318 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System.Text.Json;
 | |
| using DysonNetwork.Shared.Cache;
 | |
| using DysonNetwork.Shared.Models;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using NodaTime;
 | |
| 
 | |
| namespace DysonNetwork.Sphere.Poll;
 | |
| 
 | |
| public class PollService(AppDatabase db, ICacheService cache)
 | |
| {
 | |
|     public void ValidatePoll(SnPoll 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;
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public async Task<SnPoll?> GetPoll(Guid id)
 | |
|     {
 | |
|         var poll = await db.Polls
 | |
|             .Where(e => e.Id == id)
 | |
|             .Include(e => e.Questions)
 | |
|             .FirstOrDefaultAsync();
 | |
|         return poll;
 | |
|     }
 | |
| 
 | |
|     private const string PollAnswerCachePrefix = "poll:answer:";
 | |
| 
 | |
|     public async Task<SnPollAnswer?> GetPollAnswer(Guid pollId, Guid accountId)
 | |
|     {
 | |
|         var cacheKey = $"{PollAnswerCachePrefix}{pollId}:{accountId}";
 | |
|         var cachedAnswer = await cache.GetAsync<SnPollAnswer?>(cacheKey);
 | |
|         if (cachedAnswer is not null)
 | |
|             return cachedAnswer;
 | |
| 
 | |
|         var answer = await db.PollAnswers
 | |
|             .Where(e => e.PollId == pollId && e.AccountId == accountId)
 | |
|             .AsNoTracking()
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (answer is not null)
 | |
|             answer.Poll = null;
 | |
| 
 | |
|         // Set the answer even it is null, which stands for unanswered
 | |
|         await cache.SetAsync(cacheKey, answer, TimeSpan.FromMinutes(30));
 | |
| 
 | |
|         return answer;
 | |
|     }
 | |
| 
 | |
|     private async Task ValidatePollAnswer(Guid pollId, Dictionary<string, JsonElement> 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}");
 | |
|             if (!answer.ContainsKey(questionId))
 | |
|                 continue;
 | |
|             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");
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public async Task<SnPollAnswer> AnswerPoll(Guid pollId, Guid accountId, Dictionary<string, JsonElement> 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 SnPollAnswer
 | |
|         {
 | |
|             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<bool> 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<Dictionary<string, int>> GetPollQuestionStats(Guid questionId)
 | |
|     {
 | |
|         var cacheKey = $"{PollStatsCachePrefix}{questionId}";
 | |
| 
 | |
|         // Try to get from cache first
 | |
|         var (found, cachedStats) = await cache.GetAsyncWithStatus<Dictionary<string, int>>(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<string, int>();
 | |
| 
 | |
|         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,
 | |
|             [PollCacheGroupPrefix + question.PollId],
 | |
|             TimeSpan.FromHours(1));
 | |
| 
 | |
|         return stats;
 | |
|     }
 | |
| 
 | |
|     // Returns stats for all questions in a poll (question id -> (option id -> count))
 | |
|     public async Task<Dictionary<Guid, Dictionary<string, int>>> GetPollStats(Guid pollId)
 | |
|     {
 | |
|         var questions = await db.PollQuestions
 | |
|             .Where(q => q.PollId == pollId)
 | |
|             .ToListAsync();
 | |
| 
 | |
|         var result = new Dictionary<Guid, Dictionary<string, int>>();
 | |
| 
 | |
|         foreach (var question in questions)
 | |
|         {
 | |
|             var stats = await GetPollQuestionStats(question.Id);
 | |
|             result[question.Id] = stats;
 | |
|         }
 | |
| 
 | |
|         return result;
 | |
|     }
 | |
| 
 | |
|     public async Task<PollEmbed> MakePollEmbed(Guid pollId)
 | |
|     {
 | |
|         // Do not read the cache here
 | |
|         var poll = await db.Polls
 | |
|             .Where(e => e.Id == pollId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (poll is null)
 | |
|             throw new Exception("Poll not found");
 | |
|         return new PollEmbed { Id = poll.Id };
 | |
|     }
 | |
| 
 | |
|     public async Task<PollEmbed> LoadPollEmbed(Guid pollId, Guid? accountId)
 | |
|     {
 | |
|         var poll = await GetPoll(pollId) ?? throw new Exception("Poll not found");
 | |
|         var pollWithStats = PollWithStats.FromPoll(poll);
 | |
|         pollWithStats.Stats = await GetPollStats(poll.Id);
 | |
|         if (accountId is not null)
 | |
|             pollWithStats.UserAnswer = await GetPollAnswer(poll.Id, accountId.Value);
 | |
|         return new PollEmbed { Id = poll.Id, Poll = pollWithStats };
 | |
|     }
 | |
| }
 |