From 547e11dd85fca884e50a534a6be4dac4a1ef1d15 Mon Sep 17 00:00:00 2001 From: Mohab Date: Sun, 9 Aug 2026 15:24:51 +0300 Subject: [PATCH 1/6] feat: add backend notification system --- src/Analysim.Core/Entities/Notification.cs | 55 + .../Entities/UserNotificationPreference.cs | 20 + src/Analysim.Core/Helper/NotificationTypes.cs | 11 + .../Interfaces/INotificationService.cs | 10 + src/Analysim.Core/Models/NotificationEvent.cs | 16 + .../Data/ApplicationDbContext.cs | 52 + .../ApplicationDbContextModelSnapshot.cs | 143 ++ ...0260809112830_AddNotifications.Designer.cs | 1331 +++++++++++++++++ .../20260809112830_AddNotifications.cs | 136 ++ .../Controllers/AccountController.cs | 19 + .../Controllers/NotificationsController.cs | 265 ++++ .../Controllers/ProjectController.cs | 198 ++- .../Extensions/ServiceExtensions.cs | 5 + .../Services/NotificationService.cs | 435 ++++++ src/Analysim.Web/Startup.cs | 2 + .../NotificationPreferenceUpdateVM.cs | 8 + 16 files changed, 2602 insertions(+), 104 deletions(-) create mode 100644 src/Analysim.Core/Entities/Notification.cs create mode 100644 src/Analysim.Core/Entities/UserNotificationPreference.cs create mode 100644 src/Analysim.Core/Helper/NotificationTypes.cs create mode 100644 src/Analysim.Core/Interfaces/INotificationService.cs create mode 100644 src/Analysim.Core/Models/NotificationEvent.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260809112830_AddNotifications.cs create mode 100644 src/Analysim.Web/Controllers/NotificationsController.cs create mode 100644 src/Analysim.Web/Services/NotificationService.cs create mode 100644 src/Analysim.Web/ViewModels/Notification/NotificationPreferenceUpdateVM.cs 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/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..be52a308 --- /dev/null +++ b/src/Analysim.Core/Helper/NotificationTypes.cs @@ -0,0 +1,11 @@ +namespace Core.Helper +{ + public static class NotificationTypes + { + public const string CommentReply = "comment.reply"; + 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..60d0ab8a 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) @@ -279,5 +329,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ProjectLogs { get; set; } public DbSet Publications { 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..1f213a99 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") @@ -644,6 +715,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 +959,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") @@ -1041,6 +1173,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") 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.Web/Controllers/AccountController.cs b/src/Analysim.Web/Controllers/AccountController.cs index ec063331..0260a341 100644 --- a/src/Analysim.Web/Controllers/AccountController.cs +++ b/src/Analysim.Web/Controllers/AccountController.cs @@ -1207,6 +1207,25 @@ [FromBody] NotificationPreferencesUpdateVM formdata // Update Notification Preferences user.ReceiveCommentReplyEmails = formdata.ReceiveCommentReplyEmails; + var commentReplyPreference = await _dbContext.UserNotificationPreferences + .SingleOrDefaultAsync(p => + p.UserID == user.Id && + p.NotificationType == NotificationTypes.CommentReply); + + if (commentReplyPreference == null) + { + commentReplyPreference = new UserNotificationPreference + { + UserID = user.Id, + NotificationType = NotificationTypes.CommentReply, + InAppEnabled = true + }; + + await _dbContext.UserNotificationPreferences.AddAsync(commentReplyPreference); + } + + commentReplyPreference.EmailEnabled = formdata.ReceiveCommentReplyEmails; + // Save Changes await _dbContext.SaveChangesAsync(); diff --git a/src/Analysim.Web/Controllers/NotificationsController.cs b/src/Analysim.Web/Controllers/NotificationsController.cs new file mode 100644 index 00000000..21a8e680 --- /dev/null +++ b/src/Analysim.Web/Controllers/NotificationsController.cs @@ -0,0 +1,265 @@ +using Core.Entities; +using Core.Helper; +using Infrastructure.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Web.ViewModels.Notification; + +namespace Web.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class NotificationsController : ControllerBase + { + private static readonly string[] SupportedNotificationTypes = + { + NotificationTypes.CommentReply, + NotificationTypes.ProjectMemberAdded, + NotificationTypes.ProjectJoined, + NotificationTypes.ProjectLogCreated, + NotificationTypes.ProjectLogUpdated + }; + + private readonly ApplicationDbContext _dbContext; + + public NotificationsController(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + [HttpGet] + public async Task GetNotifications( + [FromQuery] bool unreadOnly = false, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20 + ) + { + var userIdResult = TryGetCurrentUserId(out var userId); + if (userIdResult != null) return userIdResult; + + if (page <= 0) page = 1; + if (pageSize <= 0) pageSize = 20; + if (pageSize > 100) pageSize = 100; + + var query = _dbContext.Notifications + .AsNoTracking() + .Where(n => n.RecipientUserID == userId); + + if (unreadOnly) + query = query.Where(n => !n.IsRead); + + var total = await query.CountAsync(); + + var notifications = await query + .OrderByDescending(n => n.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(n => new + { + n.NotificationID, + n.Type, + n.ActorUserID, + n.ProjectID, + n.CommentID, + n.ProjectLogID, + n.Title, + n.Body, + n.Link, + n.DataJson, + n.IsRead, + n.ReadAt, + n.CreatedAt + }) + .ToListAsync(); + + return Ok(new + { + result = notifications, + total, + page, + pageSize, + message = "Received notifications" + }); + } + + [HttpGet("unread-count")] + public async Task GetUnreadCount() + { + var userIdResult = TryGetCurrentUserId(out var userId); + if (userIdResult != null) return userIdResult; + + var count = await _dbContext.Notifications + .AsNoTracking() + .CountAsync(n => n.RecipientUserID == userId && !n.IsRead); + + return Ok(new + { + result = count, + message = "Received unread notification count" + }); + } + + [HttpPut("{notificationId}/read")] + public async Task MarkAsRead([FromRoute] int notificationId) + { + var userIdResult = TryGetCurrentUserId(out var userId); + if (userIdResult != null) return userIdResult; + + var notification = await _dbContext.Notifications + .SingleOrDefaultAsync(n => n.NotificationID == notificationId && n.RecipientUserID == userId); + + if (notification == null) + return NotFound(new { message = "Notification not found." }); + + if (!notification.IsRead) + { + notification.IsRead = true; + notification.ReadAt = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(); + } + + return Ok(new + { + result = notification, + message = "Notification marked as read" + }); + } + + [HttpPut("read-all")] + public async Task MarkAllAsRead() + { + var userIdResult = TryGetCurrentUserId(out var userId); + if (userIdResult != null) return userIdResult; + + var unreadNotifications = await _dbContext.Notifications + .Where(n => n.RecipientUserID == userId && !n.IsRead) + .ToListAsync(); + + var now = DateTime.UtcNow; + + foreach (var notification in unreadNotifications) + { + notification.IsRead = true; + notification.ReadAt = now; + } + + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = unreadNotifications.Count, + message = "All notifications marked as read" + }); + } + + [HttpGet("preferences")] + public async Task GetPreferences() + { + var userIdResult = TryGetCurrentUserId(out var userId); + if (userIdResult != null) return userIdResult; + + var user = await _dbContext.Users + .AsNoTracking() + .SingleOrDefaultAsync(u => u.Id == userId); + + if (user == null) + return NotFound(new { message = "User not found." }); + + var preferences = await _dbContext.UserNotificationPreferences + .AsNoTracking() + .Where(p => p.UserID == userId) + .ToListAsync(); + + var result = SupportedNotificationTypes.Select(type => + { + var preference = preferences.SingleOrDefault(p => p.NotificationType == type); + + return new + { + notificationType = type, + inAppEnabled = preference?.InAppEnabled ?? true, + emailEnabled = preference?.EmailEnabled ?? GetDefaultEmailPreference(user, type) + }; + }).ToList(); + + return Ok(new + { + result, + message = "Received notification preferences" + }); + } + + [HttpPut("preferences/{notificationType}")] + public async Task UpdatePreference( + [FromRoute] string notificationType, + [FromBody] NotificationPreferenceUpdateVM formdata + ) + { + var userIdResult = TryGetCurrentUserId(out var userId); + if (userIdResult != null) return userIdResult; + + if (!SupportedNotificationTypes.Contains(notificationType)) + return BadRequest(new { message = "Unsupported notification type." }); + + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) + return NotFound(new { message = "User not found." }); + + var preference = await _dbContext.UserNotificationPreferences + .SingleOrDefaultAsync(p => p.UserID == userId && p.NotificationType == notificationType); + + if (preference == null) + { + preference = new UserNotificationPreference + { + UserID = userId, + NotificationType = notificationType + }; + + await _dbContext.UserNotificationPreferences.AddAsync(preference); + } + + preference.InAppEnabled = formdata.InAppEnabled; + preference.EmailEnabled = formdata.EmailEnabled; + + if (notificationType == NotificationTypes.CommentReply) + user.ReceiveCommentReplyEmails = formdata.EmailEnabled; + + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = new + { + notificationType = preference.NotificationType, + preference.InAppEnabled, + preference.EmailEnabled + }, + message = "Notification preference updated" + }); + } + + private IActionResult TryGetCurrentUserId(out int userId) + { + userId = 0; + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + return null; + } + + private static bool GetDefaultEmailPreference(User user, string notificationType) + { + return notificationType == NotificationTypes.CommentReply && user.ReceiveCommentReplyEmails; + } + } +} diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index 010e350c..6c6e1562 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -31,6 +31,8 @@ using Analysim.Core.Entities; using Microsoft.AspNetCore.Authorization; using System.Security.Claims; +using Core.Helper; +using Core.Models; namespace Web.Controllers { @@ -41,17 +43,17 @@ public class ProjectController : ControllerBase private readonly ApplicationDbContext _dbContext; private readonly IConfiguration _configuration; - private readonly IMailNetService _mailNetService; + private readonly INotificationService _notificationService; public ProjectController( ApplicationDbContext dbContext, IConfiguration configuration, - IMailNetService mailNetService + INotificationService notificationService ) { _dbContext = dbContext; _configuration = configuration; - _mailNetService = mailNetService; + _notificationService = notificationService; } // Image Helpers @@ -1004,6 +1006,18 @@ public async Task AddProjectLog([FromForm] CreateProjectLogVM for await transaction.CommitAsync(); + try + { + await _notificationService.PublishAsync(new NotificationEvent + { + Type = NotificationTypes.ProjectLogCreated, + ActorUserID = user.Id, + ProjectID = newProjectLog.ProjectID, + ProjectLogID = newProjectLog.LogID + }); + } + catch (Exception) { } + return Ok(new { result = new @@ -1132,113 +1146,23 @@ public async Task PostComment([FromForm] CreateProjectCommentVM f _dbContext.ProjectComments.Add(newComment); await _dbContext.SaveChangesAsync(); - // Send email notification if this comment is a reply + // Publish notification if this comment is a reply if (parentComment != null && parentComment.UserID != userId) { try { - // Get the user who wrote the parent comment - var parentCommentUser = await _dbContext.Users - .SingleOrDefaultAsync(u => u.Id == parentComment.UserID); - - if (parentCommentUser != null - && parentCommentUser.ReceiveCommentReplyEmails - && !string.IsNullOrWhiteSpace(parentCommentUser.Email) - ) + await _notificationService.PublishAsync(new NotificationEvent { - // Collect comment information for email - var recipientName = string.IsNullOrWhiteSpace(parentCommentUser.UserName) - ? "there" - : parentCommentUser.UserName; - - var replyingUserName = string.IsNullOrWhiteSpace(user.UserName) - ? "Someone" - : user.UserName; - - var projectName = string.IsNullOrWhiteSpace(project.Name) - ? $"Project #{project.ProjectID}" - : project.Name; - - var parentCommentPreview = parentComment.Content.Length > 200 - ? parentComment.Content.Substring(0, 200) + "..." - : parentComment.Content; - - var replyPreview = trimmedContent.Length > 200 - ? trimmedContent.Substring(0, 200) + "..." - : trimmedContent; - - var projectRoute = string.IsNullOrWhiteSpace(project.Route) - ? project.ProjectID.ToString() - : project.Route.Trim('/'); - - // Handle link to comment - var clientBaseUrl = _configuration["ClientSettings:BaseUrl"]?.TrimEnd('/'); - - var projectCommentUrl = string.IsNullOrWhiteSpace(clientBaseUrl) - ? null - : $"{clientBaseUrl}/project/{projectRoute}/comment"; - - var linkHtml = string.IsNullOrWhiteSpace(projectCommentUrl) - ? "" - : $@" -

- View the conversation -

- "; - - var linkText = string.IsNullOrWhiteSpace(projectCommentUrl) - ? "" - : $"\n\nView the conversation: {projectCommentUrl}"; - - // Safety HtmlEncoding - var safeRecipientName = System.Net.WebUtility.HtmlEncode(recipientName); - var safeReplyingUserName = System.Net.WebUtility.HtmlEncode(replyingUserName); - var safeProjectName = System.Net.WebUtility.HtmlEncode(projectName); - var safeParentCommentPreview = System.Net.WebUtility.HtmlEncode(parentCommentPreview); - var safeReplyPreview = System.Net.WebUtility.HtmlEncode(replyPreview); - - // Build email - var subject = $"Someone replied to your comment on AnalySim"; - - var bodyHtml = $@" -

Hi {safeRecipientName},

- -

- {safeReplyingUserName} replied to your comment on - {safeProjectName}. -

- -

Your comment:

-
- {safeParentCommentPreview} -
- -

Their reply:

-
- {safeReplyPreview} -
- - {linkHtml} - "; - - var bodyText = - $"Hi {recipientName},\n\n" + - $"{replyingUserName} replied to your comment on {projectName}.\n\n" + - $"Your comment:\n\"{parentCommentPreview}\"\n\n" + - $"Their reply:\n\"{replyPreview}\"" + - linkText; - - - await _mailNetService.SendEmail( - parentCommentUser.Email, - recipientName, - subject, - bodyHtml, - bodyText - ); - } + Type = NotificationTypes.CommentReply, + ActorUserID = userId, + RecipientUserID = parentComment.UserID, + ProjectID = project.ProjectID, + CommentID = newComment.CommentID, + ParentCommentID = parentComment.CommentID, + ProjectLogID = formdata.ProjectLogID + }); } - catch (Exception){} + catch (Exception) { } } // Return @@ -1961,6 +1885,22 @@ public async Task AddUser([FromForm] ProjectUserVM formdata) _dbContext.Entry(projectUser).Reference(pu => pu.User).Load(); + try + { + await _notificationService.PublishAsync(new NotificationEvent + { + Type = NotificationTypes.ProjectMemberAdded, + ActorUserID = user.Id, + RecipientUserID = projectUser.UserID, + ProjectID = projectUser.ProjectID, + Data = new Dictionary + { + ["role"] = projectUser.UserRole + } + }); + } + catch (Exception) { } + // Return Ok Status return Ok(new { @@ -1984,6 +1924,22 @@ public async Task AddUser([FromForm] ProjectUserVM formdata) _dbContext.Entry(projectUser).Reference(pu => pu.User).Load(); + try + { + await _notificationService.PublishAsync(new NotificationEvent + { + Type = NotificationTypes.ProjectMemberAdded, + ActorUserID = user.Id, + RecipientUserID = projectUser.UserID, + ProjectID = projectUser.ProjectID, + Data = new Dictionary + { + ["role"] = projectUser.UserRole + } + }); + } + catch (Exception) { } + // Return Ok Status return Ok(new { @@ -2057,6 +2013,17 @@ public async Task FollowProject([FromRoute] int projectID) _dbContext.Entry(projectUser).State = EntityState.Modified; await _dbContext.SaveChangesAsync(); + try + { + await _notificationService.PublishAsync(new NotificationEvent + { + Type = NotificationTypes.ProjectJoined, + ActorUserID = user.Id, + ProjectID = project.ProjectID + }); + } + catch (Exception) { } + return Ok(new { result = projectUser, @@ -2078,6 +2045,17 @@ public async Task FollowProject([FromRoute] int projectID) _dbContext.Entry(projectUser).Reference(pu => pu.User).Load(); _dbContext.Entry(projectUser).Reference(pu => pu.Project).Load(); + try + { + await _notificationService.PublishAsync(new NotificationEvent + { + Type = NotificationTypes.ProjectJoined, + ActorUserID = user.Id, + ProjectID = project.ProjectID + }); + } + catch (Exception) { } + return Ok(new { result = projectUser, @@ -3157,6 +3135,18 @@ public async Task UpdateProjectLog([FromRoute] int logId, [FromFo await _dbContext.SaveChangesAsync(); await transaction.CommitAsync(); + try + { + await _notificationService.PublishAsync(new NotificationEvent + { + Type = NotificationTypes.ProjectLogUpdated, + ActorUserID = user.Id, + ProjectID = projectLog.ProjectID, + ProjectLogID = projectLog.LogID + }); + } + catch (Exception) { } + return Ok(new { message = "Project Log Updated successfully." diff --git a/src/Analysim.Web/Extensions/ServiceExtensions.cs b/src/Analysim.Web/Extensions/ServiceExtensions.cs index acf21b68..20b89fc3 100644 --- a/src/Analysim.Web/Extensions/ServiceExtensions.cs +++ b/src/Analysim.Web/Extensions/ServiceExtensions.cs @@ -151,5 +151,10 @@ public static void ConfigureMailService(this IServiceCollection services, IConfi services.AddTransient(); } + public static void ConfigureNotificationService(this IServiceCollection services) + { + services.AddScoped(); + } + } } diff --git a/src/Analysim.Web/Services/NotificationService.cs b/src/Analysim.Web/Services/NotificationService.cs new file mode 100644 index 00000000..be6c4229 --- /dev/null +++ b/src/Analysim.Web/Services/NotificationService.cs @@ -0,0 +1,435 @@ +using Core.Entities; +using Core.Helper; +using Core.Interfaces; +using Core.Models; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Web.Services +{ + public class NotificationService : INotificationService + { + private readonly ApplicationDbContext _dbContext; + private readonly IMailNetService _mailNetService; + private readonly IConfiguration _configuration; + + public NotificationService( + ApplicationDbContext dbContext, + IMailNetService mailNetService, + IConfiguration configuration + ) + { + _dbContext = dbContext; + _mailNetService = mailNetService; + _configuration = configuration; + } + + public async Task PublishAsync(NotificationEvent notificationEvent) + { + if (notificationEvent == null || string.IsNullOrWhiteSpace(notificationEvent.Type)) + return; + + switch (notificationEvent.Type) + { + case NotificationTypes.CommentReply: + await PublishCommentReplyAsync(notificationEvent); + break; + + case NotificationTypes.ProjectMemberAdded: + await PublishProjectMemberAddedAsync(notificationEvent); + break; + + case NotificationTypes.ProjectJoined: + await PublishProjectJoinedAsync(notificationEvent); + break; + + case NotificationTypes.ProjectLogCreated: + case NotificationTypes.ProjectLogUpdated: + await PublishProjectLogUpdateAsync(notificationEvent); + break; + } + } + + private async Task PublishCommentReplyAsync(NotificationEvent notificationEvent) + { + if (!notificationEvent.CommentID.HasValue) + return; + + var comment = await _dbContext.ProjectComments + .AsNoTracking() + .Include(c => c.ParentComment) + .Include(c => c.Project) + .SingleOrDefaultAsync(c => c.CommentID == notificationEvent.CommentID.Value); + + if (comment == null || comment.ParentComment == null) + return; + + var recipientUserId = notificationEvent.RecipientUserID ?? comment.ParentComment.UserID; + if (notificationEvent.ActorUserID.HasValue && recipientUserId == notificationEvent.ActorUserID.Value) + return; + + var actorName = await GetUserDisplayNameAsync(notificationEvent.ActorUserID, "Someone"); + var projectName = GetProjectName(comment.Project); + var link = BuildProjectLink(comment.Project, comment.ProjectLogID.HasValue ? "log" : "comment"); + var title = "Someone replied to your comment"; + var body = $"{actorName} replied to your comment on {projectName}."; + var data = new Dictionary(notificationEvent.Data) + { + ["parentCommentPreview"] = Preview(comment.ParentComment.Content), + ["replyPreview"] = Preview(comment.Content) + }; + + await CreateAndSendAsync( + notificationEvent, + new[] { recipientUserId }, + title, + body, + link, + data, + emailSubject: "Someone replied to your comment on AnalySim", + emailBuilder: recipientName => BuildCommentReplyEmail( + recipientName, + actorName, + projectName, + data["parentCommentPreview"], + data["replyPreview"], + BuildAbsoluteLink(link) + ) + ); + } + + private async Task PublishProjectMemberAddedAsync(NotificationEvent notificationEvent) + { + if (!notificationEvent.ProjectID.HasValue || !notificationEvent.RecipientUserID.HasValue) + return; + + var project = await _dbContext.Projects + .AsNoTracking() + .SingleOrDefaultAsync(p => p.ProjectID == notificationEvent.ProjectID.Value); + + if (project == null) + return; + + if (notificationEvent.ActorUserID.HasValue && notificationEvent.RecipientUserID.Value == notificationEvent.ActorUserID.Value) + return; + + var actorName = await GetUserDisplayNameAsync(notificationEvent.ActorUserID, "Someone"); + var projectName = GetProjectName(project); + var role = notificationEvent.Data.TryGetValue("role", out var roleValue) ? roleValue : "member"; + var link = BuildProjectLink(project, null); + var title = "You were added to a project"; + var body = $"{actorName} added you to {projectName} as {role}."; + + await CreateAndSendAsync( + notificationEvent, + new[] { notificationEvent.RecipientUserID.Value }, + title, + body, + link, + notificationEvent.Data + ); + } + + private async Task PublishProjectJoinedAsync(NotificationEvent notificationEvent) + { + if (!notificationEvent.ProjectID.HasValue || !notificationEvent.ActorUserID.HasValue) + return; + + var project = await _dbContext.Projects + .AsNoTracking() + .SingleOrDefaultAsync(p => p.ProjectID == notificationEvent.ProjectID.Value); + + if (project == null) + return; + + var recipients = await _dbContext.ProjectUsers + .AsNoTracking() + .Where(pu => + pu.ProjectID == notificationEvent.ProjectID.Value && + pu.UserRole == "owner" && + pu.UserID != notificationEvent.ActorUserID.Value) + .Select(pu => pu.UserID) + .Distinct() + .ToListAsync(); + + var actorName = await GetUserDisplayNameAsync(notificationEvent.ActorUserID, "Someone"); + var projectName = GetProjectName(project); + var link = BuildProjectLink(project, null); + var title = "Someone joined your project"; + var body = $"{actorName} joined {projectName}."; + + await CreateAndSendAsync( + notificationEvent, + recipients, + title, + body, + link, + notificationEvent.Data + ); + } + + private async Task PublishProjectLogUpdateAsync(NotificationEvent notificationEvent) + { + if (!notificationEvent.ProjectLogID.HasValue) + return; + + var projectLog = await _dbContext.ProjectLogs + .AsNoTracking() + .Include(l => l.Project) + .SingleOrDefaultAsync(l => l.LogID == notificationEvent.ProjectLogID.Value); + + if (projectLog == null) + return; + + var actorUserId = notificationEvent.ActorUserID ?? projectLog.UserID; + var recipients = await _dbContext.ProjectUsers + .AsNoTracking() + .Where(pu => + pu.ProjectID == projectLog.ProjectID && + pu.IsFollowing && + pu.UserID != actorUserId) + .Select(pu => pu.UserID) + .Distinct() + .ToListAsync(); + + var actorName = await GetUserDisplayNameAsync(actorUserId, "Someone"); + var projectName = GetProjectName(projectLog.Project); + var logTitle = string.IsNullOrWhiteSpace(projectLog.Title) ? "Project update" : projectLog.Title.Trim(); + var link = BuildProjectLink(projectLog.Project, "log"); + var created = notificationEvent.Type == NotificationTypes.ProjectLogCreated; + var title = created ? "New update on a followed project" : "Followed project update changed"; + var body = created + ? $"{actorName} posted \"{logTitle}\" on {projectName}." + : $"{actorName} updated \"{logTitle}\" on {projectName}."; + var data = new Dictionary(notificationEvent.Data) + { + ["projectLogTitle"] = logTitle, + ["projectLogPreview"] = Preview(projectLog.Content) + }; + + await CreateAndSendAsync( + notificationEvent, + recipients, + title, + body, + link, + data + ); + } + + private async Task CreateAndSendAsync( + NotificationEvent notificationEvent, + IEnumerable recipientUserIds, + string title, + string body, + string link, + IDictionary data, + string emailSubject = null, + Func emailBuilder = null + ) + { + var recipients = recipientUserIds + .Where(id => !notificationEvent.ActorUserID.HasValue || id != notificationEvent.ActorUserID.Value) + .Distinct() + .ToList(); + + if (recipients.Count == 0) + return; + + var recipientUsers = await _dbContext.Users + .Where(u => recipients.Contains(u.Id)) + .ToListAsync(); + + var notifications = new List(); + var emailJobs = new List<(User User, string Subject, string BodyHtml, string BodyText)>(); + + foreach (var recipient in recipientUsers) + { + var preferences = await GetPreferencesAsync(recipient, notificationEvent.Type); + + if (preferences.InAppEnabled) + { + notifications.Add(new Notification + { + RecipientUserID = recipient.Id, + ActorUserID = notificationEvent.ActorUserID, + Type = notificationEvent.Type, + ProjectID = notificationEvent.ProjectID, + CommentID = notificationEvent.CommentID, + ProjectLogID = notificationEvent.ProjectLogID, + Title = title, + Body = body, + Link = link, + DataJson = JsonSerializer.Serialize(data ?? new Dictionary()), + IsRead = false, + CreatedAt = DateTime.UtcNow + }); + } + + if (preferences.EmailEnabled && + emailBuilder != null && + !string.IsNullOrWhiteSpace(recipient.Email)) + { + var recipientName = string.IsNullOrWhiteSpace(recipient.UserName) ? "there" : recipient.UserName; + var email = emailBuilder(recipientName); + emailJobs.Add((recipient, emailSubject ?? title, email.BodyHtml, email.BodyText)); + } + } + + if (notifications.Count > 0) + { + await _dbContext.Notifications.AddRangeAsync(notifications); + await _dbContext.SaveChangesAsync(); + } + + foreach (var emailJob in emailJobs) + { + try + { + var recipientName = string.IsNullOrWhiteSpace(emailJob.User.UserName) + ? "there" + : emailJob.User.UserName; + + await _mailNetService.SendEmail( + emailJob.User.Email, + recipientName, + emailJob.Subject, + emailJob.BodyHtml, + emailJob.BodyText + ); + } + catch (Exception) + { + } + } + } + + private async Task<(bool InAppEnabled, bool EmailEnabled)> GetPreferencesAsync(User user, string notificationType) + { + var preference = await _dbContext.UserNotificationPreferences + .AsNoTracking() + .SingleOrDefaultAsync(p => p.UserID == user.Id && p.NotificationType == notificationType); + + if (preference != null) + return (preference.InAppEnabled, preference.EmailEnabled); + + if (notificationType == NotificationTypes.CommentReply) + return (true, user.ReceiveCommentReplyEmails); + + return (true, false); + } + + private async Task GetUserDisplayNameAsync(int? userId, string fallback) + { + if (!userId.HasValue) + return fallback; + + var username = await _dbContext.Users + .AsNoTracking() + .Where(u => u.Id == userId.Value) + .Select(u => u.UserName) + .SingleOrDefaultAsync(); + + return string.IsNullOrWhiteSpace(username) ? fallback : username; + } + + private static string GetProjectName(Project project) + { + if (project == null || string.IsNullOrWhiteSpace(project.Name)) + return "this project"; + + return project.Name; + } + + private static string BuildProjectLink(Project project, string tab) + { + if (project == null) + return null; + + var route = string.IsNullOrWhiteSpace(project.Route) + ? project.ProjectID.ToString() + : project.Route.Trim('/'); + + return string.IsNullOrWhiteSpace(tab) + ? $"/project/{route}" + : $"/project/{route}/{tab}"; + } + + private string BuildAbsoluteLink(string relativeLink) + { + if (string.IsNullOrWhiteSpace(relativeLink)) + return null; + + var clientBaseUrl = _configuration["ClientSettings:BaseUrl"]?.TrimEnd('/'); + + return string.IsNullOrWhiteSpace(clientBaseUrl) + ? null + : $"{clientBaseUrl}{relativeLink}"; + } + + private static string Preview(string value, int maxLength = 200) + { + if (string.IsNullOrWhiteSpace(value)) + return string.Empty; + + var trimmed = value.Trim(); + return trimmed.Length <= maxLength + ? trimmed + : trimmed.Substring(0, maxLength) + "..."; + } + + private static (string BodyHtml, string BodyText) BuildCommentReplyEmail( + string recipientName, + string actorName, + string projectName, + string parentCommentPreview, + string replyPreview, + string absoluteLink + ) + { + var safeRecipientName = WebUtility.HtmlEncode(recipientName); + var safeActorName = WebUtility.HtmlEncode(actorName); + var safeProjectName = WebUtility.HtmlEncode(projectName); + var safeParentCommentPreview = WebUtility.HtmlEncode(parentCommentPreview); + var safeReplyPreview = WebUtility.HtmlEncode(replyPreview); + + var linkHtml = string.IsNullOrWhiteSpace(absoluteLink) + ? "" + : $"

