diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs index 370aa31..e0d158e 100644 --- a/DysonNetwork.Sphere/Account/Account.cs +++ b/DysonNetwork.Sphere/Account/Account.cs @@ -1,9 +1,11 @@ using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Account; +[Index(nameof(Name), IsUnique = true)] public class Account : ModelBase { public long Id { get; set; } diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index e2301a8..c0f6f2a 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -31,6 +31,9 @@ public class AppDatabase( public DbSet PublisherMembers { get; set; } public DbSet Posts { get; set; } public DbSet PostReactions { get; set; } + public DbSet PostTags { get; set; } + public DbSet PostCategories { get; set; } + public DbSet PostCollections { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -80,16 +83,32 @@ public class AppDatabase( .HasForeignKey(pm => pm.AccountId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(p => p.ThreadedPost) + .WithOne() + .HasForeignKey(p => p.ThreadedPostId); modelBuilder.Entity() .HasOne(p => p.RepliedPost) .WithMany() - .HasForeignKey("RepliedPostId") + .HasForeignKey(p => p.RepliedPostId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(p => p.ForwardedPost) .WithMany() - .HasForeignKey("ForwardedPostId") + .HasForeignKey(p => p.ForwardedPostId) .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasMany(p => p.Tags) + .WithMany(t => t.Posts) + .UsingEntity(j => j.ToTable("post_tag_links")); + modelBuilder.Entity() + .HasMany(p => p.Categories) + .WithMany(c => c.Posts) + .UsingEntity(j => j.ToTable("post_category_links")); + modelBuilder.Entity() + .HasMany(p => p.Collections) + .WithMany(c => c.Posts) + .UsingEntity(j => j.ToTable("post_collection_links")); // Automatically apply soft-delete filter to all entities inheriting BaseModel foreach (var entityType in modelBuilder.Model.GetEntityTypes()) diff --git a/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.Designer.cs b/DysonNetwork.Sphere/Migrations/20250419115230_AddPost.Designer.cs similarity index 76% rename from DysonNetwork.Sphere/Migrations/20250419062728_AddPost.Designer.cs rename to DysonNetwork.Sphere/Migrations/20250419115230_AddPost.Designer.cs index de8e2b5..79b4916 100644 --- a/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.Designer.cs +++ b/DysonNetwork.Sphere/Migrations/20250419115230_AddPost.Designer.cs @@ -14,7 +14,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; namespace DysonNetwork.Sphere.Migrations { [DbContext(typeof(AppDatabase))] - [Migration("20250419062728_AddPost")] + [Migration("20250419115230_AddPost")] partial class AddPost { /// @@ -73,6 +73,10 @@ namespace DysonNetwork.Sphere.Migrations b.HasKey("Id") .HasName("pk_accounts"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_accounts_name"); + b.ToTable("accounts", (string)null); }); @@ -419,10 +423,27 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("integer") .HasColumnName("downvotes"); + b.Property("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + b.Property("ForwardedPostId") .HasColumnType("bigint") .HasColumnName("forwarded_post_id"); + b.Property("Language") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("language"); + + b.Property>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + b.Property("PublisherId") .HasColumnType("bigint") .HasColumnName("publisher_id"); @@ -431,6 +452,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("bigint") .HasColumnName("replied_post_id"); + b.Property("ThreadedPostId") + .HasColumnType("bigint") + .HasColumnName("threaded_post_id"); + b.Property("Title") .HasMaxLength(1024) .HasColumnType("character varying(1024)") @@ -456,6 +481,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("integer") .HasColumnName("views_unique"); + b.Property("Visibility") + .HasColumnType("integer") + .HasColumnName("visibility"); + b.HasKey("Id") .HasName("pk_posts"); @@ -468,9 +497,101 @@ namespace DysonNetwork.Sphere.Migrations b.HasIndex("RepliedPostId") .HasDatabaseName("ix_posts_replied_post_id"); + b.HasIndex("ThreadedPostId") + .IsUnique() + .HasDatabaseName("ix_posts_threaded_post_id"); + b.ToTable("posts", (string)null); }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_categories"); + + b.ToTable("post_categories", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_collections"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_post_collections_publisher_id"); + + b.ToTable("post_collections", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => { b.Property("Id") @@ -522,6 +643,44 @@ namespace DysonNetwork.Sphere.Migrations b.ToTable("post_reactions", (string)null); }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_tags"); + + b.ToTable("post_tags", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => { b.Property("Id") @@ -585,6 +744,10 @@ namespace DysonNetwork.Sphere.Migrations b.HasIndex("BackgroundId") .HasDatabaseName("ix_publishers_background_id"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_publishers_name"); + b.HasIndex("PictureId") .HasDatabaseName("ix_publishers_picture_id"); @@ -714,6 +877,63 @@ namespace DysonNetwork.Sphere.Migrations b.ToTable("files", (string)null); }); + modelBuilder.Entity("PostPostCategory", b => + { + b.Property("CategoriesId") + .HasColumnType("bigint") + .HasColumnName("categories_id"); + + b.Property("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.HasKey("CategoriesId", "PostsId") + .HasName("pk_post_category_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_category_links_posts_id"); + + b.ToTable("post_category_links", (string)null); + }); + + modelBuilder.Entity("PostPostCollection", b => + { + b.Property("CollectionsId") + .HasColumnType("bigint") + .HasColumnName("collections_id"); + + b.Property("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.HasKey("CollectionsId", "PostsId") + .HasName("pk_post_collection_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_collection_links_posts_id"); + + b.ToTable("post_collection_links", (string)null); + }); + + modelBuilder.Entity("PostPostTag", b => + { + b.Property("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.Property("TagsId") + .HasColumnType("bigint") + .HasColumnName("tags_id"); + + b.HasKey("PostsId", "TagsId") + .HasName("pk_post_tag_links"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_post_tag_links_tags_id"); + + b.ToTable("post_tag_links", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => { b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") @@ -839,11 +1059,30 @@ namespace DysonNetwork.Sphere.Migrations .OnDelete(DeleteBehavior.Restrict) .HasConstraintName("fk_posts_posts_replied_post_id"); + b.HasOne("DysonNetwork.Sphere.Post.Post", "ThreadedPost") + .WithOne() + .HasForeignKey("DysonNetwork.Sphere.Post.Post", "ThreadedPostId") + .HasConstraintName("fk_posts_posts_threaded_post_id"); + b.Navigation("ForwardedPost"); b.Navigation("Publisher"); b.Navigation("RepliedPost"); + + b.Navigation("ThreadedPost"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Collections") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collections_publishers_publisher_id"); + + b.Navigation("Publisher"); }); modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => @@ -929,6 +1168,57 @@ namespace DysonNetwork.Sphere.Migrations b.Navigation("Account"); }); + modelBuilder.Entity("PostPostCategory", b => + { + b.HasOne("DysonNetwork.Sphere.Post.PostCategory", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_post_categories_categories_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_posts_posts_id"); + }); + + modelBuilder.Entity("PostPostCollection", b => + { + b.HasOne("DysonNetwork.Sphere.Post.PostCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_post_collections_collections_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_posts_posts_id"); + }); + + modelBuilder.Entity("PostPostTag", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_posts_posts_id"); + + b.HasOne("DysonNetwork.Sphere.Post.PostTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_post_tags_tags_id"); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => { b.Navigation("AuthFactors"); @@ -956,6 +1246,8 @@ namespace DysonNetwork.Sphere.Migrations modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => { + b.Navigation("Collections"); + b.Navigation("Members"); b.Navigation("Posts"); diff --git a/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.cs b/DysonNetwork.Sphere/Migrations/20250419115230_AddPost.cs similarity index 53% rename from DysonNetwork.Sphere/Migrations/20250419062728_AddPost.cs rename to DysonNetwork.Sphere/Migrations/20250419115230_AddPost.cs index 4ffd386..79c7714 100644 --- a/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.cs +++ b/DysonNetwork.Sphere/Migrations/20250419115230_AddPost.cs @@ -1,4 +1,5 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; using NodaTime; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; @@ -18,6 +19,40 @@ namespace DysonNetwork.Sphere.Migrations type: "bigint", nullable: true); + migrationBuilder.CreateTable( + name: "post_categories", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + slug = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_post_categories", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "post_tags", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + slug = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_post_tags", x => x.id); + }); + migrationBuilder.CreateTable( name: "publishers", columns: table => new @@ -55,6 +90,31 @@ namespace DysonNetwork.Sphere.Migrations principalColumn: "id"); }); + migrationBuilder.CreateTable( + name: "post_collections", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + slug = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + publisher_id = table.Column(type: "bigint", nullable: false), + created_at = table.Column(type: "timestamp with time zone", nullable: false), + updated_at = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_post_collections", x => x.id); + table.ForeignKey( + name: "fk_post_collections_publishers_publisher_id", + column: x => x.publisher_id, + principalTable: "publishers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "posts", columns: table => new @@ -63,12 +123,18 @@ namespace DysonNetwork.Sphere.Migrations .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), title = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true), description = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + language = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + edited_at = table.Column(type: "timestamp with time zone", nullable: true), + published_at = table.Column(type: "timestamp with time zone", nullable: true), + visibility = table.Column(type: "integer", nullable: false), content = table.Column(type: "text", nullable: true), type = table.Column(type: "integer", nullable: false), + meta = table.Column>(type: "jsonb", nullable: true), views_unique = table.Column(type: "integer", nullable: false), views_total = table.Column(type: "integer", nullable: false), upvotes = table.Column(type: "integer", nullable: false), downvotes = table.Column(type: "integer", nullable: false), + threaded_post_id = table.Column(type: "bigint", nullable: true), replied_post_id = table.Column(type: "bigint", nullable: true), forwarded_post_id = table.Column(type: "bigint", nullable: true), publisher_id = table.Column(type: "bigint", nullable: false), @@ -91,6 +157,11 @@ namespace DysonNetwork.Sphere.Migrations principalTable: "posts", principalColumn: "id", onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_posts_posts_threaded_post_id", + column: x => x.threaded_post_id, + principalTable: "posts", + principalColumn: "id"); table.ForeignKey( name: "fk_posts_publishers_publisher_id", column: x => x.publisher_id, @@ -128,6 +199,54 @@ namespace DysonNetwork.Sphere.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "post_category_links", + columns: table => new + { + categories_id = table.Column(type: "bigint", nullable: false), + posts_id = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_post_category_links", x => new { x.categories_id, x.posts_id }); + table.ForeignKey( + name: "fk_post_category_links_post_categories_categories_id", + column: x => x.categories_id, + principalTable: "post_categories", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_post_category_links_posts_posts_id", + column: x => x.posts_id, + principalTable: "posts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "post_collection_links", + columns: table => new + { + collections_id = table.Column(type: "bigint", nullable: false), + posts_id = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_post_collection_links", x => new { x.collections_id, x.posts_id }); + table.ForeignKey( + name: "fk_post_collection_links_post_collections_collections_id", + column: x => x.collections_id, + principalTable: "post_collections", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_post_collection_links_posts_posts_id", + column: x => x.posts_id, + principalTable: "posts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "post_reactions", columns: table => new @@ -159,11 +278,56 @@ namespace DysonNetwork.Sphere.Migrations onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "post_tag_links", + columns: table => new + { + posts_id = table.Column(type: "bigint", nullable: false), + tags_id = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_post_tag_links", x => new { x.posts_id, x.tags_id }); + table.ForeignKey( + name: "fk_post_tag_links_post_tags_tags_id", + column: x => x.tags_id, + principalTable: "post_tags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_post_tag_links_posts_posts_id", + column: x => x.posts_id, + principalTable: "posts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateIndex( name: "ix_files_post_id", table: "files", column: "post_id"); + migrationBuilder.CreateIndex( + name: "ix_accounts_name", + table: "accounts", + column: "name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_post_category_links_posts_id", + table: "post_category_links", + column: "posts_id"); + + migrationBuilder.CreateIndex( + name: "ix_post_collection_links_posts_id", + table: "post_collection_links", + column: "posts_id"); + + migrationBuilder.CreateIndex( + name: "ix_post_collections_publisher_id", + table: "post_collections", + column: "publisher_id"); + migrationBuilder.CreateIndex( name: "ix_post_reactions_account_id", table: "post_reactions", @@ -174,6 +338,11 @@ namespace DysonNetwork.Sphere.Migrations table: "post_reactions", column: "post_id"); + migrationBuilder.CreateIndex( + name: "ix_post_tag_links_tags_id", + table: "post_tag_links", + column: "tags_id"); + migrationBuilder.CreateIndex( name: "ix_posts_forwarded_post_id", table: "posts", @@ -189,6 +358,12 @@ namespace DysonNetwork.Sphere.Migrations table: "posts", column: "replied_post_id"); + migrationBuilder.CreateIndex( + name: "ix_posts_threaded_post_id", + table: "posts", + column: "threaded_post_id", + unique: true); + migrationBuilder.CreateIndex( name: "ix_publisher_members_account_id", table: "publisher_members", @@ -204,6 +379,12 @@ namespace DysonNetwork.Sphere.Migrations table: "publishers", column: "background_id"); + migrationBuilder.CreateIndex( + name: "ix_publishers_name", + table: "publishers", + column: "name", + unique: true); + migrationBuilder.CreateIndex( name: "ix_publishers_picture_id", table: "publishers", @@ -224,12 +405,30 @@ namespace DysonNetwork.Sphere.Migrations name: "fk_files_posts_post_id", table: "files"); + migrationBuilder.DropTable( + name: "post_category_links"); + + migrationBuilder.DropTable( + name: "post_collection_links"); + migrationBuilder.DropTable( name: "post_reactions"); + migrationBuilder.DropTable( + name: "post_tag_links"); + migrationBuilder.DropTable( name: "publisher_members"); + migrationBuilder.DropTable( + name: "post_categories"); + + migrationBuilder.DropTable( + name: "post_collections"); + + migrationBuilder.DropTable( + name: "post_tags"); + migrationBuilder.DropTable( name: "posts"); @@ -240,6 +439,10 @@ namespace DysonNetwork.Sphere.Migrations name: "ix_files_post_id", table: "files"); + migrationBuilder.DropIndex( + name: "ix_accounts_name", + table: "accounts"); + migrationBuilder.DropColumn( name: "post_id", table: "files"); diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index fcd48a0..64639ef 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -70,6 +70,10 @@ namespace DysonNetwork.Sphere.Migrations b.HasKey("Id") .HasName("pk_accounts"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_accounts_name"); + b.ToTable("accounts", (string)null); }); @@ -416,10 +420,27 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("integer") .HasColumnName("downvotes"); + b.Property("EditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("edited_at"); + b.Property("ForwardedPostId") .HasColumnType("bigint") .HasColumnName("forwarded_post_id"); + b.Property("Language") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("language"); + + b.Property>("Meta") + .HasColumnType("jsonb") + .HasColumnName("meta"); + + b.Property("PublishedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("published_at"); + b.Property("PublisherId") .HasColumnType("bigint") .HasColumnName("publisher_id"); @@ -428,6 +449,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("bigint") .HasColumnName("replied_post_id"); + b.Property("ThreadedPostId") + .HasColumnType("bigint") + .HasColumnName("threaded_post_id"); + b.Property("Title") .HasMaxLength(1024) .HasColumnType("character varying(1024)") @@ -453,6 +478,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("integer") .HasColumnName("views_unique"); + b.Property("Visibility") + .HasColumnType("integer") + .HasColumnName("visibility"); + b.HasKey("Id") .HasName("pk_posts"); @@ -465,9 +494,101 @@ namespace DysonNetwork.Sphere.Migrations b.HasIndex("RepliedPostId") .HasDatabaseName("ix_posts_replied_post_id"); + b.HasIndex("ThreadedPostId") + .IsUnique() + .HasDatabaseName("ix_posts_threaded_post_id"); + b.ToTable("posts", (string)null); }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_categories"); + + b.ToTable("post_categories", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Description") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("description"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_collections"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_post_collections_publisher_id"); + + b.ToTable("post_collections", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => { b.Property("Id") @@ -519,6 +640,44 @@ namespace DysonNetwork.Sphere.Migrations b.ToTable("post_reactions", (string)null); }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("slug"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_tags"); + + b.ToTable("post_tags", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => { b.Property("Id") @@ -582,6 +741,10 @@ namespace DysonNetwork.Sphere.Migrations b.HasIndex("BackgroundId") .HasDatabaseName("ix_publishers_background_id"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_publishers_name"); + b.HasIndex("PictureId") .HasDatabaseName("ix_publishers_picture_id"); @@ -711,6 +874,63 @@ namespace DysonNetwork.Sphere.Migrations b.ToTable("files", (string)null); }); + modelBuilder.Entity("PostPostCategory", b => + { + b.Property("CategoriesId") + .HasColumnType("bigint") + .HasColumnName("categories_id"); + + b.Property("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.HasKey("CategoriesId", "PostsId") + .HasName("pk_post_category_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_category_links_posts_id"); + + b.ToTable("post_category_links", (string)null); + }); + + modelBuilder.Entity("PostPostCollection", b => + { + b.Property("CollectionsId") + .HasColumnType("bigint") + .HasColumnName("collections_id"); + + b.Property("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.HasKey("CollectionsId", "PostsId") + .HasName("pk_post_collection_links"); + + b.HasIndex("PostsId") + .HasDatabaseName("ix_post_collection_links_posts_id"); + + b.ToTable("post_collection_links", (string)null); + }); + + modelBuilder.Entity("PostPostTag", b => + { + b.Property("PostsId") + .HasColumnType("bigint") + .HasColumnName("posts_id"); + + b.Property("TagsId") + .HasColumnType("bigint") + .HasColumnName("tags_id"); + + b.HasKey("PostsId", "TagsId") + .HasName("pk_post_tag_links"); + + b.HasIndex("TagsId") + .HasDatabaseName("ix_post_tag_links_tags_id"); + + b.ToTable("post_tag_links", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => { b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") @@ -836,11 +1056,30 @@ namespace DysonNetwork.Sphere.Migrations .OnDelete(DeleteBehavior.Restrict) .HasConstraintName("fk_posts_posts_replied_post_id"); + b.HasOne("DysonNetwork.Sphere.Post.Post", "ThreadedPost") + .WithOne() + .HasForeignKey("DysonNetwork.Sphere.Post.Post", "ThreadedPostId") + .HasConstraintName("fk_posts_posts_threaded_post_id"); + b.Navigation("ForwardedPost"); b.Navigation("Publisher"); b.Navigation("RepliedPost"); + + b.Navigation("ThreadedPost"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostCollection", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Collections") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collections_publishers_publisher_id"); + + b.Navigation("Publisher"); }); modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => @@ -926,6 +1165,57 @@ namespace DysonNetwork.Sphere.Migrations b.Navigation("Account"); }); + modelBuilder.Entity("PostPostCategory", b => + { + b.HasOne("DysonNetwork.Sphere.Post.PostCategory", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_post_categories_categories_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_category_links_posts_posts_id"); + }); + + modelBuilder.Entity("PostPostCollection", b => + { + b.HasOne("DysonNetwork.Sphere.Post.PostCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_post_collections_collections_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_collection_links_posts_posts_id"); + }); + + modelBuilder.Entity("PostPostTag", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany() + .HasForeignKey("PostsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_posts_posts_id"); + + b.HasOne("DysonNetwork.Sphere.Post.PostTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_tag_links_post_tags_tags_id"); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => { b.Navigation("AuthFactors"); @@ -953,6 +1243,8 @@ namespace DysonNetwork.Sphere.Migrations modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => { + b.Navigation("Collections"); + b.Navigation("Members"); b.Navigation("Posts"); diff --git a/DysonNetwork.Sphere/Post/Post.cs b/DysonNetwork.Sphere/Post/Post.cs index ab08fac..51ec825 100644 --- a/DysonNetwork.Sphere/Post/Post.cs +++ b/DysonNetwork.Sphere/Post/Post.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using DysonNetwork.Sphere.Storage; +using NodaTime; namespace DysonNetwork.Sphere.Post; @@ -12,28 +13,78 @@ public enum PostType Video } +public enum PostVisibility +{ + Public, + Friends, + Unlisted, + Private +} + public class Post : ModelBase { public long Id { get; set; } [MaxLength(1024)] public string? Title { get; set; } [MaxLength(4096)] public string? Description { get; set; } + [MaxLength(128)] public string? Language { get; set; } + public Instant? EditedAt { get; set; } + public Instant? PublishedAt { get; set; } + public PostVisibility Visibility { get; set; } = PostVisibility.Public; + // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength public string? Content { get; set; } - + public PostType Type { get; set; } - [Column(TypeName = "jsonb")] Dictionary? Meta { get; set; } - + [Column(TypeName = "jsonb")] public Dictionary? Meta { get; set; } + public int ViewsUnique { get; set; } public int ViewsTotal { get; set; } public int Upvotes { get; set; } public int Downvotes { get; set; } + public long? ThreadedPostId { get; set; } + public Post? ThreadedPost { get; set; } + public long? RepliedPostId { get; set; } public Post? RepliedPost { get; set; } + public long? ForwardedPostId { get; set; } public Post? ForwardedPost { get; set; } public ICollection Attachments { get; set; } = new List(); - public ICollection Reactions { get; set; } = new List(); public Publisher Publisher { get; set; } = null!; + public ICollection Reactions { get; set; } = new List(); + public ICollection Tags { get; set; } = new List(); + public ICollection Categories { get; set; } = new List(); + public ICollection Collections { get; set; } = new List(); + + public bool Empty => Content?.Trim() is { Length: 0 } && Attachments.Count == 0 && ForwardedPostId == null; +} + +public class PostTag : ModelBase +{ + public long Id { get; set; } + [MaxLength(128)] public string Slug { get; set; } = null!; + [MaxLength(256)] public string? Name { get; set; } + public ICollection Posts { get; set; } = new List(); +} + +public class PostCategory : ModelBase +{ + public long Id { get; set; } + [MaxLength(128)] public string Slug { get; set; } = null!; + [MaxLength(256)] public string? Name { get; set; } + public ICollection Posts { get; set; } = new List(); +} + +public class PostCollection : ModelBase +{ + public long Id { get; set; } + [MaxLength(128)] public string Slug { get; set; } = null!; + [MaxLength(256)] public string? Name { get; set; } + [MaxLength(4096)] public string? Description { get; set; } + + public Publisher Publisher { get; set; } = null!; + + public ICollection Posts { get; set; } = new List(); } public enum PostReactionAttitude diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs new file mode 100644 index 0000000..407c6b9 --- /dev/null +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -0,0 +1,225 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace DysonNetwork.Sphere.Post; + +[ApiController] +[Route("/posts")] +public class PostController(AppDatabase db, PostService ps) : ControllerBase +{ + [HttpGet] + public async Task>> ListPosts([FromQuery] int offset = 0, [FromQuery] int take = 20) + { + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + var currentUser = currentUserValue as Account.Account; + + var totalCount = await db.Posts + .CountAsync(); + var posts = await db.Posts + .Include(e => e.Publisher) + .Include(e => e.ThreadedPost) + .Include(e => e.ForwardedPost) + .Include(e => e.Attachments) + .Include(e => e.Categories) + .Include(e => e.Tags) + .FilterWithVisibility(currentUser, isListing: true) + .Skip(offset) + .Take(take) + .ToListAsync(); + + Response.Headers["X-Total"] = totalCount.ToString(); + + return Ok(posts); + } + + [HttpGet("{id:long}")] + public async Task> GetPost(long id) + { + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + var currentUser = currentUserValue as Account.Account; + + var post = await db.Posts + .Where(e => e.Id == id) + .Include(e => e.Publisher) + .Include(e => e.RepliedPost) + .Include(e => e.ThreadedPost) + .Include(e => e.ForwardedPost) + .Include(e => e.Tags) + .Include(e => e.Categories) + .Include(e => e.Attachments) + .FilterWithVisibility(currentUser) + .FirstOrDefaultAsync(); + if (post is null) return NotFound(); + + return Ok(post); + } + + [HttpGet("{id:long}/replies")] + public async Task>> ListReplies(long id, [FromQuery] int offset = 0, + [FromQuery] int take = 20) + { + HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); + var currentUser = currentUserValue as Account.Account; + + var post = await db.Posts + .Where(e => e.Id == id) + .FirstOrDefaultAsync(); + if (post is null) return NotFound(); + + var totalCount = await db.Posts + .Where(e => e.RepliedPostId == post.Id) + .CountAsync(); + var posts = await db.Posts + .Where(e => e.RepliedPostId == id) + .Include(e => e.Publisher) + .Include(e => e.ThreadedPost) + .Include(e => e.ForwardedPost) + .Include(e => e.Attachments) + .Include(e => e.Categories) + .Include(e => e.Tags) + .FilterWithVisibility(currentUser, isListing: true) + .Skip(offset) + .Take(take) + .ToListAsync(); + + Response.Headers["X-Total"] = totalCount.ToString(); + + return Ok(posts); + } + + public class PostRequest + { + [MaxLength(1024)] public string? Title { get; set; } + [MaxLength(4096)] public string? Description { get; set; } + public string? Content { get; set; } + public PostVisibility? Visibility { get; set; } + public PostType? Type { get; set; } + [MaxLength(16)] public List? Tags { get; set; } + [MaxLength(8)] public List? Categories { get; set; } + [MaxLength(32)] public List? Attachments { get; set; } + public Dictionary? Meta { get; set; } + } + + [HttpPost] + public async Task> CreatePost( + [FromBody] PostRequest request, + [FromHeader(Name = "X-Pub")] string? publisherName + ) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + Publisher? publisher; + if (publisherName is null) + { + // Use the first personal publisher + publisher = await db.Publishers.FirstOrDefaultAsync(e => + e.AccountId == currentUser.Id && e.PublisherType == PublisherType.Individual); + } + else + { + publisher = await db.Publishers.FirstOrDefaultAsync(e => e.Name == publisherName); + if (publisher is null) return BadRequest("Publisher was not found."); + var member = + await db.PublisherMembers.FirstOrDefaultAsync(e => + e.AccountId == currentUser.Id && 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."); + } + + if (publisher is null) return BadRequest("Publisher was not found."); + + var post = new Post + { + Title = request.Title, + Description = request.Description, + Content = request.Content, + Visibility = request.Visibility ?? PostVisibility.Public, + Type = request.Type ?? PostType.Moment, + Meta = request.Meta, + }; + + try + { + post = await ps.PostAsync( + post, + attachments: request.Attachments, + tags: request.Tags, + categories: request.Categories + ); + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + + return post; + } + + [HttpPatch("{id:long}")] + public async Task> UpdatePost(long id, [FromBody] PostRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var post = await db.Posts + .Where(e => e.Id == id) + .Include(e => e.Publisher) + .Include(e => e.Attachments) + .Include(e => e.Categories) + .Include(e => e.Tags) + .FirstOrDefaultAsync(); + if (post is null) return NotFound(); + + var member = await db.PublisherMembers + .FirstOrDefaultAsync(e => e.AccountId == currentUser.Id && e.PublisherId == post.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 edit this publisher's post."); + + if (request.Title is not null) post.Title = request.Title; + if (request.Description is not null) post.Description = request.Description; + if (request.Content is not null) post.Content = request.Content; + if (request.Visibility is not null) post.Visibility = request.Visibility.Value; + if (request.Type is not null) post.Type = request.Type.Value; + if (request.Meta is not null) post.Meta = request.Meta; + + try + { + post = await ps.UpdatePostAsync( + post, + attachments: request.Attachments, + tags: request.Tags, + categories: request.Categories + ); + } + catch (InvalidOperationException err) + { + return BadRequest(err.Message); + } + + return Ok(post); + } + + [HttpDelete("{id:long}")] + public async Task> DeletePost(long id) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var post = await db.Posts + .Where(e => e.Id == id) + .Include(e => e.Attachments) + .FirstOrDefaultAsync(); + if (post is null) return NotFound(); + + var member = await db.PublisherMembers + .FirstOrDefaultAsync(e => e.AccountId == currentUser.Id && e.PublisherId == post.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 delete the publisher's post."); + + await ps.DeletePostAsync(post); + + return NoContent(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index 74bf021..cafa0dd 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -1,6 +1,157 @@ +using DysonNetwork.Sphere.Storage; +using Microsoft.EntityFrameworkCore; +using NodaTime; + namespace DysonNetwork.Sphere.Post; -public class PostService(AppDatabase db) +public class PostService(AppDatabase db, FileService fs) { - + public async Task PostAsync( + Post post, + List? attachments = null, + List? tags = null, + List? categories = null + ) + { + if (attachments is not null) + { + post.Attachments = await db.Files.Where(e => attachments.Contains(e.Id)).ToListAsync(); + // Re-order the list to match the id list places + post.Attachments = attachments + .Select(id => post.Attachments.First(a => a.Id == id)) + .ToList(); + } + + if (tags is not null) + { + var existingTags = await db.PostTags.Where(e => tags.Contains(e.Slug)).ToListAsync(); + + // Determine missing slugs + var existingSlugs = existingTags.Select(t => t.Slug).ToHashSet(); + var missingSlugs = tags.Where(slug => !existingSlugs.Contains(slug)).ToList(); + + var newTags = missingSlugs.Select(slug => new PostTag { Slug = slug }).ToList(); + if (newTags.Count > 0) + { + await db.PostTags.AddRangeAsync(newTags); + await db.SaveChangesAsync(); + } + + post.Tags = existingTags.Concat(newTags).ToList(); + } + + if (categories is not null) + { + post.Categories = await db.PostCategories.Where(e => categories.Contains(e.Slug)).ToListAsync(); + if (post.Categories.Count != categories.Distinct().Count()) + throw new InvalidOperationException("Categories contains one or more categories that wasn't exists."); + } + + if (post.Empty) + throw new InvalidOperationException("Cannot create a post with barely no content."); + + // TODO Notify the subscribers + + db.Posts.Add(post); + await db.SaveChangesAsync(); + await fs.MarkUsageRangeAsync(post.Attachments, 1); + + return post; + } + + public async Task UpdatePostAsync( + Post post, + List? attachments = null, + List? tags = null, + List? categories = null + ) + { + post.EditedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); + + if (attachments is not null) + { + var records = await db.Files.Where(e => attachments.Contains(e.Id)).ToListAsync(); + + var previous = post.Attachments.ToDictionary(f => f.Id); + var current = records.ToDictionary(f => f.Id); + + // Detect added files + var added = current.Keys.Except(previous.Keys).Select(id => current[id]).ToList(); + // Detect removed files + var removed = previous.Keys.Except(current.Keys).Select(id => previous[id]).ToList(); + + // Update attachments + post.Attachments = attachments.Select(id => current[id]).ToList(); + + // Call mark usage + await fs.MarkUsageRangeAsync(added, 1); + await fs.MarkUsageRangeAsync(removed, -1); + } + + if (tags is not null) + { + var existingTags = await db.PostTags.Where(e => tags.Contains(e.Slug)).ToListAsync(); + + // Determine missing slugs + var existingSlugs = existingTags.Select(t => t.Slug).ToHashSet(); + var missingSlugs = tags.Where(slug => !existingSlugs.Contains(slug)).ToList(); + + var newTags = missingSlugs.Select(slug => new PostTag { Slug = slug }).ToList(); + if (newTags.Count > 0) + { + await db.PostTags.AddRangeAsync(newTags); + await db.SaveChangesAsync(); + } + + post.Tags = existingTags.Concat(newTags).ToList(); + } + + if (categories is not null) + { + post.Categories = await db.PostCategories.Where(e => categories.Contains(e.Slug)).ToListAsync(); + if (post.Categories.Count != categories.Distinct().Count()) + throw new InvalidOperationException("Categories contains one or more categories that wasn't exists."); + } + + if (post.Empty) + throw new InvalidOperationException("Cannot edit a post to barely no content."); + + db.Update(post); + await db.SaveChangesAsync(); + + return post; + } + + public async Task DeletePostAsync(Post post) + { + db.Posts.Remove(post); + await db.SaveChangesAsync(); + await fs.MarkUsageRangeAsync(post.Attachments, -1); + } +} + +public static class PostQueryExtensions +{ + public static IQueryable FilterWithVisibility(this IQueryable source, Account.Account? currentUser, + bool isListing = false) + { + var now = Instant.FromDateTimeUtc(DateTime.UtcNow); + + source = isListing switch + { + true when currentUser is not null => source.Where(e => + e.Visibility != PostVisibility.Unlisted || e.Publisher.AccountId == currentUser.Id), + true => source.Where(e => e.Visibility != PostVisibility.Unlisted), + _ => source + }; + + if (currentUser is null) + return source + .Where(e => e.PublishedAt != null && now >= e.PublishedAt) + .Where(e => e.Visibility == PostVisibility.Public); + + return source + .Where(e => e.PublishedAt != null && now >= e.PublishedAt && e.Publisher.AccountId == currentUser.Id) + .Where(e => e.Visibility != PostVisibility.Private || e.Publisher.AccountId == currentUser.Id); + } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/Publisher.cs b/DysonNetwork.Sphere/Post/Publisher.cs index 174a9c5..be15c1b 100644 --- a/DysonNetwork.Sphere/Post/Publisher.cs +++ b/DysonNetwork.Sphere/Post/Publisher.cs @@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using DysonNetwork.Sphere.Storage; +using Microsoft.EntityFrameworkCore; using NodaTime; namespace DysonNetwork.Sphere.Post; @@ -12,6 +13,7 @@ public enum PublisherType Organizational } +[Index(nameof(Name), IsUnique = true)] public class Publisher : ModelBase { public long Id { get; set; } @@ -24,7 +26,10 @@ public class Publisher : ModelBase public CloudFile? Background { get; set; } [JsonIgnore] public ICollection Posts { get; set; } = new List(); + [JsonIgnore] public ICollection Collections { get; set; } = new List(); [JsonIgnore] public ICollection Members { get; set; } = new List(); + + public long? AccountId { get; set; } [JsonIgnore] public Account.Account? Account { get; set; } } diff --git a/DysonNetwork.Sphere/Post/PublisherController.cs b/DysonNetwork.Sphere/Post/PublisherController.cs index 9b6b1b5..785d966 100644 --- a/DysonNetwork.Sphere/Post/PublisherController.cs +++ b/DysonNetwork.Sphere/Post/PublisherController.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using Casbin; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -9,13 +10,13 @@ namespace DysonNetwork.Sphere.Post; [ApiController] [Route("/publishers")] -public class PublisherController(AppDatabase db, PublisherService ps, FileService fs) : ControllerBase +public class PublisherController(AppDatabase db, PublisherService ps, FileService fs, IEnforcer enforcer) + : ControllerBase { [HttpGet("{name}")] public async Task> GetPublisher(string name) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); - var userId = currentUser.Id; var publisher = await db.Publishers .Where(e => e.Name == name) @@ -56,7 +57,7 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic return members.ToList(); } - + public class PublisherMemberRequest { [Required] public long RelatedUserId { get; set; } @@ -65,11 +66,12 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic [HttpPost("invites/{name}")] [Authorize] - public async Task> InviteMember(string name, [FromBody] PublisherMemberRequest request) + public async Task> InviteMember(string name, + [FromBody] PublisherMemberRequest request) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); var userId = currentUser.Id; - + var relatedUser = await db.Accounts.FindAsync(request.RelatedUserId); if (relatedUser is null) return BadRequest("Related user was not found"); @@ -86,7 +88,8 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic .FirstOrDefaultAsync(); if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher."); if (member.Role < PublisherMemberRole.Manager) - return StatusCode(403, "You need at least be a manager to invite other members to collaborate this publisher."); + return StatusCode(403, + "You need at least be a manager to invite other members to collaborate this publisher."); if (member.Role < request.Role) return StatusCode(403, "You cannot invite member has higher permission than yours."); @@ -98,10 +101,10 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic PublisherId = publisher.Id, Role = request.Role, }; - + db.PublisherMembers.Add(newMember); await db.SaveChangesAsync(); - + return Ok(newMember); } @@ -111,35 +114,35 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); var userId = currentUser.Id; - + var member = await db.PublisherMembers .Where(m => m.AccountId == userId) .Where(m => m.Publisher.Name == name) .Where(m => m.JoinedAt == null) .FirstOrDefaultAsync(); if (member is null) return NotFound(); - + member.JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); db.Update(member); await db.SaveChangesAsync(); - + return Ok(member); } - + [HttpPost("invites/{name}/decline")] [Authorize] public async Task DeclineMemberInvite(string name) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); var userId = currentUser.Id; - + var member = await db.PublisherMembers .Where(m => m.AccountId == userId) .Where(m => m.Publisher.Name == name) .Where(m => m.JoinedAt == null) .FirstOrDefaultAsync(); if (member is null) return NotFound(); - + db.PublisherMembers.Remove(member); await db.SaveChangesAsync(); @@ -161,6 +164,8 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic public async Task> CreatePublisherIndividual(PublisherRequest request) { if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + if (!await enforcer.EnforceAsync(currentUser.Id.ToString(), "global", "publishers", "create")) + return StatusCode(403); var takenName = request.Name ?? currentUser.Name; var duplicateNameCount = await db.Publishers @@ -276,10 +281,10 @@ public class PublisherController(AppDatabase db, PublisherService ps, FileServic await fs.MarkUsageAsync(publisher.Picture, -1); if (publisher.Background is not null) await fs.MarkUsageAsync(publisher.Background, -1); - + db.Publishers.Remove(publisher); await db.SaveChangesAsync(); - + return NoContent(); } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Storage/FileService.cs b/DysonNetwork.Sphere/Storage/FileService.cs index 4316e12..b744cd7 100644 --- a/DysonNetwork.Sphere/Storage/FileService.cs +++ b/DysonNetwork.Sphere/Storage/FileService.cs @@ -158,10 +158,9 @@ public class FileService(AppDatabase db, IConfiguration configuration) ); file.UploadedAt = Instant.FromDateTimeUtc(DateTime.UtcNow); - await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync( - setter => setter - .SetProperty(f => f.UploadedAt, file.UploadedAt) - .SetProperty(f => f.UploadedTo, file.UploadedTo) + await db.Files.Where(f => f.Id == file.Id).ExecuteUpdateAsync(setter => setter + .SetProperty(f => f.UploadedAt, file.UploadedAt) + .SetProperty(f => f.UploadedTo, file.UploadedTo) ); return file; } @@ -215,8 +214,18 @@ public class FileService(AppDatabase db, IConfiguration configuration) public async Task MarkUsageAsync(CloudFile file, int delta) { await db.Files.Where(o => o.Id == file.Id) - .ExecuteUpdateAsync( - setter => setter.SetProperty( + .ExecuteUpdateAsync(setter => setter.SetProperty( + b => b.UsedCount, + b => b.UsedCount + delta + ) + ); + } + + public async Task MarkUsageRangeAsync(ICollection files, int delta) + { + var ids = files.Select(f => f.Id).ToArray(); + await db.Files.Where(o => ids.Contains(o.Id)) + .ExecuteUpdateAsync(setter => setter.SetProperty( b => b.UsedCount, b => b.UsedCount + delta ) diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index ec1c1b9..266e9af 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -10,7 +10,9 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -30,6 +32,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded