✨ Poll and its CRUD
This commit is contained in:
@@ -41,6 +41,10 @@ public class AppDatabase(
|
|||||||
public DbSet<PostTag> PostTags { get; set; }
|
public DbSet<PostTag> PostTags { get; set; }
|
||||||
public DbSet<PostCategory> PostCategories { get; set; }
|
public DbSet<PostCategory> PostCategories { get; set; }
|
||||||
public DbSet<PostCollection> PostCollections { 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<Realm.Realm> Realms { get; set; }
|
||||||
public DbSet<RealmMember> RealmMembers { 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 />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
using DysonNetwork.Shared.Data;
|
using DysonNetwork.Shared.Data;
|
||||||
using DysonNetwork.Sphere;
|
using DysonNetwork.Sphere;
|
||||||
using DysonNetwork.Sphere.Chat;
|
using DysonNetwork.Sphere.Chat;
|
||||||
using DysonNetwork.Sphere.Developer;
|
using DysonNetwork.Sphere.Developer;
|
||||||
|
using DysonNetwork.Sphere.Poll;
|
||||||
using DysonNetwork.Sphere.WebReader;
|
using DysonNetwork.Sphere.WebReader;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -498,6 +500,152 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.ToTable("custom_app_secrets", (string)null);
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1592,6 +1740,42 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.Navigation("App");
|
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 =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost")
|
b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost")
|
||||||
@@ -1837,6 +2021,11 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
b.Navigation("Secrets");
|
b.Navigation("Secrets");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DysonNetwork.Sphere.Poll.Poll", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Questions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
|
modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Reactions");
|
b.Navigation("Reactions");
|
||||||
@@ -1850,6 +2039,8 @@ namespace DysonNetwork.Sphere.Migrations
|
|||||||
|
|
||||||
b.Navigation("Members");
|
b.Navigation("Members");
|
||||||
|
|
||||||
|
b.Navigation("Polls");
|
||||||
|
|
||||||
b.Navigation("Posts");
|
b.Navigation("Posts");
|
||||||
|
|
||||||
b.Navigation("Subscriptions");
|
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(
|
public async Task<ActionResult<Post>> CreatePost(
|
||||||
[FromBody] PostRequest request,
|
[FromBody] PostRequest request,
|
||||||
[FromQuery(Name = "pub")] [FromHeader(Name = "X-Pub")]
|
[FromQuery(Name = "pub")] [FromHeader(Name = "X-Pub")]
|
||||||
string? publisherName
|
string? pubName
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
request.Content = TextSanitizer.Sanitize(request.Content);
|
request.Content = TextSanitizer.Sanitize(request.Content);
|
||||||
@@ -293,7 +293,7 @@ public class PostController(
|
|||||||
var accountId = Guid.Parse(currentUser.Id);
|
var accountId = Guid.Parse(currentUser.Id);
|
||||||
|
|
||||||
Publisher.Publisher? publisher;
|
Publisher.Publisher? publisher;
|
||||||
if (publisherName is null)
|
if (pubName is null)
|
||||||
{
|
{
|
||||||
// Use the first personal publisher
|
// Use the first personal publisher
|
||||||
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
|
publisher = await db.Publishers.FirstOrDefaultAsync(e =>
|
||||||
@@ -301,13 +301,9 @@ public class PostController(
|
|||||||
}
|
}
|
||||||
else
|
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.");
|
if (publisher is null) return BadRequest("Publisher was not found.");
|
||||||
var member =
|
if(!await pub.IsMemberWithRole(publisher.Id, accountId, PublisherMemberRole.Editor))
|
||||||
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)
|
|
||||||
return StatusCode(403, "You need at least be an editor to post as this publisher.");
|
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; }
|
[Column(TypeName = "jsonb")] public VerificationMark? Verification { get; set; }
|
||||||
|
|
||||||
[JsonIgnore] public ICollection<Post.Post> Posts { get; set; } = new List<Post.Post>();
|
[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<PostCollection> Collections { get; set; } = new List<PostCollection>();
|
||||||
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
|
[JsonIgnore] public ICollection<PublisherMember> Members { get; set; } = new List<PublisherMember>();
|
||||||
[JsonIgnore] public ICollection<PublisherFeature> Features { get; set; } = new List<PublisherFeature>();
|
[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.WebReader;
|
||||||
using DysonNetwork.Sphere.Developer;
|
using DysonNetwork.Sphere.Developer;
|
||||||
using DysonNetwork.Sphere.Discovery;
|
using DysonNetwork.Sphere.Discovery;
|
||||||
|
using DysonNetwork.Sphere.Poll;
|
||||||
using DysonNetwork.Sphere.Translation;
|
using DysonNetwork.Sphere.Translation;
|
||||||
|
|
||||||
namespace DysonNetwork.Sphere.Startup;
|
namespace DysonNetwork.Sphere.Startup;
|
||||||
@@ -167,6 +168,7 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<WebFeedService>();
|
services.AddScoped<WebFeedService>();
|
||||||
services.AddScoped<DiscoveryService>();
|
services.AddScoped<DiscoveryService>();
|
||||||
services.AddScoped<CustomAppService>();
|
services.AddScoped<CustomAppService>();
|
||||||
|
services.AddScoped<PollService>();
|
||||||
|
|
||||||
var translationProvider = configuration["Translation:Provider"]?.ToLower();
|
var translationProvider = configuration["Translation:Provider"]?.ToLower();
|
||||||
switch (translationProvider)
|
switch (translationProvider)
|
||||||
|
Reference in New Issue
Block a user