diff --git a/src/Analysim.Core/Entities/Project.cs b/src/Analysim.Core/Entities/Project.cs index dd80d370..b09c2d51 100644 --- a/src/Analysim.Core/Entities/Project.cs +++ b/src/Analysim.Core/Entities/Project.cs @@ -39,6 +39,9 @@ public class Project public ICollection Notebooks {get;set;} = new List(); + // Comments + public ICollection ProjectComments { get; set; } = new List(); + public int ForkedFromProjectID { get; set; } } diff --git a/src/Analysim.Core/Entities/ProjectComment.cs b/src/Analysim.Core/Entities/ProjectComment.cs new file mode 100644 index 00000000..898deb0b --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectComment.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class ProjectComment + { + // PK + [KeyAttribute] + public int CommentID { get; set; } + + // Comment Author + [ForeignKey("User")] + public int UserID { get; set; } + public User User { get; set; } = null!; + + // Project comment is linked to + [ForeignKey("Project")] + public int ProjectID { get; set; } + public Project Project{ get; set; } = null!; + + // Is this comment a reply? + [ForeignKey("ParentComment")] + public int? ParentCommentID { get; set; } + public ProjectComment? ParentComment { get; set; } + + // Comment content + public string Content { get; set; } = string.Empty; + + // Soft Delete + public bool IsDeleted { get; set; } + + // Pending Review + public bool IsPendingReview { get; set; } + + // Timestamps + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + // Replies to this comment + public ICollection Replies { get; set; } = new List(); + public ICollection CommentLikes { get; set; } = new List(); + public ICollection CommentFlags { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/src/Analysim.Core/Entities/ProjectCommentFlag.cs b/src/Analysim.Core/Entities/ProjectCommentFlag.cs new file mode 100644 index 00000000..ffcf7e71 --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectCommentFlag.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class ProjectCommentFlag + { + // PK + [KeyAttribute] + public int FlagID { get; set; } + + // Comment + [ForeignKey("ProjectComment")] + public int CommentID { get; set; } + public ProjectComment ProjectComment { get; set; } = null!; + + // Flagger + [ForeignKey("User")] + public int UserID { get; set; } + public User User { get; set; } = null!; + + // Snapshot comment content + public string CommentContentSnapshot { get; set; } = null!; + + // Timestamp + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/src/Analysim.Core/Entities/ProjectCommentLike.cs b/src/Analysim.Core/Entities/ProjectCommentLike.cs new file mode 100644 index 00000000..9e87cd80 --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectCommentLike.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class ProjectCommentLike + { + // Comment + [ForeignKey("ProjectComment")] + public int CommentID { get; set; } + public ProjectComment ProjectComment { get; set; } = null!; + + // Liker + [ForeignKey("User")] + public int UserID { get; set; } + public User User { get; set; } = null!; + + // Timestamp + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/src/Analysim.Core/Entities/User.cs b/src/Analysim.Core/Entities/User.cs index 801e98dd..53049acf 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -20,6 +20,11 @@ public class User : IdentityUser public ICollection Following { get; } = new List(); public ICollection ProjectUsers { get; } = new List(); public ICollection BlobFiles { get; } = new List(); + + // Comments + public ICollection ProjectComments { get; set; } = new List(); + public ICollection CommentLikes { get; set; } = new List(); + public ICollection CommentFlags { get; set; } = new List(); public string RegistrationSurvey {get; set;} diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 26d61bba..39ecfe46 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -10,7 +10,7 @@ public class ApplicationDbContext : IdentityDbContext, i { public ApplicationDbContext(DbContextOptions options) : base(options) { - + } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -96,20 +96,95 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity>().HasData( - new IdentityRole { Id = 1, Name = "Admin", NormalizedName = "ADMIN"}, + new IdentityRole { Id = 1, Name = "Admin", NormalizedName = "ADMIN" }, new IdentityRole { Id = 2, Name = "Customer", NormalizedName = "CUSTOMER" }, new IdentityRole { Id = 3, Name = "Moderator", NormalizedName = "MODERATOR" } ); // Many To One Relationship ( ObservableNotebookDataset -> Notebook) modelBuilder.Entity() - .HasOne(d=>d.notebook) - .WithMany(n=>n.observableNotebookDatasets) - .HasForeignKey(d=>d.NotebookID) + .HasOne(d => d.notebook) + .WithMany(n => n.observableNotebookDatasets) + .HasForeignKey(d => d.NotebookID) .OnDelete(DeleteBehavior.Cascade); + + #region Project Comments + + // One To Many Relationship (Project -> ProjectComment) + modelBuilder.Entity() + .HasMany(p => p.ProjectComments) + .WithOne(pc => pc.Project) + .HasForeignKey(pc => pc.ProjectID) + .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (User -> ProjectComment) + modelBuilder.Entity() + .HasMany(u => u.ProjectComments) + .WithOne(pc => pc.User) + .HasForeignKey(pc => pc.UserID) + .OnDelete(DeleteBehavior.Restrict); + + // Self Reference Relationship (ProjectComment -> Replies) + modelBuilder.Entity() + .HasOne(pc => pc.ParentComment) + .WithMany(pc => pc.Replies) + .HasForeignKey(pc => pc.ParentCommentID) + .OnDelete(DeleteBehavior.Restrict); + + // ProjectCommentLike composite key + modelBuilder.Entity() + .HasKey(cl => new { cl.CommentID, cl.UserID }); + + // One To Many Relationship (ProjectComment -> ProjectCommentLike) + modelBuilder.Entity() + .HasOne(cl => cl.ProjectComment) + .WithMany(pc => pc.CommentLikes) + .HasForeignKey(cl => cl.CommentID) + .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (User -> ProjectCommentLike) + modelBuilder.Entity() + .HasOne(cl => cl.User) + .WithMany(u => u.CommentLikes) + .HasForeignKey(cl => cl.UserID) + .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (ProjectComment -> ProjectCommentFlag) + modelBuilder.Entity() + .HasOne(cf => cf.ProjectComment) + .WithMany(pc => pc.CommentFlags) + .HasForeignKey(cf => cf.CommentID) + .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (User -> ProjectCommentFlag) + modelBuilder.Entity() + .HasOne(cf => cf.User) + .WithMany(u => u.CommentFlags) + .HasForeignKey(cf => cf.UserID) + .OnDelete(DeleteBehavior.Cascade); + + // Unique flag per user per comment + modelBuilder.Entity() + .HasIndex(cf => new { cf.CommentID, cf.UserID }) + .IsUnique(); + + // Indexes for common lookups + modelBuilder.Entity() + .HasIndex(pc => pc.ProjectID); + + modelBuilder.Entity() + .HasIndex(pc => pc.ParentCommentID); + + modelBuilder.Entity() + .HasIndex(cl => cl.UserID); + + modelBuilder.Entity() + .HasIndex(cf => cf.UserID); + + #endregion } - + public DbSet Tag { get; set; } public DbSet BlobFiles { get; set; } public DbSet Projects { get; set; } @@ -117,14 +192,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ProjectTags { get; set; } public DbSet UserUsers { get; set; } - public DbSet Notebook {get;set;} - public DbSet ObservableNotebookDataset { get;set;} + public DbSet Notebook { get; set; } + public DbSet ObservableNotebookDataset { get; set; } public DbSet NotebookContent { get; set; } public DbSet BlobFileContent { get; set; } - - - - + public DbSet ProjectComments { get; set; } + public DbSet ProjectCommentLikes { get; set; } + public DbSet ProjectCommentFlags { get; set; } } } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index ee014be1..b9654607 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -242,6 +242,97 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Projects"); }); + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPendingReview") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.Property("FlagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FlagID")); + + b.Property("CommentContentSnapshot") + .HasColumnType("text"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("FlagID"); + + b.HasIndex("UserID"); + + b.HasIndex("CommentID", "UserID") + .IsUnique(); + + b.ToTable("ProjectCommentFlags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CommentID", "UserID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectCommentLikes"); + }); + modelBuilder.Entity("Core.Entities.ProjectTag", b => { b.Property("ProjectID") @@ -428,21 +519,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - ConcurrencyStamp = "ebd0169f-30be-4ab7-9fb9-038a9de20efb", + ConcurrencyStamp = "45fc9c92-801d-433f-ab00-280c2334bc89", Name = "Admin", NormalizedName = "ADMIN" }, new { Id = 2, - ConcurrencyStamp = "371acb40-c941-4714-9c2c-bc9de9bff144", + ConcurrencyStamp = "922f6db8-d335-4aa4-9331-12097412cf5e", Name = "Customer", NormalizedName = "CUSTOMER" }, new { Id = 3, - ConcurrencyStamp = "7ee39326-5034-4323-85cc-6da801861458", + ConcurrencyStamp = "cecf34c5-c9e0-4b99-9fb7-1441045209d8", Name = "Moderator", NormalizedName = "MODERATOR" }); @@ -612,6 +703,70 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("notebook"); }); + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentFlags") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentFlags") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentLikes") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Core.Entities.ProjectTag", b => { b.HasOne("Core.Entities.Project", "Project") @@ -738,11 +893,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Notebooks"); + b.Navigation("ProjectComments"); + b.Navigation("ProjectTags"); b.Navigation("ProjectUsers"); }); + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + modelBuilder.Entity("Core.Entities.Tag", b => { b.Navigation("ProjectTags"); @@ -752,10 +918,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("BlobFiles"); + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + b.Navigation("Followers"); b.Navigation("Following"); + b.Navigation("ProjectComments"); + b.Navigation("ProjectUsers"); }); #pragma warning restore 612, 618 diff --git a/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs new file mode 100644 index 00000000..63ba7184 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs @@ -0,0 +1,841 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260319234816_AddProjectComments")] + partial class AddProjectComments + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("NotebookID") + .HasColumnType("integer"); + + b.Property("datasetName") + .HasColumnType("text"); + + b.Property("datasetURL") + .HasColumnType("text"); + + b.HasKey("ID"); + + b.HasIndex("NotebookID"); + + b.ToTable("ObservableNotebookDataset"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Property("ProjectID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectID")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ForkedFromProjectID") + .HasColumnType("integer"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectID"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "fa79cb69-0854-4116-be7e-0cc32666a38c", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "c0e380b3-6959-457d-b623-72d23bd85ed1", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "6c80828f-17ef-44b7-8820-bcc50e82e8f3", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs new file mode 100644 index 00000000..b2625833 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddProjectComments : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProjectComments", + columns: table => new + { + CommentID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserID = table.Column(type: "integer", nullable: false), + ProjectID = table.Column(type: "integer", nullable: false), + ParentCommentID = table.Column(type: "integer", nullable: true), + Content = table.Column(type: "text", nullable: true), + IsDeleted = table.Column(type: "boolean", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProjectComments", x => x.CommentID); + table.ForeignKey( + name: "FK_ProjectComments_AspNetUsers_UserID", + column: x => x.UserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectComments_ProjectComments_ParentCommentID", + column: x => x.ParentCommentID, + principalTable: "ProjectComments", + principalColumn: "CommentID", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectComments_Projects_ProjectID", + column: x => x.ProjectID, + principalTable: "Projects", + principalColumn: "ProjectID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "fa79cb69-0854-4116-be7e-0cc32666a38c"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "c0e380b3-6959-457d-b623-72d23bd85ed1"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "6c80828f-17ef-44b7-8820-bcc50e82e8f3"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectComments_ParentCommentID", + table: "ProjectComments", + column: "ParentCommentID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectComments_ProjectID", + table: "ProjectComments", + column: "ProjectID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectComments_UserID", + table: "ProjectComments", + column: "UserID"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProjectComments"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "ebd0169f-30be-4ab7-9fb9-038a9de20efb"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "371acb40-c941-4714-9c2c-bc9de9bff144"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "7ee39326-5034-4323-85cc-6da801861458"); + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.Designer.cs new file mode 100644 index 00000000..a7774b1b --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.Designer.cs @@ -0,0 +1,932 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260406203747_AddCommentLikesAndFlags")] + partial class AddCommentLikesAndFlags + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("NotebookID") + .HasColumnType("integer"); + + b.Property("datasetName") + .HasColumnType("text"); + + b.Property("datasetURL") + .HasColumnType("text"); + + b.HasKey("ID"); + + b.HasIndex("NotebookID"); + + b.ToTable("ObservableNotebookDataset"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Property("ProjectID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectID")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ForkedFromProjectID") + .HasColumnType("integer"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectID"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.Property("FlagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FlagID")); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("FlagID"); + + b.HasIndex("UserID"); + + b.HasIndex("CommentID", "UserID") + .IsUnique(); + + b.ToTable("ProjectCommentFlags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CommentID", "UserID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectCommentLikes"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "df646dd7-958b-4e8d-9516-832f7251f4b2", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "ace78628-a4c7-4edc-bef9-61dfb7bbe225", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "1782edfd-8928-4ec8-88e5-738cdebac23d", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentFlags") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentFlags") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentLikes") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.cs b/src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.cs new file mode 100644 index 00000000..bd4b41bf --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.cs @@ -0,0 +1,133 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddCommentLikesAndFlags : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProjectCommentFlags", + columns: table => new + { + FlagID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CommentID = table.Column(type: "integer", nullable: false), + UserID = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProjectCommentFlags", x => x.FlagID); + table.ForeignKey( + name: "FK_ProjectCommentFlags_AspNetUsers_UserID", + column: x => x.UserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProjectCommentFlags_ProjectComments_CommentID", + column: x => x.CommentID, + principalTable: "ProjectComments", + principalColumn: "CommentID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ProjectCommentLikes", + columns: table => new + { + CommentID = table.Column(type: "integer", nullable: false), + UserID = table.Column(type: "integer", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProjectCommentLikes", x => new { x.CommentID, x.UserID }); + table.ForeignKey( + name: "FK_ProjectCommentLikes_AspNetUsers_UserID", + column: x => x.UserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProjectCommentLikes_ProjectComments_CommentID", + column: x => x.CommentID, + principalTable: "ProjectComments", + principalColumn: "CommentID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "df646dd7-958b-4e8d-9516-832f7251f4b2"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "ace78628-a4c7-4edc-bef9-61dfb7bbe225"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "1782edfd-8928-4ec8-88e5-738cdebac23d"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectCommentFlags_CommentID_UserID", + table: "ProjectCommentFlags", + columns: new[] { "CommentID", "UserID" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProjectCommentFlags_UserID", + table: "ProjectCommentFlags", + column: "UserID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectCommentLikes_UserID", + table: "ProjectCommentLikes", + column: "UserID"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProjectCommentFlags"); + + migrationBuilder.DropTable( + name: "ProjectCommentLikes"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "fa79cb69-0854-4116-be7e-0cc32666a38c"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "c0e380b3-6959-457d-b623-72d23bd85ed1"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "6c80828f-17ef-44b7-8820-bcc50e82e8f3"); + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.Designer.cs new file mode 100644 index 00000000..6fcc5cbb --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.Designer.cs @@ -0,0 +1,935 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260407004516_AddCommentSnapshotToFlags")] + partial class AddCommentSnapshotToFlags + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("NotebookID") + .HasColumnType("integer"); + + b.Property("datasetName") + .HasColumnType("text"); + + b.Property("datasetURL") + .HasColumnType("text"); + + b.HasKey("ID"); + + b.HasIndex("NotebookID"); + + b.ToTable("ObservableNotebookDataset"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Property("ProjectID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectID")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ForkedFromProjectID") + .HasColumnType("integer"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectID"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.Property("FlagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FlagID")); + + b.Property("CommentContentSnapshot") + .HasColumnType("text"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("FlagID"); + + b.HasIndex("UserID"); + + b.HasIndex("CommentID", "UserID") + .IsUnique(); + + b.ToTable("ProjectCommentFlags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CommentID", "UserID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectCommentLikes"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "52b20532-0a25-4849-a146-ad8c6e7070d5", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "ca163c30-c210-4244-a85a-2e6aafb5e8ff", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "d8bde866-b51e-4044-996f-0d7f727145ee", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentFlags") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentFlags") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentLikes") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.cs b/src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.cs new file mode 100644 index 00000000..2e0f4981 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddCommentSnapshotToFlags : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CommentContentSnapshot", + table: "ProjectCommentFlags", + type: "text", + nullable: true); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "52b20532-0a25-4849-a146-ad8c6e7070d5"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "ca163c30-c210-4244-a85a-2e6aafb5e8ff"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "d8bde866-b51e-4044-996f-0d7f727145ee"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CommentContentSnapshot", + table: "ProjectCommentFlags"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "df646dd7-958b-4e8d-9516-832f7251f4b2"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "ace78628-a4c7-4edc-bef9-61dfb7bbe225"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "1782edfd-8928-4ec8-88e5-738cdebac23d"); + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.Designer.cs new file mode 100644 index 00000000..4b8c7b25 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.Designer.cs @@ -0,0 +1,938 @@ +// +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260416212905_AddPendingReviewToComments")] + partial class AddPendingReviewToComments + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("NotebookID") + .HasColumnType("integer"); + + b.Property("datasetName") + .HasColumnType("text"); + + b.Property("datasetURL") + .HasColumnType("text"); + + b.HasKey("ID"); + + b.HasIndex("NotebookID"); + + b.ToTable("ObservableNotebookDataset"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Property("ProjectID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectID")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ForkedFromProjectID") + .HasColumnType("integer"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectID"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPendingReview") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.Property("FlagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FlagID")); + + b.Property("CommentContentSnapshot") + .HasColumnType("text"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("FlagID"); + + b.HasIndex("UserID"); + + b.HasIndex("CommentID", "UserID") + .IsUnique(); + + b.ToTable("ProjectCommentFlags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CommentID", "UserID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectCommentLikes"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "45fc9c92-801d-433f-ab00-280c2334bc89", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "922f6db8-d335-4aa4-9331-12097412cf5e", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "cecf34c5-c9e0-4b99-9fb7-1441045209d8", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentFlags") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentFlags") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentLikes") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.cs b/src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.cs new file mode 100644 index 00000000..33f9bfc8 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddPendingReviewToComments : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsPendingReview", + table: "ProjectComments", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "45fc9c92-801d-433f-ab00-280c2334bc89"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "922f6db8-d335-4aa4-9331-12097412cf5e"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "cecf34c5-c9e0-4b99-9fb7-1441045209d8"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsPendingReview", + table: "ProjectComments"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "52b20532-0a25-4849-a146-ad8c6e7070d5"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "ca163c30-c210-4244-a85a-2e6aafb5e8ff"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "d8bde866-b51e-4044-996f-0d7f727145ee"); + } + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/admin/admin-routing.module.ts b/src/Analysim.Web/ClientApp/src/app/admin/admin-routing.module.ts index 27f1a267..a4d03f29 100644 --- a/src/Analysim.Web/ClientApp/src/app/admin/admin-routing.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/admin/admin-routing.module.ts @@ -7,6 +7,7 @@ import { NotebooksComponent } from './components/notebooks/notebooks.component'; import { UsersComponent } from './components/users/users.component'; import { DatasetsComponent } from './components/datasets/datasets.component'; import { ProjectsComponent } from './components/projects/projects.component'; +import { CommentsComponent } from './components/comments/comments.component'; const routes: Routes = [ { @@ -19,6 +20,7 @@ const routes: Routes = [ { path: 'users', component: UsersComponent }, { path: 'datasets', component: DatasetsComponent }, { path: 'projects', component: ProjectsComponent }, + { path: 'comments', component: CommentsComponent }, { path: '', redirectTo: 'notebooks', pathMatch: 'full' } ] } diff --git a/src/Analysim.Web/ClientApp/src/app/admin/admin.component.html b/src/Analysim.Web/ClientApp/src/app/admin/admin.component.html index 933d8867..6dc4b960 100644 --- a/src/Analysim.Web/ClientApp/src/app/admin/admin.component.html +++ b/src/Analysim.Web/ClientApp/src/app/admin/admin.component.html @@ -12,6 +12,9 @@ +
diff --git a/src/Analysim.Web/ClientApp/src/app/admin/admin.module.ts b/src/Analysim.Web/ClientApp/src/app/admin/admin.module.ts index 189bc92a..5c142c4d 100644 --- a/src/Analysim.Web/ClientApp/src/app/admin/admin.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/admin/admin.module.ts @@ -15,6 +15,10 @@ import { ProjectsModule } from '../projects/projects.module'; import { UserDisplayComponent } from './components/users/user-display/user-display.component'; import { ProjectDisplayComponent } from './components/projects/project-display/project-display.component'; import { DatasetActionsComponent } from './components/datasets/dataset-actions/dataset-actions.component'; +import { CommentsComponent } from './components/comments/comments.component'; +import { FlaggedCommentItemComponent } from './components/comments/flagged-comment-item/flagged-comment-item.component'; +import { ModalDeleteReportsComponent } from './components/comments/modal-delete-reports/modal-delete-reports.component'; +import { ModalIgnoreReportsComponent } from './components/comments/modal-ignore-reports/modal-ignore-reports.component'; @NgModule({ declarations: [ @@ -29,7 +33,11 @@ import { DatasetActionsComponent } from './components/datasets/dataset-actions/d AdminNotebookItemDisplayComponent, UserDisplayComponent, ProjectDisplayComponent, - DatasetActionsComponent + DatasetActionsComponent, + CommentsComponent, + FlaggedCommentItemComponent, + ModalDeleteReportsComponent, + ModalIgnoreReportsComponent ], imports: [ CommonModule, diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.html b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.html new file mode 100644 index 00000000..1c925adb --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.html @@ -0,0 +1,13 @@ + +
+

Flagged Comments

+
Loading…
+
+ + +
+
diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.scss b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.scss new file mode 100644 index 00000000..07ab2b9d --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.scss @@ -0,0 +1,5 @@ +.comment-items-area{ + display: flex; + flex-direction: column; + gap: var(--space-2); +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.spec.ts new file mode 100644 index 00000000..0a8cf1be --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CommentsComponent } from './comments.component'; + +describe('CommentsComponent', () => { + let component: CommentsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ CommentsComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(CommentsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.ts new file mode 100644 index 00000000..10344eb7 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.ts @@ -0,0 +1,35 @@ +import { Component, OnInit } from '@angular/core'; +import { FlaggedCommentGroup } from 'src/app/interfaces/project-comment-flag'; +import { ProjectService } from 'src/app/services/project.service'; + +@Component({ + selector: 'app-comments', + templateUrl: './comments.component.html', + styleUrls: ['./comments.component.scss'] +}) +export class CommentsComponent implements OnInit { + flaggedComments: FlaggedCommentGroup[] = []; + isLoading: boolean = false; + + constructor(private projectService: ProjectService) {} + + ngOnInit(): void { + this.loadComments(); + } + + loadComments(): void { + this.isLoading = true; + + this.projectService.getFlaggedProjectComments().subscribe({ + next: (flaggedComments) => { + this.flaggedComments = flaggedComments; + this.isLoading = false; + }, + error: (error) => { + console.log('Failed to load flagged comments', error); + this.isLoading = false; + }, + }); + } + +} diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.html b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.html new file mode 100644 index 00000000..cf710152 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.html @@ -0,0 +1,50 @@ +
+
+ {{ flaggedComment.commentOwnerUsername }} + {{ flaggedComment.commentProjectName }} - {{flaggedComment.commentProjectOwner}} + {{ flags.length }} {{ flags.length == 1 ? "flag" : "flags"}} +
+ + + +
+
+ +
+ Reported By + Date + Comment Snapshot +
+ +
+
+ {{ flag.flaggedByUsername }} + {{ flag.createdAt | date:'MMM d, y • h:mm a' }} + {{ flag.commentContentSnapshot }} +
+
+ + +
No flags
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.scss b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.scss new file mode 100644 index 00000000..718cebab --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.scss @@ -0,0 +1,100 @@ +.flagged-comment-table { + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + background: var(--surface-0); + overflow: hidden; + font-size: var(--font-size-large); +} + +.flagged-comment-table__header { + display: grid; + grid-template-columns: auto 1fr auto auto; + gap: var(--space-3); + align-items: center; + padding: var(--space-2) var(--space-3); + background: var(--background-color-tertiary); + border-bottom: 1px solid var(--border-color); + font-size: var(--font-size-large); +} + +.flagged-comment-table__comment { + font-weight: 600; +} + +.flagged-comment-table__owner { + color: var(--text-muted); +} + +.flagged-comment-table__count { + color: var(--color-danger); + font-weight: 600; +} + +.flagged-comment-table__actions { + display: flex; + gap: var(--space-1); +} + +.flagged-comment-table__head, +.flagged-comment-table__row { + display: grid; + grid-template-columns: .25fr .5fr 3fr; + gap: var(--space-3); + align-items: start; + padding: var(--space-2) var(--space-3); +} + +.flagged-comment-table__head { + border-bottom: 1px solid var(--border-color); + font-size: var(--font-size-base); + font-weight: 600; + color: var(--text-muted); + background: var(--primary-color); +} + +.flagged-comment-table__row { + border-bottom: 1px solid var(--border-color); + font-size: var(--font-size-base); +} + +.flagged-comment-table__row:last-child { + border-bottom: none; +} + +.cell { + min-width: 0; + word-break: break-word; +} + +.cell--id { + font-weight: 600; +} + +.cell--muted { + color: var(--text-muted); +} + +.cell--content { + line-height: 1.35; +} + +.flagged-comment-table__empty { + padding: var(--space-3); + font-size: var(--font-size-base); + color: var(--text-muted); +} + +@media (max-width: 768px) { + .flagged-comment-table__header { + grid-template-columns: 1fr; + } + + .flagged-comment-table__head { + display: none; + } + + .flagged-comment-table__row { + grid-template-columns: 1fr; + gap: var(--space-1); + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.spec.ts new file mode 100644 index 00000000..226cedfd --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { FlaggedCommentItemComponent } from './flagged-comment-item.component'; + +describe('FlaggedCommentItemComponent', () => { + let component: FlaggedCommentItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ FlaggedCommentItemComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(FlaggedCommentItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.ts new file mode 100644 index 00000000..b0fa4982 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.ts @@ -0,0 +1,84 @@ +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; +import { + FlaggedCommentGroup, + ProjectCommentFlagRow, +} from 'src/app/interfaces/project-comment-flag'; + +@Component({ + selector: 'app-flagged-comment-item', + templateUrl: './flagged-comment-item.component.html', + styleUrls: ['./flagged-comment-item.component.scss'], +}) +export class FlaggedCommentItemComponent implements OnInit { + @Input() flaggedComment: FlaggedCommentGroup; + + @Output() onReload = new EventEmitter(); + + // Modals + @ViewChild('ignoreModal') ignoreModal: TemplateRef; + @ViewChild('deleteModal') deleteModal: TemplateRef; + + // Modal Refs + ignoreModalRef: BsModalRef; + deleteModalRef: BsModalRef; + + flags: ProjectCommentFlagRow[]; + isDeleted: boolean; + isDeleting: boolean; + isIgnoring: boolean; + + constructor(private modalService: BsModalService, private router: Router,) {} + + ngOnInit(): void { + this.flags = this.flaggedComment.flags; + } + + // Delete + + onDelete(): void { + this.toggleModalDelete(); + this.isDeleting = true; + } + + onHandleSuccessfulDelete(): void { + this.isDeleted = true; + this.isDeleting = false; + this.onReload.emit(); + } + + toggleModalDelete() { + this.deleteModalRef = this.modalService.show(this.deleteModal) + } + + // Ignore + + onIgnore(): void { + this.toggleModalIgnore(); + this.isIgnoring = true; + } + + onHandleSuccessfulIgnore(): void { + this.isIgnoring = false; + this.onReload.emit(); + } + + toggleModalIgnore() { + this.ignoreModalRef = this.modalService.show(this.ignoreModal) + } + + // View + onView(): void { + const p = this.flaggedComment; + this.router.navigate([`/project/${p.commentProjectOwner}/${p.commentProjectName}/comment/`], { fragment: String(p.commentID), queryParams: { returnUrl: this.router.url }, }); + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.html b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.html new file mode 100644 index 00000000..6f478e80 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.html @@ -0,0 +1,48 @@ + diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.scss b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.scss new file mode 100644 index 00000000..e4caf7ff --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.scss @@ -0,0 +1,15 @@ +.modal-confirm{ + text-align: center; + font-weight: 700; + font-size: large; + padding: 0; + padding-bottom: 1rem; + margin: 0; +} + +.modal-warning{ + text-align: center; + font-size: small; + padding: 0; + margin: 0; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.spec.ts new file mode 100644 index 00000000..87ba8b42 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModalDeleteReportsComponent } from './modal-delete-reports.component'; + +describe('ModalDeleteReportsComponent', () => { + let component: ModalDeleteReportsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ModalDeleteReportsComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModalDeleteReportsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.ts new file mode 100644 index 00000000..6787607f --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { ProjectService } from 'src/app/services/project.service'; + +@Component({ + selector: 'app-modal-delete-reports', + templateUrl: './modal-delete-reports.component.html', + styleUrls: ['./modal-delete-reports.component.scss'] +}) +export class ModalDeleteReportsComponent implements OnInit { + @Input() deleteModalRef: BsModalRef + @Input() commentID: number; + + @Output() onSuccessfulDelete = new EventEmitter(); + @Output() onCancelDelete = new EventEmitter(); + + errorResult: String; + errorStatusAlert = false; + + constructor(private projectService: ProjectService) { } + + ngOnInit(): void {} + + onDelete(): void { + this.projectService.deleteCommentAndReports(this.commentID).subscribe({ + next: (result) => { + console.log('Deleted comment / removed reports for: ', this.commentID); + this.onSuccessfulDelete.emit(); + this.deleteModalRef.hide(); + }, + error: (error) => { + this.errorStatusAlert = true; + this.errorResult = "Error: unable to delete comment / remove reports, please contact developers for assistance"; + console.log(error); + }, + }); + + } + + closeModal() { + this.onCancelDelete.emit(); + this.deleteModalRef.hide(); + } + +} diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.html b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.html new file mode 100644 index 00000000..0701b780 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.html @@ -0,0 +1,48 @@ + diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.scss b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.scss new file mode 100644 index 00000000..e4caf7ff --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.scss @@ -0,0 +1,15 @@ +.modal-confirm{ + text-align: center; + font-weight: 700; + font-size: large; + padding: 0; + padding-bottom: 1rem; + margin: 0; +} + +.modal-warning{ + text-align: center; + font-size: small; + padding: 0; + margin: 0; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.spec.ts new file mode 100644 index 00000000..02d461cd --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModalIgnoreReportsComponent } from './modal-ignore-reports.component'; + +describe('ModalIgnoreReportsComponent', () => { + let component: ModalIgnoreReportsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ModalIgnoreReportsComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModalIgnoreReportsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.ts b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.ts new file mode 100644 index 00000000..1c0283b7 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.ts @@ -0,0 +1,43 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { ProjectService } from 'src/app/services/project.service'; + +@Component({ + selector: 'app-modal-ignore-reports', + templateUrl: './modal-ignore-reports.component.html', + styleUrls: ['./modal-ignore-reports.component.scss'] +}) +export class ModalIgnoreReportsComponent implements OnInit { + @Input() ignoreModalRef: BsModalRef + @Input() commentID: number; + + @Output() onSuccessfulIgnore = new EventEmitter(); + @Output() onCancelIgnore = new EventEmitter(); + + errorResult: String; + errorStatusAlert = false; + + constructor(private projectService: ProjectService) { } + + ngOnInit(): void {} + + onIgnore(): void { + this.projectService.removeallCommentReports(this.commentID).subscribe({ + next: (result) => { + console.log('Removed comment reports', this.commentID); + this.onSuccessfulIgnore.emit(); + this.ignoreModalRef.hide(); + }, + error: (error) => { + this.errorStatusAlert = true; + this.errorResult = "Error: unable to remove reports, please contact developers for assistance"; + console.log(error); + }, + }); + } + + closeModal() { + this.onCancelIgnore.emit(); + this.ignoreModalRef.hide(); + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/app-routing.module.ts b/src/Analysim.Web/ClientApp/src/app/app-routing.module.ts index 8e7273a3..7149d531 100644 --- a/src/Analysim.Web/ClientApp/src/app/app-routing.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/app-routing.module.ts @@ -38,7 +38,9 @@ const routes: Routes = []; { path: '404', component: NotFoundComponent }, { path: '**', component: NotFoundComponent } // todo: Add verify page routing and component - ])], + ], { + anchorScrolling: 'enabled' + })], exports: [RouterModule] }) export class AppRoutingModule { } diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts new file mode 100644 index 00000000..272244b4 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts @@ -0,0 +1,22 @@ +export interface ProjectCommentFlag { + flagID: number; + commentID: number; + userID: number; + commentContentSnapshot: string; + createdAt: string; +} + +export interface FlaggedCommentGroup { + commentID: number; + commentOwnerUsername: string; + commentProjectName: string; + commentProjectOwner: string; + flags: ProjectCommentFlagRow[]; +} + +export interface ProjectCommentFlagRow { + flagID: number; + commentContentSnapshot: string; + flaggedByUsername: string; + createdAt: string; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-like.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-like.ts new file mode 100644 index 00000000..af22dfc5 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-like.ts @@ -0,0 +1,5 @@ +export interface ProjectCommentLike { + commentID: number; + userID: number; + createdAt: string; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts new file mode 100644 index 00000000..a89f78f4 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts @@ -0,0 +1,18 @@ +import { ProjectCommentFlag } from "./project-comment-flag"; +import { ProjectCommentLike } from "./project-comment-like"; + +export interface ProjectComment { + commentID: number; + userID: number; + authorName: string; + projectID: number; + parentCommentID: number | null; + content: string; + isDeleted: boolean; + isPendingReview: boolean; + createdAt: string; + updatedAt: string; + replies: ProjectComment[]; + commentLikes: ProjectCommentLike[]; + commentFlags: ProjectCommentFlag[]; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html index 86e11588..45aa1671 100644 --- a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html +++ b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html @@ -63,10 +63,10 @@ -
- - diff --git a/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts b/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts index 0632ac9b..5cb2a282 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts @@ -56,6 +56,10 @@ import { SaveConfirmationModalComponent } from './project-overview/project-overv import { ModalDatasetsComponent } from './project-overview/project-overview-view/project-content/modal-datasets/modal-datasets.component'; import { DatasetFolderViewComponent } from './project-overview/project-overview-view/project-content/dataset-folder-view/dataset-folder-view.component'; import { SaveNotebookModalComponent } from './project-overview/project-overview-view/project-content/save-notebook-modal/save-notebook-modal.component'; +import { ProjectCommentItemComponent } from './project-comments/project-comment-item/project-comment-item.component'; +import { ProjectCommentBoxComponent } from './project-comments/project-comment-box/project-comment-box.component'; +import { ModalReportCommentComponent } from './project-comments/modal-report-comment/modal-report-comment.component'; +import { ModalDeleteCommentComponent } from './project-comments/modal-delete-comment/modal-delete-comment.component'; @NgModule({ declarations: [ @@ -104,6 +108,10 @@ import { SaveNotebookModalComponent } from './project-overview/project-overview- ModalDatasetsComponent, DatasetFolderViewComponent, SaveNotebookModalComponent, + ProjectCommentItemComponent, + ProjectCommentBoxComponent, + ModalReportCommentComponent, + ModalDeleteCommentComponent, ], imports: [ CommonModule, diff --git a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts index 210b32c2..510c7079 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts @@ -14,6 +14,8 @@ import { NotificationService } from './notification.service'; import { saveAs } from 'file-saver'; import { Notebook, NotebookFile, NotebookURL } from '../interfaces/notebook'; import { getItem } from 'localforage'; +import { ProjectComment } from '../interfaces/project-comment'; +import { FlaggedCommentGroup } from '../interfaces/project-comment-flag'; @Injectable({ providedIn: 'root' @@ -39,7 +41,8 @@ export class ProjectService { private urlDownloadImage: string = this.baseUrl + "downloadFile/" private urlDownloadNotebook: string = this.baseUrl + "DownloadNotebook/" private urlGetNotebookVersions: string = this.baseUrl + "getnotebookversions/" - + private urlGetProjectComments: string = this.baseUrl + "getprojectcomments/"; + private urlGetFlaggedProjectComments: string = this.baseUrl + "GetAllFlaggedComments"; // Post private urlCreateProject: string = this.baseUrl + "createproject" @@ -51,6 +54,10 @@ export class ProjectService { private urlForkProject: string = this.baseUrl + "forkproject" private urlForkProjectWithoutBlob: string = this.baseUrl + "forkprojectwithoutblob" private urlAddDatasetToNotebook: string = this.baseUrl + "addDatasetToNotebook" + private urlPostComment: string = this.baseUrl + "postcomment"; + private urlLikeComment: string = this.baseUrl + "likecomment/"; + private urlReportComment: string = this.baseUrl + "reportcomment/"; + private urlDeleteCommentandReports: string = this.baseUrl + "deletecommentandreports/"; // Put private urlUpdateProject: string = this.baseUrl + "updateproject/" @@ -58,12 +65,17 @@ export class ProjectService { private urlUpdateFile: string = this.baseUrl + "updateFile" private urlRenameNotebook: string = this.baseUrl + "RenameNotebook" private urlDeleteDatasetFromNotebook: string = this.baseUrl + "deleteDatasetFromNotebook/" + private urlUpdateComment: string = this.baseUrl + "updatecomment/"; + private urlDeleteComment: string = this.baseUrl + "deletecomment/"; // Delete private urlDeleteProject: string = this.baseUrl + "deleteproject/" private urlRemoveUser: string = this.baseUrl + "removeuser/" private urlRemoveTag: string = this.baseUrl + "removetag/" private urlDeleteFile: string = this.baseUrl + "deleteFile/" + private urlUnlikeComment: string = this.baseUrl + "likecomment/"; + private urlRemoveCommentReport: string = this.baseUrl + "removecommentreport/"; + private urlRemoveAllCommentReports: string = this.baseUrl + "removeallcommentreports/"; // Extra private urlGetUserList: string = this.baseUrl + "getuserlist/" @@ -90,7 +102,7 @@ export class ProjectService { }), catchError(error => { console.log(error) - return throwError(error) + return throwError(error) }) ) } @@ -801,5 +813,203 @@ export class ProjectService { ) } + getProjectComments(projectID: number): Observable { + return this.http.get(this.urlGetProjectComments + projectID) + .pipe( + map(body => { + console.log(body.message); + return body.result; + }), + catchError(error => { + console.log(error); + return throwError(() => error); + }) + ); + } + + postComment(projectID: number, content: string, parentID: number | null): Observable { + let body = new FormData(); + body.append('projectID', projectID.toString()); + body.append('content', content); + if(parentID != null) body.append('parentCommentID', parentID.toString()); + + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.post(this.urlPostComment, body, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + updateComment(commentID: number, content: string): Observable { + let body = new FormData(); + body.append('content', content); + + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.put(this.urlUpdateComment + commentID, body, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + deleteComment(commentID: number): Observable { + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.put(this.urlDeleteComment + commentID, null, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + likeComment(commentID: number): Observable { + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.post(this.urlLikeComment + commentID, null, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + unlikeComment(commentID: number): Observable { + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.delete(this.urlUnlikeComment + commentID, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + reportComment(commentID: number): Observable { + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.post(this.urlReportComment + commentID, null, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + removeCommentReport(commentID: number): Observable { + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.delete(this.urlRemoveCommentReport + commentID, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + removeallCommentReports(commentID: number): Observable { + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.delete(this.urlRemoveAllCommentReports + commentID, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } + + getFlaggedProjectComments(): Observable { + const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); + + return this.http.get(this.urlGetFlaggedProjectComments, {headers}).pipe( + map(body => { + console.log(body.message); + return body.result; + }), + catchError(error => { + console.log(error); + return throwError(() => error); + }) + ); + } + + deleteCommentAndReports(commentID: number): Observable { + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.post(this.urlDeleteCommentandReports + commentID, null, {headers}).pipe( + map(body => { + console.log(body.message); + return body; + }), + catchError(error => { + console.log(error); + return throwError(error); + }) + ); + } } diff --git a/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss b/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss index 5ec05910..b02e81a7 100644 --- a/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss +++ b/src/Analysim.Web/ClientApp/src/assets/styles/abstracts/_tokens.scss @@ -25,7 +25,8 @@ --c-border-on-dark-weak: rgba(255, 255, 255, 0.35); /* Semantic */ - --c-danger-600: #dc3545; + --c-danger-600: #b92433; + --c-danger-background: #d345541f; --c-success-600: #28a745; /* Links */ diff --git a/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss b/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss index f2cc6672..8ab99e59 100644 --- a/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss +++ b/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss @@ -56,6 +56,7 @@ border: var(--button-border-width) var(--button-border-style) var(--button-border-color) !important; color: var(--button-border-color) !important; + font-weight: 600; } .btn-outline-primary:hover:not(:disabled):not(.disabled), @@ -66,12 +67,25 @@ color: var(--button-font-color) !important; } +.btn-ghost-primary { + background: #fff !important; + color: var(--button-border-color) !important; + font-weight: 600; +} + +.btn-ghost-primary:hover:not(:disabled):not(.disabled), +.btn-ghost-primary:focus:not(:disabled):not(.disabled) { + background: var(--button-background-color) !important; + color: var(--button-font-color) !important; +} + /* Quiet danger button */ .btn-danger { - background: transparent !important; + background: #fff !important; border: var(--button-border-width) var(--button-border-style) var(--color-danger) !important; color: var(--color-danger) !important; + font-weight: 600; } /* Hover = full danger */ @@ -82,3 +96,15 @@ var(--color-danger) !important; color: var(--c-text-inverse) !important; } + +.btn-ghost-danger { + background: transparent !important; + color: var(--color-danger) !important; + font-weight: 600; +} + +.btn-ghost-danger:hover:not(:disabled):not(.disabled), +.btn-ghost-danger:focus:not(:disabled):not(.disabled) { + background: var(--color-danger) !important; + color: var(--c-text-inverse) !important; +} diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index f4d01fb6..a9304ed5 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -325,12 +325,402 @@ public async Task GetNotebookVersions([FromRoute] int notebookID) } } + /* + * Type : GET + * URL : /api/projects/getprojectcomments/projectId + * Description: Gets all comments for a project + */ + [HttpGet("[action]/{projectId}")] + public async Task GetProjectComments([FromRoute] int projectId) + { + if (projectId <= 0) + return BadRequest("Invalid project id."); + + var flatComments = await _dbContext.ProjectComments + .AsNoTracking() + .Where(p => p.ProjectID == projectId) + .OrderBy(c => c.CreatedAt) + .Select(c => new ProjectCommentVM + { + CommentID = c.CommentID, + UserID = c.UserID, + AuthorName = c.User.UserName, + ProjectID = c.ProjectID, + ParentCommentID = c.ParentCommentID, + Content = c.Content, + IsDeleted = c.IsDeleted, + IsPendingReview = c.IsPendingReview, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt, + + Replies = new List(), + + CommentLikes = c.CommentLikes + .Select(l => new ProjectCommentLikeVM + { + CommentID = l.CommentID, + UserID = l.UserID, + CreatedAt = l.CreatedAt + }).ToList(), + + CommentFlags = c.CommentFlags + .Select(f => new ProjectCommentFlagVM + { + CommentID = f.CommentID, + UserID = f.UserID, + CreatedAt = f.CreatedAt + }).ToList() + }).ToListAsync(); + + var commentLookup = flatComments.ToDictionary(c => c.CommentID); // create lookup (O(1) lookup speed) + var rootComments = new List(); // top level comments + + // sort comments into nest object + foreach (var comment in flatComments) + { + if(comment.ParentCommentID.HasValue && commentLookup.TryGetValue(comment.ParentCommentID.Value, out var parent)) + { + parent.Replies.Add(comment); + } + else + { + rootComments.Add(comment); + } + } + + return Ok(new + { + result = rootComments, + message = "Received Comments" + }); + } + + /* + * Type : GET + * URL : /api/projects/GetALlFlaggedComments + * Description: Gets all flagged comments + */ + [Authorize] + [HttpGet("[action]")] + public async Task GetAllFlaggedComments() + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Get Admins + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + + // Validate User is Admin + bool isAdmin = admins.Any(u => string.Equals(u, user.UserName, StringComparison.OrdinalIgnoreCase)); + if (!isAdmin) return Forbid(); + + // Get all flags with related data + var flaggedComments = await _dbContext.ProjectCommentFlags + .Include(f => f.ProjectComment) + .ThenInclude(c => c.User) + .Include(f => f.ProjectComment) + .ThenInclude(c => c.Project) + .ThenInclude(p => p.ProjectUsers) + .ThenInclude(pu => pu.User) + .Include(f => f.User) + .OrderBy(f => f.CommentID) + .ThenByDescending(f => f.CreatedAt) + .ToListAsync(); + var result = flaggedComments + .GroupBy(f => new + { + f.CommentID, + CommentOwnerUsername = f.ProjectComment.User.UserName, + CommentProjectName = f.ProjectComment.Project.Name, + CommentProjectOwner = f.ProjectComment.Project.ProjectUsers.FirstOrDefault(pu => pu.UserRole == "owner")?.User?.UserName + }) + .Select(g => new FlaggedCommentGroupVM + { + CommentID = g.Key.CommentID, + CommentOwnerUsername = g.Key.CommentOwnerUsername, + CommentProjectName = g.Key.CommentProjectName, + CommentProjectOwner = g.Key.CommentProjectOwner, + Flags = g.Select(f => new ProjectCommentFlagRowVM + { + FlagID = f.FlagID, + CommentContentSnapshot = f.CommentContentSnapshot, + FlaggedByUsername = f.User.UserName, + CreatedAt = f.CreatedAt + }).ToList() + }).ToList(); + return Ok(new + { + result, + message = "Received Flagged Comments" + }); + } #endregion #region POST REQUEST + + /* + * Type : POST + * URL : /api/project/postcomment + * Param : CreateProjectCommentVM + * Description: Post a new comment to a project + */ + [Authorize] + [HttpPost("[action]")] + public async Task PostComment([FromForm] CreateProjectCommentVM formdata) + { + // Validate VM + if (!ModelState.IsValid) return BadRequest(ModelState); + + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Validate Project + var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); + if (project == null) return NotFound(new { message = "Project Not Found" }); + + // Validate Content + if (string.IsNullOrWhiteSpace(formdata.Content)) + return BadRequest(new { message = "Content is required." }); + + var trimmedContent = formdata.Content.Trim(); + + if (trimmedContent.Length > 1000) + return BadRequest(new { message = "Maximum length for Content is 1000 characters." }); + + // Validate Parent Comment + ProjectComment? parentComment = null; + + if (formdata.ParentCommentID.HasValue) + { + parentComment = await _dbContext.ProjectComments + .SingleOrDefaultAsync(c => c.CommentID == formdata.ParentCommentID.Value); + + if (parentComment == null) + return NotFound(new { message = "Parent Comment Not Found." }); + + if (parentComment.ProjectID != formdata.ProjectID) + return BadRequest(new { message = "Parent comment does not belong to this project." }); + + if (parentComment.IsDeleted) + return BadRequest(new { message = "Cannot reply to a deleted comment." }); + + if (parentComment.IsPendingReview) + return BadRequest(new { message = "Cannot reply to a comment under review." }); + } + + // Create Comment + var newComment = new ProjectComment + { + UserID = userId, + User = user, + ProjectID = formdata.ProjectID, + Project = project, + ParentCommentID = formdata.ParentCommentID, + ParentComment = parentComment, + Content = trimmedContent, + IsDeleted = false, + IsPendingReview = false, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + + // Add Comment to DB + _dbContext.ProjectComments.Add(newComment); + await _dbContext.SaveChangesAsync(); + + // Return + return Ok(new + { + message = "Comment posted successfully.", + commentId = newComment.CommentID + }); + } + + /* + * Type : POST + * URL : /api/project/likecomment/{commentID} + * Param : {commentID} + * Description: Like a comment + */ + [Authorize] + [HttpPost("[action]/{commentID}")] + public async Task LikeComment([FromRoute] int commentID) + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Validate Comment + var commentExists = await _dbContext.ProjectComments + .AnyAsync(c => c.CommentID == commentID && !c.IsDeleted && !c.IsPendingReview); + + if (!commentExists) + return NotFound(new { message = "Comment not found." }); + + // Prevent duplicate likes + var alreadyLiked = await _dbContext.ProjectCommentLikes + .AnyAsync(l => l.CommentID == commentID && l.UserID == userId); + + if (alreadyLiked) + return BadRequest(new { message = "Comment already liked." }); + + // Create Like + var newLike = new ProjectCommentLike + { + CommentID = commentID, + UserID = userId, + CreatedAt = DateTime.UtcNow + }; + + // Add Like to DB + _dbContext.ProjectCommentLikes.Add(newLike); + await _dbContext.SaveChangesAsync(); + + // Return + return Ok(new { message = "Comment liked successfully."}); + } + + /* + * Type : POST + * URL : /api/project/reportcomment/{commentID} + * Param : {commentID} + * Description: report a comment + */ + [Authorize] + [HttpPost("[action]/{commentID}")] + public async Task ReportComment([FromRoute] int commentID) + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Validate Comment + var comment = await _dbContext.ProjectComments + .SingleOrDefaultAsync(c => c.CommentID == commentID && !c.IsDeleted); + + if (comment == null) + return NotFound(new { message = "Comment not found." }); + + // Prevent duplicate flags by user + var alreadyFlagged = await _dbContext.ProjectCommentFlags + .AnyAsync(f => f.CommentID == commentID && f.UserID == userId); + + if (alreadyFlagged) + return BadRequest(new { message = "Comment already flagged by user." }); + + // Create Flag + var newReport = new ProjectCommentFlag + { + CommentID = commentID, + UserID = userId, + CommentContentSnapshot = comment.Content, + CreatedAt = DateTime.UtcNow + }; + + _dbContext.ProjectCommentFlags.Add(newReport); + await _dbContext.SaveChangesAsync(); + + // Get number of times this comment has been flagged + var flagCount = await _dbContext.ProjectCommentFlags.Where(f => f.CommentID == commentID).CountAsync(); + + if(flagCount >= 3) + { + comment.IsPendingReview = true; + comment.UpdatedAt = DateTime.UtcNow; + } + + await _dbContext.SaveChangesAsync(); + + return Ok(new { message = "Comment reported successfully.", isPendingReview = flagCount >= 3}); + } + + /* + * Type : POST + * URL : /api/project/deletecommentandreports/{commentID} + * Param : {commentID} + * Description: set comment IsDeleted = true and deletes reports + */ + [Authorize] + [HttpPost("[action]/{commentID}")] + public async Task DeleteCommentAndReports([FromRoute] int commentID) + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Get Admins + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + + // Validate User is Admin + bool isAdmin = admins.Any(u => string.Equals(u, user.UserName, StringComparison.OrdinalIgnoreCase)); + if (!isAdmin) return Forbid(); + + // Validate Comment + var comment = await _dbContext.ProjectComments.SingleOrDefaultAsync(c => c.CommentID == commentID); + if (comment == null) return NotFound(new { message = "Comment Not Found" }); + + if(comment.IsDeleted) return BadRequest(new {message = "Cannot delete already deleted comment."}); + + // Find Reports + var reports = await _dbContext.ProjectCommentFlags + .Where(f => f.CommentID == commentID) + .ToListAsync(); + + if (!reports.Any()) + return NotFound(new { message = "No reports found for this comment." }); + + // Update Comment + comment.IsDeleted = true; + comment.UpdatedAt = DateTime.UtcNow; + + // Remove Reports + _dbContext.ProjectCommentFlags.RemoveRange(reports); + + await _dbContext.SaveChangesAsync(); + + return Ok(new { message = "Comment deleted and reports removed successfully."}); + } + /* * Type : POST * URL : /api/project/forkproject @@ -1628,6 +2018,106 @@ public async Task AddDatasetToNotebook([FromForm] int notebookID, #endregion #region PUT REQUEST + + /* + * Type : PUT + * URL : /api/project/updatecomment/{commentID} + * Param : {commentID}, UpdateProjectCommentVM + * Description: Update a comment + */ + [Authorize] + [HttpPut("[action]/{commentID}")] + public async Task UpdateComment([FromRoute] int commentID, [FromForm] UpdateProjectCommentVM formdata) + { + // Validate VM + if (!ModelState.IsValid) return BadRequest(ModelState); + + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Validate Content + if (string.IsNullOrWhiteSpace(formdata.Content)) + return BadRequest(new { message = "Content is required." }); + + var trimmedContent = formdata.Content.Trim(); + + if (trimmedContent.Length > 1000) + return BadRequest(new { message = "Maximum length for Content is 1000 characters." }); + + // Validate Comment + var comment = await _dbContext.ProjectComments.SingleOrDefaultAsync(c => c.CommentID == commentID); + if (comment == null) return NotFound(new { message = "Comment Not Found" }); + + if(comment.IsDeleted) return BadRequest(new {message = "Cannot edit deleted comments."}); + if(comment.IsPendingReview) return BadRequest(new {message = "Cannot edit comments under review."}); + if(comment.UserID != user.Id) return Unauthorized(new {message = "Edited comment does not belong to current user."}); + + // Update comment + comment.Content = trimmedContent; + comment.UpdatedAt = DateTime.UtcNow; + + // Update Comment in DB + await _dbContext.SaveChangesAsync(); + + // Return + return Ok(new { message = "Comment updated successfully."}); + } + + /* + * Type : PUT + * URL : /api/project/deletecomment/{commentID} + * Param : {commentID} + * Description: set comment IsDeleted = true + */ + [Authorize] + [HttpPut("[action]/{commentID}")] + public async Task DeleteComment([FromRoute] int commentID) + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Get Admins + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + + // Validate User is Admin + bool isAdmin = admins.Any(u => string.Equals(u, user.UserName, StringComparison.OrdinalIgnoreCase)); + + // Validate Comment + var comment = await _dbContext.ProjectComments.SingleOrDefaultAsync(c => c.CommentID == commentID); + if (comment == null) return NotFound(new { message = "Comment Not Found" }); + + if(comment.IsDeleted) return BadRequest(new {message = "Cannot delete already deleted comment."}); + if(comment.IsPendingReview) return BadRequest(new {message = "Cannot delete comment while under review."}); + + if (!isAdmin && comment.UserID != user.Id) return Unauthorized(new {message = "Comment does not belong to current user."}); + + // Update comment + comment.IsDeleted = true; + comment.UpdatedAt = DateTime.UtcNow; + + // Update Comment in DB + await _dbContext.SaveChangesAsync(); + + // Return + return Ok(new { message = "Comment deleted successfully."}); + } + /* * Type : PUT * URL : /api/project/updateproject/ @@ -1858,6 +2348,143 @@ public async Task RenameNotebook([FromForm] NotebookNameChangeVM #endregion #region DELETE REQUEST + + /* + * Type : DELETE + * URL : /api/project/likecomment/{commentID} + * Param : {commentID} + * Description: unlikes a comment + */ + [Authorize] + [HttpDelete("likecomment/{commentID}")] + public async Task UnlikeComment([FromRoute] int commentID) + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + // Find existing like + var like = await _dbContext.ProjectCommentLikes + .FirstOrDefaultAsync(l => l.CommentID == commentID && l.UserID == userId); + + if (like == null) return NotFound(new { message = "Like not found." }); + + + // Remove like + _dbContext.ProjectCommentLikes.Remove(like); + await _dbContext.SaveChangesAsync(); + + // Return + return Ok(new { message = "Comment unliked successfully."}); + } + + /* + * Type : DELETE + * URL : /api/project/removeCommentReport/{commentID} + * Param : {commentID} + * Description: removes a comment report / flag + */ + [Authorize] + [HttpDelete("[action]/{commentID}")] + public async Task RemoveCommentReport([FromRoute] int commentID) + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + // Validate Comment + var comment = await _dbContext.ProjectComments + .SingleOrDefaultAsync(c => c.CommentID == commentID && !c.IsDeleted); + + if (comment == null) + return NotFound(new { message = "Comment not found." }); + + // Find existing report + var report = await _dbContext.ProjectCommentFlags + .FirstOrDefaultAsync(f => f.CommentID == commentID && f.UserID == userId); + + if (report == null) return NotFound(new { message = "Report not found." }); + + // Remove report + _dbContext.ProjectCommentFlags.Remove(report); + await _dbContext.SaveChangesAsync(); + + // Get number of times this comment has been flagged + var flagCount = await _dbContext.ProjectCommentFlags.Where(f => f.CommentID == commentID).CountAsync(); + + if(flagCount < 3) + { + comment.IsPendingReview = false; + comment.UpdatedAt = DateTime.UtcNow; + } + + await _dbContext.SaveChangesAsync(); + + // Return + return Ok(new { message = "Comment report removed successfully.", isPendingReview = flagCount >= 3}); + } + + /* + * Type : DELETE + * URL : /api/project/RemoveAllCommentReports/{commentID} + * Param : {commentID} + * Description: removes all reports / flags for a comment + */ + [Authorize] + [HttpDelete("[action]/{commentID}")] + public async Task RemoveAllCommentReports([FromRoute] int commentID) + { + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if(string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new {message = "Invalid user identifier."}); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new {message = "User Not Found."}); + + // Get Admins + var admins = _configuration.GetSection("AdminUsers").Get>() ?? new List(); + + // Validate User is Admin + bool isAdmin = admins.Any(u => string.Equals(u, user.UserName, StringComparison.OrdinalIgnoreCase)); + if (!isAdmin) return Forbid(); + + // Validate Comment + var comment = await _dbContext.ProjectComments + .SingleOrDefaultAsync(c => c.CommentID == commentID); + + if (comment == null) + return NotFound(new { message = "Comment not found." }); + + // Find reports + var reports = await _dbContext.ProjectCommentFlags + .Where(f => f.CommentID == commentID) + .ToListAsync(); + + if (!reports.Any()) + return NotFound(new { message = "No reports found for this comment." }); + + // Remove reports + _dbContext.ProjectCommentFlags.RemoveRange(reports); + await _dbContext.SaveChangesAsync(); + + // Remove pending review + comment.IsPendingReview = false; + comment.UpdatedAt = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(); + + return Ok(new { message = "Comment reports removed successfully."}); + } + /* * Type : DELETE * URL : /api/project/deleteproject/ diff --git a/src/Analysim.Web/ViewModels/Project/CreateProjectCommentVM.cs b/src/Analysim.Web/ViewModels/Project/CreateProjectCommentVM.cs new file mode 100644 index 00000000..9d940c4d --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/CreateProjectCommentVM.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Analysim.Web.ViewModels.Project +{ + public class CreateProjectCommentVM + { + [Required(ErrorMessage = "Project ID is a required field.")] + public int ProjectID { get; set; } + + [Required(ErrorMessage = "Content is a required field.")] + [MaxLength(1000, ErrorMessage = "Maximum length for Content is 1000 characters.")] + public string Content { get; set; } = string.Empty; + + public int? ParentCommentID { get; set; } + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ViewModels/Project/ProjectCommentFlagVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectCommentFlagVM.cs new file mode 100644 index 00000000..50d6887f --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectCommentFlagVM.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace Analysim.Web.ViewModels.Project +{ + public class ProjectCommentFlagVM + { + public int FlagID { get; set; } + public int CommentID { get; set; } + public int UserID { get; set; } + public string CommentContentSnapshot { get; set; } + public DateTime CreatedAt { get; set; } + } + + public class FlaggedCommentGroupVM + { + public int CommentID { get; set; } + public string CommentOwnerUsername { get; set; } = null!; + public string CommentProjectName { get; set; } = null!; + public string CommentProjectOwner { get; set; } = null!; + public List Flags { get; set; } = new(); + } + + public class ProjectCommentFlagRowVM + { + public int FlagID { get; set; } + public string CommentContentSnapshot { get; set; } = null!; + public string FlaggedByUsername { get; set; } = null!; + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ViewModels/Project/ProjectCommentLikeVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectCommentLikeVM.cs new file mode 100644 index 00000000..da63bfbe --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectCommentLikeVM.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace Analysim.Web.ViewModels.Project +{ + public class ProjectCommentLikeVM + { + public int CommentID { get; set; } + public int UserID { get; set; } + public DateTime CreatedAt { get; set; } + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs new file mode 100644 index 00000000..dabbed11 --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace Analysim.Web.ViewModels.Project +{ + public class ProjectCommentVM + { + public int CommentID { get; set; } + public int UserID { get; set; } + public string AuthorName { get; set; } = string.Empty; + public int ProjectID { get; set; } + public int? ParentCommentID { get; set; } + public string Content { get; set; } = string.Empty; + public bool IsDeleted { get; set; } + public bool IsPendingReview { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Replies { get; set; } = new(); + public List? CommentLikes { get; set; } = new(); + public List? CommentFlags { get; set; } = new(); + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ViewModels/Project/UpdateProjectCommentVM.cs b/src/Analysim.Web/ViewModels/Project/UpdateProjectCommentVM.cs new file mode 100644 index 00000000..f7db9201 --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/UpdateProjectCommentVM.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Analysim.Web.ViewModels.Project +{ + public class UpdateProjectCommentVM + { + [Required(ErrorMessage = "Content is a required field.")] + [MaxLength(1000, ErrorMessage = "Maximum length for Content is 1000 characters.")] + public string Content { get; set; } = string.Empty; + } +} \ No newline at end of file