Compare commits

...

3 Commits

Author SHA1 Message Date
a932108c87 Poll stats 2025-08-02 18:45:19 +08:00
71eccbb466 Poll answer and un-answer 2025-08-02 18:18:48 +08:00
700803f7a6 Poll and its CRUD 2025-08-02 17:54:51 +08:00
10 changed files with 3016 additions and 8 deletions

View File

@@ -41,6 +41,10 @@ public class AppDatabase(
public DbSet<PostTag> PostTags { get; set; }
public DbSet<PostCategory> PostCategories { get; set; }
public DbSet<PostCollection> PostCollections { get; set; }
public DbSet<Poll.Poll> Polls { get; set; }
public DbSet<Poll.PollQuestion> PollQuestions { get; set; }
public DbSet<Poll.PollAnswer> PollAnswers { get; set; }
public DbSet<Realm.Realm> Realms { get; set; }
public DbSet<RealmMember> RealmMembers { get; set; }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Sphere.Poll;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Sphere.Migrations
{
/// <inheritdoc />
public partial class AddPoll : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "polls",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
title = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: true),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
ended_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true),
publisher_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_polls", x => x.id);
table.ForeignKey(
name: "fk_polls_publishers_publisher_id",
column: x => x.publisher_id,
principalTable: "publishers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "poll_answers",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
answer = table.Column<Dictionary<string, JsonElement>>(type: "jsonb", nullable: false),
account_id = table.Column<Guid>(type: "uuid", nullable: false),
poll_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_poll_answers", x => x.id);
table.ForeignKey(
name: "fk_poll_answers_polls_poll_id",
column: x => x.poll_id,
principalTable: "polls",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "poll_questions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
options = table.Column<List<PollOption>>(type: "jsonb", nullable: true),
title = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
description = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
order = table.Column<int>(type: "integer", nullable: false),
is_required = table.Column<bool>(type: "boolean", nullable: false),
poll_id = table.Column<Guid>(type: "uuid", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_poll_questions", x => x.id);
table.ForeignKey(
name: "fk_poll_questions_polls_poll_id",
column: x => x.poll_id,
principalTable: "polls",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_poll_answers_poll_id",
table: "poll_answers",
column: "poll_id");
migrationBuilder.CreateIndex(
name: "ix_poll_questions_poll_id",
table: "poll_questions",
column: "poll_id");
migrationBuilder.CreateIndex(
name: "ix_polls_publisher_id",
table: "polls",
column: "publisher_id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "poll_answers");
migrationBuilder.DropTable(
name: "poll_questions");
migrationBuilder.DropTable(
name: "polls");
}
}
}

View File

@@ -1,10 +1,12 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using System.Text.Json;
using DysonNetwork.Shared.Data;
using DysonNetwork.Sphere;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -498,6 +500,152 @@ namespace DysonNetwork.Sphere.Migrations
b.ToTable("custom_app_secrets", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Poll.Poll", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<Instant?>("EndedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ended_at");
b.Property<Guid>("PublisherId")
.HasColumnType("uuid")
.HasColumnName("publisher_id");
b.Property<string>("Title")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("title");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_polls");
b.HasIndex("PublisherId")
.HasDatabaseName("ix_polls_publisher_id");
b.ToTable("polls", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Poll.PollAnswer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Dictionary<string, JsonElement>>("Answer")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("answer");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid>("PollId")
.HasColumnType("uuid")
.HasColumnName("poll_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_poll_answers");
b.HasIndex("PollId")
.HasDatabaseName("ix_poll_answers_poll_id");
b.ToTable("poll_answers", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Poll.PollQuestion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("Description")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("description");
b.Property<bool>("IsRequired")
.HasColumnType("boolean")
.HasColumnName("is_required");
b.Property<List<PollOption>>("Options")
.HasColumnType("jsonb")
.HasColumnName("options");
b.Property<int>("Order")
.HasColumnType("integer")
.HasColumnName("order");
b.Property<Guid>("PollId")
.HasColumnType("uuid")
.HasColumnName("poll_id");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("title");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_poll_questions");
b.HasIndex("PollId")
.HasDatabaseName("ix_poll_questions_poll_id");
b.ToTable("poll_questions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
{
b.Property<Guid>("Id")
@@ -1592,6 +1740,42 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("App");
});
modelBuilder.Entity("DysonNetwork.Sphere.Poll.Poll", b =>
{
b.HasOne("DysonNetwork.Sphere.Publisher.Publisher", "Publisher")
.WithMany("Polls")
.HasForeignKey("PublisherId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_polls_publishers_publisher_id");
b.Navigation("Publisher");
});
modelBuilder.Entity("DysonNetwork.Sphere.Poll.PollAnswer", b =>
{
b.HasOne("DysonNetwork.Sphere.Poll.Poll", "Poll")
.WithMany()
.HasForeignKey("PollId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_poll_answers_polls_poll_id");
b.Navigation("Poll");
});
modelBuilder.Entity("DysonNetwork.Sphere.Poll.PollQuestion", b =>
{
b.HasOne("DysonNetwork.Sphere.Poll.Poll", "Poll")
.WithMany("Questions")
.HasForeignKey("PollId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_poll_questions_polls_poll_id");
b.Navigation("Poll");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
{
b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost")
@@ -1837,6 +2021,11 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Secrets");
});
modelBuilder.Entity("DysonNetwork.Sphere.Poll.Poll", b =>
{
b.Navigation("Questions");
});
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
{
b.Navigation("Reactions");
@@ -1850,6 +2039,8 @@ namespace DysonNetwork.Sphere.Migrations
b.Navigation("Members");
b.Navigation("Polls");
b.Navigation("Posts");
b.Navigation("Subscriptions");

View File

@@ -0,0 +1,87 @@
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 class PollWithAnswer : Poll
{
public PollAnswer? UserAnswer { get; set; }
public Dictionary<Guid, Dictionary<string, int>> Stats { get; set; } = new(); // question id -> (option id -> count)
public static PollWithAnswer FromPoll(Poll poll, PollAnswer? userAnswer = null)
{
return new PollWithAnswer
{
Id = poll.Id,
Title = poll.Title,
Description = poll.Description,
EndedAt = poll.EndedAt,
PublisherId = poll.PublisherId,
Publisher = poll.Publisher,
Questions = poll.Questions,
CreatedAt = poll.CreatedAt,
UpdatedAt = poll.UpdatedAt,
UserAnswer = userAnswer
};
}
}
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!;
}

View File

@@ -0,0 +1,248 @@
using System.Text.Json;
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("{id:guid}")]
public async Task<ActionResult<PollWithAnswer>> GetPoll(Guid id)
{
var poll = await db.Polls
.Include(p => p.Questions)
.FirstOrDefaultAsync(p => p.Id == id);
if (poll is null) return NotFound("Poll not found");
var pollWithAnswer = PollWithAnswer.FromPoll(poll);
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Ok(pollWithAnswer);
var accountId = Guid.Parse(currentUser.Id);
var answer = await polls.GetPollAnswer(id, accountId);
if (answer is not null)
pollWithAnswer.UserAnswer = answer;
pollWithAnswer.Stats = await polls.GetPollStats(id);
return Ok(pollWithAnswer);
}
public class PollAnswerRequest
{
public required Dictionary<string, JsonElement> Answer { get; set; }
}
[HttpPost("{id:guid}/answer")]
[Authorize]
public async Task<ActionResult<PollAnswer>> AnswerPoll(Guid id, [FromBody] PollAnswerRequest request)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
try
{
return await polls.AnswerPoll(id, accountId, request.Answer);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("{id:guid}/answer")]
[Authorize]
public async Task<IActionResult> DeletePollAnswer(Guid id)
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
var accountId = Guid.Parse(currentUser.Id);
try
{
await polls.UnAnswerPoll(id, accountId);
return NoContent();
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
[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);
}
}
}

View File

@@ -0,0 +1,285 @@
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 = $"{PollAnswerCachePrefix}{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();
// 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,
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;
}
}

