diff --git a/src/Analysim.Core/Entities/Notification.cs b/src/Analysim.Core/Entities/Notification.cs new file mode 100644 index 00000000..ffdc09d8 --- /dev/null +++ b/src/Analysim.Core/Entities/Notification.cs @@ -0,0 +1,55 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class Notification + { + [Key] + public int NotificationID { get; set; } + + [ForeignKey("RecipientUser")] + public int RecipientUserID { get; set; } + public User RecipientUser { get; set; } + + [ForeignKey("ActorUser")] + public int? ActorUserID { get; set; } + public User ActorUser { get; set; } + + [Required] + [MaxLength(100)] + public string Type { get; set; } + + [ForeignKey("Project")] + public int? ProjectID { get; set; } + public Project Project { get; set; } + + [ForeignKey("ProjectComment")] + public int? CommentID { get; set; } + public ProjectComment ProjectComment { get; set; } + + [ForeignKey("ProjectLog")] + public int? ProjectLogID { get; set; } + public ProjectLog ProjectLog { get; set; } + + [Required] + [MaxLength(200)] + public string Title { get; set; } + + [Required] + [MaxLength(1000)] + public string Body { get; set; } + + [MaxLength(500)] + public string Link { get; set; } + + public string DataJson { get; set; } + + public bool IsRead { get; set; } + + public DateTime? ReadAt { get; set; } + + public DateTime CreatedAt { get; set; } + } +} diff --git a/src/Analysim.Core/Entities/Project.cs b/src/Analysim.Core/Entities/Project.cs index 06013a5d..1cbed8b2 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 ProjectMembershipRequests { get; set; } = new List(); public int ForkedFromProjectID { get; set; } } } - diff --git a/src/Analysim.Core/Entities/ProjectMembershipRequest.cs b/src/Analysim.Core/Entities/ProjectMembershipRequest.cs new file mode 100644 index 00000000..3765e424 --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectMembershipRequest.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class ProjectMembershipRequest + { + [Key] + public int ProjectMembershipRequestID { get; set; } + + [Required(ErrorMessage = "Project ID is a required field.")] + [ForeignKey("Project")] + public int ProjectID { get; set; } + public Project Project { get; set; } + + [Required(ErrorMessage = "Requester User ID is a required field.")] + [ForeignKey("RequesterUser")] + public int RequesterUserID { get; set; } + public User RequesterUser { get; set; } + + [Required(ErrorMessage = "Target User ID is a required field.")] + [ForeignKey("TargetUser")] + public int TargetUserID { get; set; } + public User TargetUser { get; set; } + + [Required(ErrorMessage = "Created By User ID is a required field.")] + [ForeignKey("CreatedByUser")] + public int CreatedByUserID { get; set; } + public User CreatedByUser { get; set; } + + [Required(ErrorMessage = "Request Type is a required field.")] + public string Type { get; set; } + + [Required(ErrorMessage = "Request Status is a required field.")] + public string Status { get; set; } + + [MaxLength(500, ErrorMessage = "Maximum length for Message is 500 Character.")] + public string Message { get; set; } + + [Required(ErrorMessage = "Created At is a required field.")] + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? RespondedAt { get; set; } + } +} diff --git a/src/Analysim.Core/Entities/UserNotificationPreference.cs b/src/Analysim.Core/Entities/UserNotificationPreference.cs new file mode 100644 index 00000000..901d8bb5 --- /dev/null +++ b/src/Analysim.Core/Entities/UserNotificationPreference.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class UserNotificationPreference + { + [ForeignKey("User")] + public int UserID { get; set; } + public User User { get; set; } + + [Required] + [MaxLength(100)] + public string NotificationType { get; set; } + + public bool InAppEnabled { get; set; } = true; + + public bool EmailEnabled { get; set; } + } +} diff --git a/src/Analysim.Core/Helper/NotificationTypes.cs b/src/Analysim.Core/Helper/NotificationTypes.cs new file mode 100644 index 00000000..92a42af9 --- /dev/null +++ b/src/Analysim.Core/Helper/NotificationTypes.cs @@ -0,0 +1,12 @@ +namespace Core.Helper +{ + public static class NotificationTypes + { + public const string CommentReply = "comment.reply"; + public const string ProjectInvitationReceived = "project.invitation.received"; + public const string ProjectMemberAdded = "project.member.added"; + public const string ProjectJoined = "project.joined"; + public const string ProjectLogCreated = "project.log.created"; + public const string ProjectLogUpdated = "project.log.updated"; + } +} diff --git a/src/Analysim.Core/Interfaces/INotificationService.cs b/src/Analysim.Core/Interfaces/INotificationService.cs new file mode 100644 index 00000000..96bb9a7d --- /dev/null +++ b/src/Analysim.Core/Interfaces/INotificationService.cs @@ -0,0 +1,10 @@ +using System.Threading.Tasks; +using Core.Models; + +namespace Core.Interfaces +{ + public interface INotificationService + { + Task PublishAsync(NotificationEvent notificationEvent); + } +} diff --git a/src/Analysim.Core/Models/NotificationEvent.cs b/src/Analysim.Core/Models/NotificationEvent.cs new file mode 100644 index 00000000..b147754c --- /dev/null +++ b/src/Analysim.Core/Models/NotificationEvent.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Core.Models +{ + public class NotificationEvent + { + public string Type { get; set; } + public int? ActorUserID { get; set; } + public int? RecipientUserID { get; set; } + public int? ProjectID { get; set; } + public int? CommentID { get; set; } + public int? ParentCommentID { get; set; } + public int? ProjectLogID { get; set; } + public IDictionary Data { get; set; } = new Dictionary(); + } +} diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 79751278..6d348234 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -25,6 +25,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasKey(pt => new { pt.ProjectID, pt.TagID }); modelBuilder.Entity().HasKey(uu => new { uu.UserID, uu.FollowerID }); modelBuilder.Entity().HasKey(nc => new { nc.NotebookID, nc.Version }); + modelBuilder.Entity().HasKey(p => new { p.UserID, p.NotificationType }); modelBuilder.Entity() .HasIndex(rt => rt.TokenHash) @@ -84,6 +85,55 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(rt => rt.UserID) .OnDelete(DeleteBehavior.Cascade); + #region Notifications + + modelBuilder.Entity() + .HasOne(n => n.RecipientUser) + .WithMany() + .HasForeignKey(n => n.RecipientUserID) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(n => n.ActorUser) + .WithMany() + .HasForeignKey(n => n.ActorUserID) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasOne(n => n.Project) + .WithMany() + .HasForeignKey(n => n.ProjectID) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasOne(n => n.ProjectComment) + .WithMany() + .HasForeignKey(n => n.CommentID) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasOne(n => n.ProjectLog) + .WithMany() + .HasForeignKey(n => n.ProjectLogID) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasOne(p => p.User) + .WithMany() + .HasForeignKey(p => p.UserID) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(n => new { n.RecipientUserID, n.IsRead, n.CreatedAt }); + + modelBuilder.Entity() + .HasIndex(n => n.Type); + + modelBuilder.Entity() + .HasIndex(p => p.NotificationType); + + #endregion + // One To Many Relationship (Project -> Blob) modelBuilder.Entity() .HasMany(p => p.BlobFiles) @@ -258,6 +308,37 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(pp => pp.Project) .HasForeignKey(pp => pp.ProjectID) .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (Project -> ProjectMembershipRequest) + modelBuilder.Entity() + .HasMany(p => p.ProjectMembershipRequests) + .WithOne(pmr => pmr.Project) + .HasForeignKey(pmr => pmr.ProjectID) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(pmr => pmr.RequesterUser) + .WithMany() + .HasForeignKey(pmr => pmr.RequesterUserID) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(pmr => pmr.TargetUser) + .WithMany() + .HasForeignKey(pmr => pmr.TargetUserID) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(pmr => pmr.CreatedByUser) + .WithMany() + .HasForeignKey(pmr => pmr.CreatedByUserID) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(pmr => new { pmr.ProjectID, pmr.TargetUserID, pmr.Type, pmr.Status }); + + modelBuilder.Entity() + .HasIndex(pmr => pmr.CreatedByUserID); } @@ -279,5 +360,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ProjectLogs { get; set; } public DbSet Publications { get; set; } + public DbSet ProjectMembershipRequests { get; set; } + public DbSet Notifications { get; set; } + public DbSet UserNotificationPreferences { get; set; } } } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 91c60939..16a22f52 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -176,6 +176,77 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Notebook"); }); + modelBuilder.Entity("Core.Entities.Notification", b => + { + b.Property("NotificationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotificationID")); + + b.Property("ActorUserID") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataJson") + .HasColumnType("text"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Link") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ProjectLogID") + .HasColumnType("integer"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientUserID") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("NotificationID"); + + b.HasIndex("ActorUserID"); + + b.HasIndex("CommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ProjectLogID"); + + b.HasIndex("Type"); + + b.HasIndex("RecipientUserID", "IsRead", "CreatedAt"); + + b.ToTable("Notifications"); + }); + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => { b.Property("ID") @@ -414,6 +485,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ProjectTags"); }); + modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => + { + b.Property("ProjectMembershipRequestID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectMembershipRequestID")); + + b.Property("CreatedByUserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequesterUserID") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserID") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectMembershipRequestID"); + + b.HasIndex("CreatedByUserID"); + + b.HasIndex("RequesterUserID"); + + b.HasIndex("TargetUserID"); + + b.HasIndex("ProjectID", "TargetUserID", "Type", "Status"); + + b.ToTable("ProjectMembershipRequests"); + }); + modelBuilder.Entity("Core.Entities.ProjectUser", b => { b.Property("UserID") @@ -644,6 +766,28 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("Core.Entities.UserNotificationPreference", b => + { + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("NotificationType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmailEnabled") + .HasColumnType("boolean"); + + b.Property("InAppEnabled") + .HasColumnType("boolean"); + + b.HasKey("UserID", "NotificationType"); + + b.HasIndex("NotificationType"); + + b.ToTable("UserNotificationPreferences"); + }); + modelBuilder.Entity("Core.Entities.UserUser", b => { b.Property("UserID") @@ -866,6 +1010,45 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Project"); }); + modelBuilder.Entity("Core.Entities.Notification", b => + { + b.HasOne("Core.Entities.User", "ActorUser") + .WithMany() + .HasForeignKey("ActorUserID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany() + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.ProjectLog", "ProjectLog") + .WithMany() + .HasForeignKey("ProjectLogID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.User", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActorUser"); + + b.Navigation("Project"); + + b.Navigation("ProjectComment"); + + b.Navigation("ProjectLog"); + + b.Navigation("RecipientUser"); + }); + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => { b.HasOne("Core.Entities.Notebook", "notebook") @@ -1019,6 +1202,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => + { + b.HasOne("Core.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectMembershipRequests") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "RequesterUser") + .WithMany() + .HasForeignKey("RequesterUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Core.Entities.User", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Project"); + + b.Navigation("RequesterUser"); + + b.Navigation("TargetUser"); + }); + modelBuilder.Entity("Core.Entities.Publication", b => { b.HasOne("Core.Entities.Project", "Project") @@ -1041,6 +1259,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Core.Entities.UserNotificationPreference", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany() + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Core.Entities.UserUser", b => { b.HasOne("Core.Entities.User", "Follower") @@ -1133,6 +1362,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ProjectLogs"); + b.Navigation("ProjectMembershipRequests"); + b.Navigation("ProjectTags"); b.Navigation("ProjectUsers"); diff --git a/src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.Designer.cs new file mode 100644 index 00000000..63d1f8d1 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.Designer.cs @@ -0,0 +1,1331 @@ +// +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("20260809112830_AddNotifications")] + partial class AddNotifications + { + /// + 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.Notification", b => + { + b.Property("NotificationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotificationID")); + + b.Property("ActorUserID") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataJson") + .HasColumnType("text"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Link") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ProjectLogID") + .HasColumnType("integer"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientUserID") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("NotificationID"); + + b.HasIndex("ActorUserID"); + + b.HasIndex("CommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ProjectLogID"); + + b.HasIndex("Type"); + + b.HasIndex("RecipientUserID", "IsRead", "CreatedAt"); + + b.ToTable("Notifications"); + }); + + 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.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.UserNotificationPreference", b => + { + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("NotificationType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmailEnabled") + .HasColumnType("boolean"); + + b.Property("InAppEnabled") + .HasColumnType("boolean"); + + b.HasKey("UserID", "NotificationType"); + + b.HasIndex("NotificationType"); + + b.ToTable("UserNotificationPreferences"); + }); + + 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.Notification", b => + { + b.HasOne("Core.Entities.User", "ActorUser") + .WithMany() + .HasForeignKey("ActorUserID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany() + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.ProjectLog", "ProjectLog") + .WithMany() + .HasForeignKey("ProjectLogID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.User", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActorUser"); + + b.Navigation("Project"); + + b.Navigation("ProjectComment"); + + b.Navigation("ProjectLog"); + + b.Navigation("RecipientUser"); + }); + + 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.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.UserNotificationPreference", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany() + .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("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("ProjectUsers"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.cs b/src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.cs new file mode 100644 index 00000000..0a0383f7 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.cs @@ -0,0 +1,136 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class AddNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + NotificationID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RecipientUserID = table.Column(type: "integer", nullable: false), + ActorUserID = table.Column(type: "integer", nullable: true), + Type = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + ProjectID = table.Column(type: "integer", nullable: true), + CommentID = table.Column(type: "integer", nullable: true), + ProjectLogID = table.Column(type: "integer", nullable: true), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Body = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Link = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + DataJson = table.Column(type: "text", nullable: true), + IsRead = table.Column(type: "boolean", nullable: false), + ReadAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.NotificationID); + table.ForeignKey( + name: "FK_Notifications_AspNetUsers_ActorUserID", + column: x => x.ActorUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Notifications_AspNetUsers_RecipientUserID", + column: x => x.RecipientUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Notifications_ProjectComments_CommentID", + column: x => x.CommentID, + principalTable: "ProjectComments", + principalColumn: "CommentID", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Notifications_ProjectLogs_ProjectLogID", + column: x => x.ProjectLogID, + principalTable: "ProjectLogs", + principalColumn: "LogID", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Notifications_Projects_ProjectID", + column: x => x.ProjectID, + principalTable: "Projects", + principalColumn: "ProjectID", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "UserNotificationPreferences", + columns: table => new + { + UserID = table.Column(type: "integer", nullable: false), + NotificationType = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + InAppEnabled = table.Column(type: "boolean", nullable: false), + EmailEnabled = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserNotificationPreferences", x => new { x.UserID, x.NotificationType }); + table.ForeignKey( + name: "FK_UserNotificationPreferences_AspNetUsers_UserID", + column: x => x.UserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_ActorUserID", + table: "Notifications", + column: "ActorUserID"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_CommentID", + table: "Notifications", + column: "CommentID"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_ProjectID", + table: "Notifications", + column: "ProjectID"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_ProjectLogID", + table: "Notifications", + column: "ProjectLogID"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_RecipientUserID_IsRead_CreatedAt", + table: "Notifications", + columns: new[] { "RecipientUserID", "IsRead", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_Type", + table: "Notifications", + column: "Type"); + + migrationBuilder.CreateIndex( + name: "IX_UserNotificationPreferences_NotificationType", + table: "UserNotificationPreferences", + column: "NotificationType"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications"); + + migrationBuilder.DropTable( + name: "UserNotificationPreferences"); + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs b/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs new file mode 100644 index 00000000..9fd53cdd --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs @@ -0,0 +1,89 @@ +using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260830040500_AddProjectMembershipRequests")] + public partial class AddProjectMembershipRequests : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProjectMembershipRequests", + columns: table => new + { + ProjectMembershipRequestID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProjectID = table.Column(type: "integer", nullable: false), + RequesterUserID = table.Column(type: "integer", nullable: false), + TargetUserID = table.Column(type: "integer", nullable: false), + CreatedByUserID = table.Column(type: "integer", nullable: false), + Type = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + Message = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RespondedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ProjectMembershipRequests", x => x.ProjectMembershipRequestID); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_AspNetUsers_CreatedByUserID", + column: x => x.CreatedByUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_AspNetUsers_RequesterUserID", + column: x => x.RequesterUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_AspNetUsers_TargetUserID", + column: x => x.TargetUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_Projects_ProjectID", + column: x => x.ProjectID, + principalTable: "Projects", + principalColumn: "ProjectID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_CreatedByUserID", + table: "ProjectMembershipRequests", + column: "CreatedByUserID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_ProjectID_TargetUserID_Type_Status", + table: "ProjectMembershipRequests", + columns: new[] { "ProjectID", "TargetUserID", "Type", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_RequesterUserID", + table: "ProjectMembershipRequests", + column: "RequesterUserID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_TargetUserID", + table: "ProjectMembershipRequests", + column: "TargetUserID"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProjectMembershipRequests"); + } + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-membership-request.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-membership-request.ts new file mode 100644 index 00000000..20f82e2a --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-membership-request.ts @@ -0,0 +1,19 @@ +import { Project } from './project'; +import { User } from './user'; + +export interface ProjectMembershipRequest { + projectMembershipRequestID: number + projectID: number + project: Project + requesterUserID: number + requesterUser: User + targetUserID: number + targetUser: User + createdByUserID: number + createdByUser: User + type: string + status: string + message: string + createdAt: Date + respondedAt: Date +} diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/user-notification.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/user-notification.ts new file mode 100644 index 00000000..207f22f4 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/user-notification.ts @@ -0,0 +1,38 @@ +export interface UserNotification { + notificationID: number; + type: string; + actorUserID?: number; + projectID?: number; + commentID?: number; + projectLogID?: number; + title: string; + body: string; + link?: string; + dataJson?: string; + isRead: boolean; + readAt?: Date; + createdAt: Date; +} + +export interface UserNotificationPage { + result: UserNotification[]; + total: number; + page: number; + pageSize: number; + message: string; +} + +export interface UserNotificationPreference { + notificationType: string; + inAppEnabled: boolean; + emailEnabled: boolean; +} + +export const UserNotificationTypes = { + CommentReply: 'comment.reply', + ProjectInvitationReceived: 'project.invitation.received', + ProjectMemberAdded: 'project.member.added', + ProjectJoined: 'project.joined', + ProjectLogCreated: 'project.log.created', + ProjectLogUpdated: 'project.log.updated', +}; 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 45aa1671..a14d0960 100644 --- a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html +++ b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html @@ -70,6 +70,59 @@ + + + + @@ -97,6 +106,45 @@
{{ profile.userName }}
+ + @@ -106,4 +154,4 @@
{{ profile.userName }}
- \ No newline at end of file + diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss index dceeb4de..db07220d 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss @@ -20,4 +20,43 @@ .nav-item:hover { cursor: pointer; -} \ No newline at end of file +} + +.profile-invite-row { + align-items: center; + border: 1px solid #dee2e6; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 14px; +} + +.profile-invite-main { + min-width: 0; +} + +.profile-invite-title { + font-weight: 500; +} + +.profile-invite-title a { + color: var(--link-on-light); +} + +.profile-invite-actions { + display: flex; + flex-shrink: 0; + gap: 8px; +} + +.profile-invite-actions .btn-outline-danger { + background-color: transparent; + color: #dc3545; +} + +@media (max-width: 576px) { + .profile-invite-row { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts index 1a4330ba..d536c8db 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts @@ -4,10 +4,10 @@ import { AccountService } from '../services/account.service'; import { User } from '../interfaces/user'; import { Project } from '../interfaces/project'; import { ProjectService } from '../services/project.service'; -import { of, pipe, Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { UserUser } from '../interfaces/user-user'; +import { Observable } from 'rxjs'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { ProjectMembershipRequest } from '../interfaces/project-membership-request'; +import { ProjectUser } from '../interfaces/project-user'; @Component({ selector: 'app-profile', @@ -29,17 +29,29 @@ export class ProfileComponent implements OnInit { projects: Project[] followings: User[] followers: User[] + membershipRequests: ProjectMembershipRequest[] = [] - tabActive: boolean[] = [true, false, false] + tabActive: boolean[] = [true, false, false, false] showError: boolean + requestActionID: number = null + requestedTab: string = null currentUser: User = null async ngOnInit() { + this.route.queryParams.subscribe(params => { + this.requestedTab = params["tab"] + this.applyRequestedTab() + }) + if (this.accountService.checkLoginStatus()) { await this.accountService.currentUser.then((x) => this.currentUser$ = x) - this.currentUser$.subscribe(x => this.currentUser = x) + this.currentUser$.subscribe(x => { + this.currentUser = x + this.loadMembershipRequests() + this.applyRequestedTab() + }) } this.route.params.subscribe(params => { @@ -47,7 +59,10 @@ export class ProfileComponent implements OnInit { this.projects = [] this.followings = [] this.followers = [] + this.membershipRequests = [] this.showError = false + this.requestActionID = null + this.tabActive = [true, false, false, false] let username = params["username"] @@ -58,7 +73,9 @@ export class ProfileComponent implements OnInit { this.loadProject(result) this.loadFollowing(result) this.loadFollower(result) + this.loadMembershipRequests() this.profileImage() + this.applyRequestedTab() }, error => { this.showError = true }) @@ -86,6 +103,8 @@ export class ProfileComponent implements OnInit { } changeTab(num: number) { + if (num == 3 && !this.isOwnProfile) return + this.tabActive.forEach((t, i) => { if (num != i) this.tabActive[i] = false @@ -95,6 +114,12 @@ export class ProfileComponent implements OnInit { }); } + private applyRequestedTab() { + if (this.requestedTab == "invites" && this.isOwnProfile) { + this.tabActive = [false, false, false, true] + } + } + followUser() { if (!this.accountService.checkLoginStatus()) this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } }) @@ -126,6 +151,92 @@ export class ProfileComponent implements OnInit { ) } + get isOwnProfile(): boolean { + return this.profile != null && + this.currentUser != null && + this.profile.id == this.currentUser.id + } + + get pendingInvitations(): ProjectMembershipRequest[] { + if (this.currentUser == null) return [] + + return this.membershipRequests.filter(request => + request.type == "invitation" && + request.status == "pending" && + request.targetUserID == this.currentUser.id) + } + + loadMembershipRequests() { + if (!this.isOwnProfile) return + + this.projectService.getMyMembershipRequests().subscribe( + result => { + this.membershipRequests = result.filter(request => + request.type == "invitation" && + request.status == "pending" && + request.targetUserID == this.currentUser.id) + }, error => { + console.log(error) + } + ) + } + + acceptInvitation(request: ProjectMembershipRequest) { + if (this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.acceptProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.removeMembershipRequest(request.projectMembershipRequestID) + this.addProjectUser(result) + this.loadAcceptedProject(result) + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + rejectInvitation(request: ProjectMembershipRequest) { + if (this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.rejectProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.removeMembershipRequest(result.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + private removeMembershipRequest(requestID: number) { + this.membershipRequests = this.membershipRequests + .filter(request => request.projectMembershipRequestID != requestID) + } + + private addProjectUser(projectUser: ProjectUser) { + let index = this.profile.projectUsers.findIndex(item => item.projectID == projectUser.projectID) + if(index > -1) this.profile.projectUsers[index] = projectUser + else this.profile.projectUsers.push(projectUser) + } + + private loadAcceptedProject(projectUser: ProjectUser) { + this.projectService.getProjectByID(projectUser.projectID).subscribe( + project => { + let index = this.projects.findIndex(item => item.projectID == project.projectID) + if(index > -1) this.projects[index] = project + else this.projects.push(project) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + loadProject(profile: User) { let projectIDs: number[] = profile.projectUsers.map(pu => pu.projectID) diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html index d064ddb3..d16cc416 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html @@ -20,8 +20,12 @@

Project Creation

- - + + + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.ts index 6a03fa87..058ed82e 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.ts @@ -5,6 +5,7 @@ import { Router } from '@angular/router'; import { Project } from 'src/app/interfaces/project'; import { ProjectUser } from 'src/app/interfaces/project-user'; import { ProjectTag } from 'src/app/interfaces/project-tag'; +import { ProjectMembershipRequest } from 'src/app/interfaces/project-membership-request'; @Component({ selector: 'app-project-create', @@ -14,6 +15,7 @@ import { ProjectTag } from 'src/app/interfaces/project-tag'; export class ProjectCreateComponent implements OnInit { project : Project = null + membershipRequests : ProjectMembershipRequest[] = [] constructor(private projectService : ProjectService, private formBuilder : FormBuilder, @@ -36,4 +38,8 @@ export class ProjectCreateComponent implements OnInit { this.project.projectUsers = projectUsers } + addMembershipRequest(request : ProjectMembershipRequest){ + this.membershipRequests.push(request) + } + } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html index 40aa8b7b..d2d26a80 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html @@ -5,13 +5,13 @@
+ {{invitationMessage}} -
\ No newline at end of file +
diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts index 322c7ab7..ed06e182 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts @@ -4,6 +4,7 @@ import { FormGroup, FormControl, FormBuilder } from '@angular/forms'; import { ProjectService } from 'src/app/services/project.service'; import { User } from 'src/app/interfaces/user'; import { AccountService } from 'src/app/services/account.service'; +import { ProjectMembershipRequest } from 'src/app/interfaces/project-membership-request'; @Component({ selector: 'app-project-form-users', @@ -19,11 +20,13 @@ export class ProjectFormUsersComponent implements OnInit { // Form Control - Project Tag/Role @Input() project : Project - @Output() updateProject = new EventEmitter() + @Input() membershipRequests : ProjectMembershipRequest[] = [] + @Output() invitationCreated = new EventEmitter() users : User[] = [] filteredUsers : User[] = [] userForm: FormGroup userName: FormControl + invitationMessage: string = '' ngOnInit(): void { this.loadUser() @@ -35,12 +38,11 @@ export class ProjectFormUsersComponent implements OnInit { } ) - // Initialize FormGroup using FormBuilder + this.userName = new FormControl(''); this.userForm = this.formBuilder.group({ userName: this.userName, }); - this.userName = new FormControl(''); this.userForm.get("userName").valueChanges.subscribe( val => { this.filteredUsers =this.filterUser(val) @@ -53,17 +55,23 @@ export class ProjectFormUsersComponent implements OnInit { if(val == null || val == "") return [] - var existingUser : User[] = [] + var existingUserIDs : number[] = [] this.project.projectUsers .forEach(x => { if(x.userRole != "follower"){ - existingUser.push(x.user) + existingUserIDs.push(x.userID) } }) return this.users.filter(x => { if(count < 5){ - if(x.userName.toLowerCase().indexOf(val.toLowerCase()) != -1 && existingUser.find(u => x.userName == u.userName) == undefined){ + let hasPendingMembershipRequest = this.membershipRequests.find(request => + request.targetUserID == x.id && + request.status == "pending") != undefined + + if(x.userName.toLowerCase().indexOf(val.toLowerCase()) != -1 && + existingUserIDs.find(userID => x.id == userID) == undefined && + !hasPendingMembershipRequest){ count++ return true } @@ -76,13 +84,16 @@ export class ProjectFormUsersComponent implements OnInit { } - public addUser(user : User){ - this.projectService.addUser(this.project.projectID, user.id, "member", false).subscribe( + public inviteUser(user : User){ + this.invitationMessage = '' + + this.projectService.inviteProjectMember(this.project.projectID, user.id).subscribe( result => { - this.project.projectUsers.push(result) - this.updateProject.emit(this.project) + this.invitationCreated.emit(result) + this.invitationMessage = "Invitation sent to " + user.userName }, error =>{ console.log(error) + this.invitationMessage = error?.error?.message || "Failed to send invitation." } ) this.userForm.reset() @@ -96,9 +107,6 @@ export class ProjectFormUsersComponent implements OnInit { // Map Project in Project User this.project.projectUsers.map(pu => pu.user = result.find(u => u.id == pu.userID)) - // Map Project - this.users = this.project.projectUsers - .map(x => x.user) }, error =>{ console.log(error) } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html index 330efdf0..e092e010 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html @@ -14,19 +14,81 @@ (setProject)="setProject($event)"> -
-
-
Project Tags
- +
+
Project Tags
+ - -
+ (updateProjectTags)="updateProjectTags($event)"> + + +
+
Project Members
+ + + + + +
+
Join Requests
+
    +
  • +
    +
    + {{request.requesterUser?.userName}} + {{request.createdAt | date:'medium'}} +
    +
    + + +
    +
    +
  • +
+

No pending join requests.

+ +
Pending Invitations
+
    +
  • +
    +
    + {{request.targetUser?.userName}} + {{request.createdAt | date:'medium'}} +
    + +
    +
  • +
+

No pending invitations.

+ diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss index e69de29b..9fdd0956 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss @@ -0,0 +1,19 @@ +.membership-request-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.membership-request-actions { + display: flex; + align-items: center; + gap: 8px; +} + +@media (max-width: 576px) { + .membership-request-row { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts index a11c7a19..6c5d945d 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts @@ -1,8 +1,10 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Project } from 'src/app/interfaces/project'; -import { ProjectUser } from 'src/app/interfaces/project-user'; import { ProjectTag } from 'src/app/interfaces/project-tag'; +import { ProjectUser } from 'src/app/interfaces/project-user'; +import { ProjectMembershipRequest } from 'src/app/interfaces/project-membership-request'; +import { ProjectService } from 'src/app/services/project.service'; @Component({ selector: 'app-project-edit', @@ -14,8 +16,13 @@ export class ProjectEditComponent implements OnInit { project : Project = null; owner: string = null; projectname: string = null; + membershipRequests: ProjectMembershipRequest[] = []; + requestActionID: number = null; - constructor(private route: ActivatedRoute) { } + constructor( + private route: ActivatedRoute, + private projectService: ProjectService + ) { } ngOnInit(): void { @@ -27,6 +34,7 @@ export class ProjectEditComponent implements OnInit { setProject(project : Project){ this.project = project + this.loadMembershipRequests() } updateProjectTags(projectTags : ProjectTag[]){ @@ -37,4 +45,77 @@ export class ProjectEditComponent implements OnInit { this.project.projectUsers = projectUsers } + get pendingJoinRequests(): ProjectMembershipRequest[] { + return this.membershipRequests.filter(request => + request.type == "join_request" && + request.status == "pending") + } + + get pendingInvitations(): ProjectMembershipRequest[] { + return this.membershipRequests.filter(request => + request.type == "invitation" && + request.status == "pending") + } + + loadMembershipRequests(){ + if(this.project == null) return + + this.projectService.getProjectMembershipRequests(this.project.projectID).subscribe( + result => { + this.membershipRequests = result + }, error => { + console.log(error) + } + ) + } + + acceptRequest(request: ProjectMembershipRequest){ + if(this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.acceptProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + let index = this.project.projectUsers.findIndex(projectUser => projectUser.userID == result.userID) + if(index > -1) this.project.projectUsers[index] = result + else this.project.projectUsers.push(result) + + this.membershipRequests = this.membershipRequests.filter(item => item.projectMembershipRequestID != request.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + rejectRequest(request: ProjectMembershipRequest){ + if(this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.rejectProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.membershipRequests = this.membershipRequests.filter(item => item.projectMembershipRequestID != result.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + cancelInvitation(request: ProjectMembershipRequest){ + if(this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.cancelProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.membershipRequests = this.membershipRequests.filter(item => item.projectMembershipRequestID != result.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html index 344d9a77..c41964ab 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html @@ -1,9 +1,9 @@ -
+
    - +
-
\ No newline at end of file +
diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts index f33e7e48..25b28cdb 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts @@ -11,24 +11,11 @@ export class ProjectListUsersComponent implements OnInit { constructor() { } @Input() projectUsers : ProjectUser[] - @Output() updateProjectUsers = new EventEmitter() @Output() removeProjectUsers = new EventEmitter() ngOnInit(): void { } - updateUser(projectUsers : ProjectUser[]){ - this.projectUsers = projectUsers - } - - updateProjectUser(projectUser : ProjectUser){ - let index = this.projectUsers.findIndex(x => x.userID == projectUser.userID) - if (index > -1) { - this.projectUsers[index] = projectUser - } - this.updateProjectUsers.emit(this.projectUsers) - } - removeProjectUser(projectUser : ProjectUser){ let index = this.projectUsers.indexOf(projectUser,0) if (index > -1) { diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html index 195908ab..1b4a01e6 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html @@ -9,11 +9,9 @@
{{projectUser.user.userName}}
- +
- + {{projectUser.userRole | titlecase}}
@@ -22,4 +20,4 @@
- \ No newline at end of file + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts index 1ead5d00..fd691ee5 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts @@ -12,26 +12,11 @@ export class ProjectUserComponent implements OnInit { constructor(private projectService : ProjectService) { } @Input() projectUser : ProjectUser - @Output() updatedUser = new EventEmitter() @Output() removedUser = new EventEmitter() - roles: any[] = ['member', 'admin']; - ngOnInit(): void { } - updateUser(event : Event){ - var role = (event.target as HTMLSelectElement).value; - this.projectUser.userRole = role.toLowerCase(); - this.projectService.updateUser(this.projectUser).subscribe( - result => { - this.updatedUser.emit(this.projectUser) - }, error =>{ - console.log(error) - } - ) - } - deleteUser(){ this.projectService.removeUser(this.projectUser.projectID, this.projectUser.userID).subscribe( result => { 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..c0d89cf2 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 @@ -111,11 +111,77 @@

-
+