From f401c85539a7e7410cc1aac2595e8fe3e4647965 Mon Sep 17 00:00:00 2001 From: Mohab Date: Mon, 7 Sep 2026 12:54:56 +0300 Subject: [PATCH] add project recommendations --- src/Analysim.Core/Entities/Project.cs | 2 +- .../Entities/ProjectRecommendation.cs | 29 + src/Analysim.Core/Entities/User.cs | 1 + .../Data/ApplicationDbContext.cs | 18 + ...1118_AddProjectRecommendations.Designer.cs | 1246 +++++++++++++++++ ...0260906231118_AddProjectRecommendations.cs | 63 + .../ApplicationDbContextModelSnapshot.cs | 58 + .../app/interfaces/project-recommendation.ts | 9 + .../ClientApp/src/app/interfaces/project.ts | 4 +- .../modal-recommend.component.html | 67 + .../modal-recommend.component.scss | 26 + .../modal-recommend.component.ts | 112 ++ .../project-stat-counter.component.html | 24 +- .../project-stat-counter.component.ts | 19 + .../projects/project/project.component.html | 24 +- .../app/projects/project/project.component.ts | 45 + .../src/app/projects/projects.module.ts | 2 + .../src/app/services/project.service.ts | 61 + .../Controllers/ProjectController.cs | 222 ++- .../Project/CreateProjectRecommendationVM.cs | 11 + .../Project/ProjectRecommendationVM.cs | 15 + 21 files changed, 2049 insertions(+), 9 deletions(-) create mode 100644 src/Analysim.Core/Entities/ProjectRecommendation.cs create mode 100644 src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.Designer.cs create mode 100644 src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.cs create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/project-recommendation.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.ts create mode 100644 src/Analysim.Web/ViewModels/Project/CreateProjectRecommendationVM.cs create mode 100644 src/Analysim.Web/ViewModels/Project/ProjectRecommendationVM.cs diff --git a/src/Analysim.Core/Entities/Project.cs b/src/Analysim.Core/Entities/Project.cs index 06013a5d..9bfa9fab 100644 --- a/src/Analysim.Core/Entities/Project.cs +++ b/src/Analysim.Core/Entities/Project.cs @@ -45,8 +45,8 @@ public class Project // Logs public ICollection ProjectLogs { get; set; } = new List(); public ICollection Publications { get; set; } = new List(); + public ICollection ProjectRecommendations { get; set; } = new List(); public int ForkedFromProjectID { get; set; } } } - diff --git a/src/Analysim.Core/Entities/ProjectRecommendation.cs b/src/Analysim.Core/Entities/ProjectRecommendation.cs new file mode 100644 index 00000000..e681038c --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectRecommendation.cs @@ -0,0 +1,29 @@ +#nullable enable + +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class ProjectRecommendation + { + [KeyAttribute] + public int ProjectRecommendationID { get; set; } + + [ForeignKey("Project")] + public int ProjectID { get; set; } + public Project Project { get; set; } = null!; + + [ForeignKey("User")] + public int UserID { get; set; } + public User User { get; set; } = null!; + + [Required(ErrorMessage = "Recommendation comment is a required field.")] + [MaxLength(1000, ErrorMessage = "Maximum length for Recommendation Comment is 1000 characters.")] + public string Comment { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +} diff --git a/src/Analysim.Core/Entities/User.cs b/src/Analysim.Core/Entities/User.cs index fde6c0f2..35430c9c 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -29,6 +29,7 @@ public class User : IdentityUser // Logs public ICollection ProjectLogs { get; set; } = new List(); + public ICollection ProjectRecommendations { get; set; } = new List(); public ICollection RefreshTokens { get; set; } = new List(); diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 79751278..f796afee 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -258,6 +258,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(pp => pp.Project) .HasForeignKey(pp => pp.ProjectID) .OnDelete(DeleteBehavior.Cascade); + + // One recommendation per user per project + modelBuilder.Entity() + .HasIndex(pr => new { pr.UserID, pr.ProjectID }) + .IsUnique(); + + modelBuilder.Entity() + .HasMany(p => p.ProjectRecommendations) + .WithOne(pr => pr.Project) + .HasForeignKey(pr => pr.ProjectID) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(u => u.ProjectRecommendations) + .WithOne(pr => pr.User) + .HasForeignKey(pr => pr.UserID) + .OnDelete(DeleteBehavior.Cascade); } @@ -279,5 +296,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ProjectLogs { get; set; } public DbSet Publications { get; set; } + public DbSet ProjectRecommendations { get; set; } } } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.Designer.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.Designer.cs new file mode 100644 index 00000000..ab46dcff --- /dev/null +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.Designer.cs @@ -0,0 +1,1246 @@ +// +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("20260906231118_AddProjectRecommendations")] + partial class AddProjectRecommendations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .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.HasIndex("Route") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .IsRequired() + .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("ProjectLogID") + .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("ProjectLogID"); + + 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.ProjectLog", b => + { + b.Property("LogID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LogID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ReferencedNotebookID") + .HasColumnType("integer"); + + b.Property("ReferencedNotebookVersion") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("LogID"); + + b.HasIndex("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ReferencedNotebookID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectLogs"); + }); + + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => + { + b.Property("ProjectRecommendationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectRecommendationID")); + + b.Property("Comment") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("ProjectRecommendationID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID", "ProjectID") + .IsUnique(); + + b.ToTable("ProjectRecommendations"); + }); + + 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.Publication", b => + { + b.Property("PublicationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PublicationID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Doi") + .HasColumnType("text"); + + b.Property("Issue") + .HasColumnType("text"); + + b.Property("Journal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Pages") + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("SourceAuthor") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("Volume") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("PublicationID"); + + b.HasIndex("ProjectID"); + + b.ToTable("Publications"); + }); + + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.Property("RefreshTokenID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RefreshTokenID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByIp") + .HasColumnType("text"); + + b.Property("RevokedReason") + .HasColumnType("text"); + + b.Property("TokenFamilyID") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("RefreshTokenID"); + + b.HasIndex("TokenFamilyID"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserID"); + + b.ToTable("RefreshTokens"); + }); + + 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.HasIndex("Name") + .IsUnique(); + + 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("ReceiveCommentReplyEmails") + .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 = "d7c6278e-3f78-4db6-8832-aa41ea9fd62c", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "7383e31e-a884-4cd6-82f7-a6287b4e30a5", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "398ef1f2-6b82-45f2-9601-ed2129f197e9", + 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.ProjectLog", "ProjectLog") + .WithMany("Comments") + .HasForeignKey("ProjectLogID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("ProjectLog"); + + 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.ProjectLog", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany() + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectLogs") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Notebook", "ReferencedNotebook") + .WithMany() + .HasForeignKey("ReferencedNotebookID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectLogs") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BlobFile"); + + b.Navigation("Project"); + + b.Navigation("ReferencedNotebook"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectRecommendations") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectRecommendations") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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.Publication", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Publications") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("ProjectLogs"); + + b.Navigation("ProjectRecommendations"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + + b.Navigation("Publications"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.ProjectLog", b => + { + b.Navigation("Comments"); + }); + + 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("ProjectLogs"); + + b.Navigation("ProjectRecommendations"); + + b.Navigation("ProjectUsers"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.cs new file mode 100644 index 00000000..e200916a --- /dev/null +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260906231118_AddProjectRecommendations.cs @@ -0,0 +1,63 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddProjectRecommendations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProjectRecommendations", + columns: table => new + { + ProjectRecommendationID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", Npgsql.EntityFrameworkCore.PostgreSQL.Metadata.NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProjectID = table.Column(type: "integer", nullable: false), + UserID = table.Column(type: "integer", nullable: false), + Comment = table.Column(type: "character varying(1000)", maxLength: 1000, 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_ProjectRecommendations", x => x.ProjectRecommendationID); + table.ForeignKey( + name: "FK_ProjectRecommendations_AspNetUsers_UserID", + column: x => x.UserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProjectRecommendations_Projects_ProjectID", + column: x => x.ProjectID, + principalTable: "Projects", + principalColumn: "ProjectID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProjectRecommendations_ProjectID", + table: "ProjectRecommendations", + column: "ProjectID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectRecommendations_UserID_ProjectID", + table: "ProjectRecommendations", + columns: new[] { "UserID", "ProjectID" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProjectRecommendations"); + } + } +} diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 91c60939..68872a89 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -397,6 +397,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ProjectLogs"); }); + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => + { + b.Property("ProjectRecommendationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectRecommendationID")); + + b.Property("Comment") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("ProjectRecommendationID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID", "ProjectID") + .IsUnique(); + + b.ToTable("ProjectRecommendations"); + }); + modelBuilder.Entity("Core.Entities.ProjectTag", b => { b.Property("ProjectID") @@ -981,6 +1016,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectRecommendations") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectRecommendations") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + modelBuilder.Entity("Core.Entities.ProjectTag", b => { b.HasOne("Core.Entities.Project", "Project") @@ -1133,6 +1187,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ProjectLogs"); + b.Navigation("ProjectRecommendations"); + b.Navigation("ProjectTags"); b.Navigation("ProjectUsers"); @@ -1175,6 +1231,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ProjectLogs"); + b.Navigation("ProjectRecommendations"); + b.Navigation("ProjectUsers"); b.Navigation("RefreshTokens"); diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-recommendation.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-recommendation.ts new file mode 100644 index 00000000..a2cf78ad --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-recommendation.ts @@ -0,0 +1,9 @@ +export interface ProjectRecommendation { + projectRecommendationID: number; + userID: number; + authorName: string; + projectID: number; + comment: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts index f1b3c355..ed474ae2 100644 --- a/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts @@ -2,6 +2,7 @@ import { BlobFile } from 'src/app/interfaces/blob-file'; import { ProjectTag } from 'src/app/interfaces/project-tag'; import { ProjectUser } from './project-user'; import { Notebook } from './notebook'; +import { ProjectRecommendation } from './project-recommendation'; export interface Project { projectID: number; @@ -15,5 +16,6 @@ export interface Project { projectUsers: Array; blobFiles: Array; projectTags: Array + projectRecommendations: Array forkedFromProjectID: number; -} \ No newline at end of file +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.html new file mode 100644 index 00000000..7e4a1575 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.html @@ -0,0 +1,67 @@ + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.scss new file mode 100644 index 00000000..e0a7ed49 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.scss @@ -0,0 +1,26 @@ +.recommend-modal { + .modal-body textarea { + resize: vertical; + } + + .recommendation-list { + max-height: 220px; + overflow-y: auto; + } + + .recommendation-item { + margin-bottom: var(--space-3); + } + + .recommendation-author { + font-weight: 600; + } + + .recommendation-comment { + white-space: pre-wrap; + } + + .recommendation-empty { + color: var(--gray); + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.ts new file mode 100644 index 00000000..e59f2b1f --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/modal-recommend/modal-recommend.component.ts @@ -0,0 +1,112 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { BsModalRef } from 'ngx-bootstrap/modal'; +import { Project } from 'src/app/interfaces/project'; +import { ProjectRecommendation } from 'src/app/interfaces/project-recommendation'; +import { User } from 'src/app/interfaces/user'; +import { ProjectService } from 'src/app/services/project.service'; + +@Component({ + selector: 'app-modal-recommend', + templateUrl: './modal-recommend.component.html', + styleUrls: ['./modal-recommend.component.scss'] +}) +export class ModalRecommendComponent implements OnInit { + constructor(private projectService: ProjectService) { } + + @Input() recommendModalRef: BsModalRef; + @Input() project: Project; + @Input() currentUser: User; + @Input() recommendation: ProjectRecommendation; + @Input() isOwner: boolean; + @Input() allowAction: boolean = true; + + @Output() recommendationSaved = new EventEmitter(); + @Output() recommendationRemoved = new EventEmitter(); + + comment: string = ''; + recommendations: ProjectRecommendation[] = []; + isLoading: boolean = false; + errorResult: string = ''; + errorStatusAlert: boolean = false; + + ngOnInit(): void { + this.comment = this.recommendation?.comment || ''; + this.loadRecommendations(); + } + + get canEditRecommendation(): boolean { + return this.allowAction && !this.isOwner; + } + + closeModal() { + this.recommendModalRef.hide(); + } + + saveRecommendation() { + if (!this.canEditRecommendation) return; + + const comment = this.comment.trim(); + if (!comment) { + this.handleError('Recommendation comment is required.'); + return; + } + + this.isLoading = true; + this.errorStatusAlert = false; + + const request = this.recommendation + ? this.projectService.updateRecommendation(this.recommendation.projectRecommendationID, comment) + : this.projectService.recommendProject(this.project.projectID, comment); + + request.subscribe(result => { + this.recommendationSaved.emit(result); + this.upsertRecommendation(result); + this.isLoading = false; + this.closeModal(); + }, error => { + console.log(error); + this.handleError(error?.error?.message || 'Unable to save recommendation.'); + }); + } + + removeRecommendation() { + if (!this.canEditRecommendation) return; + if (!this.recommendation) return; + + this.isLoading = true; + this.errorStatusAlert = false; + + this.projectService.unrecommendProject(this.project.projectID).subscribe(result => { + this.recommendationRemoved.emit(result); + this.recommendations = this.recommendations.filter(x => x.projectRecommendationID != result.projectRecommendationID); + this.isLoading = false; + this.closeModal(); + }, error => { + console.log(error); + this.handleError(error?.error?.message || 'Unable to remove recommendation.'); + }); + } + + handleError(text: string) { + this.errorResult = text; + this.errorStatusAlert = true; + this.isLoading = false; + } + + loadRecommendations() { + this.projectService.getProjectRecommendations(this.project.projectID).subscribe(result => { + this.recommendations = result; + }, error => { + console.log(error); + }); + } + + upsertRecommendation(recommendation: ProjectRecommendation) { + let index = this.recommendations.findIndex(x => x.projectRecommendationID == recommendation.projectRecommendationID); + if (index > -1) { + this.recommendations[index] = recommendation; + } else { + this.recommendations.unshift(recommendation); + } + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.html index 1f79dd0a..947da68a 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.html @@ -7,10 +7,17 @@
-
Recommendation
+ +
0 new
-
0
+
{{ project.projectRecommendations?.length || 0 }}
@@ -48,4 +55,15 @@ [currentUser]="currentUser" > - \ No newline at end of file + + + + + + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.ts index 0230a224..3e0cc3be 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/project-stat-counter/project-stat-counter.component.ts @@ -1,6 +1,7 @@ import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { BsModalRef, BsModalService } from 'ngx-bootstrap/modal'; import { Project } from 'src/app/interfaces/project'; +import { ProjectRecommendation } from 'src/app/interfaces/project-recommendation'; import { User } from 'src/app/interfaces/user'; @Component({ @@ -13,8 +14,10 @@ export class ProjectStatCounterComponent implements OnInit { @Input() currentUser: User; @ViewChild('memberListModal') memberListModal: TemplateRef + @ViewChild('recommendationListModal') recommendationListModal: TemplateRef memberListModalRef: BsModalRef; + recommendationListModalRef: BsModalRef; constructor(private modalService: BsModalService) { } @@ -25,4 +28,20 @@ export class ProjectStatCounterComponent implements OnInit { this.memberListModalRef = this.modalService.show(this.memberListModal) } + toggleModalRecommendationList() { + this.recommendationListModalRef = this.modalService.show(this.recommendationListModal) + } + + get currentRecommendation(): ProjectRecommendation { + if (this.currentUser == null) return null + if (this.project == null || this.project.projectRecommendations == null) return null + return this.project.projectRecommendations.find(x => x.userID == this.currentUser.id) || null + } + + get isOwner(): boolean { + if (this.currentUser == null || this.project == null || this.project.projectUsers == null) return false + + const projectUser = this.project.projectUsers.find(x => x.userID == this.currentUser.id) + return projectUser?.userRole == 'owner' + } } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html index 5a0e9dc0..15ece72e 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html @@ -113,7 +113,16 @@

Share - Recommend + + {{ isRecommended ? "Edit Recommendation" : "Recommend" }} + Join Leave + + + + + @ViewChild('deleteModal') deleteModal: TemplateRef + @ViewChild('recommendModal') recommendModal: TemplateRef @ViewChildren(ProjectFileExplorerComponent) fileExplorer: ProjectFileExplorerComponent @ViewChild('displayNotebookModal') displayNotebookModal: TemplateRef; forkModalRef: BsModalRef; deleteModalRef: BsModalRef; + recommendModalRef: BsModalRef; displayNotebookModalRef: BsModalRef; project: Project = null @@ -290,6 +293,16 @@ export class ProjectComponent implements OnInit { return false; } + get currentRecommendation(): ProjectRecommendation { + if (this.currentUser == null) return null + if (this.project == null || this.project.projectRecommendations == null) return null + return this.project.projectRecommendations.find(x => x.userID == this.currentUser.id) || null + } + + get isRecommended(): boolean { + return this.currentRecommendation != null + } + getProjectUsers({ user }) { var userreturn = this.accountService.getUserByID(user) return userreturn @@ -349,6 +362,38 @@ export class ProjectComponent implements OnInit { } } + recommendProject() { + if (!this.accountService.checkLoginStatus()) { + this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } }) + return + } + + this.toggleMoreOption = false + this.recommendModalRef = this.modalService.show(this.recommendModal) + } + + onRecommendationSaved(recommendation: ProjectRecommendation) { + if (this.project.projectRecommendations == null) { + this.project.projectRecommendations = [] + } + + let index = this.project.projectRecommendations.findIndex(x => x.projectRecommendationID == recommendation.projectRecommendationID) + if (index > -1) { + this.project.projectRecommendations[index] = recommendation + } else { + this.project.projectRecommendations.push(recommendation) + } + } + + onRecommendationRemoved(recommendation: ProjectRecommendation) { + if (this.project.projectRecommendations == null) return + + let index = this.project.projectRecommendations.findIndex(x => x.projectRecommendationID == recommendation.projectRecommendationID) + if (index > -1) { + this.project.projectRecommendations.splice(index, 1) + } + } + unFollowProject() { if (this.isFollowLoading) return if(!this.projectUser) return 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 e4342259..ba3dec9b 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/projects.module.ts @@ -66,6 +66,7 @@ import { PublicationItemComponent } from './project-overview/project-overview-vi import { ModalEditPublicationComponent } from './project-overview/project-overview-view/project-publication/modal-edit-publication/modal-edit-publication.component'; import { ModalDeletePublicationComponent } from './project-overview/project-overview-view/project-publication/modal-delete-publication/modal-delete-publication.component'; import { ProjectStatCounterComponent } from './project/project-stat-counter/project-stat-counter.component'; +import { ModalRecommendComponent } from './project/modal-recommend/modal-recommend.component'; @NgModule({ declarations: [ @@ -124,6 +125,7 @@ import { ProjectStatCounterComponent } from './project/project-stat-counter/proj ModalEditPublicationComponent, ModalDeletePublicationComponent, ProjectStatCounterComponent, + ModalRecommendComponent, ], 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 4836a4b4..bac375d0 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts @@ -18,6 +18,7 @@ import { FlaggedCommentGroup } from '../interfaces/project-comment-flag'; import { ProjectLog } from '../interfaces/project-log'; import { ExpiredProjectLog } from '../interfaces/expired-project-log'; import { Publication } from '../interfaces/publication'; +import { ProjectRecommendation } from '../interfaces/project-recommendation'; @Injectable({ providedIn: 'root' @@ -49,6 +50,7 @@ export class ProjectService { private urlGetProjectLogComments: string = this.baseUrl + "getprojectlogcomments/"; private urlGetExpiredProjectLogs: string = this.baseUrl + "getexpiredprojectlogs/"; private urlGetPublications: string = this.baseUrl + "GetPublications/"; + private urlGetProjectRecommendations: string = this.baseUrl + "getprojectrecommendations/"; private urlGetProjectNotebookRefs: string = this.baseUrl + "getprojectnotebookreferences/"; // Post @@ -69,6 +71,7 @@ export class ProjectService { private urlDeleteCommentandReports: string = this.baseUrl + "deletecommentandreports/"; private urlAddProjectLog: string = this.baseUrl + "addprojectlog"; private urlAddPublication: string = this.baseUrl + "addPublication"; + private urlRecommendProject: string = this.baseUrl + "recommendproject/"; // Put private urlUpdateProject: string = this.baseUrl + "updateproject/" @@ -82,6 +85,7 @@ export class ProjectService { private urlDeleteProjectLog: string = this.baseUrl + "deletelog/"; private urlRepostProjectLog: string = this.baseUrl + "repostlog/"; private urlUpdatePublication: string = this.baseUrl + "updatePublication/"; + private urlUpdateRecommendation: string = this.baseUrl + "updaterecommendation/"; // Delete private urlDeleteProject: string = this.baseUrl + "deleteproject/" @@ -93,6 +97,7 @@ export class ProjectService { private urlRemoveAllCommentReports: string = this.baseUrl + "removeallcommentreports/"; private urlDeleteExpiredProjectLog: string = this.baseUrl + "deleteexpiredprojectlog/"; private urlDeletePublication: string = this.baseUrl + "deletepublication/"; + private urlUnrecommendProject: string = this.baseUrl + "unrecommendproject/"; // Extra private urlGetUserList: string = this.baseUrl + "getuserlist/" @@ -373,6 +378,62 @@ export class ProjectService { ) } + getProjectRecommendations(projectID: number): Observable { + return this.http.get(this.urlGetProjectRecommendations + projectID).pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + recommendProject(projectID: number, comment: string): Observable { + let body = new FormData() + body.append('comment', comment) + return this.http.post(this.urlRecommendProject + projectID, body).pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + updateRecommendation(recommendationID: number, comment: string): Observable { + let body = new FormData() + body.append('comment', comment) + return this.http.put(this.urlUpdateRecommendation + recommendationID, body).pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + unrecommendProject(projectID: number): Observable { + return this.http.delete(this.urlUnrecommendProject + projectID).pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + addTag(projectID: number, tagName: string): Observable { let body = new FormData() body.append('projectid', projectID.toString()) diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index 010e350c..35dcac2a 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -97,6 +97,7 @@ public IActionResult GetProjectByID([FromRoute] int projectID) var project = _dbContext.Projects .Include(p => p.BlobFiles) .Include(p => p.ProjectUsers) + .Include(p => p.ProjectRecommendations) .Include(p => p.Notebooks) .Include(p => p.ProjectTags).ThenInclude(pt => pt.Tag) .SingleOrDefault(p => p.ProjectID == projectID); @@ -124,6 +125,7 @@ public IActionResult GetProjectByRoute([FromRoute] string owner, [FromRoute] str .Include(p => p.BlobFiles) .Include(p => p.Notebooks) .Include(p => p.ProjectUsers).ThenInclude(pu => pu.User) + .Include(p => p.ProjectRecommendations) .Include(p => p.ProjectTags).ThenInclude(pt => pt.Tag) .SingleOrDefault(p => p.Route.ToLower() == owner.ToLower() + "/" + projectname.ToLower()); if (project == null) return NotFound(new { message = "Project Not Found" }); @@ -148,6 +150,7 @@ public IActionResult GetProjectRange([FromQuery(Name = "id")] List idList) var projects = _dbContext.Projects .Include(p => p.BlobFiles) .Include(p => p.ProjectUsers) + .Include(p => p.ProjectRecommendations) .Include(p => p.ProjectTags).ThenInclude(pt => pt.Tag) .Where(p => idList.Contains(p.ProjectID)) .ToList(); @@ -173,6 +176,7 @@ public IActionResult GetProjectList() var projects = _dbContext.Projects .Include(p => p.BlobFiles) .Include(p => p.ProjectUsers) + .Include(p => p.ProjectRecommendations) .Include(p => p.ProjectTags).ThenInclude(pt => pt.Tag) .ToList(); @@ -199,6 +203,7 @@ public IActionResult Search([FromQuery(Name = "term")] List searchTerms) var matchedProject = _dbContext.Projects .Include(p => p.BlobFiles) .Include(p => p.ProjectUsers) + .Include(p => p.ProjectRecommendations) .Include(p => p.ProjectTags).ThenInclude(pt => pt.Tag) .ToList() .Where(p => matchedTag.Any(mt => p.ProjectTags.Any(pt => pt.Tag.Name.ToLower() == mt.Name.ToLower()))); @@ -761,10 +766,49 @@ public async Task GetPublications([FromRoute] int projectId) }); } - /* + /* * Type : GET - * URL : /api/projects/GetProjectNotebookReferences/projectId - * Description: Gets all Notebooks for a project + * URL : /api/project/getprojectrecommendations/projectId + * Description: Gets all recommendations for a project + */ + [HttpGet("[action]/{projectId}")] + public async Task GetProjectRecommendations([FromRoute] int projectId) + { + if (projectId <= 0) + return BadRequest(new { message = "Invalid project id." }); + + var projectExists = await _dbContext.Projects + .AsNoTracking() + .AnyAsync(p => p.ProjectID == projectId); + if (!projectExists) return NotFound(new { message = "Project Not Found" }); + + var recommendations = await _dbContext.ProjectRecommendations + .AsNoTracking() + .Where(pr => pr.ProjectID == projectId) + .OrderByDescending(pr => pr.UpdatedAt) + .Select(pr => new ProjectRecommendationVM + { + ProjectRecommendationID = pr.ProjectRecommendationID, + UserID = pr.UserID, + AuthorName = pr.User.UserName, + ProjectID = pr.ProjectID, + Comment = pr.Comment, + CreatedAt = pr.CreatedAt, + UpdatedAt = pr.UpdatedAt + }) + .ToListAsync(); + + return Ok(new + { + result = recommendations, + message = "Received Project Recommendations" + }); + } + + /* + * Type : GET + * URL : /api/projects/GetProjectNotebookReferences/projectId + * Description: Gets all Notebooks for a project */ [HttpGet("[action]/{projectId}")] public async Task GetProjectNotebookReferences([FromRoute] int projectId) @@ -1032,6 +1076,79 @@ public async Task AddProjectLog([FromForm] CreateProjectLogVM for } } + /* + * Type : POST + * URL : /api/project/recommendproject/projectId + * Param : CreateProjectRecommendationVM + * Description: Recommend a project as the authenticated user + */ + [Authorize] + [HttpPost("[action]/{projectId}")] + public async Task RecommendProject([FromRoute] int projectId, [FromForm] CreateProjectRecommendationVM formdata) + { + if (projectId <= 0) + return BadRequest(new { message = "Invalid project id." }); + + if (!ModelState.IsValid) return BadRequest(ModelState); + if (string.IsNullOrWhiteSpace(formdata.Comment)) + return BadRequest(new { message = "Recommendation comment is required." }); + + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new { message = "User Not Found." }); + + var project = await _dbContext.Projects.FindAsync(projectId); + if (project == null) return NotFound(new { message = "Project Not Found" }); + + var projectUser = await _dbContext.ProjectUsers.FindAsync(user.Id, project.ProjectID); + if (projectUser != null && string.Equals(projectUser.UserRole, "owner", StringComparison.OrdinalIgnoreCase)) + { + return StatusCode(StatusCodes.Status403Forbidden, new { message = "Project owners cannot recommend their own project" }); + } + + var existingRecommendation = await _dbContext.ProjectRecommendations + .SingleOrDefaultAsync(pr => pr.UserID == user.Id && pr.ProjectID == project.ProjectID); + if (existingRecommendation != null) + { + return Conflict(new { message = "Project already recommended by this user." }); + } + + var now = DateTime.UtcNow; + var recommendation = new ProjectRecommendation + { + ProjectID = project.ProjectID, + Project = project, + UserID = user.Id, + User = user, + Comment = formdata.Comment.Trim(), + CreatedAt = now, + UpdatedAt = now + }; + + await _dbContext.ProjectRecommendations.AddAsync(recommendation); + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = new ProjectRecommendationVM + { + ProjectRecommendationID = recommendation.ProjectRecommendationID, + UserID = recommendation.UserID, + AuthorName = user.UserName, + ProjectID = recommendation.ProjectID, + Comment = recommendation.Comment, + CreatedAt = recommendation.CreatedAt, + UpdatedAt = recommendation.UpdatedAt + }, + message = "Project recommended successfully." + }); + } + /* * Type : POST * URL : /api/project/postcomment @@ -3280,6 +3397,60 @@ public async Task RepostLog([FromRoute] int projectLogID) return Ok(new { message = "Project log reposted successfully." }); } + /* + * Type : PUT + * URL : /api/project/updaterecommendation/{recommendationID} + * Param : {recommendationID}, CreateProjectRecommendationVM + * Description: Update a project recommendation + */ + [Authorize] + [HttpPut("[action]/{recommendationID}")] + public async Task UpdateRecommendation([FromRoute] int recommendationID, [FromForm] CreateProjectRecommendationVM formdata) + { + if (recommendationID <= 0) + return BadRequest(new { message = "Invalid recommendation id." }); + + if (!ModelState.IsValid) return BadRequest(ModelState); + if (string.IsNullOrWhiteSpace(formdata.Comment)) + return BadRequest(new { message = "Recommendation comment is required." }); + + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new { message = "User Not Found." }); + + var recommendation = await _dbContext.ProjectRecommendations + .SingleOrDefaultAsync(pr => pr.ProjectRecommendationID == recommendationID); + if (recommendation == null) return NotFound(new { message = "Recommendation Not Found" }); + + if (recommendation.UserID != user.Id) return Forbid(); + + recommendation.Comment = formdata.Comment.Trim(); + recommendation.UpdatedAt = DateTime.UtcNow; + + _dbContext.Entry(recommendation).State = EntityState.Modified; + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = new ProjectRecommendationVM + { + ProjectRecommendationID = recommendation.ProjectRecommendationID, + UserID = recommendation.UserID, + AuthorName = user.UserName, + ProjectID = recommendation.ProjectID, + Comment = recommendation.Comment, + CreatedAt = recommendation.CreatedAt, + UpdatedAt = recommendation.UpdatedAt + }, + message = "Project recommendation updated successfully." + }); + } + /* * Type : PUT * URL : /api/project/updatecomment/{commentID} @@ -3641,6 +3812,51 @@ public async Task DeletePublication([FromRoute] int publicationId return Ok(new { message = "Publication deleted successfully."}); } + /* + * Type : DELETE + * URL : /api/project/unrecommendproject/{projectID} + * Param : {projectID} + * Description: Remove the authenticated user's project recommendation + */ + [Authorize] + [HttpDelete("[action]/{projectID}")] + public async Task UnrecommendProject([FromRoute] int projectID) + { + if (projectID <= 0) + return BadRequest(new { message = "Invalid project id." }); + + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new { message = "User Not Found." }); + + var recommendation = await _dbContext.ProjectRecommendations + .SingleOrDefaultAsync(pr => pr.UserID == user.Id && pr.ProjectID == projectID); + if (recommendation == null) return NotFound(new { message = "Recommendation Not Found" }); + + _dbContext.ProjectRecommendations.Remove(recommendation); + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = new ProjectRecommendationVM + { + ProjectRecommendationID = recommendation.ProjectRecommendationID, + UserID = recommendation.UserID, + AuthorName = user.UserName, + ProjectID = recommendation.ProjectID, + Comment = recommendation.Comment, + CreatedAt = recommendation.CreatedAt, + UpdatedAt = recommendation.UpdatedAt + }, + message = "Project recommendation removed successfully." + }); + } + /* * Type : DELETE * URL : /api/project/deleteexpiredprojectlog/{logId} diff --git a/src/Analysim.Web/ViewModels/Project/CreateProjectRecommendationVM.cs b/src/Analysim.Web/ViewModels/Project/CreateProjectRecommendationVM.cs new file mode 100644 index 00000000..f75df505 --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/CreateProjectRecommendationVM.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Analysim.Web.ViewModels.Project +{ + public class CreateProjectRecommendationVM + { + [Required(ErrorMessage = "Recommendation comment is a required field.")] + [MaxLength(1000, ErrorMessage = "Maximum length for Recommendation Comment is 1000 characters.")] + public string Comment { get; set; } = string.Empty; + } +} diff --git a/src/Analysim.Web/ViewModels/Project/ProjectRecommendationVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectRecommendationVM.cs new file mode 100644 index 00000000..f09dde65 --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectRecommendationVM.cs @@ -0,0 +1,15 @@ +using System; + +namespace Analysim.Web.ViewModels.Project +{ + public class ProjectRecommendationVM + { + public int ProjectRecommendationID { get; set; } + public int UserID { get; set; } + public string AuthorName { get; set; } = string.Empty; + public int ProjectID { get; set; } + public string Comment { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + } +}