From 88af4d4fabac7beae77dd3dd5160639c1543fa60 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 28 Mar 2026 23:04:28 -0400 Subject: [PATCH 1/4] feat: Add project comments' database table with relationships --- src/Analysim.Core/Entities/Project.cs | 3 + src/Analysim.Core/Entities/ProjectComment.cs | 42 + src/Analysim.Core/Entities/User.cs | 3 + .../Data/ApplicationDbContext.cs | 35 +- .../ApplicationDbContextModelSnapshot.cs | 81 +- ...60319234816_AddProjectComments.Designer.cs | 841 ++++++++++++++++++ .../20260319234816_AddProjectComments.cs | 114 +++ 7 files changed, 1112 insertions(+), 7 deletions(-) create mode 100644 src/Analysim.Core/Entities/ProjectComment.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260319234816_AddProjectComments.cs 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..ab8f223d --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectComment.cs @@ -0,0 +1,42 @@ +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; } // ASK ABOUT THIS "Delete your own comments (but still leave the space for it)" + + // Timestamps + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + + // Replies to this comment + public ICollection Replies { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/src/Analysim.Core/Entities/User.cs b/src/Analysim.Core/Entities/User.cs index 801e98dd..5416765b 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -20,6 +20,9 @@ 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 string RegistrationSurvey {get; set;} diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 26d61bba..716e3982 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -107,6 +107,36 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(n=>n.observableNotebookDatasets) .HasForeignKey(d=>d.NotebookID) .OnDelete(DeleteBehavior.Cascade); + + // 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); + + // Indexes for common lookups + modelBuilder.Entity() + .HasIndex(pc => pc.ProjectID); + + modelBuilder.Entity() + .HasIndex(pc => pc.ParentCommentID); } @@ -122,9 +152,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet NotebookContent { get; set; } public DbSet BlobFileContent { get; set; } - - - - + public DbSet ProjectComments { get; set; } } } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index ee014be1..af670c31 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -242,6 +242,46 @@ 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("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") @@ -428,21 +468,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - ConcurrencyStamp = "ebd0169f-30be-4ab7-9fb9-038a9de20efb", + ConcurrencyStamp = "fa79cb69-0854-4116-be7e-0cc32666a38c", Name = "Admin", NormalizedName = "ADMIN" }, new { Id = 2, - ConcurrencyStamp = "371acb40-c941-4714-9c2c-bc9de9bff144", + ConcurrencyStamp = "c0e380b3-6959-457d-b623-72d23bd85ed1", Name = "Customer", NormalizedName = "CUSTOMER" }, new { Id = 3, - ConcurrencyStamp = "7ee39326-5034-4323-85cc-6da801861458", + ConcurrencyStamp = "6c80828f-17ef-44b7-8820-bcc50e82e8f3", Name = "Moderator", NormalizedName = "MODERATOR" }); @@ -612,6 +652,32 @@ 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.ProjectTag", b => { b.HasOne("Core.Entities.Project", "Project") @@ -738,11 +804,18 @@ 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("Replies"); + }); + modelBuilder.Entity("Core.Entities.Tag", b => { b.Navigation("ProjectTags"); @@ -756,6 +829,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); + } + } +} From bdac962312ddc245929a64e93e3af6c8ba60cbdc Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 28 Mar 2026 23:06:44 -0400 Subject: [PATCH 2/4] feat: Implement comment rendering within projects --- .../src/app/interfaces/project-comment.ts | 12 ++ .../project-comment-box.component.html | 17 +++ .../project-comment-box.component.scss | 28 ++++ .../project-comment-box.component.spec.ts | 23 ++++ .../project-comment-box.component.ts | 15 +++ .../project-comment-item.component.html | 82 ++++++++++++ .../project-comment-item.component.scss | 122 ++++++++++++++++++ .../project-comment-item.component.spec.ts | 23 ++++ .../project-comment-item.component.ts | 42 ++++++ .../project-comments.component.html | 67 +++------- .../project-comments.component.scss | 5 +- .../project-comments.component.ts | 32 ++++- .../projects/project/project.component.html | 2 +- .../src/app/projects/projects.module.ts | 4 + .../src/app/services/project.service.ts | 18 ++- .../styles/base/_bootstrap-overrides.scss | 26 ++++ .../Controllers/ProjectController.cs | 51 +++++++- .../ViewModels/Project/ProjectCommentVM.cs | 19 +++ 18 files changed, 530 insertions(+), 58 deletions(-) create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts create mode 100644 src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs 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..469e1f2c --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts @@ -0,0 +1,12 @@ +export interface ProjectComment { + commentID: number; + userID: number; + authorName: string; + projectID: number; + parentCommentID: number | null; + content: string; + isDeleted: boolean; + createdAt: string; + updatedAt: string; + replies: ProjectComment[]; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html new file mode 100644 index 00000000..027906f8 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html @@ -0,0 +1,17 @@ +
+ + +
+ + + +
+
\ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss new file mode 100644 index 00000000..27a5693d --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.scss @@ -0,0 +1,28 @@ +.comment-box { + border: 1px solid #ddd; + border-radius: 8px; + padding: 12px; + background: #fff; + + textarea { + width: 100%; + resize: none; + border-radius: 6px; + border: 1px solid #ccc; + padding: 8px; + font-size: 14px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + outline: none; + border-color: #4a90e2; + box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.15); + } + } +} + +.button-area { + display: flex; + gap: 6px; + justify-content: end; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts new file mode 100644 index 00000000..7ef1715a --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProjectCommentBoxComponent } from './project-comment-box.component'; + +describe('ProjectCommentBoxComponent', () => { + let component: ProjectCommentBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ProjectCommentBoxComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProjectCommentBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts new file mode 100644 index 00000000..030de842 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts @@ -0,0 +1,15 @@ +import { Component, OnInit } from '@angular/core'; + +@Component({ + selector: 'app-project-comment-box', + templateUrl: './project-comment-box.component.html', + styleUrls: ['./project-comment-box.component.scss'] +}) +export class ProjectCommentBoxComponent implements OnInit { + + constructor() { } + + ngOnInit(): void { + } + +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html new file mode 100644 index 00000000..b13ddc02 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html @@ -0,0 +1,82 @@ +
+ +
+
+ + {{ comment.authorName }} + + + + {{ comment.createdAt | date: "MMM d, y • h:mm a" }} + +
+
+ + +
+
+ + +
+ {{ comment.isDeleted ? "[deleted]" : comment.content }} +
+ + +
+
+ +
+ +
+ + + + + +
+
+ +
+
+ + +
+
+
diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss new file mode 100644 index 00000000..9e8e1a53 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss @@ -0,0 +1,122 @@ +.comment-item { + border: var(--border-w-1) var(--border-style) var(--border-color); + border-radius: var(--radius-1); + padding: var(--space-3); + background: var(--surface-0); + + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +// Header +.comment-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.comment-meta { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.comment-author { + font-weight: 600; + font-size: var(--fs-16); + color: var(--text-on-light); +} + +.comment-date { + font-size: var(--fs-12); + opacity: 0.7; +} + +// Body +.comment-body { + font-size: var(--fs-16); + line-height: 1.5; + word-break: break-word; + margin-left: 0.5rem; +} + +// Action Buttons +.actions { + display: flex; + justify-content: flex-end; +} + +.comment-actions { + margin-left: auto; + display: inline-flex; + align-items: stretch; +} + +.comment-actions .btn { + padding: var(--space-2) var(--space-2) !important; + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.actions-left{ + display: inline-flex; + align-items: stretch; + gap: var(--space-4); +} + +.upvote{ + padding: var(--space-1) var(--space-2) !important; +} + +.comment-actions .btn > * { + font-size: medium; +} + +// Open Thread Button +.thread-toggle { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.thread-toggle > * { + display: inline-block; + transition: transform 0.25s ease; + transform-origin: center; + font-size: small; +} + +.thread-toggle.open > * { + transform: rotate(90deg) !important; +} + +// Replies +.comment-replies { + max-height: 0; + opacity: 0; + overflow: hidden; + margin-left: 0.5rem; + padding-left: 1rem; + border-left: 2px solid #ddd; + margin-top: 0; + transition: + max-height 0.3s ease, + opacity 0.25s ease, + margin-top 0.3s ease; +} + +.comment-replies.open { + max-height: 1000px; + opacity: 1; + margin-top: 0.75rem; +} + +.comment-replies-inner { + display: flex; + flex-direction: column; + gap: 0.75rem; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts new file mode 100644 index 00000000..65561cd9 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ProjectCommentItemComponent } from './project-comment-item.component'; + +describe('ProjectCommentItemComponent', () => { + let component: ProjectCommentItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ProjectCommentItemComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ProjectCommentItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts new file mode 100644 index 00000000..bc1f5a9f --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts @@ -0,0 +1,42 @@ +import { Component, Input } from '@angular/core'; +import { Observable } from 'rxjs'; +import { ProjectComment } from 'src/app/interfaces/project-comment'; +import { User } from 'src/app/interfaces/user'; +import { AccountService } from 'src/app/services/account.service'; + +@Component({ + selector: 'app-project-comment-item', + templateUrl: './project-comment-item.component.html', + styleUrls: ['./project-comment-item.component.scss'] +}) +export class ProjectCommentItemComponent { + @Input() comment!: ProjectComment; + + isOpen = false; + currentUser$: Observable = null; + currentUser: User = null; + isOwner = false; + + constructor(private accountService: AccountService) {} + + async ngOnInit(): Promise { + this.currentUser$ = await this.accountService.currentUser; + + this.currentUser$.subscribe((user) => { + this.currentUser = user; + this.isOwner = !!user && user.id === this.comment.userID; + }); + } + + onReply(): void { + // opens comment box below comment being replied to + } + + onViewThread(): void { + this.isOpen = !this.isOpen; + } + + onReport(): void { + // notify admins of comment + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html index 1e9d6716..92a02ef7 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html @@ -1,56 +1,25 @@
- Comments -
- -
- -
-
+ Comments
-
    -
  • -
    - Tammie Smith - 02/23/2023 -
    - -
    -
    -
  • -
  • -
    - Oneal Smith - 02/23/2023 -
    - -
    -
    -
  • -
-
+
+
+ Loading comments... +
+ +
+ +
+ +
+ No comments yet. +
- - 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..78f834e7 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,8 @@ 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'; @NgModule({ declarations: [ @@ -104,6 +106,8 @@ import { SaveNotebookModalComponent } from './project-overview/project-overview- ModalDatasetsComponent, DatasetFolderViewComponent, SaveNotebookModalComponent, + ProjectCommentItemComponent, + ProjectCommentBoxComponent, ], 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..3646c042 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,7 @@ 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'; @Injectable({ providedIn: 'root' @@ -39,6 +40,7 @@ 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/"; // Post @@ -90,7 +92,7 @@ export class ProjectService { }), catchError(error => { console.log(error) - return throwError(error) + return throwError(error) }) ) } @@ -801,5 +803,19 @@ 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); + }) + ); + } + } 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..61f27235 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; 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..78cedeaa 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -325,8 +325,57 @@ 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, + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt, + Replies = new List() + }).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" + }); + } #endregion diff --git a/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs new file mode 100644 index 00000000..d7b66e1a --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs @@ -0,0 +1,19 @@ +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 DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public List Replies { get; set; } = new(); + } +} \ No newline at end of file From eeb599615605a8882c3a9fead5b6b307cc519854 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 9 Apr 2026 16:30:17 -0400 Subject: [PATCH 3/4] feat: Complete project comments - implement project comment functionality - add nested replies support - add comment likes/upvotes - add comment flag/report flow - add comment editing and soft delete behavior - add admin flagged-comments review endpoints/UI - add frontend components and API integration for comments - add confirmation modals for sensitive actions --- src/Analysim.Core/Entities/ProjectComment.cs | 4 +- .../Entities/ProjectCommentFlag.cs | 30 + .../Entities/ProjectCommentLike.cs | 23 + src/Analysim.Core/Entities/User.cs | 2 + .../Data/ApplicationDbContext.cs | 65 +- .../ApplicationDbContextModelSnapshot.cs | 100 +- ...203747_AddCommentLikesAndFlags.Designer.cs | 932 +++++++++++++++++ .../20260406203747_AddCommentLikesAndFlags.cs | 133 +++ ...4516_AddCommentSnapshotToFlags.Designer.cs | 935 ++++++++++++++++++ ...0260407004516_AddCommentSnapshotToFlags.cs | 67 ++ .../src/app/admin/admin-routing.module.ts | 2 + .../src/app/admin/admin.component.html | 3 + .../ClientApp/src/app/admin/admin.module.ts | 10 +- .../comments/comments.component.html | 13 + .../comments/comments.component.scss | 5 + .../comments/comments.component.spec.ts | 23 + .../components/comments/comments.component.ts | 35 + .../flagged-comment-item.component.html | 51 + .../flagged-comment-item.component.scss | 100 ++ .../flagged-comment-item.component.spec.ts | 23 + .../flagged-comment-item.component.ts | 77 ++ .../modal-delete-reports.component.html | 48 + .../modal-delete-reports.component.scss | 15 + .../modal-delete-reports.component.spec.ts | 23 + .../modal-delete-reports.component.ts | 45 + .../modal-ignore-reports.component.html | 48 + .../modal-ignore-reports.component.scss | 15 + .../modal-ignore-reports.component.spec.ts | 23 + .../modal-ignore-reports.component.ts | 43 + .../app/interfaces/project-comment-flag.ts | 20 + .../app/interfaces/project-comment-like.ts | 5 + .../src/app/interfaces/project-comment.ts | 5 + .../modal-delete-comment.component.html | 48 + .../modal-delete-comment.component.scss | 15 + .../modal-delete-comment.component.spec.ts | 23 + .../modal-delete-comment.component.ts | 45 + .../modal-report-comment.component.html | 61 ++ .../modal-report-comment.component.scss | 30 + .../modal-report-comment.component.spec.ts | 23 + .../modal-report-comment.component.ts | 64 ++ .../project-comment-box.component.html | 9 +- .../project-comment-box.component.ts | 49 +- .../project-comment-item.component.html | 182 +++- .../project-comment-item.component.scss | 34 +- .../project-comment-item.component.ts | 182 +++- .../project-comments.component.html | 11 +- .../project-comments.component.ts | 46 +- .../src/app/projects/projects.module.ts | 4 + .../src/app/services/project.service.ts | 196 +++- .../styles/base/_bootstrap-overrides.scss | 2 +- .../Controllers/ProjectController.cs | 525 +++++++++- .../Project/CreateProjectCommentVM.cs | 16 + .../Project/ProjectCommentFlagVM.cs | 29 + .../Project/ProjectCommentLikeVM.cs | 12 + .../ViewModels/Project/ProjectCommentVM.cs | 2 + .../Project/UpdateProjectCommentVM.cs | 11 + 56 files changed, 4461 insertions(+), 81 deletions(-) create mode 100644 src/Analysim.Core/Entities/ProjectCommentFlag.cs create mode 100644 src/Analysim.Core/Entities/ProjectCommentLike.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260406203747_AddCommentLikesAndFlags.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260407004516_AddCommentSnapshotToFlags.cs create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/comments.component.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-delete-reports/modal-delete-reports.component.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/admin/components/comments/modal-ignore-reports/modal-ignore-reports.component.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-like.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.spec.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.ts create mode 100644 src/Analysim.Web/ViewModels/Project/CreateProjectCommentVM.cs create mode 100644 src/Analysim.Web/ViewModels/Project/ProjectCommentFlagVM.cs create mode 100644 src/Analysim.Web/ViewModels/Project/ProjectCommentLikeVM.cs create mode 100644 src/Analysim.Web/ViewModels/Project/UpdateProjectCommentVM.cs diff --git a/src/Analysim.Core/Entities/ProjectComment.cs b/src/Analysim.Core/Entities/ProjectComment.cs index ab8f223d..5d175719 100644 --- a/src/Analysim.Core/Entities/ProjectComment.cs +++ b/src/Analysim.Core/Entities/ProjectComment.cs @@ -30,7 +30,7 @@ public class ProjectComment public string Content { get; set; } = string.Empty; // Soft Delete - public bool IsDeleted { get; set; } // ASK ABOUT THIS "Delete your own comments (but still leave the space for it)" + public bool IsDeleted { get; set; } // Timestamps public DateTime CreatedAt { get; set; } @@ -38,5 +38,7 @@ public class ProjectComment // 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 5416765b..53049acf 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -23,6 +23,8 @@ public class User : IdentityUser // 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 716e3982..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,19 +96,19 @@ 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); - // COMMENTS + #region Project Comments // One To Many Relationship (Project -> ProjectComment) modelBuilder.Entity() @@ -131,15 +131,60 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .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; } @@ -147,11 +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 af670c31..8f13043a 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -282,6 +282,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -468,21 +516,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - ConcurrencyStamp = "fa79cb69-0854-4116-be7e-0cc32666a38c", + ConcurrencyStamp = "52b20532-0a25-4849-a146-ad8c6e7070d5", Name = "Admin", NormalizedName = "ADMIN" }, new { Id = 2, - ConcurrencyStamp = "c0e380b3-6959-457d-b623-72d23bd85ed1", + ConcurrencyStamp = "ca163c30-c210-4244-a85a-2e6aafb5e8ff", Name = "Customer", NormalizedName = "CUSTOMER" }, new { Id = 3, - ConcurrencyStamp = "6c80828f-17ef-44b7-8820-bcc50e82e8f3", + ConcurrencyStamp = "d8bde866-b51e-4044-996f-0d7f727145ee", Name = "Moderator", NormalizedName = "MODERATOR" }); @@ -678,6 +726,44 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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") @@ -813,6 +899,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Core.Entities.ProjectComment", b => { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + b.Navigation("Replies"); }); @@ -825,6 +915,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) { b.Navigation("BlobFiles"); + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + b.Navigation("Followers"); b.Navigation("Following"); 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.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..640993e4 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.html @@ -0,0 +1,51 @@ +
+
+ Comment ID: {{ flaggedComment.commentID }} + {{ flaggedComment.commentOwnerUsername }} + {{ flags.length }} {{ flags.length == 1 ? "flag" : "flags"}} +
+ + +
+
+ +
+ Flag ID + Reported By + Date + Comment Snapshot +
+ +
+
+ {{ flag.flagID }} + {{ 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..646ab6c9 --- /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: 70px 140px 170px 1fr; + 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..d2443900 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/admin/components/comments/flagged-comment-item/flagged-comment-item.component.ts @@ -0,0 +1,77 @@ +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +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,) {} + + 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) + } +} 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/interfaces/project-comment-flag.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts new file mode 100644 index 00000000..44eb6730 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts @@ -0,0 +1,20 @@ +export interface ProjectCommentFlag { + flagID: number; + commentID: number; + userID: number; + commentContentSnapshot: string; + createdAt: string; +} + +export interface FlaggedCommentGroup { + commentID: number; + commentOwnerUsername: 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 index 469e1f2c..4055f954 100644 --- a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts @@ -1,3 +1,6 @@ +import { ProjectCommentFlag } from "./project-comment-flag"; +import { ProjectCommentLike } from "./project-comment-like"; + export interface ProjectComment { commentID: number; userID: number; @@ -9,4 +12,6 @@ export interface ProjectComment { 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/projects/project-comments/modal-delete-comment/modal-delete-comment.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.html new file mode 100644 index 00000000..f56de2e2 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.html @@ -0,0 +1,48 @@ + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.scss new file mode 100644 index 00000000..e4caf7ff --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.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/projects/project-comments/modal-delete-comment/modal-delete-comment.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.spec.ts new file mode 100644 index 00000000..21a0d42e --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModalDeleteCommentComponent } from './modal-delete-comment.component'; + +describe('ModalDeleteCommentComponent', () => { + let component: ModalDeleteCommentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ModalDeleteCommentComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModalDeleteCommentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.ts new file mode 100644 index 00000000..4371ecd6 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-delete-comment/modal-delete-comment.component.ts @@ -0,0 +1,45 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; +import { User } from 'src/app/interfaces/user'; +import { ProjectService } from 'src/app/services/project.service'; + +@Component({ + selector: 'app-modal-delete-comment', + templateUrl: './modal-delete-comment.component.html', + styleUrls: ['./modal-delete-comment.component.scss'] +}) +export class ModalDeleteCommentComponent implements OnInit { + @Input() deleteModalRef: BsModalRef + @Input() commentID: number; + @Input() currentUser: User; + + @Output() onSuccessfulDelete = new EventEmitter(); + @Output() onCancelDelete = new EventEmitter(); + + errorResult: String; + errorStatusAlert = false; + + constructor(private projectService: ProjectService) { } + + ngOnInit(): void {} + + onDeleteComment(): void { + this.projectService.deleteComment(this.commentID).subscribe({ + next: (result) => { + console.log('Deleted comment', this.commentID); + this.onSuccessfulDelete.emit(); + this.deleteModalRef.hide(); + }, + error: (error) => { + this.errorStatusAlert = true; + this.errorResult = "Error: unable to delete comment, please contact developers for assistance"; + console.log(error); + }, + }); + } + + closeModal() { + this.onCancelDelete.emit(); + this.deleteModalRef.hide(); + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.html new file mode 100644 index 00000000..18b70a5a --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.html @@ -0,0 +1,61 @@ + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.scss new file mode 100644 index 00000000..d7ecf204 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.scss @@ -0,0 +1,30 @@ +.modal-confirm{ + text-align: center; + font-weight: 700; + font-size: large; + padding: 0; + padding-bottom: 1rem; + margin: 0; +} + +.modal-comment-title{ + font-size: medium; + padding: 0; + padding-bottom: .25rem; + margin: 0; +} + +.modal-comment-content{ + font-size: small; + padding: .5rem; + border: var(--border-w-1) var(--border-style) var(--border-color); + border-radius: var(--border-radius); +} + +.modal-revoke-text{ + text-align: center; + font-size: medium; + padding: 0; + padding-bottom: 1rem; + margin: 0; +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.spec.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.spec.ts new file mode 100644 index 00000000..6819b54e --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ModalReportCommentComponent } from './modal-report-comment.component'; + +describe('ModalReportCommentComponent', () => { + let component: ModalReportCommentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ ModalReportCommentComponent ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(ModalReportCommentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.ts new file mode 100644 index 00000000..65c9ee88 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/modal-report-comment/modal-report-comment.component.ts @@ -0,0 +1,64 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { User } from 'src/app/interfaces/user'; +import { ProjectService } from 'src/app/services/project.service'; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; + +@Component({ + selector: 'app-modal-report-comment', + templateUrl: './modal-report-comment.component.html', + styleUrls: ['./modal-report-comment.component.scss'], +}) +export class ModalReportCommentComponent implements OnInit { + @Input() flagModalRef: BsModalRef + @Input() commentID: number; + @Input() commentContentSnapshot: string; + @Input() currentUser: User; + @Input() isCurrentlyFlagged: boolean; + + @Output() onSuccessfulFlag = new EventEmitter(); + @Output() onSuccessfulRemove = new EventEmitter(); + @Output() onCancelFlag = new EventEmitter(); + + errorResult: String; + errorStatusAlert = false; + + constructor(private projectService: ProjectService) { } + + ngOnInit(): void {} + + onFlagComment(): void { + this.projectService.reportComment(this.commentID).subscribe({ + next: (result) => { + console.log('Reported comment', this.commentID); + this.onSuccessfulFlag.emit(); + this.flagModalRef.hide(); + }, + error: (error) => { + this.errorStatusAlert = true; + this.errorResult = "Error: unable to report comment, please contact developers for assistance"; + console.log(error); + }, + }); + } + + onRemoveFlagComment(): void { + this.projectService.removeCommentReport(this.commentID).subscribe({ + next: (result) => { + console.log('Comment Report Removed', this.commentID); + this.onSuccessfulRemove.emit(); + this.flagModalRef.hide(); + }, + error: (error) => { + this.errorStatusAlert = true; + this.errorResult = "Error: unable to remove comment report, please contact developers for assistance"; + console.log(error); + }, + }); + } + + closeModal() { + this.onCancelFlag.emit(); + this.flagModalRef.hide(); + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html index 027906f8..93102cfa 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.html @@ -1,17 +1,18 @@
- -
\ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts index 030de842..c1504082 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-box/project-comment-box.component.ts @@ -1,4 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, Input, OnInit } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { EventEmitter, Output } from '@angular/core'; @Component({ selector: 'app-project-comment-box', @@ -6,10 +8,55 @@ import { Component, OnInit } from '@angular/core'; styleUrls: ['./project-comment-box.component.scss'] }) export class ProjectCommentBoxComponent implements OnInit { + @Input() parentId?: number; + @Input() parentName?: string; + @Input() editingContent?: string; + + @Output() submitComment? = new EventEmitter(); + @Output() cancelReply = new EventEmitter(); + @Output() submitEdit? = new EventEmitter(); + @Output() cancelEdit? = new EventEmitter(); + + placeholder = "Write a comment..."; + content = ""; + isEditing = false; constructor() { } ngOnInit(): void { + if(this.parentId && this.parentName) { + this.placeholder = `Replying to ${this.parentName}...` + } + if(this.editingContent) { + this.content = this.editingContent; + this.isEditing = true; + } + } + + onClear(): void { + if(this.isEditing){ + this.cancelEdit.emit(); + return; + } + + if(this.parentId){ + this.cancelReply.emit(); + } else { + this.content = ""; + } + } + + onPost(): void { + const trimmed = this.content.trim(); + if (!trimmed) return; + + if(this.isEditing) { + this.submitEdit.emit(trimmed); + return; + } + + this.submitComment.emit(trimmed); + this.onClear(); } } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html index b13ddc02..9cfe37f1 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.html @@ -2,81 +2,187 @@
+ {{ comment.authorName }} + {{ comment.createdAt | date: "MMM d, y • h:mm a" }}
-
- - -
-
- - -
- {{ comment.isDeleted ? "[deleted]" : comment.content }} -
- -
-
+ +
+ -
-
- - + +
+ +
+ +
+ {{ isDeleted ? "[deleted]" : comment.content }} +
+ + +
+ +
+ +
+ + +
+ + + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+ +
+ + + + + + + + + + + + + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss index 9e8e1a53..2ea1ad1f 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.scss @@ -45,7 +45,7 @@ // Action Buttons .actions { display: flex; - justify-content: flex-end; + justify-content: space-between; } .comment-actions { @@ -69,7 +69,36 @@ } .upvote{ - padding: var(--space-1) var(--space-2) !important; + padding: 0 var(--space-2) !important; + display: flex; + gap: var(--space-2); +} + +.icon-btn { + border-radius: var(--button-border-radius) !important; + padding: var(--button-padding) !important; + transition: var(--motion-nav); + border: none; + align-items: center; + + /* Disabled state */ + &:disabled, + &.disabled { + cursor: not-allowed; + opacity: var(--opacity-disabled); + } +} + +.icon-btn-ghost { + background: #fff !important; + color: var(--button-border-color) !important; + font-weight: 600; +} + +.icon-btn-ghost-danger { + background: transparent !important; + color: var(--color-danger) !important; + font-weight: 600; } .comment-actions .btn > * { @@ -88,6 +117,7 @@ transition: transform 0.25s ease; transform-origin: center; font-size: small; + padding: var(--space-1) 0 !important; } .thread-toggle.open > * { diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts index bc1f5a9f..8f278b93 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comment-item/project-comment-item.component.ts @@ -1,8 +1,11 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild, } from '@angular/core'; +import { Router } from '@angular/router'; +import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; import { Observable } from 'rxjs'; import { ProjectComment } from 'src/app/interfaces/project-comment'; import { User } from 'src/app/interfaces/user'; import { AccountService } from 'src/app/services/account.service'; +import { ProjectService } from 'src/app/services/project.service'; @Component({ selector: 'app-project-comment-item', @@ -10,14 +13,51 @@ import { AccountService } from 'src/app/services/account.service'; styleUrls: ['./project-comment-item.component.scss'] }) export class ProjectCommentItemComponent { + // Inputs @Input() comment!: ProjectComment; - - isOpen = false; + @Input() refreshReply: void; + + // Outputs + @Output() submitReply = new EventEmitter<{content: string, parentCommentId: number}>(); + @Output() submitEdit = new EventEmitter<{content: string, commentId: number}>(); + + // Modals + @ViewChild('flagModal') flagModal: TemplateRef + @ViewChild('deleteModal') deleteModal: TemplateRef + + // Modal Refs + flagModalRef: BsModalRef; + deleteModalRef: BsModalRef; + + // Current User currentUser$: Observable = null; currentUser: User = null; isOwner = false; - constructor(private accountService: AccountService) {} + // Comment State + isOpen = false; + isReplying = false; + isEditing = false; + + // Comment Likes + isLikedByCurrentUser = false; + numLikes = 0; + isLiking = false; + + // Comment Delete Handling + isDeleting = false; + isDeleted = false; + + // Comment Report Handling + isFlaggedByCurrentUser = false; + isFlagging = false; + + constructor( + private accountService: AccountService, + private projectService: ProjectService, + private modalService: BsModalService, + private router: Router, + ) {} async ngOnInit(): Promise { this.currentUser$ = await this.accountService.currentUser; @@ -25,18 +65,144 @@ export class ProjectCommentItemComponent { this.currentUser$.subscribe((user) => { this.currentUser = user; this.isOwner = !!user && user.id === this.comment.userID; + + this.isLikedByCurrentUser = + this.comment.commentLikes?.some((like) => like.userID === user?.id) ?? false; + + this.isFlaggedByCurrentUser = + this.comment.commentFlags?.some((flag) => flag.userID === user?.id) ?? false; }); - } - onReply(): void { - // opens comment box below comment being replied to + this.numLikes = this.comment.commentLikes.length; + this.isDeleted = this.comment.isDeleted; } + // Threads / Replies + onViewThread(): void { this.isOpen = !this.isOpen; } + // opens comment box below comment being replied to + onOpenReply(): void { + if (!this.accountService.checkLoginStatus()) { + this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } }); + return; + } + + this.isReplying = !this.isReplying; + if(this.isReplying){ + this.isOpen = true; + } + } + + // Submits reply to parent / handles nested replied based on reply shape + onHandleReply(reply: string | {content: string, parentCommentId: number}): void { + if (typeof reply === 'string') { + this.submitReply.emit({ + content: reply, + parentCommentId: this.comment.commentID + }); + } else { + this.submitReply.emit(reply); + } + } + + // Like comment + + onUpvote(): void { + if (!this.accountService.checkLoginStatus()) { + this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } }); + return; + } + + if(this.isOwner) return; + this.isLiking = true; + const comId = this.comment.commentID; + + if(this.isLikedByCurrentUser){ + this.projectService.unlikeComment(comId).subscribe({ + next: (result) => { + this.numLikes--; + this.isLiking = false; + this.isLikedByCurrentUser = false; + }, + error: (error) => { + console.log('Failed to unlike comment', error); + this.isLiking = false; + }, + }); + } else { + this.projectService.likeComment(comId).subscribe({ + next: (result) => { + this.numLikes++; + this.isLiking = false; + this.isLikedByCurrentUser = true; + }, + error: (error) => { + console.log('Failed to like comment', error); + this.isLiking = false; + }, + }); + } + } + + // Edit + + onEditMyComment(): void { + if(this.comment.isDeleted) return; + this.isEditing = !this.isEditing; + } + + onHandleEdit(edit: string){ + this.submitEdit.emit({content: edit, commentId: this.comment.commentID}); + } + + onHandleNestedEdit(edit: { content: string; commentId: number }): void { + this.submitEdit.emit(edit); + } + + // Delete + + onDeleteMyComment(): void { + if(this.comment.isDeleted) return; + this.toggleModalDelete(); + this.isDeleting = true; + } + + onHandleSuccessfulDelete(): void { + this.isDeleted = true; + this.isDeleting = false; + } + + toggleModalDelete() { + this.deleteModalRef = this.modalService.show(this.deleteModal) + } + + // Report + onReport(): void { - // notify admins of comment + if (!this.accountService.checkLoginStatus()) { + this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } }); + return; + } + + this.toggleModalFlag(); + this.isFlagging = true; + } + + onHandleSuccessfulFlag(): void { + this.isFlaggedByCurrentUser = true; + this.isFlagging = false; + } + + onHandleSuccessfulRemove(): void { + this.isFlaggedByCurrentUser = false; + this.isFlagging = false; } + + toggleModalFlag() { + this.flagModalRef = this.modalService.show(this.flagModal) + } + } \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html index 92a02ef7..6f322aa9 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.html @@ -3,12 +3,8 @@ Comments
-
- Loading comments... -
-
- +
@@ -18,7 +14,10 @@
+ [comment]="comment" + (submitReply)="onPostComment($event)" + (submitEdit)="onPutEdit($event)" + >
diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.ts index 645b86e2..2876f47a 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-comments/project-comments.component.ts @@ -6,11 +6,11 @@ import { ProjectCommentBoxComponent } from './project-comment-box/project-commen @Component({ selector: 'app-project-comments', templateUrl: './project-comments.component.html', - styleUrls: ['./project-comments.component.scss'] + styleUrls: ['./project-comments.component.scss'], }) export class ProjectCommentsComponent implements OnInit { @Input() projectId!: number; - + comments: ProjectComment[] = []; isLoading: boolean = false; @@ -33,11 +33,47 @@ export class ProjectCommentsComponent implements OnInit { error: (error) => { console.log('Failed to load comments', error); this.isLoading = false; - } + }, }); } - onAddComment(): void { - // Open comment box + onPostComment( + content: string | { content: string; parentCommentId: number }, + ): void { + var comment: string = ''; + var parentId: number | null = null; + + if (typeof content === 'string') { + comment = content; + } else { + comment = content.content; + parentId = content.parentCommentId; + } + + this.projectService + .postComment(this.projectId, comment, parentId) + .subscribe({ + next: (result) => { + console.log('Posted new comment', result.commentId); + this.loadComments(); + }, + error: (error) => { + console.log('Failed to post comment', error); + }, + }); + } + + onPutEdit(content: { content: string; commentId: number }): void { + this.projectService + .updateComment(content.commentId, content.content) + .subscribe({ + next: (result) => { + console.log('Updated comment', content.commentId); + this.loadComments(); + }, + error: (error) => { + console.log('Failed to update comment', error); + }, + }); } } 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 78f834e7..5cb2a282 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts @@ -58,6 +58,8 @@ import { DatasetFolderViewComponent } from './project-overview/project-overview- 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: [ @@ -108,6 +110,8 @@ import { ProjectCommentBoxComponent } from './project-comments/project-comment-b 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 3646c042..510c7079 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts @@ -15,6 +15,7 @@ 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' @@ -41,7 +42,7 @@ export class ProjectService { 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" @@ -53,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/" @@ -60,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/" @@ -817,5 +827,189 @@ export class ProjectService { ); } + 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/base/_bootstrap-overrides.scss b/src/Analysim.Web/ClientApp/src/assets/styles/base/_bootstrap-overrides.scss index 61f27235..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 @@ -81,7 +81,7 @@ /* 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; diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index 78cedeaa..1e702aa1 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -351,7 +351,24 @@ public async Task GetProjectComments([FromRoute] int projectId) IsDeleted = c.IsDeleted, CreatedAt = c.CreatedAt, UpdatedAt = c.UpdatedAt, - Replies = new List() + + 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) @@ -377,9 +394,309 @@ public async Task GetProjectComments([FromRoute] int projectId) }); } + /* + * 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.User) + .OrderBy(f => f.CommentID) + .ThenByDescending(f => f.CreatedAt) + .ToListAsync(); + + var result = flaggedComments + .GroupBy(f => new + { + f.CommentID, + CommentOwnerUsername = f.ProjectComment.User.UserName + }) + .Select(g => new FlaggedCommentGroupVM + { + CommentID = g.Key.CommentID, + CommentOwnerUsername = g.Key.CommentOwnerUsername, + 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." }); + } + + // Create Comment + var newComment = new ProjectComment + { + UserID = userId, + User = user, + ProjectID = formdata.ProjectID, + Project = project, + ParentCommentID = formdata.ParentCommentID, + ParentComment = parentComment, + Content = trimmedContent, + IsDeleted = 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); + + 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(); + + return Ok(new { message = "Comment reported successfully."}); + } + + /* + * 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 @@ -1677,6 +1994,104 @@ 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.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 (!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/ @@ -1907,6 +2322,114 @@ 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." }); + } + + // 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(); + + // Return + return Ok(new { message = "Comment report removed successfully."}); + } + + /* + * 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(); + + // 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(); + + 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..eb96e0b9 --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectCommentFlagVM.cs @@ -0,0 +1,29 @@ +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 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 index d7b66e1a..87a561d4 100644 --- a/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs +++ b/src/Analysim.Web/ViewModels/Project/ProjectCommentVM.cs @@ -15,5 +15,7 @@ public class ProjectCommentVM 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 From f9faf9880931c5069fe7b3baad3ad0021aadd987 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 16 Apr 2026 19:38:06 -0400 Subject: [PATCH 4/4] feat: Add project comment improvements Comment Box: - Removed root comment box when user is not logged in - Added error display and character limit check to comment box Admin Panel (Comments) - Removed flag ID / Comment ID from flagged comments list - Added project name to flagged comments list - Implemented link to view comment to flagged comments list - Admin is routed to page and automatically scrolled to comment - Comments that are nested ping their parents to open Pending Reviews - Implemented logic to block comment body if reported by 3 or more people - Added IsPendingReview boolean in ProjectComment entity / table (with migrations) - Updated Report/Remove-Report Comment endpoints to return IsPendingReview status - Allows comment to render itself without reloading. Minor UI tweaks --- src/Analysim.Core/Entities/ProjectComment.cs | 3 + .../ApplicationDbContextModelSnapshot.cs | 9 +- ...905_AddPendingReviewToComments.Designer.cs | 938 ++++++++++++++++++ ...260416212905_AddPendingReviewToComments.cs | 68 ++ .../flagged-comment-item.component.html | 11 +- .../flagged-comment-item.component.scss | 2 +- .../flagged-comment-item.component.ts | 9 +- .../ClientApp/src/app/app-routing.module.ts | 4 +- .../app/interfaces/project-comment-flag.ts | 2 + .../src/app/interfaces/project-comment.ts | 1 + .../src/app/navbar/navbar.component.html | 6 +- .../modal-report-comment.component.ts | 10 +- .../project-comment-box.component.html | 4 + .../project-comment-box.component.scss | 13 + .../project-comment-box.component.ts | 9 +- .../project-comment-item.component.html | 17 +- .../project-comment-item.component.ts | 31 +- .../project-comments.component.html | 17 +- .../project-comments.component.scss | 9 + .../project-comments.component.ts | 37 +- .../src/assets/styles/abstracts/_tokens.scss | 3 +- .../Controllers/ProjectController.cs | 65 +- .../Project/ProjectCommentFlagVM.cs | 2 + .../ViewModels/Project/ProjectCommentVM.cs | 1 + 24 files changed, 1226 insertions(+), 45 deletions(-) create mode 100644 src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260416212905_AddPendingReviewToComments.cs diff --git a/src/Analysim.Core/Entities/ProjectComment.cs b/src/Analysim.Core/Entities/ProjectComment.cs index 5d175719..898deb0b 100644 --- a/src/Analysim.Core/Entities/ProjectComment.cs +++ b/src/Analysim.Core/Entities/ProjectComment.cs @@ -32,6 +32,9 @@ public class ProjectComment // 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; } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 8f13043a..b9654607 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -259,6 +259,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsDeleted") .HasColumnType("boolean"); + b.Property("IsPendingReview") + .HasColumnType("boolean"); + b.Property("ParentCommentID") .HasColumnType("integer"); @@ -516,21 +519,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - ConcurrencyStamp = "52b20532-0a25-4849-a146-ad8c6e7070d5", + ConcurrencyStamp = "45fc9c92-801d-433f-ab00-280c2334bc89", Name = "Admin", NormalizedName = "ADMIN" }, new { Id = 2, - ConcurrencyStamp = "ca163c30-c210-4244-a85a-2e6aafb5e8ff", + ConcurrencyStamp = "922f6db8-d335-4aa4-9331-12097412cf5e", Name = "Customer", NormalizedName = "CUSTOMER" }, new { Id = 3, - ConcurrencyStamp = "d8bde866-b51e-4044-996f-0d7f727145ee", + ConcurrencyStamp = "cecf34c5-c9e0-4b99-9fb7-1441045209d8", Name = "Moderator", NormalizedName = "MODERATOR" }); 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/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 index 640993e4..cf710152 100644 --- 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 @@ -1,16 +1,16 @@
- Comment ID: {{ flaggedComment.commentID }} - {{ flaggedComment.commentOwnerUsername }} + {{ flaggedComment.commentOwnerUsername }} + {{ flaggedComment.commentProjectName }} - {{flaggedComment.commentProjectOwner}} {{ flags.length }} {{ flags.length == 1 ? "flag" : "flags"}}
- - + + +
- Flag ID Reported By Date Comment Snapshot @@ -18,7 +18,6 @@
- {{ flag.flagID }} {{ flag.flaggedByUsername }} {{ flag.createdAt | date:'MMM d, y • h:mm a' }} {{ flag.commentContentSnapshot }} 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 index 646ab6c9..718cebab 100644 --- 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 @@ -38,7 +38,7 @@ .flagged-comment-table__head, .flagged-comment-table__row { display: grid; - grid-template-columns: 70px 140px 170px 1fr; + grid-template-columns: .25fr .5fr 3fr; gap: var(--space-3); align-items: start; padding: var(--space-2) var(--space-3); 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 index d2443900..b0fa4982 100644 --- 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 @@ -7,6 +7,7 @@ import { TemplateRef, ViewChild, } from '@angular/core'; +import { Router } from '@angular/router'; import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; import { FlaggedCommentGroup, @@ -36,7 +37,7 @@ export class FlaggedCommentItemComponent implements OnInit { isDeleting: boolean; isIgnoring: boolean; - constructor(private modalService: BsModalService,) {} + constructor(private modalService: BsModalService, private router: Router,) {} ngOnInit(): void { this.flags = this.flaggedComment.flags; @@ -74,4 +75,10 @@ export class FlaggedCommentItemComponent implements OnInit { 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/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 index 44eb6730..272244b4 100644 --- a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment-flag.ts @@ -9,6 +9,8 @@ export interface ProjectCommentFlag { export interface FlaggedCommentGroup { commentID: number; commentOwnerUsername: string; + commentProjectName: string; + commentProjectOwner: string; flags: ProjectCommentFlagRow[]; } diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts index 4055f954..a89f78f4 100644 --- a/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-comment.ts @@ -9,6 +9,7 @@ export interface ProjectComment { parentCommentID: number | null; content: string; isDeleted: boolean; + isPendingReview: boolean; createdAt: string; updatedAt: string; replies: ProjectComment[]; 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 @@ -