View the conversation

"; + + var linkText = string.IsNullOrWhiteSpace(absoluteLink) + ? "" + : $"\n\nView the conversation: {absoluteLink}"; + + var bodyHtml = $@" +

Hi {safeRecipientName},

+

{safeActorName} replied to your comment on {safeProjectName}.

+

Your comment:

+
+ {safeParentCommentPreview} +
+

Their reply:

+
+ {safeReplyPreview} +
+ {linkHtml} + "; + + var bodyText = + $"Hi {recipientName},\n\n" + + $"{actorName} replied to your comment on {projectName}.\n\n" + + $"Your comment:\n\"{parentCommentPreview}\"\n\n" + + $"Their reply:\n\"{replyPreview}\"" + + linkText; + + return (bodyHtml, bodyText); + } + } +} diff --git a/src/Analysim.Web/Startup.cs b/src/Analysim.Web/Startup.cs index c3c9243d..cf73a793 100644 --- a/src/Analysim.Web/Startup.cs +++ b/src/Analysim.Web/Startup.cs @@ -73,6 +73,8 @@ public void ConfigureServices(IServiceCollection services) services.ConfigureMailService(Configuration); + services.ConfigureNotificationService(); + services.AddControllers(config => { config.RespectBrowserAcceptHeader = true; diff --git a/src/Analysim.Web/ViewModels/Notification/NotificationPreferenceUpdateVM.cs b/src/Analysim.Web/ViewModels/Notification/NotificationPreferenceUpdateVM.cs new file mode 100644 index 00000000..bea8d9cf --- /dev/null +++ b/src/Analysim.Web/ViewModels/Notification/NotificationPreferenceUpdateVM.cs @@ -0,0 +1,8 @@ +namespace Web.ViewModels.Notification +{ + public class NotificationPreferenceUpdateVM + { + public bool InAppEnabled { get; set; } + public bool EmailEnabled { get; set; } + } +} From b10c4526e6a7d19688940c0e758dd7fa14c74d4b Mon Sep 17 00:00:00 2001 From: Mohab Date: Mon, 10 Aug 2026 03:40:51 +0300 Subject: [PATCH 2/6] integrate notifications into frontend --- .../src/app/interfaces/user-notification.ts | 37 +++++ .../src/app/navbar/navbar.component.html | 55 +++++++- .../src/app/navbar/navbar.component.scss | 123 ++++++++++++++++ .../src/app/navbar/navbar.component.ts | 77 ++++++++++ .../profile-setting.component.html | 58 ++++++-- .../profile-setting.component.scss | 34 ++++- .../profile-setting.component.ts | 112 +++++++++++++++ .../app/services/user-notification.service.ts | 132 ++++++++++++++++++ 8 files changed, 618 insertions(+), 10 deletions(-) create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/user-notification.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/services/user-notification.service.ts 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..ac3aa467 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/user-notification.ts @@ -0,0 +1,37 @@ +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', + 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 @@ + + - @@ -22,4 +22,4 @@ -
\ 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..6e8e7b26 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 @@ -76,11 +76,10 @@ 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.projectService.inviteProjectMember(this.project.projectID, user.id).subscribe( result => { - this.project.projectUsers.push(result) - this.updateProject.emit(this.project) + console.log(result) }, error =>{ console.log(error) } 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..e21409b4 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,7 +1,6 @@ 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'; @Component({ @@ -33,8 +32,4 @@ export class ProjectEditComponent implements OnInit { this.project.projectTags = projectTags } - updateProjectUsers(projectUsers : ProjectUser[]){ - this.project.projectUsers = projectUsers - } - } 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..262b1d0a 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 @@ -3,7 +3,7 @@
    - +
- \ 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..63dedd0f 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 @@ -114,8 +114,26 @@

Share Recommend - Join - Leave + + Join + + + Leave + { - this.project.projectUsers.push(result) - this.projectUser = result; - }, error => { - console.log(error) - } - ) - } - else { - // Modify Project User - this.projectUser.userRole = "member" - this.projectService.updateUser(this.projectUser).subscribe( - result => { - let index = this.project.projectUsers.findIndex(pu => pu.userID == result.userID) - this.project.projectUsers[index] = result - this.projectUser = result; - }, error => { - console.log(error) - } - ) - } - } - } - - leaveProject() { - if (this.projectUser.isFollowing == false) - this.projectService.removeUser(this.projectUser.projectID, this.projectUser.userID).subscribe( + this.projectService.requestJoinProject(this.project.projectID).subscribe( result => { - let index = this.project.projectUsers.indexOf(result) - this.project.projectUsers.splice(index, 1) - this.projectUser = null + console.log(result) }, error => { console.log(error) } ) - else { - this.projectUser.userRole = "follower" - this.projectService.updateUser(this.projectUser).subscribe( - result => { - let index = this.project.projectUsers.findIndex(pu => pu.userID == result.userID) - this.project.projectUsers[index] = result + } + } + + leaveProject() { + if(!this.projectUser) return + + const wasFollowing = this.projectUser.isFollowing + + this.projectService.leaveProject(this.projectUser.projectID).subscribe( + result => { + let index = this.project.projectUsers.findIndex(pu => pu.userID == result.userID) + + if (wasFollowing) { + if (index > -1) this.project.projectUsers[index] = result this.projectUser = result - }, error => { - console.log(error) } - ) - } + else { + if (index > -1) this.project.projectUsers.splice(index, 1) + this.projectUser = null + } + }, error => { + console.log(error) + } + ) } forkProject() { 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..ee5d5fe8 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 { ProjectMembershipRequest } from '../interfaces/project-membership-request'; @Injectable({ providedIn: 'root' @@ -50,10 +51,13 @@ export class ProjectService { private urlGetExpiredProjectLogs: string = this.baseUrl + "getexpiredprojectlogs/"; private urlGetPublications: string = this.baseUrl + "GetPublications/"; private urlGetProjectNotebookRefs: string = this.baseUrl + "getprojectnotebookreferences/"; + private urlGetProjectMembershipRequests: string = this.baseUrl + "getprojectmembershiprequests/"; + private urlGetMyMembershipRequests: string = this.baseUrl + "getmymembershiprequests"; // Post private urlCreateProject: string = this.baseUrl + "createproject" - private urlAddUser: string = this.baseUrl + "adduser" + private urlRequestJoinProject: string = this.baseUrl + "requestjoinproject/" + private urlInviteProjectMember: string = this.baseUrl + "inviteprojectmember" private urlFollowProject: string = this.baseUrl + "followproject/" private urlUnfollowProject: string = this.baseUrl + "unfollowproject/" private urlAddTag: string = this.baseUrl + "addtag" @@ -72,7 +76,8 @@ export class ProjectService { // Put private urlUpdateProject: string = this.baseUrl + "updateproject/" - private urlupdateUser: string = this.baseUrl + "updateuser" + private urlAcceptProjectMembershipRequest: string = this.baseUrl + "acceptprojectmembershiprequest/" + private urlRejectProjectMembershipRequest: string = this.baseUrl + "rejectprojectmembershiprequest/" private urlUpdateFile: string = this.baseUrl + "updateFile" private urlRenameNotebook: string = this.baseUrl + "RenameNotebook" private urlDeleteDatasetFromNotebook: string = this.baseUrl + "deleteDatasetFromNotebook/" @@ -88,6 +93,8 @@ export class ProjectService { private urlRemoveUser: string = this.baseUrl + "removeuser/" private urlRemoveTag: string = this.baseUrl + "removetag/" private urlDeleteFile: string = this.baseUrl + "deleteFile/" + private urlCancelProjectMembershipRequest: string = this.baseUrl + "cancelprojectmembershiprequest/" + private urlLeaveProject: string = this.baseUrl + "leaveproject/" private urlUnlikeComment: string = this.baseUrl + "likecomment/"; private urlRemoveCommentReport: string = this.baseUrl + "removecommentreport/"; private urlRemoveAllCommentReports: string = this.baseUrl + "removeallcommentreports/"; @@ -326,15 +333,110 @@ export class ProjectService { ) } + getProjectMembershipRequests(projectID: number): Observable { + return this.http.get(this.urlGetProjectMembershipRequests + projectID) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + getMyMembershipRequests(): Observable { + return this.http.get(this.urlGetMyMembershipRequests) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + requestJoinProject(projectID: number): Observable { + return this.http.post(this.urlRequestJoinProject + projectID, null) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } - addUser(projectID: number, userID: number, userRole: string, isFollowing: boolean): Observable { + inviteProjectMember(projectID: number, userID: number, message: string = ''): Observable { let body = new FormData() body.append('projectid', projectID.toString()) body.append('userid', userID.toString()) - body.append('userrole', userRole) - body.append('isFollowing', isFollowing ? 'true' : 'false') - return this.http.post(this.urlAddUser, body) + body.append('message', message) + return this.http.post(this.urlInviteProjectMember, body) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + acceptProjectMembershipRequest(requestID: number): Observable { + return this.http.put(this.urlAcceptProjectMembershipRequest + requestID, null) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + rejectProjectMembershipRequest(requestID: number): Observable { + return this.http.put(this.urlRejectProjectMembershipRequest + requestID, null) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + cancelProjectMembershipRequest(requestID: number): Observable { + return this.http.delete(this.urlCancelProjectMembershipRequest + requestID) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + leaveProject(projectID: number): Observable { + return this.http.delete(this.urlLeaveProject + projectID) .pipe( map(body => { console.log(body.message) @@ -554,26 +656,6 @@ export class ProjectService { ) } - updateUser(userRole: ProjectUser): Observable { - let body = new FormData() - body.append('userid', userRole.userID.toString()) - body.append('projectid', userRole.projectID.toString()) - body.append('userrole', userRole.userRole) - body.append('isFollowing', userRole.isFollowing ? 'true' : 'false') - return this.http.put(this.urlupdateUser, body) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) - } - - deleteDatasetFromNotebook(notebookID: number, file: BlobFile): Observable { let body = new FormData(); body.append('notebookID', notebookID.toString()); diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index 010e350c..1d9b38e7 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -54,6 +54,16 @@ IMailNetService mailNetService _mailNetService = mailNetService; } + private const string ProjectOwnerRole = "owner"; + private const string ProjectMemberRole = "member"; + private const string ProjectFollowerRole = "follower"; + private const string MembershipRequestTypeJoin = "join_request"; + private const string MembershipRequestTypeInvitation = "invitation"; + private const string MembershipRequestStatusPending = "pending"; + private const string MembershipRequestStatusAccepted = "accepted"; + private const string MembershipRequestStatusRejected = "rejected"; + private const string MembershipRequestStatusCancelled = "cancelled"; + // Image Helpers private static string BuildImageDataUrl(byte[] imageBytes, string extension) { @@ -82,6 +92,62 @@ private static string GetImageContentType(string extension) }; } + private bool TryGetCurrentUserId(out int userId) + { + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + return int.TryParse(userIdClaim, out userId); + } + + private async Task GetCurrentUserAsync(int userId) + { + return await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + } + + private async Task IsProjectOwnerAsync(int projectID, int userID) + { + return await _dbContext.ProjectUsers + .AnyAsync(pu => + pu.ProjectID == projectID && + pu.UserID == userID && + pu.UserRole == ProjectOwnerRole); + } + + private IQueryable ProjectMembershipRequestsWithUsers() + { + return _dbContext.ProjectMembershipRequests + .Include(pmr => pmr.Project) + .Include(pmr => pmr.RequesterUser) + .Include(pmr => pmr.TargetUser) + .Include(pmr => pmr.CreatedByUser); + } + + private async Task AddProjectMemberAsync(int projectID, int userID) + { + var projectUser = await _dbContext.ProjectUsers.FindAsync(userID, projectID); + + if (projectUser == null) + { + projectUser = new ProjectUser + { + ProjectID = projectID, + UserID = userID, + UserRole = ProjectMemberRole, + IsFollowing = false + }; + + await _dbContext.ProjectUsers.AddAsync(projectUser); + return projectUser; + } + + if (projectUser.UserRole == ProjectFollowerRole) + { + projectUser.UserRole = ProjectMemberRole; + _dbContext.Entry(projectUser).State = EntityState.Modified; + } + + return projectUser; + } + #region GET REQUEST /* * Type : GET @@ -1918,80 +1984,6 @@ await _dbContext.ProjectTags.AddRangeAsync( }); } - /* - * Type : POST - * URL : /api/project/adduser - * Param : ProjectUserViewModel - * Description: Add User To Project - */ - [Authorize] - [HttpPost("[action]")] - public async Task AddUser([FromForm] ProjectUserVM formdata) - { - // Find User - var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) - { - return Unauthorized(new { message = "Invalid user identifier." }); - } - var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); - if (user == null) return NotFound(new { message = "User Not Found" }); - - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == formdata.ProjectID && - aup.UserRole == "owner")); - - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); - - // Find Tag In Database - var projectUser = _dbContext.ProjectUsers.Find(formdata.UserID, formdata.ProjectID); - - // Update Project User If Exist - if (projectUser != null) - { - // If Project User Is Not Follower Return Error - if (projectUser.UserRole != "follower") return Conflict(new { result = formdata, message = "Project User Already Exist" }); - - // Add Tag To Project - projectUser.UserRole = formdata.UserRole; - - await _dbContext.SaveChangesAsync(); - - _dbContext.Entry(projectUser).Reference(pu => pu.User).Load(); - - // Return Ok Status - return Ok(new - { - result = projectUser, - message = "Project User Successfully Updated" - }); - } - - // Create Many To Many Connection - projectUser = new ProjectUser - { - ProjectID = formdata.ProjectID, - UserID = formdata.UserID, - UserRole = formdata.UserRole, - IsFollowing = formdata.IsFollowing - }; - - // Add To Database And Save Change - await _dbContext.ProjectUsers.AddAsync(projectUser); - await _dbContext.SaveChangesAsync(); - - _dbContext.Entry(projectUser).Reference(pu => pu.User).Load(); - - // Return Ok Status - return Ok(new - { - result = projectUser, - message = "Project User Successfully Created" - }); - } - /* * Type : POST * URL : /api/project/followproject/{projectID} @@ -2159,6 +2151,454 @@ public async Task UnfollowProject([FromRoute] int projectID) }); } + /* + * Type : GET + * URL : /api/project/getprojectmembershiprequests/{projectID} + * Description: Get all membership requests for a project. Owner only. + */ + [Authorize] + [HttpGet("[action]/{projectID}")] + public async Task GetProjectMembershipRequests([FromRoute] int projectID) + { + if (projectID <= 0) + return BadRequest(new { message = "Invalid project id." }); + + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var user = await GetCurrentUserAsync(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 isOwner = await IsProjectOwnerAsync(projectID, user.Id); + if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + + var requests = await ProjectMembershipRequestsWithUsers() + .Where(pmr => pmr.ProjectID == projectID) + .OrderByDescending(pmr => pmr.CreatedAt) + .ToListAsync(); + + return Ok(new + { + result = requests, + message = "Project membership requests received." + }); + } + + /* + * Type : GET + * URL : /api/project/getmymembershiprequests + * Description: Get current user's join requests and invitations. + */ + [Authorize] + [HttpGet("[action]")] + public async Task GetMyMembershipRequests() + { + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var user = await GetCurrentUserAsync(userId); + if (user == null) return NotFound(new { message = "User Not Found" }); + + var requests = await ProjectMembershipRequestsWithUsers() + .Where(pmr => pmr.TargetUserID == user.Id || pmr.RequesterUserID == user.Id) + .OrderByDescending(pmr => pmr.CreatedAt) + .ToListAsync(); + + return Ok(new + { + result = requests, + message = "Membership requests received." + }); + } + + /* + * Type : POST + * URL : /api/project/requestjoinproject/{projectID} + * Description: Create a pending join request for the authenticated user. + */ + [Authorize] + [HttpPost("[action]/{projectID}")] + public async Task RequestJoinProject([FromRoute] int projectID) + { + if (projectID <= 0) + return BadRequest(new { message = "Invalid project id." }); + + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var user = await GetCurrentUserAsync(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, projectID); + if (projectUser != null && projectUser.UserRole != ProjectFollowerRole) + { + return Conflict(new + { + result = projectUser, + message = "You are already a member of this project." + }); + } + + var pendingInvitation = await ProjectMembershipRequestsWithUsers() + .SingleOrDefaultAsync(pmr => + pmr.ProjectID == projectID && + pmr.TargetUserID == user.Id && + pmr.Type == MembershipRequestTypeInvitation && + pmr.Status == MembershipRequestStatusPending); + + if (pendingInvitation != null) + { + return Conflict(new + { + result = pendingInvitation, + message = "You already have a pending invitation for this project." + }); + } + + var existingRequest = await ProjectMembershipRequestsWithUsers() + .SingleOrDefaultAsync(pmr => + pmr.ProjectID == projectID && + pmr.TargetUserID == user.Id && + pmr.Type == MembershipRequestTypeJoin && + pmr.Status == MembershipRequestStatusPending); + + if (existingRequest != null) + { + return Ok(new + { + result = existingRequest, + message = "Join request is already pending." + }); + } + + var joinRequest = new ProjectMembershipRequest + { + ProjectID = projectID, + RequesterUserID = user.Id, + TargetUserID = user.Id, + CreatedByUserID = user.Id, + Type = MembershipRequestTypeJoin, + Status = MembershipRequestStatusPending, + CreatedAt = DateTimeOffset.UtcNow + }; + + await _dbContext.ProjectMembershipRequests.AddAsync(joinRequest); + await _dbContext.SaveChangesAsync(); + + joinRequest = await ProjectMembershipRequestsWithUsers() + .SingleAsync(pmr => pmr.ProjectMembershipRequestID == joinRequest.ProjectMembershipRequestID); + + return Ok(new + { + result = joinRequest, + message = "Join request submitted successfully." + }); + } + + /* + * Type : POST + * URL : /api/project/inviteprojectmember + * Param : ProjectMembershipInvitationVM + * Description: Create a pending project invitation. Owner only. + */ + [Authorize] + [HttpPost("[action]")] + public async Task InviteProjectMember([FromForm] ProjectMembershipInvitationVM formdata) + { + if (!ModelState.IsValid) return BadRequest(ModelState); + + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var owner = await GetCurrentUserAsync(userId); + if (owner == null) return NotFound(new { message = "User Not Found" }); + + var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); + if (project == null) return NotFound(new { message = "Project Not Found" }); + + var isOwner = await IsProjectOwnerAsync(formdata.ProjectID, owner.Id); + if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + + var targetUser = await _dbContext.Users.FindAsync(formdata.UserID); + if (targetUser == null) return NotFound(new { message = "Target user not found." }); + if (targetUser.Id == owner.Id) return BadRequest(new { message = "Cannot invite yourself." }); + + var projectUser = await _dbContext.ProjectUsers.FindAsync(targetUser.Id, formdata.ProjectID); + if (projectUser != null && projectUser.UserRole != ProjectFollowerRole) + { + return Conflict(new + { + result = projectUser, + message = "User is already a member of this project." + }); + } + + var pendingJoinRequest = await ProjectMembershipRequestsWithUsers() + .SingleOrDefaultAsync(pmr => + pmr.ProjectID == formdata.ProjectID && + pmr.TargetUserID == targetUser.Id && + pmr.Type == MembershipRequestTypeJoin && + pmr.Status == MembershipRequestStatusPending); + + if (pendingJoinRequest != null) + { + return Conflict(new + { + result = pendingJoinRequest, + message = "User already has a pending join request for this project." + }); + } + + var existingInvitation = await ProjectMembershipRequestsWithUsers() + .SingleOrDefaultAsync(pmr => + pmr.ProjectID == formdata.ProjectID && + pmr.TargetUserID == targetUser.Id && + pmr.Type == MembershipRequestTypeInvitation && + pmr.Status == MembershipRequestStatusPending); + + if (existingInvitation != null) + { + return Ok(new + { + result = existingInvitation, + message = "Project invitation is already pending." + }); + } + + var invitation = new ProjectMembershipRequest + { + ProjectID = formdata.ProjectID, + RequesterUserID = owner.Id, + TargetUserID = targetUser.Id, + CreatedByUserID = owner.Id, + Type = MembershipRequestTypeInvitation, + Status = MembershipRequestStatusPending, + Message = formdata.Message, + CreatedAt = DateTimeOffset.UtcNow + }; + + await _dbContext.ProjectMembershipRequests.AddAsync(invitation); + await _dbContext.SaveChangesAsync(); + + invitation = await ProjectMembershipRequestsWithUsers() + .SingleAsync(pmr => pmr.ProjectMembershipRequestID == invitation.ProjectMembershipRequestID); + + return Ok(new + { + result = invitation, + message = "Project invitation created successfully." + }); + } + + /* + * Type : PUT + * URL : /api/project/acceptprojectmembershiprequest/{requestID} + * Description: Accept a join request or invitation. + */ + [Authorize] + [HttpPut("[action]/{requestID}")] + public async Task AcceptProjectMembershipRequest([FromRoute] int requestID) + { + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var user = await GetCurrentUserAsync(userId); + if (user == null) return NotFound(new { message = "User Not Found" }); + + var membershipRequest = await ProjectMembershipRequestsWithUsers() + .SingleOrDefaultAsync(pmr => pmr.ProjectMembershipRequestID == requestID); + + if (membershipRequest == null) return NotFound(new { message = "Membership request not found." }); + if (membershipRequest.Status != MembershipRequestStatusPending) + return Conflict(new { result = membershipRequest, message = "Membership request is not pending." }); + + if (membershipRequest.Type == MembershipRequestTypeJoin) + { + var isOwner = await IsProjectOwnerAsync(membershipRequest.ProjectID, user.Id); + if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + } + else if (membershipRequest.Type == MembershipRequestTypeInvitation) + { + if (membershipRequest.TargetUserID != user.Id) + return Unauthorized(new { message = "Only the invited user can accept this invitation." }); + } + else + { + return BadRequest(new { message = "Invalid membership request type." }); + } + + await using var tx = await _dbContext.Database.BeginTransactionAsync(); + + var projectUser = await AddProjectMemberAsync(membershipRequest.ProjectID, membershipRequest.TargetUserID); + membershipRequest.Status = MembershipRequestStatusAccepted; + membershipRequest.RespondedAt = DateTimeOffset.UtcNow; + _dbContext.Entry(membershipRequest).State = EntityState.Modified; + + await _dbContext.SaveChangesAsync(); + await tx.CommitAsync(); + + _dbContext.Entry(projectUser).Reference(pu => pu.User).Load(); + _dbContext.Entry(projectUser).Reference(pu => pu.Project).Load(); + + return Ok(new + { + result = projectUser, + request = membershipRequest, + message = "Project membership request accepted successfully." + }); + } + + /* + * Type : PUT + * URL : /api/project/rejectprojectmembershiprequest/{requestID} + * Description: Reject a join request or invitation. + */ + [Authorize] + [HttpPut("[action]/{requestID}")] + public async Task RejectProjectMembershipRequest([FromRoute] int requestID) + { + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var user = await GetCurrentUserAsync(userId); + if (user == null) return NotFound(new { message = "User Not Found" }); + + var membershipRequest = await ProjectMembershipRequestsWithUsers() + .SingleOrDefaultAsync(pmr => pmr.ProjectMembershipRequestID == requestID); + + if (membershipRequest == null) return NotFound(new { message = "Membership request not found." }); + if (membershipRequest.Status != MembershipRequestStatusPending) + return Conflict(new { result = membershipRequest, message = "Membership request is not pending." }); + + if (membershipRequest.Type == MembershipRequestTypeJoin) + { + var isOwner = await IsProjectOwnerAsync(membershipRequest.ProjectID, user.Id); + if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + } + else if (membershipRequest.Type == MembershipRequestTypeInvitation) + { + if (membershipRequest.TargetUserID != user.Id) + return Unauthorized(new { message = "Only the invited user can reject this invitation." }); + } + else + { + return BadRequest(new { message = "Invalid membership request type." }); + } + + membershipRequest.Status = MembershipRequestStatusRejected; + membershipRequest.RespondedAt = DateTimeOffset.UtcNow; + _dbContext.Entry(membershipRequest).State = EntityState.Modified; + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = membershipRequest, + message = "Project membership request rejected successfully." + }); + } + + /* + * Type : DELETE + * URL : /api/project/cancelprojectmembershiprequest/{requestID} + * Description: Cancel a pending join request or invitation. + */ + [Authorize] + [HttpDelete("[action]/{requestID}")] + public async Task CancelProjectMembershipRequest([FromRoute] int requestID) + { + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var user = await GetCurrentUserAsync(userId); + if (user == null) return NotFound(new { message = "User Not Found" }); + + var membershipRequest = await ProjectMembershipRequestsWithUsers() + .SingleOrDefaultAsync(pmr => pmr.ProjectMembershipRequestID == requestID); + + if (membershipRequest == null) return NotFound(new { message = "Membership request not found." }); + if (membershipRequest.Status != MembershipRequestStatusPending) + return Conflict(new { result = membershipRequest, message = "Membership request is not pending." }); + + if (membershipRequest.Type == MembershipRequestTypeJoin) + { + if (membershipRequest.RequesterUserID != user.Id) + return Unauthorized(new { message = "Only the requester can cancel this join request." }); + } + else if (membershipRequest.Type == MembershipRequestTypeInvitation) + { + var isOwner = await IsProjectOwnerAsync(membershipRequest.ProjectID, user.Id); + if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + } + else + { + return BadRequest(new { message = "Invalid membership request type." }); + } + + membershipRequest.Status = MembershipRequestStatusCancelled; + membershipRequest.RespondedAt = DateTimeOffset.UtcNow; + _dbContext.Entry(membershipRequest).State = EntityState.Modified; + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = membershipRequest, + message = "Project membership request cancelled successfully." + }); + } + + /* + * Type : DELETE + * URL : /api/project/leaveproject/{projectID} + * Description: Leave project as the authenticated member. + */ + [Authorize] + [HttpDelete("[action]/{projectID}")] + public async Task LeaveProject([FromRoute] int projectID) + { + if (projectID <= 0) + return BadRequest(new { message = "Invalid project id." }); + + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var user = await GetCurrentUserAsync(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, projectID); + if (projectUser == null || projectUser.UserRole == ProjectFollowerRole) + return NotFound(new { message = "Project membership not found." }); + + if (projectUser.UserRole == ProjectOwnerRole) + return BadRequest(new { message = "Project owner cannot leave the project." }); + + if (projectUser.IsFollowing) + { + projectUser.UserRole = ProjectFollowerRole; + _dbContext.Entry(projectUser).State = EntityState.Modified; + } + else + { + _dbContext.ProjectUsers.Remove(projectUser); + } + + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = projectUser, + message = "Project left successfully." + }); + } + /* * Type : POST * URL : /api/project/addtag @@ -3501,72 +3941,6 @@ public async Task DeleteDatasetFromNotebook([FromForm] int notebo } } - /* - * Type : PUT - * URL : /api/project/updateuser - * Param : ProjectUserViewModel - * Description: Update Project - */ - [Authorize] - [HttpPut("[action]")] - public async Task UpdateUser([FromForm] ProjectUserVM formdata) - { - // Find User - var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) - { - return Unauthorized(new { message = "Invalid user identifier." }); - } - var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); - if (user == null) return NotFound(new { message = "User Not Found" }); - - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == formdata.ProjectID && - aup.UserRole == "owner")); - - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); - - - // Find Many To Many - var userRole = await _dbContext.ProjectUsers.FindAsync(formdata.UserID, formdata.ProjectID); - if (userRole == null) return NotFound(new { message = "User Not Found" }); - - //Remove Follower If Not Following - if (formdata.UserRole == "follower" && formdata.IsFollowing == false) - { - // Remove User Role - _dbContext.ProjectUsers.Remove(userRole); - - // Save Change - await _dbContext.SaveChangesAsync(); - - return Ok(new - { - result = userRole, - message = "Follower successfully deleted" - }); - } - - // Update Role - userRole.UserRole = formdata.UserRole; - userRole.IsFollowing = formdata.IsFollowing; - - // Set Entity State - _dbContext.Entry(userRole).State = EntityState.Modified; - - // Save Change - await _dbContext.SaveChangesAsync(); - - // Return Ok Status - return Ok(new - { - result = userRole, - message = "Project successfully updated." - }); - } - [Authorize] [HttpPut("[action]")] public async Task RenameNotebook([FromForm] NotebookNameChangeVM notebookNameChangeVM) @@ -4098,6 +4472,9 @@ public async Task RemoveUser([FromRoute] int projectID, int userI var projectUser = await _dbContext.ProjectUsers.FindAsync(userID, projectID); if (projectUser == null) return NotFound(new { message = "User Not Found" }); + if (projectUser.UserRole == ProjectOwnerRole) + return BadRequest(new { message = "Project owner cannot be removed." }); + // Remove User Role _dbContext.ProjectUsers.Remove(projectUser); diff --git a/src/Analysim.Web/ViewModels/Project/ProjectUserVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectMembershipRequestVM.cs similarity index 54% rename from src/Analysim.Web/ViewModels/Project/ProjectUserVM.cs rename to src/Analysim.Web/ViewModels/Project/ProjectMembershipRequestVM.cs index 26dbd45c..9a7d59a5 100644 --- a/src/Analysim.Web/ViewModels/Project/ProjectUserVM.cs +++ b/src/Analysim.Web/ViewModels/Project/ProjectMembershipRequestVM.cs @@ -2,18 +2,15 @@ namespace Web.ViewModels.Project { - public class ProjectUserVM + public class ProjectMembershipInvitationVM { - [Required(ErrorMessage = "User ID is a required field.")] - public int UserID { get; set; } - [Required(ErrorMessage = "Project ID is a required field.")] public int ProjectID { get; set; } - [Required(ErrorMessage = "User Role is a required field.")] - public string UserRole { get; set; } + [Required(ErrorMessage = "User ID is a required field.")] + public int UserID { get; set; } - [Required(ErrorMessage = "Is Following is a required field.")] - public bool IsFollowing { get; set; } + [MaxLength(500, ErrorMessage = "Maximum length for Message is 500 Character.")] + public string Message { get; set; } } } From eba7e42a7405c927e54f753d22898417607a1da4 Mon Sep 17 00:00:00 2001 From: Mohab Date: Sun, 30 Aug 2026 17:14:00 +0300 Subject: [PATCH 4/6] implement membership request UI --- ...0830040500_AddProjectMembershipRequests.cs | 4 + .../src/app/profile/profile.component.html | 50 +++++++- .../src/app/profile/profile.component.scss | 41 ++++++- .../src/app/profile/profile.component.ts | 107 +++++++++++++++- .../project-create.component.html | 6 +- .../project-create.component.ts | 6 + .../project-form-users.component.html | 7 +- .../project-form-users.component.ts | 29 +++-- .../project-edit/project-edit.component.html | 78 ++++++++++-- .../project-edit/project-edit.component.scss | 19 +++ .../project-edit/project-edit.component.ts | 88 +++++++++++++- .../project-list-users.component.html | 2 +- .../projects/project/project.component.html | 57 ++++++++- .../projects/project/project.component.scss | 65 ++++++---- .../app/projects/project/project.component.ts | 115 +++++++++++++++++- 15 files changed, 610 insertions(+), 64 deletions(-) diff --git a/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs b/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs index df56ddea..9fd53cdd 100644 --- a/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs +++ b/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs @@ -1,4 +1,6 @@ using System; +using Infrastructure.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; @@ -6,6 +8,8 @@ namespace Infrastructure.Migrations { + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260830040500_AddProjectMembershipRequests")] public partial class AddProjectMembershipRequests : Migration { protected override void Up(MigrationBuilder migrationBuilder) diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.html b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.html index 3f75fa90..d1a2d2e2 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.html +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.html @@ -71,6 +71,15 @@
{{ profile.userName }}
+ + @@ -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..9a5206c9 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,9 +29,11 @@ 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 currentUser: User = null @@ -39,7 +41,10 @@ export class ProfileComponent implements OnInit { async ngOnInit() { 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.route.params.subscribe(params => { @@ -47,7 +52,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,6 +66,7 @@ export class ProfileComponent implements OnInit { this.loadProject(result) this.loadFollowing(result) this.loadFollower(result) + this.loadMembershipRequests() this.profileImage() }, error => { this.showError = true @@ -86,6 +95,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 @@ -126,6 +137,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 ac13429d..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,7 +20,11 @@

Project Creation

- + +
+ + + + + +

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 e21409b4..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 @@ -2,6 +2,9 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Project } from 'src/app/interfaces/project'; 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', @@ -13,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 { @@ -26,10 +34,88 @@ export class ProjectEditComponent implements OnInit { setProject(project : Project){ this.project = project + this.loadMembershipRequests() } updateProjectTags(projectTags : ProjectTag[]){ this.project.projectTags = projectTags } + updateProjectUsers(projectUsers : ProjectUser[]){ + 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 262b1d0a..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,5 +1,5 @@ -
+