diff --git a/DysonNetwork.Sphere/Account/AccountController.cs b/DysonNetwork.Sphere/Account/AccountController.cs index 6f66853..085c54b 100644 --- a/DysonNetwork.Sphere/Account/AccountController.cs +++ b/DysonNetwork.Sphere/Account/AccountController.cs @@ -3,12 +3,13 @@ using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; namespace DysonNetwork.Sphere.Account; [ApiController] [Route("/accounts")] -public class AccountController(AppDatabase db, FileService fs) : ControllerBase +public class AccountController(AppDatabase db, FileService fs, IMemoryCache memCache) : ControllerBase { [HttpGet("{name}")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -77,9 +78,8 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase [ProducesResponseType(StatusCodes.Status200OK)] public async Task> GetMe() { - var userIdClaim = User.FindFirst("user_id")?.Value; - long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return BadRequest("Invalid or missing user_id claim."); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var userId = currentUser.Id; var account = await db.Accounts .Include(e => e.Profile) @@ -101,15 +101,12 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase [HttpPatch("me")] public async Task> UpdateBasicInfo([FromBody] BasicInfoRequest request) { - var userIdClaim = User.FindFirst("user_id")?.Value; - long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return BadRequest("Invalid or missing user_id claim."); - - var account = await db.Accounts.FindAsync(userId); - if (account is null) return BadRequest("Unable to get your account."); + if (HttpContext.Items["CurrentUser"] is not Account account) return Unauthorized(); if (request.Nick is not null) account.Nick = request.Nick; if (request.Language is not null) account.Language = request.Language; + + memCache.Remove($"user_${account.Id}"); await db.SaveChangesAsync(); return account; @@ -130,9 +127,8 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase [HttpPatch("me/profile")] public async Task> UpdateProfile([FromBody] ProfileRequest request) { - var userIdClaim = User.FindFirst("user_id")?.Value; - long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return BadRequest("Invalid or missing user_id claim."); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var userId = currentUser.Id; var profile = await db.AccountProfiles .Where(p => p.Account.Id == userId) @@ -170,6 +166,9 @@ public class AccountController(AppDatabase db, FileService fs) : ControllerBase db.Update(profile); await db.SaveChangesAsync(); + + memCache.Remove($"user_${userId}"); + return profile; } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Account/RelationshipController.cs b/DysonNetwork.Sphere/Account/RelationshipController.cs index e3a5d60..204bd8e 100644 --- a/DysonNetwork.Sphere/Account/RelationshipController.cs +++ b/DysonNetwork.Sphere/Account/RelationshipController.cs @@ -14,9 +14,8 @@ public class RelationshipController(AppDatabase db, AccountService accounts) : C public async Task>> ListRelationships([FromQuery] int offset = 0, [FromQuery] int take = 20) { - var userIdClaim = User.FindFirst("user_id")?.Value; - long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return BadRequest("Invalid or missing user_id claim."); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var userId = currentUser.Id; var totalCount = await db.AccountRelationships .CountAsync(r => r.Account.Id == userId); @@ -42,12 +41,8 @@ public class RelationshipController(AppDatabase db, AccountService accounts) : C [Authorize] public async Task> CreateRelationship([FromBody] RelationshipCreateRequest request) { - var userIdClaim = User.FindFirst("user_id")?.Value; - long? userId = long.TryParse(userIdClaim, out var id) ? id : null; - if (userId is null) return BadRequest("Invalid or missing user_id claim."); - - var currentUser = await db.Accounts.FindAsync(userId.Value); - if (currentUser is null) return BadRequest("Failed to get your current user"); + if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); + var relatedUser = await db.Accounts.FindAsync(request.UserId); if (relatedUser is null) return BadRequest("Invalid related user"); diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index 3213a2a..e2301a8 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -27,6 +27,10 @@ public class AppDatabase( public DbSet AuthSessions { get; set; } public DbSet AuthChallenges { get; set; } public DbSet Files { get; set; } + public DbSet Publishers { get; set; } + public DbSet PublisherMembers { get; set; } + public DbSet Posts { get; set; } + public DbSet PostReactions { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -63,6 +67,30 @@ public class AppDatabase( .WithMany(a => a.IncomingRelationships) .HasForeignKey(r => r.RelatedId); + modelBuilder.Entity() + .HasKey(pm => new { pm.PublisherId, pm.AccountId }); + modelBuilder.Entity() + .HasOne(pm => pm.Publisher) + .WithMany(p => p.Members) + .HasForeignKey(pm => pm.PublisherId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(pm => pm.Account) + .WithMany() + .HasForeignKey(pm => pm.AccountId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(p => p.RepliedPost) + .WithMany() + .HasForeignKey("RepliedPostId") + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(p => p.ForwardedPost) + .WithMany() + .HasForeignKey("ForwardedPostId") + .OnDelete(DeleteBehavior.Restrict); + // Automatically apply soft-delete filter to all entities inheriting BaseModel foreach (var entityType in modelBuilder.Model.GetEntityTypes()) { @@ -119,7 +147,7 @@ public class AppDatabaseRecyclingJob(AppDatabase db, ILogger e.Profile) + .Where(e => e.Id == userId) + .FirstOrDefaultAsync(); + + if (user is not null) + { + cache.Set($"user_{userId}", user, TimeSpan.FromMinutes(10)); + } + } + + if (user is not null) + { + context.Items["CurrentUser"] = user; + } + } + + await next(context); + } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.Designer.cs b/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.Designer.cs new file mode 100644 index 0000000..de8e2b5 --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.Designer.cs @@ -0,0 +1,966 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Sphere; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20250419062728_AddPost")] + partial class AddPost + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", 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("IsSuperuser") + .HasColumnType("boolean") + .HasColumnName("is_superuser"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("language"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_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("Secret") + .HasColumnType("text") + .HasColumnName("secret"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_account_auth_factors"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_auth_factors_account_id"); + + b.ToTable("account_auth_factors", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("VerifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("verified_at"); + + b.HasKey("Id") + .HasName("pk_account_contacts"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_account_contacts_account_id"); + + b.ToTable("account_contacts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("BackgroundId") + .HasColumnType("text") + .HasColumnName("background_id"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("bio"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("FirstName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("first_name"); + + b.Property("LastName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("last_name"); + + b.Property("MiddleName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("middle_name"); + + b.Property("PictureId") + .HasColumnType("text") + .HasColumnName("picture_id"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_account_profiles"); + + b.HasIndex("BackgroundId") + .HasDatabaseName("ix_account_profiles_background_id"); + + b.HasIndex("PictureId") + .HasDatabaseName("ix_account_profiles_picture_id"); + + b.ToTable("account_profiles", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => + { + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("RelatedId") + .HasColumnType("bigint") + .HasColumnName("related_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("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("AccountId", "RelatedId") + .HasName("pk_account_relationships"); + + b.HasIndex("RelatedId") + .HasDatabaseName("ix_account_relationships_related_id"); + + b.ToTable("account_relationships", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property>("Audiences") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("audiences"); + + b.Property>("BlacklistFactors") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("blacklist_factors"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeviceId") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("device_id"); + + b.Property("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("IpAddress") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("ip_address"); + + b.Property("Nonce") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("nonce"); + + b.Property>("Scopes") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("scopes"); + + b.Property("StepRemain") + .HasColumnType("integer") + .HasColumnName("step_remain"); + + b.Property("StepTotal") + .HasColumnType("integer") + .HasColumnName("step_total"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("user_agent"); + + b.HasKey("Id") + .HasName("pk_auth_challenges"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_auth_challenges_account_id"); + + b.ToTable("auth_challenges", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("ChallengeId") + .HasColumnType("uuid") + .HasColumnName("challenge_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("ExpiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expired_at"); + + b.Property("LastGrantedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_granted_at"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_auth_sessions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_auth_sessions_account_id"); + + b.HasIndex("ChallengeId") + .HasDatabaseName("ix_auth_sessions_challenge_id"); + + b.ToTable("auth_sessions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + 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("Downvotes") + .HasColumnType("integer") + .HasColumnName("downvotes"); + + b.Property("ForwardedPostId") + .HasColumnType("bigint") + .HasColumnName("forwarded_post_id"); + + b.Property("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property("RepliedPostId") + .HasColumnType("bigint") + .HasColumnName("replied_post_id"); + + b.Property("Title") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Upvotes") + .HasColumnType("integer") + .HasColumnName("upvotes"); + + b.Property("ViewsTotal") + .HasColumnType("integer") + .HasColumnName("views_total"); + + b.Property("ViewsUnique") + .HasColumnType("integer") + .HasColumnName("views_unique"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("ForwardedPostId") + .HasDatabaseName("ix_posts_forwarded_post_id"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_posts_publisher_id"); + + b.HasIndex("RepliedPostId") + .HasDatabaseName("ix_posts_replied_post_id"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasColumnName("post_id"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("symbol"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_reactions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_post_reactions_account_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reactions_post_id"); + + b.ToTable("post_reactions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("BackgroundId") + .HasColumnType("text") + .HasColumnName("background_id"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("bio"); + + 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") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property("PictureId") + .HasColumnType("text") + .HasColumnName("picture_id"); + + b.Property("PublisherType") + .HasColumnType("integer") + .HasColumnName("publisher_type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_publishers"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_publishers_account_id"); + + b.HasIndex("BackgroundId") + .HasDatabaseName("ix_publishers_background_id"); + + b.HasIndex("PictureId") + .HasDatabaseName("ix_publishers_picture_id"); + + b.ToTable("publishers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b => + { + b.Property("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_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("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("PublisherId", "AccountId") + .HasName("pk_publisher_members"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_publisher_members_account_id"); + + b.ToTable("publisher_members", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => + { + b.Property("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_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>("FileMeta") + .HasColumnType("jsonb") + .HasColumnName("file_meta"); + + b.Property("Hash") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("hash"); + + b.Property("MimeType") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("mime_type"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("name"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasColumnName("post_id"); + + b.Property("Size") + .HasColumnType("bigint") + .HasColumnName("size"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("UploadedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("uploaded_at"); + + b.Property("UploadedTo") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("uploaded_to"); + + b.Property("UsedCount") + .HasColumnType("integer") + .HasColumnName("used_count"); + + b.Property>("UserMeta") + .HasColumnType("jsonb") + .HasColumnName("user_meta"); + + b.HasKey("Id") + .HasName("pk_files"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_files_account_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_files_post_id"); + + b.ToTable("files", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountAuthFactor", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("AuthFactors") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_auth_factors_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.AccountContact", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_contacts_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Profile", b => + { + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background") + .WithMany() + .HasForeignKey("BackgroundId") + .HasConstraintName("fk_account_profiles_files_background_id"); + + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithOne("Profile") + .HasForeignKey("DysonNetwork.Sphere.Account.Profile", "Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_profiles_accounts_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Picture") + .WithMany() + .HasForeignKey("PictureId") + .HasConstraintName("fk_account_profiles_files_picture_id"); + + b.Navigation("Account"); + + b.Navigation("Background"); + + b.Navigation("Picture"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Relationship", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("OutgoingRelationships") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_relationships_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Account.Account", "Related") + .WithMany("IncomingRelationships") + .HasForeignKey("RelatedId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_relationships_accounts_related_id"); + + b.Navigation("Account"); + + b.Navigation("Related"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Challenge", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Challenges") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_challenges_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Auth.Session", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany("Sessions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Auth.Challenge", "Challenge") + .WithMany() + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_sessions_auth_challenges_challenge_id"); + + b.Navigation("Account"); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost") + .WithMany() + .HasForeignKey("ForwardedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_forwarded_post_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Posts") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_posts_publishers_publisher_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", "RepliedPost") + .WithMany() + .HasForeignKey("RepliedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_replied_post_id"); + + b.Navigation("ForwardedPost"); + + b.Navigation("Publisher"); + + b.Navigation("RepliedPost"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_reactions_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", "Post") + .WithMany("Reactions") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_reactions_posts_post_id"); + + b.Navigation("Account"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .HasConstraintName("fk_publishers_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background") + .WithMany() + .HasForeignKey("BackgroundId") + .HasConstraintName("fk_publishers_files_background_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Picture") + .WithMany() + .HasForeignKey("PictureId") + .HasConstraintName("fk_publishers_files_picture_id"); + + b.Navigation("Account"); + + b.Navigation("Background"); + + b.Navigation("Picture"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_members_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Members") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_members_publishers_publisher_id"); + + b.Navigation("Account"); + + b.Navigation("Publisher"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_files_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany("Attachments") + .HasForeignKey("PostId") + .HasConstraintName("fk_files_posts_post_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Account.Account", b => + { + b.Navigation("AuthFactors"); + + b.Navigation("Challenges"); + + b.Navigation("Contacts"); + + b.Navigation("IncomingRelationships"); + + b.Navigation("OutgoingRelationships"); + + b.Navigation("Profile") + .IsRequired(); + + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.Navigation("Attachments"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.Navigation("Members"); + + b.Navigation("Posts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.cs b/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.cs new file mode 100644 index 0000000..4ffd386 --- /dev/null +++ b/DysonNetwork.Sphere/Migrations/20250419062728_AddPost.cs @@ -0,0 +1,248 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Sphere.Migrations +{ + /// + public partial class AddPost : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "post_id", + table: "files", + type: "bigint", + nullable: true); + + migrationBuilder.CreateTable( + name: "publishers", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + publisher_type = table.Column(type: "integer", nullable: false), + name = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + nick = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + bio = table.Column(type: "character varying(4096)", maxLength: 4096, nullable: true), + picture_id = table.Column(type: "text", nullable: true), + background_id = table.Column(type: "text", nullable: true), + account_id = table.Column(type: "bigint", 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_publishers", x => x.id); + table.ForeignKey( + name: "fk_publishers_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id"); + table.ForeignKey( + name: "fk_publishers_files_background_id", + column: x => x.background_id, + principalTable: "files", + principalColumn: "id"); + table.ForeignKey( + name: "fk_publishers_files_picture_id", + column: x => x.picture_id, + principalTable: "files", + principalColumn: "id"); + }); + + migrationBuilder.CreateTable( + name: "posts", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .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), + content = table.Column(type: "text", nullable: true), + type = table.Column(type: "integer", nullable: false), + 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), + 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), + 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_posts", x => x.id); + table.ForeignKey( + name: "fk_posts_posts_forwarded_post_id", + column: x => x.forwarded_post_id, + principalTable: "posts", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_posts_posts_replied_post_id", + column: x => x.replied_post_id, + principalTable: "posts", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "fk_posts_publishers_publisher_id", + column: x => x.publisher_id, + principalTable: "publishers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "publisher_members", + columns: table => new + { + publisher_id = table.Column(type: "bigint", nullable: false), + account_id = table.Column(type: "bigint", nullable: false), + role = table.Column(type: "integer", nullable: false), + joined_at = table.Column(type: "timestamp with time zone", 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_publisher_members", x => new { x.publisher_id, x.account_id }); + table.ForeignKey( + name: "fk_publisher_members_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_publisher_members_publishers_publisher_id", + column: x => x.publisher_id, + principalTable: "publishers", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "post_reactions", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + symbol = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + attitude = table.Column(type: "integer", nullable: false), + post_id = table.Column(type: "bigint", nullable: false), + account_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_reactions", x => x.id); + table.ForeignKey( + name: "fk_post_reactions_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_post_reactions_posts_post_id", + column: x => x.post_id, + principalTable: "posts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_files_post_id", + table: "files", + column: "post_id"); + + migrationBuilder.CreateIndex( + name: "ix_post_reactions_account_id", + table: "post_reactions", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_post_reactions_post_id", + table: "post_reactions", + column: "post_id"); + + migrationBuilder.CreateIndex( + name: "ix_posts_forwarded_post_id", + table: "posts", + column: "forwarded_post_id"); + + migrationBuilder.CreateIndex( + name: "ix_posts_publisher_id", + table: "posts", + column: "publisher_id"); + + migrationBuilder.CreateIndex( + name: "ix_posts_replied_post_id", + table: "posts", + column: "replied_post_id"); + + migrationBuilder.CreateIndex( + name: "ix_publisher_members_account_id", + table: "publisher_members", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_publishers_account_id", + table: "publishers", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_publishers_background_id", + table: "publishers", + column: "background_id"); + + migrationBuilder.CreateIndex( + name: "ix_publishers_picture_id", + table: "publishers", + column: "picture_id"); + + migrationBuilder.AddForeignKey( + name: "fk_files_posts_post_id", + table: "files", + column: "post_id", + principalTable: "posts", + principalColumn: "id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "fk_files_posts_post_id", + table: "files"); + + migrationBuilder.DropTable( + name: "post_reactions"); + + migrationBuilder.DropTable( + name: "publisher_members"); + + migrationBuilder.DropTable( + name: "posts"); + + migrationBuilder.DropTable( + name: "publishers"); + + migrationBuilder.DropIndex( + name: "ix_files_post_id", + table: "files"); + + migrationBuilder.DropColumn( + name: "post_id", + table: "files"); + } + } +} diff --git a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs index 8c3cf7b..fcd48a0 100644 --- a/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Sphere/Migrations/AppDatabaseModelSnapshot.cs @@ -386,6 +386,247 @@ namespace DysonNetwork.Sphere.Migrations b.ToTable("auth_sessions", (string)null); }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + 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("Downvotes") + .HasColumnType("integer") + .HasColumnName("downvotes"); + + b.Property("ForwardedPostId") + .HasColumnType("bigint") + .HasColumnName("forwarded_post_id"); + + b.Property("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property("RepliedPostId") + .HasColumnType("bigint") + .HasColumnName("replied_post_id"); + + b.Property("Title") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)") + .HasColumnName("title"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Upvotes") + .HasColumnType("integer") + .HasColumnName("upvotes"); + + b.Property("ViewsTotal") + .HasColumnType("integer") + .HasColumnName("views_total"); + + b.Property("ViewsUnique") + .HasColumnType("integer") + .HasColumnName("views_unique"); + + b.HasKey("Id") + .HasName("pk_posts"); + + b.HasIndex("ForwardedPostId") + .HasDatabaseName("ix_posts_forwarded_post_id"); + + b.HasIndex("PublisherId") + .HasDatabaseName("ix_posts_publisher_id"); + + b.HasIndex("RepliedPostId") + .HasDatabaseName("ix_posts_replied_post_id"); + + b.ToTable("posts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("Attitude") + .HasColumnType("integer") + .HasColumnName("attitude"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("PostId") + .HasColumnType("bigint") + .HasColumnName("post_id"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("symbol"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_post_reactions"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_post_reactions_account_id"); + + b.HasIndex("PostId") + .HasDatabaseName("ix_post_reactions_post_id"); + + b.ToTable("post_reactions", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_id"); + + b.Property("BackgroundId") + .HasColumnType("text") + .HasColumnName("background_id"); + + b.Property("Bio") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("bio"); + + 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") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Nick") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("nick"); + + b.Property("PictureId") + .HasColumnType("text") + .HasColumnName("picture_id"); + + b.Property("PublisherType") + .HasColumnType("integer") + .HasColumnName("publisher_type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_publishers"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_publishers_account_id"); + + b.HasIndex("BackgroundId") + .HasDatabaseName("ix_publishers_background_id"); + + b.HasIndex("PictureId") + .HasDatabaseName("ix_publishers_picture_id"); + + b.ToTable("publishers", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b => + { + b.Property("PublisherId") + .HasColumnType("bigint") + .HasColumnName("publisher_id"); + + b.Property("AccountId") + .HasColumnType("bigint") + .HasColumnName("account_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("JoinedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("PublisherId", "AccountId") + .HasName("pk_publisher_members"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_publisher_members_account_id"); + + b.ToTable("publisher_members", (string)null); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => { b.Property("Id") @@ -429,6 +670,10 @@ namespace DysonNetwork.Sphere.Migrations .HasColumnType("character varying(1024)") .HasColumnName("name"); + b.Property("PostId") + .HasColumnType("bigint") + .HasColumnName("post_id"); + b.Property("Size") .HasColumnType("bigint") .HasColumnName("size"); @@ -460,6 +705,9 @@ namespace DysonNetwork.Sphere.Migrations b.HasIndex("AccountId") .HasDatabaseName("ix_files_account_id"); + b.HasIndex("PostId") + .HasDatabaseName("ix_files_post_id"); + b.ToTable("files", (string)null); }); @@ -567,6 +815,100 @@ namespace DysonNetwork.Sphere.Migrations b.Navigation("Challenge"); }); + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.HasOne("DysonNetwork.Sphere.Post.Post", "ForwardedPost") + .WithMany() + .HasForeignKey("ForwardedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_forwarded_post_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Posts") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_posts_publishers_publisher_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", "RepliedPost") + .WithMany() + .HasForeignKey("RepliedPostId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_posts_posts_replied_post_id"); + + b.Navigation("ForwardedPost"); + + b.Navigation("Publisher"); + + b.Navigation("RepliedPost"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PostReaction", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_reactions_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Post", "Post") + .WithMany("Reactions") + .HasForeignKey("PostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_post_reactions_posts_post_id"); + + b.Navigation("Account"); + + b.Navigation("Post"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .HasConstraintName("fk_publishers_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Background") + .WithMany() + .HasForeignKey("BackgroundId") + .HasConstraintName("fk_publishers_files_background_id"); + + b.HasOne("DysonNetwork.Sphere.Storage.CloudFile", "Picture") + .WithMany() + .HasForeignKey("PictureId") + .HasConstraintName("fk_publishers_files_picture_id"); + + b.Navigation("Account"); + + b.Navigation("Background"); + + b.Navigation("Picture"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.PublisherMember", b => + { + b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_members_accounts_account_id"); + + b.HasOne("DysonNetwork.Sphere.Post.Publisher", "Publisher") + .WithMany("Members") + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_publisher_members_publishers_publisher_id"); + + b.Navigation("Account"); + + b.Navigation("Publisher"); + }); + modelBuilder.Entity("DysonNetwork.Sphere.Storage.CloudFile", b => { b.HasOne("DysonNetwork.Sphere.Account.Account", "Account") @@ -576,6 +918,11 @@ namespace DysonNetwork.Sphere.Migrations .IsRequired() .HasConstraintName("fk_files_accounts_account_id"); + b.HasOne("DysonNetwork.Sphere.Post.Post", null) + .WithMany("Attachments") + .HasForeignKey("PostId") + .HasConstraintName("fk_files_posts_post_id"); + b.Navigation("Account"); }); @@ -596,6 +943,20 @@ namespace DysonNetwork.Sphere.Migrations b.Navigation("Sessions"); }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Post", b => + { + b.Navigation("Attachments"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("DysonNetwork.Sphere.Post.Publisher", b => + { + b.Navigation("Members"); + + b.Navigation("Posts"); + }); #pragma warning restore 612, 618 } } diff --git a/DysonNetwork.Sphere/Post/Post.cs b/DysonNetwork.Sphere/Post/Post.cs new file mode 100644 index 0000000..ab08fac --- /dev/null +++ b/DysonNetwork.Sphere/Post/Post.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using DysonNetwork.Sphere.Storage; + +namespace DysonNetwork.Sphere.Post; + +public enum PostType +{ + Moment, + Article, + Video +} + +public class Post : ModelBase +{ + public long Id { get; set; } + [MaxLength(1024)] public string? Title { get; set; } + [MaxLength(4096)] public string? Description { get; set; } + // ReSharper disable once EntityFramework.ModelValidation.UnlimitedStringLength + public string? Content { get; set; } + + public PostType Type { get; set; } + [Column(TypeName = "jsonb")] 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 Post? RepliedPost { 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 enum PostReactionAttitude +{ + Positive, + Neutral, + Negative, +} + +public class PostReaction : ModelBase +{ + public long Id { get; set; } + [MaxLength(256)] public string Symbol { get; set; } = null!; + public PostReactionAttitude Attitude { get; set; } + + public long PostId { get; set; } + [JsonIgnore] public Post Post { get; set; } = null!; + public Account.Account Account { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs new file mode 100644 index 0000000..74bf021 --- /dev/null +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -0,0 +1,6 @@ +namespace DysonNetwork.Sphere.Post; + +public class PostService(AppDatabase db) +{ + +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/Publisher.cs b/DysonNetwork.Sphere/Post/Publisher.cs new file mode 100644 index 0000000..174a9c5 --- /dev/null +++ b/DysonNetwork.Sphere/Post/Publisher.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; +using DysonNetwork.Sphere.Storage; +using NodaTime; + +namespace DysonNetwork.Sphere.Post; + +public enum PublisherType +{ + Individual, + Organizational +} + +public class Publisher : ModelBase +{ + public long Id { get; set; } + public PublisherType PublisherType { get; set; } + [MaxLength(256)] public string Name { get; set; } = string.Empty; + [MaxLength(256)] public string Nick { get; set; } = string.Empty; + [MaxLength(4096)] public string? Bio { get; set; } + + public CloudFile? Picture { get; set; } + public CloudFile? Background { get; set; } + + [JsonIgnore] public ICollection Posts { get; set; } = new List(); + [JsonIgnore] public ICollection Members { get; set; } = new List(); + [JsonIgnore] public Account.Account? Account { get; set; } +} + +public enum PublisherMemberRole +{ + Owner = 100, + Manager = 75, + Editor = 50, + Viewer = 25 +} + +public class PublisherMember : ModelBase +{ + public long PublisherId { get; set; } + [JsonIgnore] public Publisher Publisher { get; set; } = null!; + public long AccountId { get; set; } + [JsonIgnore] public Account.Account Account { get; set; } = null!; + + public PublisherMemberRole Role { get; set; } + public Instant? JoinedAt { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/PublisherController.cs b/DysonNetwork.Sphere/Post/PublisherController.cs new file mode 100644 index 0000000..9b6b1b5 --- /dev/null +++ b/DysonNetwork.Sphere/Post/PublisherController.cs @@ -0,0 +1,285 @@ +using System.ComponentModel.DataAnnotations; +using DysonNetwork.Sphere.Storage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Sphere.Post; + +[ApiController] +[Route("/publishers")] +public class PublisherController(AppDatabase db, PublisherService ps, FileService fs) : 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) + .FirstOrDefaultAsync(); + if (publisher is null) return NotFound(); + + return Ok(publisher); + } + + [HttpGet] + [Authorize] + public async Task>> ListManagedPublishers() + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var members = await db.PublisherMembers + .Where(m => m.AccountId == userId) + .Where(m => m.JoinedAt != null) + .Include(e => e.Publisher) + .ToListAsync(); + + return members.Select(m => m.Publisher).ToList(); + } + + [HttpGet("invites")] + [Authorize] + public async Task>> ListInvites() + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var members = await db.PublisherMembers + .Where(m => m.AccountId == userId) + .Where(m => m.JoinedAt == null) + .Include(e => e.Publisher) + .ToListAsync(); + + return members.ToList(); + } + + public class PublisherMemberRequest + { + [Required] public long RelatedUserId { get; set; } + [Required] public PublisherMemberRole Role { get; set; } + } + + [HttpPost("invites/{name}")] + [Authorize] + 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"); + + var publisher = await db.Publishers + .Where(p => p.Name == name) + .Include(publisher => publisher.Picture) + .Include(publisher => publisher.Background) + .FirstOrDefaultAsync(); + if (publisher is null) return NotFound(); + + var member = await db.PublisherMembers + .Where(m => m.AccountId == userId) + .Where(m => m.PublisherId == publisher.Id) + .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."); + if (member.Role < request.Role) + return StatusCode(403, "You cannot invite member has higher permission than yours."); + + var newMember = new PublisherMember + { + Account = relatedUser, + AccountId = relatedUser.Id, + Publisher = publisher, + PublisherId = publisher.Id, + Role = request.Role, + }; + + db.PublisherMembers.Add(newMember); + await db.SaveChangesAsync(); + + return Ok(newMember); + } + + [HttpPost("invites/{name}/accept")] + [Authorize] + public async Task> AcceptMemberInvite(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(); + + 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(); + + return NoContent(); + } + + public class PublisherRequest + { + [MaxLength(256)] public string? Name { get; set; } + [MaxLength(256)] public string? Nick { get; set; } + [MaxLength(4096)] public string? Bio { get; set; } + + public string? PictureId { get; set; } + public string? BackgroundId { get; set; } + } + + [HttpPost("individual")] + [Authorize] + public async Task> CreatePublisherIndividual(PublisherRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + + var takenName = request.Name ?? currentUser.Name; + var duplicateNameCount = await db.Publishers + .Where(p => p.Name == takenName) + .CountAsync(); + if (duplicateNameCount > 0) + return BadRequest( + "The name you requested has already be taken, " + + "if it is your account name, " + + "you can request a taken down to the publisher which created with " + + "your name firstly to get your name back." + ); + + CloudFile? picture = null, background = null; + if (request.PictureId is not null) + { + picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); + if (picture is null) return BadRequest("Invalid picture id, unable to find the file on cloud."); + } + + if (request.BackgroundId is not null) + { + background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); + if (background is null) return BadRequest("Invalid background id, unable to find the file on cloud."); + } + + var publisher = await ps.CreateIndividualPublisher( + currentUser, + request.Name, + request.Nick, + request.Bio, + picture, + background + ); + + return Ok(publisher); + } + + [HttpPatch("{name}")] + [Authorize] + public async Task> UpdatePublisher(string name, PublisherRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var publisher = await db.Publishers + .Where(p => p.Name == name) + .Include(publisher => publisher.Picture) + .Include(publisher => publisher.Background) + .FirstOrDefaultAsync(); + if (publisher is null) return NotFound(); + + var member = await db.PublisherMembers + .Where(m => m.AccountId == userId) + .Where(m => m.PublisherId == publisher.Id) + .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 the manager to update the publisher profile."); + + if (request.Name is not null) publisher.Name = request.Name; + if (request.Nick is not null) publisher.Nick = request.Nick; + if (request.Bio is not null) publisher.Bio = request.Bio; + if (request.PictureId is not null) + { + var picture = await db.Files.Where(f => f.Id == request.PictureId).FirstOrDefaultAsync(); + if (picture is null) return BadRequest("Invalid picture id."); + if (publisher.Picture is not null) await fs.MarkUsageAsync(publisher.Picture, -1); + + publisher.Picture = picture; + await fs.MarkUsageAsync(picture, 1); + } + + if (request.BackgroundId is not null) + { + var background = await db.Files.Where(f => f.Id == request.BackgroundId).FirstOrDefaultAsync(); + if (background is null) return BadRequest("Invalid background id."); + if (publisher.Background is not null) await fs.MarkUsageAsync(publisher.Background, -1); + + publisher.Background = background; + await fs.MarkUsageAsync(background, 1); + } + + db.Update(publisher); + await db.SaveChangesAsync(); + + return Ok(publisher); + } + + [HttpDelete("{name}")] + [Authorize] + public async Task> DeletePublisher(string name) + { + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + var userId = currentUser.Id; + + var publisher = await db.Publishers + .Where(p => p.Name == name) + .Include(publisher => publisher.Picture) + .Include(publisher => publisher.Background) + .FirstOrDefaultAsync(); + if (publisher is null) return NotFound(); + + var member = await db.PublisherMembers + .Where(m => m.AccountId == userId) + .Where(m => m.PublisherId == publisher.Id) + .FirstOrDefaultAsync(); + if (member is null) return StatusCode(403, "You are not even a member of the targeted publisher."); + if (member.Role < PublisherMemberRole.Owner) + return StatusCode(403, "You need to be the owner to delete the publisher."); + + if (publisher.Picture is not null) + 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/Post/PublisherService.cs b/DysonNetwork.Sphere/Post/PublisherService.cs new file mode 100644 index 0000000..c70599b --- /dev/null +++ b/DysonNetwork.Sphere/Post/PublisherService.cs @@ -0,0 +1,48 @@ +using DysonNetwork.Sphere.Storage; +using NodaTime; + +namespace DysonNetwork.Sphere.Post; + +public class PublisherService(AppDatabase db, FileService fs) +{ + public async Task CreateIndividualPublisher( + Account.Account account, + string? name, + string? nick, + string? bio, + CloudFile? picture, + CloudFile? background + ) + { + var publisher = new Publisher + { + PublisherType = PublisherType.Individual, + Name = name ?? account.Name, + Nick = nick ?? account.Nick, + Bio = bio ?? account.Profile.Bio, + Picture = picture ?? account.Profile.Picture, + Background = background ?? account.Profile.Background, + Account = account, + Members = new List + { + new() + { + AccountId = account.Id, + Account = account, + Role = PublisherMemberRole.Owner, + JoinedAt = Instant.FromDateTimeUtc(DateTime.UtcNow) + } + } + }; + + db.Publishers.Add(publisher); + await db.SaveChangesAsync(); + + if (publisher.Picture is not null) await fs.MarkUsageAsync(publisher.Picture, 1); + if (publisher.Background is not null) await fs.MarkUsageAsync(publisher.Background, 1); + + return publisher; + } + + // TODO Able to create organizational publisher when the realm system is completed +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Program.cs b/DysonNetwork.Sphere/Program.cs index f704f61..9efae50 100644 --- a/DysonNetwork.Sphere/Program.cs +++ b/DysonNetwork.Sphere/Program.cs @@ -7,6 +7,7 @@ using Casbin.Persist.Adapter.EFCore; using DysonNetwork.Sphere; using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Auth; +using DysonNetwork.Sphere.Post; using DysonNetwork.Sphere.Storage; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.HttpOverrides; @@ -29,6 +30,8 @@ builder.Host.UseContentRoot(Directory.GetCurrentDirectory()); // Add services to the container. builder.Services.AddDbContext(); +builder.Services.AddMemoryCache(); + builder.Services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; @@ -117,6 +120,8 @@ builder.Services.AddOpenApi(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Timed task @@ -167,6 +172,7 @@ app.UseCors(opts => app.UseHttpsRedirection(); app.UseAuthorization(); +app.UseMiddleware(); app.MapControllers(); diff --git a/DysonNetwork.Sphere/Storage/FileController.cs b/DysonNetwork.Sphere/Storage/FileController.cs index c9ab38e..3f42e0e 100644 --- a/DysonNetwork.Sphere/Storage/FileController.cs +++ b/DysonNetwork.Sphere/Storage/FileController.cs @@ -83,9 +83,8 @@ public class FileController( [HttpDelete("{id}")] public async Task DeleteFile(string id) { - var userIdClaim = User.FindFirst("user_id")?.Value; - if (userIdClaim is null) return Unauthorized(); - var userId = long.Parse(userIdClaim); + if (HttpContext.Items["CurrentUser"] is not Account.Account currentUser) return Unauthorized(); + var userId = currentUser.Id; var file = await db.Files .Where(e => e.Id == id) diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index a0e769c..ec1c1b9 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -1,4 +1,5 @@  + ForceIncluded ForceIncluded ForceIncluded ForceIncluded