✨ Poll stats
This commit is contained in:
@@ -20,13 +20,14 @@ public class Poll : ModelBase
|
|||||||
public Publisher.Publisher Publisher { get; set; } = null!;
|
public Publisher.Publisher Publisher { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class PollWithUserAnswer : Poll
|
public class PollWithAnswer : Poll
|
||||||
{
|
{
|
||||||
public PollAnswer? UserAnswer { get; set; }
|
public PollAnswer? UserAnswer { get; set; }
|
||||||
|
public Dictionary<Guid, Dictionary<string, int>> Stats { get; set; } = new(); // question id -> (option id -> count)
|
||||||
|
|
||||||
public static PollWithUserAnswer FromPoll(Poll poll, PollAnswer? userAnswer = null)
|
public static PollWithAnswer FromPoll(Poll poll, PollAnswer? userAnswer = null)
|
||||||
{
|
{
|
||||||
return new PollWithUserAnswer
|
return new PollWithAnswer
|
||||||
{
|
{
|
||||||
Id = poll.Id,
|
Id = poll.Id,
|
||||||
Title = poll.Title,
|
Title = poll.Title,
|
||||||
|
@@ -13,13 +13,13 @@ namespace DysonNetwork.Sphere.Poll;
|
|||||||
public class PollController(AppDatabase db, PollService polls, PublisherService pub) : ControllerBase
|
public class PollController(AppDatabase db, PollService polls, PublisherService pub) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{id:guid}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<ActionResult<PollWithUserAnswer>> GetPoll(Guid id)
|
public async Task<ActionResult<PollWithAnswer>> GetPoll(Guid id)
|
||||||
{
|
{
|
||||||
var poll = await db.Polls
|
var poll = await db.Polls
|
||||||
.Include(p => p.Questions)
|
.Include(p => p.Questions)
|
||||||
.FirstOrDefaultAsync(p => p.Id == id);
|
.FirstOrDefaultAsync(p => p.Id == id);
|
||||||
if (poll is null) return NotFound("Poll not found");
|
if (poll is null) return NotFound("Poll not found");
|
||||||
var pollWithAnswer = PollWithUserAnswer.FromPoll(poll);
|
var pollWithAnswer = PollWithAnswer.FromPoll(poll);
|
||||||
|
|
||||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Ok(pollWithAnswer);
|
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Ok(pollWithAnswer);
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ public class PollController(AppDatabase db, PollService polls, PublisherService
|
|||||||
var answer = await polls.GetPollAnswer(id, accountId);
|
var answer = await polls.GetPollAnswer(id, accountId);
|
||||||
if (answer is not null)
|
if (answer is not null)
|
||||||
pollWithAnswer.UserAnswer = answer;
|
pollWithAnswer.UserAnswer = answer;
|
||||||
|
pollWithAnswer.Stats = await polls.GetPollStats(id);
|
||||||
|
|
||||||
return Ok(pollWithAnswer);
|
return Ok(pollWithAnswer);
|
||||||
}
|
}
|
||||||
|
@@ -35,7 +35,7 @@ public class PollService(AppDatabase db, ICacheService cache)
|
|||||||
|
|
||||||
public async Task<PollAnswer?> GetPollAnswer(Guid pollId, Guid accountId)
|
public async Task<PollAnswer?> GetPollAnswer(Guid pollId, Guid accountId)
|
||||||
{
|
{
|
||||||
var cacheKey = $"poll:answer:{pollId}:{accountId}";
|
var cacheKey = $"{PollAnswerCachePrefix}{pollId}:{accountId}";
|
||||||
var cachedAnswer = await cache.GetAsync<PollAnswer?>(cacheKey);
|
var cachedAnswer = await cache.GetAsync<PollAnswer?>(cacheKey);
|
||||||
if (cachedAnswer is not null)
|
if (cachedAnswer is not null)
|
||||||
return cachedAnswer;
|
return cachedAnswer;
|
||||||
@@ -123,9 +123,12 @@ public class PollService(AppDatabase db, ICacheService cache)
|
|||||||
await db.PollAnswers.AddAsync(answerRecord);
|
await db.PollAnswers.AddAsync(answerRecord);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Invalidate the cache for this poll answer
|
// Update cache for this poll answer and invalidate stats cache
|
||||||
var cacheKey = $"poll:answer:{pollId}:{accountId}";
|
var answerCacheKey = $"poll:answer:{pollId}:{accountId}";
|
||||||
await cache.SetAsync(cacheKey, answerRecord, TimeSpan.FromMinutes(30));
|
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;
|
return answerRecord;
|
||||||
}
|
}
|
||||||
@@ -137,11 +140,144 @@ public class PollService(AppDatabase db, ICacheService cache)
|
|||||||
.Where(e => e.PollId == pollId && e.AccountId == accountId)
|
.Where(e => e.PollId == pollId && e.AccountId == accountId)
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, now)) > 0;
|
.ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, now)) > 0;
|
||||||
|
|
||||||
if (result)
|
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)
|
||||||
{
|
{
|
||||||
// Remove the cached answer if it exists
|
return cachedStats;
|
||||||
var cacheKey = $"poll:answer:{pollId}:{accountId}";
|
}
|
||||||
await cache.RemoveAsync(cacheKey);
|
|
||||||
|
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,
|
||||||
|
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<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;
|
return result;
|
||||||
|
Reference in New Issue
Block a user