From 547e11dd85fca884e50a534a6be4dac4a1ef1d15 Mon Sep 17 00:00:00 2001 From: Mohab Date: Sun, 9 Aug 2026 15:24:51 +0300 Subject: [PATCH 1/2] 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/2] 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 @@ + +