✨ Poll and its CRUD
This commit is contained in:
64
DysonNetwork.Sphere/Poll/Poll.cs
Normal file
64
DysonNetwork.Sphere/Poll/Poll.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Poll;
|
||||
|
||||
public class Poll : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public List<PollQuestion> Questions { get; set; } = new();
|
||||
|
||||
[MaxLength(1024)] public string? Title { get; set; }
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
|
||||
public Instant? EndedAt { get; set; }
|
||||
|
||||
public Guid PublisherId { get; set; }
|
||||
public Publisher.Publisher Publisher { get; set; } = null!;
|
||||
}
|
||||
|
||||
public enum PollQuestionType
|
||||
{
|
||||
SingleChoice,
|
||||
MultipleChoice,
|
||||
YesNo,
|
||||
Rating,
|
||||
FreeText
|
||||
}
|
||||
|
||||
public class PollQuestion : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public PollQuestionType Type { get; set; }
|
||||
[Column(TypeName = "jsonb")] public List<PollOption>? Options { get; set; }
|
||||
|
||||
[MaxLength(1024)] public string Title { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
public int Order { get; set; } = 0;
|
||||
public bool IsRequired { get; set; }
|
||||
|
||||
public Guid PollId { get; set; }
|
||||
[JsonIgnore] public Poll Poll { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class PollOption
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[Required] [MaxLength(1024)] public string Label { get; set; } = null!;
|
||||
[MaxLength(4096)] public string? Description { get; set; }
|
||||
public int Order { get; set; } = 0;
|
||||
}
|
||||
|
||||
public class PollAnswer : ModelBase
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
[Column(TypeName = "jsonb")] public Dictionary<string, JsonElement> Answer { get; set; } = null!;
|
||||
|
||||
public Guid AccountId { get; set; }
|
||||
public Guid PollId { get; set; }
|
||||
[JsonIgnore] public Poll Poll { get; set; } = null!;
|
||||
}
|
189
DysonNetwork.Sphere/Poll/PollController.cs
Normal file
189
DysonNetwork.Sphere/Poll/PollController.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using DysonNetwork.Sphere.Publisher;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Poll;
|
||||
|
||||
[ApiController]
|
||||
[Route("/api/polls")]
|
||||
public class PollController(AppDatabase db, PollService polls, PublisherService pub) : ControllerBase
|
||||
{
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<List<Poll>>> ListPolls(
|
||||
[FromQuery] bool active = false,
|
||||
[FromQuery] int offset = 0,
|
||||
[FromQuery] int take = 20
|
||||
)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
var publishers = (await pub.GetUserPublishers(accountId)).Select(p => p.Id).ToList();
|
||||
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
var query = db.Polls
|
||||
.Where(e => publishers.Contains(e.PublisherId));
|
||||
if (active) query = query.Where(e => e.EndedAt > now);
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
HttpContext.Response.Headers.Append("X-Total", totalCount.ToString());
|
||||
|
||||
var polls = await query
|
||||
.Skip(offset)
|
||||
.Take(take)
|
||||
.ToListAsync();
|
||||
return Ok(polls);
|
||||
}
|
||||
|
||||
public class PollRequest
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public Instant? EndedAt { get; set; }
|
||||
public List<PollQuestion>? Questions { get; set; }
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Poll>> CreatePoll([FromBody] PollRequest request, [FromQuery] string pubName)
|
||||
{
|
||||
if (request.Questions is null) return BadRequest("Questions are required.");
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
var publisher = await pub.GetPublisherByName(pubName);
|
||||
if (publisher is null) return BadRequest("Publisher was not found.");
|
||||
if (!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You need at least be an editor to create polls as this publisher.");
|
||||
|
||||
var poll = new Poll
|
||||
{
|
||||
Title = request.Title,
|
||||
Description = request.Description,
|
||||
EndedAt = request.EndedAt,
|
||||
PublisherId = publisher.Id,
|
||||
Questions = request.Questions
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
polls.ValidatePoll(poll);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
|
||||
db.Polls.Add(poll);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(poll);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<Poll>> UpdatePoll(Guid id, [FromBody] PollRequest request)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
// Start a transaction
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var poll = await db.Polls
|
||||
.Include(p => p.Questions)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (poll == null) return NotFound("Poll not found");
|
||||
|
||||
// Check if user is an editor of the publisher that owns the poll
|
||||
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You need to be at least an editor to update this poll.");
|
||||
|
||||
// Update properties if they are provided in the request
|
||||
if (request.Title != null) poll.Title = request.Title;
|
||||
if (request.Description != null) poll.Description = request.Description;
|
||||
if (request.EndedAt.HasValue) poll.EndedAt = request.EndedAt;
|
||||
|
||||
// Update questions if provided
|
||||
if (request.Questions != null)
|
||||
{
|
||||
// Remove existing questions
|
||||
db.PollQuestions.RemoveRange(poll.Questions);
|
||||
|
||||
// Add new questions
|
||||
poll.Questions = request.Questions;
|
||||
}
|
||||
|
||||
polls.ValidatePoll(poll);
|
||||
|
||||
poll.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Commit the transaction if all operations succeed
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return Ok(poll);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeletePoll(Guid id)
|
||||
{
|
||||
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
|
||||
var accountId = Guid.Parse(currentUser.Id);
|
||||
|
||||
// Start a transaction
|
||||
await using var transaction = await db.Database.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var poll = await db.Polls
|
||||
.Include(p => p.Questions)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (poll == null) return NotFound("Poll not found");
|
||||
|
||||
// Check if user is an editor of the publisher that owns the poll
|
||||
if (!await pub.IsMemberWithRole(poll.PublisherId, accountId, PublisherMemberRole.Editor))
|
||||
return StatusCode(403, "You need to be at least an editor to delete this poll.");
|
||||
|
||||
// Delete all answers for this poll
|
||||
var answers = await db.PollAnswers
|
||||
.Where(a => a.PollId == id)
|
||||
.ToListAsync();
|
||||
|
||||
if (answers.Count != 0)
|
||||
db.PollAnswers.RemoveRange(answers);
|
||||
|
||||
// Delete all questions for this poll
|
||||
if (poll.Questions.Count != 0)
|
||||
db.PollQuestions.RemoveRange(poll.Questions);
|
||||
|
||||
// Finally, delete the poll itself
|
||||
db.Polls.Remove(poll);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Commit the transaction if all operations succeed
|
||||
await transaction.CommitAsync();
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
return StatusCode(500, "An error occurred while deleting the poll... " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
149
DysonNetwork.Sphere/Poll/PollService.cs
Normal file
149
DysonNetwork.Sphere/Poll/PollService.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
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<PollAnswer?> GetPollAnswer(Guid pollId, Guid accountId)
|
||||
{
|
||||
var cacheKey = $"poll:answer:{pollId}:{accountId}";
|
||||
var cachedAnswer = await cache.GetAsync<PollAnswer?>(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<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}");
|
||||
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<PollAnswer> 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 PollAnswer
|
||||
{
|
||||
PollId = pollId,
|
||||
AccountId = accountId,
|
||||
Answer = answer
|
||||
};
|
||||
await db.PollAnswers.AddAsync(answerRecord);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Invalidate the cache for this poll answer
|
||||
var cacheKey = $"poll:answer:{pollId}:{accountId}";
|
||||
await cache.SetAsync(cacheKey, answerRecord, TimeSpan.FromMinutes(30));
|
||||
|
||||
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)
|
||||
{
|
||||
// Remove the cached answer if it exists
|
||||
var cacheKey = $"poll:answer:{pollId}:{accountId}";
|
||||
await cache.RemoveAsync(cacheKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user