View File

@@ -282,7 +282,7 @@ public class PostController(
public async Task<ActionResult<Post>> CreatePost(
[FromBody] PostRequest request,
[FromQuery(Name = "pub")] [FromHeader(Name = "X-Pub")]
string? publisherName
string? pubName
)
{
request.Content = TextSanitizer.Sanitize(request.Content);
@@ -293,7 +293,7 @@ public class PostController(
var accountId = Guid.Parse(currentUser.Id);
Publisher.Publisher? publisher;
if (publisherName is null)
if (pubName is null)
{
// Use the first personal publisher
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
@@ -301,13 +301,9 @@ public class PostController(
}
else
{
publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == publisherName);
publisher = await pub.GetPublisherByName(pubName);
if (publisher is null) return BadRequest("Publisher was not found.");
var member =
await db.PublisherMembers.FirstOrDefaultAsync(e =>
e.AccountId == accountId && e.PublisherId == publisher.Id);
if (member is null) return StatusCode(403, "You even wasn't a member of the publisher you specified.");
if (member.Role < PublisherMemberRole.Editor)
if(!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
return StatusCode(403, "You need at least be an editor to post as this publisher.");
}

View File

@@ -35,6 +35,7 @@ public class Publisher : ModelBase, IIdentifiedResource
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
[JsonIgnore] public ICollection<Post.Post> Posts { get; set; } = new List<Post.Post>();
[JsonIgnore] public ICollection<Poll.Poll> Polls { get; set; } = new List<Poll.Poll>();
[JsonIgnore] public ICollection<PostCollection> Collections { get; set; } = new List<PostCollection>();
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
[JsonIgnore] public ICollection<PublisherFeature> Features { get; set; } = new List<PublisherFeature>();

View File

@@ -19,6 +19,7 @@ using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Developer;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Translation;
namespace DysonNetwork.Sphere.Startup;
@@ -167,6 +168,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<WebFeedService>();
services.AddScoped<DiscoveryService>();
services.AddScoped<CustomAppService>();
services.AddScoped<PollService>();
var translationProvider = configuration["Translation:Provider"]?.ToLower();
switch (translationProvider)