✨ Poll and its CRUD
This commit is contained in:
@@ -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; }
|
||||
|
2073
DysonNetwork.Sphere/Migrations/20250802095248_AddPoll.Designer.cs
generated
Normal file
2073
DysonNetwork.Sphere/Migrations/20250802095248_AddPoll.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
121
DysonNetwork.Sphere/Migrations/20250802095248_AddPoll.cs
Normal file
121
DysonNetwork.Sphere/Migrations/20250802095248_AddPoll.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
@@ -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");
|
||||
|
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;
|
||||
}
|
||||
}
|
@@ -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.");
|
||||
}
|
||||
|
||||
|
@@ -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>();
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user