From 2b5d85f299a34c6a0b8a5c21e2ea943c80b50a1f Mon Sep 17 00:00:00 2001 From: Mohab Sobhy Date: Sun, 22 Feb 2026 15:23:21 +0200 Subject: [PATCH] feat: implement real-time notification system using SignalR --- src/Analysim.Core/Entities/Notification.cs | 43 + src/Analysim.Core/Entities/User.cs | 3 + .../Interfaces/INotificationService.cs | 10 + .../Data/ApplicationDbContext.cs | 8 + .../ApplicationDbContextModelSnapshot.cs | 63 +- ...1123014_AddNotificationsToUser.Designer.cs | 823 ++++++++++++++++++ .../20260221123014_AddNotificationsToUser.cs | 102 +++ src/Analysim.Web/ClientApp/package-lock.json | 273 +++++- src/Analysim.Web/ClientApp/package.json | 1 + .../ClientApp/src/app/app.module.ts | 2 + .../src/app/interfaces/app-notification.ts | 10 + .../src/app/navbar/navbar.component.html | 11 +- .../src/app/navbar/navbar.component.ts | 2 +- .../notification-bell.component.html | 61 ++ .../notification-bell.component.scss | 182 ++++ .../notification-bell.component.ts | 78 ++ .../src/app/services/account.service.ts | 643 +++++--------- .../src/app/services/notification.service.ts | 124 ++- .../Controllers/AccountController.cs | 33 +- .../Controllers/NotificationController.cs | 113 +++ .../Controllers/ProjectController.cs | 17 +- .../Extensions/ServiceExtensions.cs | 80 +- src/Analysim.Web/Hubs/NotificationHub.cs | 38 + .../Services/NotificationService.cs | 55 ++ src/Analysim.Web/Startup.cs | 6 + 25 files changed, 2286 insertions(+), 495 deletions(-) create mode 100644 src/Analysim.Core/Entities/Notification.cs create mode 100644 src/Analysim.Core/Interfaces/INotificationService.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.Designer.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.cs create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/app-notification.ts create mode 100644 src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.html create mode 100644 src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.scss create mode 100644 src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.ts create mode 100644 src/Analysim.Web/Controllers/NotificationController.cs create mode 100644 src/Analysim.Web/Hubs/NotificationHub.cs create mode 100644 src/Analysim.Web/Services/NotificationService.cs diff --git a/src/Analysim.Core/Entities/Notification.cs b/src/Analysim.Core/Entities/Notification.cs new file mode 100644 index 00000000..37d79b2e --- /dev/null +++ b/src/Analysim.Core/Entities/Notification.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public enum NotificationType + { + ProjectInvitation, + NewFollower, + SecurityAlert + } + + public class Notification + { + [Key] + public int Id { get; set; } + + [Required] + public int UserId { get; set; } + + [ForeignKey(nameof(UserId))] + public User User { get; set; } + + [Required] + [MaxLength(200)] + public string Title { get; set; } + + [Required] + [MaxLength(1000)] + public string Message { get; set; } + + [Required] + public NotificationType Type { get; set; } + + public bool IsRead { get; set; } = false; + + [MaxLength(500)] + public string LinkUrl { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + } +} diff --git a/src/Analysim.Core/Entities/User.cs b/src/Analysim.Core/Entities/User.cs index 801e98dd..cfcd6527 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -20,8 +20,11 @@ public class User : IdentityUser public ICollection Following { get; } = new List(); public ICollection ProjectUsers { get; } = new List(); public ICollection BlobFiles { get; } = new List(); + public ICollection Notifications { get; } = new List(); public string RegistrationSurvey {get; set;} + public string LastLoginIp { get; set; } + } } diff --git a/src/Analysim.Core/Interfaces/INotificationService.cs b/src/Analysim.Core/Interfaces/INotificationService.cs new file mode 100644 index 00000000..85490e79 --- /dev/null +++ b/src/Analysim.Core/Interfaces/INotificationService.cs @@ -0,0 +1,10 @@ +using Core.Entities; +using System.Threading.Tasks; + +namespace Core.Interfaces +{ + public interface INotificationService + { + Task SendNotificationAsync(int userId, string title, string message, NotificationType type, string linkUrl); + } +} diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 26d61bba..11283e4a 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -107,6 +107,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithMany(n=>n.observableNotebookDatasets) .HasForeignKey(d=>d.NotebookID) .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (User -> Notifications) + modelBuilder.Entity() + .HasMany(u => u.Notifications) + .WithOne(n => n.User) + .HasForeignKey(n => n.UserId) + .OnDelete(DeleteBehavior.Cascade); } @@ -121,6 +128,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ObservableNotebookDataset { get;set;} public DbSet NotebookContent { get; set; } public DbSet BlobFileContent { get; set; } + public DbSet Notifications { get; set; } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index ee014be1..ffb33e51 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -176,6 +176,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Notebook"); }); + modelBuilder.Entity("Core.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Notifications"); + }); + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => { b.Property("ID") @@ -328,6 +369,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("EmailConfirmed") .HasColumnType("boolean"); + b.Property("LastLoginIp") + .HasColumnType("text"); + b.Property("LastOnline") .HasColumnType("timestamp with time zone"); @@ -428,21 +472,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - ConcurrencyStamp = "ebd0169f-30be-4ab7-9fb9-038a9de20efb", + ConcurrencyStamp = "b4bb5161-16f0-43aa-9033-b01d1a7a2474", Name = "Admin", NormalizedName = "ADMIN" }, new { Id = 2, - ConcurrencyStamp = "371acb40-c941-4714-9c2c-bc9de9bff144", + ConcurrencyStamp = "f4a579fd-f47f-4ec4-8b67-3dce047786c0", Name = "Customer", NormalizedName = "CUSTOMER" }, new { Id = 3, - ConcurrencyStamp = "7ee39326-5034-4323-85cc-6da801861458", + ConcurrencyStamp = "6050bbb3-a5ad-439a-9eac-7149501bc57d", Name = "Moderator", NormalizedName = "MODERATOR" }); @@ -601,6 +645,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Project"); }); + modelBuilder.Entity("Core.Entities.Notification", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => { b.HasOne("Core.Entities.Notebook", "notebook") @@ -756,6 +811,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Following"); + b.Navigation("Notifications"); + b.Navigation("ProjectUsers"); }); #pragma warning restore 612, 618 diff --git a/src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.Designer.cs new file mode 100644 index 00000000..50224bd7 --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.Designer.cs @@ -0,0 +1,823 @@ +// +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("20260221123014_AddNotificationsToUser")] + partial class AddNotificationsToUser + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("LinkUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + 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.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastLoginIp") + .HasColumnType("text"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "b4bb5161-16f0-43aa-9033-b01d1a7a2474", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "f4a579fd-f47f-4ec4-8b67-3dce047786c0", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "6050bbb3-a5ad-439a-9eac-7149501bc57d", + 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", "User") + .WithMany("Notifications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("Notifications"); + + b.Navigation("ProjectUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.cs b/src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.cs new file mode 100644 index 00000000..bb82161f --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260221123014_AddNotificationsToUser.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddNotificationsToUser : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastLoginIp", + table: "AspNetUsers", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "Notifications", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "integer", nullable: false), + Title = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Message = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: false), + Type = table.Column(type: "integer", nullable: false), + IsRead = table.Column(type: "boolean", nullable: false), + LinkUrl = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Notifications", x => x.Id); + table.ForeignKey( + name: "FK_Notifications_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "b4bb5161-16f0-43aa-9033-b01d1a7a2474"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "f4a579fd-f47f-4ec4-8b67-3dce047786c0"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "6050bbb3-a5ad-439a-9eac-7149501bc57d"); + + migrationBuilder.CreateIndex( + name: "IX_Notifications_UserId", + table: "Notifications", + column: "UserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Notifications"); + + migrationBuilder.DropColumn( + name: "LastLoginIp", + table: "AspNetUsers"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "ebd0169f-30be-4ab7-9fb9-038a9de20efb"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "371acb40-c941-4714-9c2c-bc9de9bff144"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "7ee39326-5034-4323-85cc-6da801861458"); + } + } +} diff --git a/src/Analysim.Web/ClientApp/package-lock.json b/src/Analysim.Web/ClientApp/package-lock.json index 361c055d..3d433e52 100644 --- a/src/Analysim.Web/ClientApp/package-lock.json +++ b/src/Analysim.Web/ClientApp/package-lock.json @@ -17,6 +17,7 @@ "@angular/platform-browser-dynamic": "^14.0.0", "@angular/router": "^14.0.0", "@fortawesome/fontawesome-free": "^6.1.1", + "@microsoft/signalr": "^6.0.25", "@observablehq/runtime": "^4.28.0", "bootstrap": "^4.4.1", "bootstrap-icons": "^1.11.3", @@ -2633,6 +2634,40 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "node_modules/@microsoft/signalr": { + "version": "6.0.25", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-6.0.25.tgz", + "integrity": "sha512-8AzrpxS+E0yn1tXSlv7+UlURLmSxTQDgbvOT0pGKXjZT7MkhnDP+/GLuk7veRtUjczou/x32d9PHhYlr2NBy6Q==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^1.0.7", + "fetch-cookie": "^0.11.0", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + } + }, + "node_modules/@microsoft/signalr/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@ngtools/webpack": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-14.0.3.tgz", @@ -3568,6 +3603,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6001,7 +6048,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "dependencies": { "iconv-lite": "^0.6.2" @@ -6011,7 +6057,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6643,6 +6688,15 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter-asyncresource": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz", @@ -6664,6 +6718,15 @@ "node": ">=0.8.x" } }, + "node_modules/eventsource": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.2.tgz", + "integrity": "sha512-xAH3zWhgO2/3KIniEKYPr8plNSzlGINOUqYj0m0u7AB81iRw8b/3E73W6AuU+6klLbaSFmZnaETQ2lXPfAydrA==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -6891,6 +6954,18 @@ "node": ">=0.8.0" } }, + "node_modules/fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "license": "Unlicense", + "dependencies": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -9948,6 +10023,48 @@ "dev": true, "optional": true }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -15757,13 +15874,15 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@csstools/postcss-unset-value/-/postcss-unset-value-1.0.1.tgz", "integrity": "sha512-f1G1WGDXEU/RN1TWAxBPQgQudtLnLQPyiWdtypkPC+mVYNKFKH/HYXSxH4MVNqwF8M0eDsoiU7HumJHCg/L/jg==", - "dev": true + "dev": true, + "requires": {} }, "@csstools/selector-specificity": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-2.0.1.tgz", "integrity": "sha512-aG20vknL4/YjQF9BSV7ts4EWm/yrjagAN7OWBNmlbEOUiu0llj4OGrFoOKK3g2vey4/p2omKCoHrWtPxSwV3HA==", - "dev": true + "dev": true, + "requires": {} }, "@discoveryjs/json-ext": { "version": "0.5.7", @@ -15928,11 +16047,32 @@ "integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==", "dev": true }, + "@microsoft/signalr": { + "version": "6.0.25", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-6.0.25.tgz", + "integrity": "sha512-8AzrpxS+E0yn1tXSlv7+UlURLmSxTQDgbvOT0pGKXjZT7MkhnDP+/GLuk7veRtUjczou/x32d9PHhYlr2NBy6Q==", + "requires": { + "abort-controller": "^3.0.0", + "eventsource": "^1.0.7", + "fetch-cookie": "^0.11.0", + "node-fetch": "^2.6.7", + "ws": "^7.4.5" + }, + "dependencies": { + "ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "requires": {} + } + } + }, "@ngtools/webpack": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-14.0.3.tgz", "integrity": "sha512-PwvgCeY7mbijazovpA0ggeo81A3yzwOb8AfVD3yfGT15Z2qnEVyL+05Tj6ttRTngceF3gsERamFcB6lRKdcjdw==", - "dev": true + "dev": true, + "requires": {} }, "@nodelib/fs.scandir": { "version": "2.1.5", @@ -16799,6 +16939,14 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "requires": { + "event-target-shim": "^5.0.0" + } + }, "accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -16834,7 +16982,8 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", - "dev": true + "dev": true, + "requires": {} }, "acorn-walk": { "version": "7.2.0", @@ -17234,7 +17383,8 @@ "bootstrap": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz", - "integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==" + "integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==", + "requires": {} }, "bootstrap-icons": { "version": "1.11.3", @@ -17921,7 +18071,8 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/css-prefers-color-scheme/-/css-prefers-color-scheme-6.0.3.tgz", "integrity": "sha512-4BqMbZksRkJQx2zAjrokiGMd07RqOa2IxIrrN10lyBe9xhn9DEvjUK79J6jkeiv9D9hQFXKb6g1jwU62jziJZA==", - "dev": true + "dev": true, + "requires": {} }, "css-select": { "version": "4.3.0", @@ -18550,7 +18701,6 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, "optional": true, "requires": { "iconv-lite": "^0.6.2" @@ -18560,7 +18710,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "optional": true, "requires": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -18932,6 +19081,11 @@ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true }, + "event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" + }, "eventemitter-asyncresource": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz", @@ -18950,6 +19104,11 @@ "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true }, + "eventsource": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-1.1.2.tgz", + "integrity": "sha512-xAH3zWhgO2/3KIniEKYPr8plNSzlGINOUqYj0m0u7AB81iRw8b/3E73W6AuU+6klLbaSFmZnaETQ2lXPfAydrA==" + }, "execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -19135,6 +19294,14 @@ "websocket-driver": ">=0.5.1" } }, + "fetch-cookie": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-0.11.0.tgz", + "integrity": "sha512-BQm7iZLFhMWFy5CZ/162sAGjBfdNWb7a8LEqqnzsHFhxT/X/SVj/z2t2nu3aJvjlbQkrAlTUApplPRjWyH4mhA==", + "requires": { + "tough-cookie": "^2.3.3 || ^3.0.1 || ^4.0.0" + } + }, "figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -19682,7 +19849,8 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true + "dev": true, + "requires": {} }, "ieee754": { "version": "1.2.1", @@ -20487,7 +20655,8 @@ "ws": { "version": "7.5.9", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==" + "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "requires": {} } } }, @@ -20711,7 +20880,8 @@ "version": "1.7.0", "resolved": "https://registry.npmjs.org/karma-jasmine-html-reporter/-/karma-jasmine-html-reporter-1.7.0.tgz", "integrity": "sha512-pzum1TL7j90DTE86eFt48/s12hqwQuiD+e5aXx2Dc9wDEn2LfGq6RoAxEZZjFiN0RDSCOnosEKRZWxbQ+iMpQQ==", - "dev": true + "dev": true, + "requires": {} }, "karma-source-map-support": { "version": "1.4.0", @@ -21108,7 +21278,8 @@ "marked-highlight": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.1.3.tgz", - "integrity": "sha512-t35JWm2u8HanOJ+gSJBAYQ0Jgr3vy+gl7ORAXN8bSEQFHl5FYXH0A7YXVMrfhmKaSuBSy6LidXECn3U9Qv/dHA==" + "integrity": "sha512-t35JWm2u8HanOJ+gSJBAYQ0Jgr3vy+gl7ORAXN8bSEQFHl5FYXH0A7YXVMrfhmKaSuBSy6LidXECn3U9Qv/dHA==", + "requires": {} }, "media-typer": { "version": "0.3.0", @@ -21413,6 +21584,35 @@ "dev": true, "optional": true }, + "node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "requires": { + "whatwg-url": "^5.0.0" + }, + "dependencies": { + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } + }, "node-forge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", @@ -22270,13 +22470,15 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-font-variant/-/postcss-font-variant-5.0.0.tgz", "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", - "dev": true + "dev": true, + "requires": {} }, "postcss-gap-properties": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/postcss-gap-properties/-/postcss-gap-properties-3.0.3.tgz", "integrity": "sha512-rPPZRLPmEKgLk/KlXMqRaNkYTUpE7YC+bOIQFN5xcu1Vp11Y4faIXv6/Jpft6FMnl6YRxZqDZG0qQOW80stzxQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-image-set-function": { "version": "4.0.6", @@ -22302,7 +22504,8 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-initial/-/postcss-initial-4.0.1.tgz", "integrity": "sha512-0ueD7rPqX8Pn1xJIjay0AZeIuDoF+V+VvMt/uOnn+4ezUKhZM/NokDeP6DwMNyIoYByuN/94IQnt5FEkaN59xQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-lab-function": { "version": "4.2.0", @@ -22329,19 +22532,22 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/postcss-logical/-/postcss-logical-5.0.4.tgz", "integrity": "sha512-RHXxplCeLh9VjinvMrZONq7im4wjWGlRJAqmAVLXyZaXwfDWP73/oq4NdIp+OZwhQUMj0zjqDfM5Fj7qby+B4g==", - "dev": true + "dev": true, + "requires": {} }, "postcss-media-minmax": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/postcss-media-minmax/-/postcss-media-minmax-5.0.0.tgz", "integrity": "sha512-yDUvFf9QdFZTuCUg0g0uNSHVlJ5X1lSzDZjPSFaiCWvjgsvu8vEVxtahPrLMinIDEEGnx6cBe6iqdx5YWz08wQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-modules-extract-imports": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true + "dev": true, + "requires": {} }, "postcss-modules-local-by-default": { "version": "4.0.5", @@ -22392,13 +22598,15 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/postcss-overflow-shorthand/-/postcss-overflow-shorthand-3.0.3.tgz", "integrity": "sha512-CxZwoWup9KXzQeeIxtgOciQ00tDtnylYIlJBBODqkgS/PU2jISuWOL/mYLHmZb9ZhZiCaNKsCRiLp22dZUtNsg==", - "dev": true + "dev": true, + "requires": {} }, "postcss-page-break": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/postcss-page-break/-/postcss-page-break-3.0.4.tgz", "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", - "dev": true + "dev": true, + "requires": {} }, "postcss-place": { "version": "7.0.4", @@ -22475,7 +22683,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/postcss-replace-overflow-wrap/-/postcss-replace-overflow-wrap-4.0.0.tgz", "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", - "dev": true + "dev": true, + "requires": {} }, "postcss-selector-not": { "version": "5.0.0", @@ -23036,7 +23245,8 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true + "dev": true, + "requires": {} }, "json-schema-traverse": { "version": "0.4.1", @@ -23588,7 +23798,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", - "dev": true + "dev": true, + "requires": {} }, "stylus": { "version": "0.57.0", @@ -23748,7 +23959,8 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true + "dev": true, + "requires": {} }, "json-schema-traverse": { "version": "0.4.1", @@ -24168,7 +24380,8 @@ "version": "3.5.2", "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true + "dev": true, + "requires": {} }, "json-schema-traverse": { "version": "0.4.1", @@ -24268,7 +24481,8 @@ "version": "8.8.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.8.0.tgz", "integrity": "sha512-JDAgSYQ1ksuwqfChJusw1LSJ8BizJ2e/vVu5Lxjq3YvNJNlROv1ui4i+c/kUUrPheBvQl4c5UbERhTwKa6QBJQ==", - "dev": true + "dev": true, + "requires": {} } } }, @@ -24409,7 +24623,8 @@ "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", - "dev": true + "dev": true, + "requires": {} }, "xml-name-validator": { "version": "3.0.0", diff --git a/src/Analysim.Web/ClientApp/package.json b/src/Analysim.Web/ClientApp/package.json index 31f13ba0..527b3405 100644 --- a/src/Analysim.Web/ClientApp/package.json +++ b/src/Analysim.Web/ClientApp/package.json @@ -19,6 +19,7 @@ "@angular/platform-browser-dynamic": "^14.0.0", "@angular/router": "^14.0.0", "@fortawesome/fontawesome-free": "^6.1.1", + "@microsoft/signalr": "^6.0.25", "@observablehq/runtime": "^4.28.0", "bootstrap": "^4.4.1", "bootstrap-icons": "^1.11.3", diff --git a/src/Analysim.Web/ClientApp/src/app/app.module.ts b/src/Analysim.Web/ClientApp/src/app/app.module.ts index 9d006cc0..26c0020d 100644 --- a/src/Analysim.Web/ClientApp/src/app/app.module.ts +++ b/src/Analysim.Web/ClientApp/src/app/app.module.ts @@ -32,6 +32,7 @@ import { EmailConfirmationComponent } from './email-confirmation/email-confirmat import { EmailForgotPassComponent } from './email-confirmation/email-forgot-pass/email-forgot-pass.component'; import { ResetPasswordComponent } from './email-confirmation/reset-password/reset-password.component'; import { EmailResendVerificationComponent } from './email-confirmation/email-resend-verification/email-resend-verification.component'; +import { NotificationBellComponent } from './notification-bell/notification-bell.component'; @NgModule({ declarations: [ @@ -58,6 +59,7 @@ import { EmailResendVerificationComponent } from './email-confirmation/email-res EmailForgotPassComponent, ResetPasswordComponent, EmailResendVerificationComponent, + NotificationBellComponent, ], imports: [ BrowserModule, diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/app-notification.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/app-notification.ts new file mode 100644 index 00000000..3cd05919 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/app-notification.ts @@ -0,0 +1,10 @@ +export interface AppNotification { + id: number; + userId: number; + title: string; + message: string; + type: 'ProjectInvitation' | 'NewFollower' | 'SecurityAlert'; + isRead: boolean; + linkUrl: string; + createdAt: Date; +} 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 23a58def..27716280 100644 --- a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html +++ b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.html @@ -2,7 +2,7 @@ \ No newline at end of file + diff --git a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts index f4b7efbb..d8dbd52c 100644 --- a/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/navbar/navbar.component.ts @@ -110,4 +110,4 @@ export class NavbarComponent implements OnInit { onLogout() { this.accountService.logout(); } -} \ No newline at end of file +} diff --git a/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.html b/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.html new file mode 100644 index 00000000..c2023355 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.html @@ -0,0 +1,61 @@ + +
+ + + + + +
+ + +
+ Notifications + +
+ + +
+ + + +
+ +

No notifications yet

+
+ + +
+ +
+ +
+ +
+

{{ n.title }}

+

{{ n.message }}

+ {{ n.createdAt | date:'short' }} +
+ + +
+ +
+
+ +
+ +
diff --git a/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.scss b/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.scss new file mode 100644 index 00000000..64739cd4 --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.scss @@ -0,0 +1,182 @@ +// ── Wrapper ──────────────────────────────────────────────────────────────── +.notification-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +// ── Bell Button ──────────────────────────────────────────────────────────── +.notification-bell { + position: relative; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.85); + font-size: 1.1rem; + padding: 0.25rem 0.4rem; + border-radius: var(--nav-link-radius, 4px); + cursor: pointer; + transition: color 0.15s; + + &:hover { + color: #94bdff; + } +} + +// ── Unread Badge ─────────────────────────────────────────────────────────── +.notification-badge { + position: absolute; + top: -4px; + right: -6px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 999px; + background: #e74c3c; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 18px; + text-align: center; + pointer-events: none; +} + +// ── Dropdown Panel ───────────────────────────────────────────────────────── +.notification-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: 340px; + background: var(--background-color, #fff); + border: 1px solid var(--border-color, #dee2e6); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14); + z-index: 1050; + overflow: hidden; +} + +// ── Header ───────────────────────────────────────────────────────────────── +.notification-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border-color, #dee2e6); + background: var(--background-color-secondary, #f8f9fa); +} + +.notification-title { + font-weight: 600; + font-size: 0.9rem; + color: #fff; +} + +.mark-all-btn { + font-size: 0.75rem; + color: var(--c-primary, #0d6efd); + background: transparent; + border: none; + padding: 0; + cursor: pointer; + + &:hover { text-decoration: underline; } +} + +// ── List ─────────────────────────────────────────────────────────────────── +.notification-list { + max-height: 380px; + overflow-y: auto; +} + +// ── Empty State ──────────────────────────────────────────────────────────── +.notification-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 1rem; + color: var(--c-text-muted, #6c757d); + gap: 0.5rem; + + i { font-size: 1.8rem; opacity: 0.5; } + p { margin: 0; font-size: 0.85rem; } +} + +// ── Notification Item ────────────────────────────────────────────────────── +.notification-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border-color, #dee2e6); + cursor: pointer; + transition: background 0.12s; + position: relative; + + &:last-child { border-bottom: none; } + &:hover { background: #001f42; } + &.unread { background: rgba(13, 110, 253, 0.05); } +} + +// ── Type Icon Circle ─────────────────────────────────────────────────────── +.notification-icon { + flex-shrink: 0; + width: 34px; + height: 34px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + color: #fff; + + &.type-projectinvitation { background: #6f42c1; } + &.type-newfollower { background: #0d6efd; } + &.type-securityalert { background: #dc3545; } + // fallback for any unknown type + &:not([class*="type-projectinvitation"]):not([class*="type-newfollower"]):not([class*="type-securityalert"]) { + background: #6c757d; + } +} + +// ── Content ──────────────────────────────────────────────────────────────── +.notification-content { + flex: 1; + min-width: 0; + + p { margin: 0; } +} + +.notification-item-title { + font-weight: 600; + font-size: 0.82rem; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notification-item-msg { + font-size: 0.78rem; + color: var(--c-text-muted, #6c757d); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.notification-time { + display: block; + font-size: 0.7rem; + color: var(--c-text-muted, #6c757d); + margin-top: 2px; +} + +// ── Unread Dot ───────────────────────────────────────────────────────────── +.unread-dot { + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: #0d6efd; + margin-top: 4px; +} diff --git a/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.ts b/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.ts new file mode 100644 index 00000000..f84c1fde --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/notification-bell/notification-bell.component.ts @@ -0,0 +1,78 @@ +import { Component, OnInit, OnDestroy, HostListener } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable, Subscription } from 'rxjs'; +import { filter, take } from 'rxjs/operators'; +import { NotificationService } from '../services/notification.service'; +import { AccountService } from '../services/account.service'; +import { AppNotification } from '../interfaces/app-notification'; + +@Component({ + selector: 'app-notification-bell', + templateUrl: './notification-bell.component.html', + styleUrls: ['./notification-bell.component.scss'] +}) +export class NotificationBellComponent implements OnInit, OnDestroy { + notifications$: Observable; + unreadCount$: Observable; + showDropdown = false; + private loginSub: Subscription; + + constructor( + public notificationService: NotificationService, + private accountService: AccountService, + private router: Router + ) { + this.notifications$ = this.notificationService.notifications$; + this.unreadCount$ = this.notificationService.unreadCount$; + } + + ngOnInit() { + this.loginSub = this.accountService.isLoggedIn + .pipe(filter(isLoggedIn => isLoggedIn === true), take(1)) + .subscribe(() => { + const token = localStorage.getItem('jwt'); + if (!token) return; + + this.notificationService.fetchNotifications(); + this.notificationService.startSignalRConnection(token); + }); + } + + ngOnDestroy() { + this.loginSub?.unsubscribe(); + } + + @HostListener('document:click') + onDocumentClick() { + this.showDropdown = false; + } + + toggleDropdown(event: MouseEvent) { + event.stopPropagation(); + this.showDropdown = !this.showDropdown; + } + + onNotificationClick(notification: AppNotification) { + this.notificationService.markRead(notification.id); + this.showDropdown = false; + this.router.navigate([notification.linkUrl]); + } + + onMarkAllRead(event: MouseEvent) { + event.stopPropagation(); + this.notificationService.markAllRead(); + } + + stopProp(event: MouseEvent) { + event.stopPropagation(); + } + + getNotificationIcon(type: string): string { + switch (type) { + case 'ProjectInvitation': return 'fa fa-users'; + case 'NewFollower': return 'fa fa-user-plus'; + case 'SecurityAlert': return 'fa fa-shield'; + default: return 'fa fa-bell'; + } + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/services/account.service.ts b/src/Analysim.Web/ClientApp/src/app/services/account.service.ts index 696915c7..714a34bb 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/account.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/account.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; -import { BehaviorSubject, Observable, empty, throwError } from 'rxjs'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; import { Router } from '@angular/router'; import jwt_decode from "jwt-decode"; @@ -17,460 +17,299 @@ export class AccountService { constructor(private http: HttpClient, private router: Router, private notfi: NotificationService) { } - // Url to access Web API + // ─── URLs ───────────────────────────────────────────────────────────────── private baseUrl: string = '/api/account/' - // Get - private urlGetUserByID: string = this.baseUrl + "getuserbyid/" - private urlGetUserByName: string = this.baseUrl + "getuserbyname/" - private urlGetUserRange: string = this.baseUrl + "getuserrange?" - private urlGetUserList: string = this.baseUrl + "getuserlist" - private urlGetProfileImage: string = this.baseUrl + "getprofileimage?" - private urlSearch: string = this.baseUrl + "search?" - private urlVerify: string = this.baseUrl + "verify" - private urlIsAdmin: string = this.baseUrl + "isAdmin/" - - // Post - private urlFollow: string = this.baseUrl + "follow" - private urlRegister: string = this.baseUrl + "register" - private urlLogin: string = this.baseUrl + "login" + private urlGetUserByID: string = this.baseUrl + "getuserbyid/" + private urlGetUserByName: string = this.baseUrl + "getuserbyname/" + private urlGetUserRange: string = this.baseUrl + "getuserrange?" + private urlGetUserList: string = this.baseUrl + "getuserlist" + private urlGetProfileImage: string = this.baseUrl + "getprofileimage?" + private urlSearch: string = this.baseUrl + "search?" + private urlVerify: string = this.baseUrl + "verify" + private urlIsAdmin: string = this.baseUrl + "isAdmin/" + + private urlFollow: string = this.baseUrl + "follow" + private urlRegister: string = this.baseUrl + "register" + private urlLogin: string = this.baseUrl + "login" private urlUploadProfileImage: string = this.baseUrl + "uploadprofileimage" - // Post - private urlUpdateUser: string = this.baseUrl + "updateuser/" - private urlForgotPassEmail: string = this.baseUrl + "forgotPassword/" - private urlResetPassword: string = this.baseUrl + "resetPassword?" - private urlChangePassword: string = this.baseUrl + "changePassword" + private urlUpdateUser: string = this.baseUrl + "updateuser/" + private urlForgotPassEmail: string = this.baseUrl + "forgotPassword/" + private urlResetPassword: string = this.baseUrl + "resetPassword?" + private urlChangePassword: string = this.baseUrl + "changePassword" private urlReSendVerification: string = this.baseUrl + "sendConfirmationEmail" - // Delete - private urlUnfollow: string = this.baseUrl + "unfollow/" + private urlUnfollow: string = this.baseUrl + "unfollow/" private urlDeleteProfileImage: string = this.baseUrl + "deleteprofileimage/" - private urlDeleteUser: string = this.baseUrl + "deleteUser/" + private urlDeleteUser: string = this.baseUrl + "deleteUser/" - // Unuse - private urlGetProjects: string = this.baseUrl + "getprojects/" - private urlGetFollowers: string = this.baseUrl + "getfollowers/" - private urlGetFollowings: string = this.baseUrl + "getfollowings/" + private urlGetProjects: string = this.baseUrl + "getprojects/" + private urlGetFollowers: string = this.baseUrl + "getfollowers/" + private urlGetFollowings: string = this.baseUrl + "getfollowings/" - //User properties - private loginStatus = new BehaviorSubject(this.checkLoginStatus()) - private user = new BehaviorSubject(null) - private userID = new BehaviorSubject(parseInt(localStorage.getItem('userID'))) + // ─── State ──────────────────────────────────────────────────────────────── + + isLoggedIn = new BehaviorSubject(this.checkLoginStatus()); + + private user = new BehaviorSubject(null); + private userID = new BehaviorSubject(parseInt(localStorage.getItem('userID'))); + + // ─── GET ────────────────────────────────────────────────────────────────── getUserByID(userID: number): Observable { - return this.http.get(this.urlGetUserByID + userID) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlGetUserByID + userID).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } getUserByName(username: string): Observable { - return this.http.get(this.urlGetUserByName + username) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlGetUserByName + username).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } getIsAdmin(username: string): Observable { - return this.http.get(this.urlIsAdmin + username) - .pipe( - map(body => { - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlIsAdmin + username).pipe( + map(body => body.result), + catchError(error => { console.log(error); return throwError(error); }) + ); } getUserRange(ids: number[]): Observable { - let params = new HttpParams() - ids.forEach(x => params = params.append("id", x.toString())) - - return this.http.get(this.urlGetUserRange, { params: params }) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + let params = new HttpParams(); + ids.forEach(x => params = params.append("id", x.toString())); + + return this.http.get(this.urlGetUserRange, { params }).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } getUserList(): Observable { - return this.http.get(this.urlGetUserList) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlGetUserList).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } getProfileImage(userID: number): Observable { - let params = new HttpParams() - params = params.append("id", userID.toString()) - - return this.http.get(this.urlGetProfileImage, { params: params }) - .pipe( - map(body => { - // console.log(body.message) - if (body.result) return body.result; - return null; - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + let params = new HttpParams(); + params = params.append("id", userID.toString()); + + return this.http.get(this.urlGetProfileImage, { params }).pipe( + map(body => body.result ?? null), + catchError(error => { console.log(error); return throwError(error); }) + ); } search(searchTerms: string[]): Observable { - let params = new HttpParams() - searchTerms.forEach(function (x) { - params = params.append("term", x) - }) - - return this.http.get(this.urlSearch, { params: params }) - .pipe( - map(body => { - if (!body) return [] - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + let params = new HttpParams(); + searchTerms.forEach(x => params = params.append("term", x)); + + return this.http.get(this.urlSearch, { params }).pipe( + map(body => { if (!body) return []; console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } + // ─── POST ───────────────────────────────────────────────────────────────── + follow(userID: number, followerID: number): Observable { - let body = new FormData() - body.append('userID', userID.toString()) - body.append('followerID', followerID.toString()) - - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - - return this.http.post(this.urlFollow, body, {headers}) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + let body = new FormData(); + body.append('userID', userID.toString()); + body.append('followerID', followerID.toString()); + + return this.http.post(this.urlFollow, body, { headers: this.authHeaders() }).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } register(username: string, password: string, emailaddress: string, registrationSurvey: string) { - let body = new FormData() - body.append('emailaddress', emailaddress) - body.append('username', username) - body.append('password', password) - body.append('registrationSurvey', registrationSurvey) - - return this.http.post(this.urlRegister, body) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + let body = new FormData(); + body.append('emailaddress', emailaddress); + body.append('username', username); + body.append('password', password); + body.append('registrationSurvey', registrationSurvey); + + return this.http.post(this.urlRegister, body).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } login(username: string, password: string) { - let body = new FormData() - body.append('username', username) - body.append('password', password) - - return this.http.post(this.urlLogin, body) - .pipe( - map(body => { - if (body && body.token) { - this.loginStatus.next(true) - this.user.next(body.result) - this.userID = new BehaviorSubject(parseInt(body.result.id)) - localStorage.setItem('loginStatus', '1') - localStorage.setItem('jwt', body.token) - localStorage.setItem('userID', body.result.id) - localStorage.setItem('expiration', body.expiration) - } - return body - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - - ) + let body = new FormData(); + body.append('username', username); + body.append('password', password); + + return this.http.post(this.urlLogin, body).pipe( + map(body => { + if (body && body.token) { + localStorage.setItem('loginStatus', '1'); + localStorage.setItem('jwt', body.token); + localStorage.setItem('userID', body.result.id); + localStorage.setItem('expiration', body.expiration); + + this.isLoggedIn.next(true); + this.user.next(body.result); + this.userID = new BehaviorSubject(parseInt(body.result.id)); + + this.notfi.fetchNotifications(); + this.notfi.startSignalRConnection(body.token); + } + return body; + }), + catchError(error => { console.log(error); return throwError(error); }) + ); } resetPassword(userID: string, token: string) { - let body = new FormData() - body.append('user', userID) - body.append('code', token) - - return this.http.post(this.urlResetPassword, body) - .pipe( - map(body => { - return body - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - - ) + let body = new FormData(); + body.append('user', userID); + body.append('code', token); + + return this.http.post(this.urlResetPassword, body).pipe( + map(body => body), + catchError(error => { console.log(error); return throwError(error); }) + ); } resendVerificationLink(email: string) { let params = new HttpParams(); params = params.append("EmailAddress", email); - return this.http.get(this.urlReSendVerification, { params: params }) - .pipe( - map(body => { - return body - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - - ) + return this.http.get(this.urlReSendVerification, { params }).pipe( + map(body => body), + catchError(error => { console.log(error); return throwError(error); }) + ); } sendPasswordResetToken(email: string) { - let body = new FormData() - body.append('EmailAddress', email) + let body = new FormData(); + body.append('EmailAddress', email); console.log("sendPasswordResetToken is called"); - return this.http.post(this.urlForgotPassEmail, body) - .pipe( - map(body => { - return body - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - - ) - } + return this.http.post(this.urlForgotPassEmail, body).pipe( + map(body => body), + catchError(error => { console.log(error); return throwError(error); }) + ); + } changePassword(userID: string, password: string, confirmPassword: string, token: string) { - let body = new FormData() - body.append('userID', userID) - body.append('NewPassword', password) - body.append('ConfirmPassword', confirmPassword) - body.append('passwordToken', token) - - return this.http.post(this.urlChangePassword, body) - .pipe( - map(body => { - return body - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - - ) + let body = new FormData(); + body.append('userID', userID); + body.append('NewPassword', password); + body.append('ConfirmPassword', confirmPassword); + body.append('passwordToken', token); + + return this.http.post(this.urlChangePassword, body).pipe( + map(body => body), + catchError(error => { console.log(error); return throwError(error); }) + ); } uploadProfileImage(file: any, userID: number): Observable { - let body = new FormData() - body.append('file', file) - body.append('userID', userID.toString()) - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); + let body = new FormData(); + body.append('file', file); + body.append('userID', userID.toString()); - return this.http.post(this.urlUploadProfileImage, body, {headers}).pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) + return this.http.post(this.urlUploadProfileImage, body, { headers: this.authHeaders() }).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) ); } + // ─── PUT ────────────────────────────────────────────────────────────────── + updateUser(bio: string, userID: number): Observable { - let body = new FormData() - body.append('bio', bio) - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - - return this.http.put(this.urlUpdateUser + userID, body, {headers}) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + let body = new FormData(); + body.append('bio', bio); + + return this.http.put(this.urlUpdateUser + userID, body, { headers: this.authHeaders() }).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } + // ─── DELETE ─────────────────────────────────────────────────────────────── + unfollow(userID: number, followerID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlUnfollow + userID + '/' + followerID, {headers}) - .pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.delete(this.urlUnfollow + userID + '/' + followerID, { headers: this.authHeaders() }).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } deleteProfileImage(blobFileID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlDeleteProfileImage + blobFileID, {headers}).pipe( - map(body => { - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) + return this.http.delete(this.urlDeleteProfileImage + blobFileID, { headers: this.authHeaders() }).pipe( + map(body => { console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) ); } deleteUser(userID: number): Observable { - const headers = new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); - - return this.http.delete(this.urlDeleteUser + userID, {headers}).pipe( - map(body => { - console.log(body.message) - return body.message - }), - catchError(error => { - console.log(error) - return throwError(error) - }) + return this.http.delete(this.urlDeleteUser + userID, { headers: this.authHeaders() }).pipe( + map(body => { console.log(body.message); return body.message; }), + catchError(error => { console.log(error); return throwError(error); }) ); } + // ─── Auth Helpers ───────────────────────────────────────────────────────── + checkLoginStatus(): boolean { - // Get Login Cookie - var loginCookie = localStorage.getItem('loginStatus'); - - // Check Login Cookie - // 0 = Not Logged In - // 1 = Logged In - if (loginCookie == "1") { - // Return False If Null - if (localStorage.getItem('jwt') === null || localStorage.getItem('jwt') === undefined) { - return false; - } - - // Get and Decode the Token - const token = localStorage.getItem('jwt'); - const decoded: any = jwt_decode(token) - - // Check if the cookie is valid - if (decoded.exp === undefined) { - return false; - } - - // Get Current Time - const date = new Date(0) - - // Convert Expiration to UTC - let tokenExpDate = date.setUTCSeconds(decoded.exp) - - // Compare Expiration time with current time - if (tokenExpDate.valueOf() > new Date().valueOf()) { - return true; - } - - // Return False Since Token Expire - this.user = new BehaviorSubject(null) + const loginCookie = localStorage.getItem('loginStatus'); + + if (loginCookie !== "1") { + this.user = new BehaviorSubject(null); return false; } - this.user = new BehaviorSubject(null) + + const token = localStorage.getItem('jwt'); + if (!token) return false; + + const decoded: any = jwt_decode(token); + if (decoded.exp === undefined) return false; + + const tokenExpDate = new Date(0).setUTCSeconds(decoded.exp); + if (tokenExpDate.valueOf() > new Date().valueOf()) return true; + + this.user = new BehaviorSubject(null); return false; } logout() { - // Set Login Status to false - this.loginStatus.next(false) + this.notfi.stopConnection(); + + this.isLoggedIn.next(false); - // Remove item from localStorage - localStorage.setItem('loginStatus', '0') - localStorage.removeItem('jwt') - localStorage.removeItem('expiration') - localStorage.removeItem('userID') + localStorage.setItem('loginStatus', '0'); + localStorage.removeItem('jwt'); + localStorage.removeItem('expiration'); + localStorage.removeItem('userID'); - // Navigate back to the login page - this.router.navigate(['/login']) + this.router.navigate(['/login']); } - get isLoggedIn() { - return this.loginStatus.asObservable() + private authHeaders(): HttpHeaders { + return new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); } + // ─── Getters ────────────────────────────────────────────────────────────── + get currentUser() { - if (this.userID.value != null && this.user.value == null && this.loginStatus.value == true) { - let promise = new Promise((resolve, reject) => { - this.getUserByID(this.userID.value) - .toPromise() - .then( - body => { - this.user.next(body) - resolve(this.user.asObservable()) - } - ) - }) - return promise - } - else { - let promise = new Promise((resolve, reject) => { - resolve(this.user.asObservable()) - }) - return promise + if (this.userID.value != null && this.user.value == null && this.isLoggedIn.value == true) { + return new Promise((resolve) => { + this.getUserByID(this.userID.value).toPromise().then(body => { + this.user.next(body); + resolve(this.user.asObservable()); + }); + }); } + return Promise.resolve(this.user.asObservable()); } setCurrentUser(modifiedUser: User): void { @@ -478,72 +317,36 @@ export class AccountService { } get currentUserID() { - return this.userID.asObservable() + return this.userID.asObservable(); } - // Error + // ─── Unused / Legacy ───────────────────────────────────────────────────── + getProjectList(userID: number): Observable { - return this.http.get(this.urlGetProjects + userID) - .pipe( - map(body => { - console.log(body) - if (body == null) - return [] - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlGetProjects + userID).pipe( + map(body => { if (!body) return []; console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } getFollower(userID: number): Observable { - return this.http.get(this.urlGetFollowers + userID) - .pipe( - map(body => { - if (body == null) - return [] - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlGetFollowers + userID).pipe( + map(body => { if (!body) return []; console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } getFollowing(followerID: number): Observable { - return this.http.get(this.urlGetFollowings + followerID) - .pipe( - map(body => { - if (body == null) - return [] - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlGetFollowings + followerID).pipe( + map(body => { if (!body) return []; console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } testApiCall(): Observable { - return this.http.get(this.urlVerify) - .pipe( - map(body => { - if (body == null) - return [] - console.log(body.message) - return body.result - }), - catchError(error => { - console.log(error) - return throwError(error) - }) - ) + return this.http.get(this.urlVerify).pipe( + map(body => { if (!body) return []; console.log(body.message); return body.result; }), + catchError(error => { console.log(error); return throwError(error); }) + ); } } diff --git a/src/Analysim.Web/ClientApp/src/app/services/notification.service.ts b/src/Analysim.Web/ClientApp/src/app/services/notification.service.ts index e7f73f51..8d797298 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/notification.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/notification.service.ts @@ -1,33 +1,127 @@ import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { BehaviorSubject, Observable, throwError } from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; import { ToastrService } from 'ngx-toastr'; +import * as signalR from '@microsoft/signalr'; +import { AppNotification } from '../interfaces/app-notification'; @Injectable({ providedIn: 'root' }) - export class NotificationService { - constructor(private toastr: ToastrService) { - this.toastr.toastrConfig.positionClass = "toast-bottom-right"; + private baseUrl = '/api/notification/'; + private hubConnection: signalR.HubConnection | null = null; + + private _notifications = new BehaviorSubject([]); + notifications$ = this._notifications.asObservable(); + + get unreadCount$(): Observable { + return this.notifications$.pipe( + map(list => list.filter(n => !n.isRead).length) + ); + } + + constructor(private http: HttpClient, private toastr: ToastrService) { + this.toastr.toastrConfig.positionClass = 'toast-bottom-right'; + } + + // ─── Toastr helpers ─────────────────────────────────────────────────────── + + showSuccess(message: string, title: string) { + this.toastr.success(message, title); + console.log(title + ':' + message); + } + + showInfo(message: string, title: string) { + this.toastr.info(message, title); + console.log(title + ':' + message); + } + + showMessage(message: string, title: string) { + this.toastr.error(message, title); + console.log(title + ':' + message); } - showSuccess(message, title){ - this.toastr.success(message, title) - console.log(title + ":" + message) + showWarning(message: string, title: string) { + this.toastr.warning(message, title); + console.log(title + ':' + message); } - showInfo(message, title){ - this.toastr.info(message, title) - console.log(title + ":" + message) + // ─── HTTP Methods ───────────────────────────────────────────────────────── + + fetchNotifications(): void { + const headers = this.authHeaders(); + this.http.get(this.baseUrl + 'getnotifications', { headers }) + .pipe( + map(body => body.result as AppNotification[]), + catchError(err => { console.error(err); return throwError(err); }) + ) + .subscribe(notifications => { + this._notifications.next(notifications || []); + }); + } + + markRead(id: number): void { + const headers = this.authHeaders(); + this.http.put(`${this.baseUrl}markread/${id}`, null, { headers }) + .pipe(catchError(err => { console.error(err); return throwError(err); })) + .subscribe(() => { + const updated = this._notifications.value.map(n => + n.id === id ? { ...n, isRead: true } : n + ); + this._notifications.next(updated); + }); + } + + markAllRead(): void { + const headers = this.authHeaders(); + this.http.put(`${this.baseUrl}markallread`, null, { headers }) + .pipe(catchError(err => { console.error(err); return throwError(err); })) + .subscribe(() => { + const updated = this._notifications.value.map(n => ({ ...n, isRead: true })); + this._notifications.next(updated); + }); + } + + // ─── SignalR ────────────────────────────────────────────────────────────── + + startSignalRConnection(token: string): void { + if (this.hubConnection) return; + + this.hubConnection = new signalR.HubConnectionBuilder() + .withUrl('/hubs/notification', { + accessTokenFactory: () => token, + }) + .withAutomaticReconnect() + .configureLogging(signalR.LogLevel.Warning) + .build(); + + this.hubConnection.on('ReceiveNotification', (notification: AppNotification) => { + const current = this._notifications.value; + this._notifications.next([notification, ...current]); + this.toastr.info(notification.message, notification.title); + }); + + this.hubConnection.start() + .then(() => console.log('SignalR connected')) + .catch(err => console.error('SignalR connection error:', err)); } - showMessage(message, title){ - this.toastr.error(message, title) - console.log(title + ":" + message) + stopConnection(): void { + if (this.hubConnection) { + this.hubConnection.stop().then(() => { + console.log('SignalR disconnected'); + this.hubConnection = null; + this._notifications.next([]); + }); + } } - showWarning(message, title){ - this.toastr.warning(message, title) - console.log(title + ":" + message) + // ─── Helper ─────────────────────────────────────────────────────────────── + + private authHeaders(): HttpHeaders { + return new HttpHeaders().set('Authorization', `Bearer ${localStorage.getItem('jwt')}`); } } diff --git a/src/Analysim.Web/Controllers/AccountController.cs b/src/Analysim.Web/Controllers/AccountController.cs index 26e46338..b2246521 100644 --- a/src/Analysim.Web/Controllers/AccountController.cs +++ b/src/Analysim.Web/Controllers/AccountController.cs @@ -41,13 +41,15 @@ public class AccountController : ControllerBase private readonly ApplicationDbContext _dbContext; private readonly ILoggerManager _loggerManager; private readonly IMailNetService _mailNetService; + private readonly INotificationService _notificationService; private readonly IConfiguration _configuration; public AccountController(IOptions jwtSettings, UserManager userManager, SignInManager signManager, ApplicationDbContext dbContext, ILoggerManager loggerManager, - IMailNetService mailNetService,IConfiguration configuration) + IMailNetService mailNetService, IConfiguration configuration, + INotificationService notificationService) { _jwtSettings = jwtSettings.Value; _userManager = userManager; @@ -56,6 +58,7 @@ public AccountController(IOptions jwtSettings, UserManager us _loggerManager = loggerManager; _mailNetService = mailNetService; _configuration = configuration; + _notificationService = notificationService; } #region GET REQUEST @@ -267,6 +270,15 @@ public async Task Follow([FromForm] AccountFollowVM formdata) // Save Change await _dbContext.SaveChangesAsync(); + // Notify the followed user + await _notificationService.SendNotificationAsync( + userToFollow.Id, + "New Follower", + $"{user.UserName} started following you.", + NotificationType.NewFollower, + $"/profile/{user.UserName}" + ); + return Ok(new { result = userFollower, @@ -664,6 +676,25 @@ public async Task Login([FromForm] AccountLoginVM formdata) // Update Last Online user.LastOnline = DateTime.UtcNow; + // Check login IP for security alert + var currentIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + if (!string.IsNullOrEmpty(currentIp) && + !string.IsNullOrEmpty(user.LastLoginIp) && + user.LastLoginIp != currentIp) + { + // New IP detected — send security alert (fire-and-forget, don't block login) + await _notificationService.SendNotificationAsync( + user.Id, + "Security Alert", + $"We detected a login from a new location ({currentIp}). If this wasn't you, please secure your account.", + NotificationType.SecurityAlert, + "/settings/security" + ); + } + + // Update last login IP + user.LastLoginIp = currentIp; + // Save Database Change await _dbContext.SaveChangesAsync(); diff --git a/src/Analysim.Web/Controllers/NotificationController.cs b/src/Analysim.Web/Controllers/NotificationController.cs new file mode 100644 index 00000000..3cdc5b05 --- /dev/null +++ b/src/Analysim.Web/Controllers/NotificationController.cs @@ -0,0 +1,113 @@ +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Core.Entities; +using Infrastructure.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Web.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class NotificationController : ControllerBase + { + private readonly ApplicationDbContext _dbContext; + + public NotificationController(ApplicationDbContext dbContext) + { + _dbContext = dbContext; + } + + /* + * Type : GET + * URL : /api/notification/getnotifications + * Description: Return current user's notifications, newest first + * Response Status: 200 Ok, 401 Unauthorized + */ + [HttpGet("[action]")] + public async Task GetNotifications() + { + var userId = GetUserId(); + if (userId == null) return Unauthorized(new { message = "Invalid user identifier." }); + + var notifications = await _dbContext.Notifications + .Where(n => n.UserId == userId.Value) + .OrderByDescending(n => n.CreatedAt) + .Select(n => new + { + id = n.Id, + userId = n.UserId, + title = n.Title, + message = n.Message, + type = n.Type.ToString(), + isRead = n.IsRead, + linkUrl = n.LinkUrl, + createdAt = n.CreatedAt + }) + .ToListAsync(); + + return Ok(new + { + result = notifications, + message = "Received notifications" + }); + } + + /* + * Type : PUT + * URL : /api/notification/markread/{id} + * Description: Mark a single notification as read + * Response Status: 200 Ok, 401 Unauthorized, 404 Not Found + */ + [HttpPut("[action]/{id}")] + public async Task MarkRead([FromRoute] int id) + { + var userId = GetUserId(); + if (userId == null) return Unauthorized(new { message = "Invalid user identifier." }); + + var notification = await _dbContext.Notifications + .SingleOrDefaultAsync(n => n.Id == id && n.UserId == userId.Value); + + if (notification == null) return NotFound(new { message = "Notification not found." }); + + notification.IsRead = true; + await _dbContext.SaveChangesAsync(); + + return Ok(new { message = "Notification marked as read." }); + } + + /* + * Type : PUT + * URL : /api/notification/markallread + * Description: Mark all of current user's notifications as read + * Response Status: 200 Ok, 401 Unauthorized + */ + [HttpPut("[action]")] + public async Task MarkAllRead() + { + var userId = GetUserId(); + if (userId == null) return Unauthorized(new { message = "Invalid user identifier." }); + + var unread = await _dbContext.Notifications + .Where(n => n.UserId == userId.Value && !n.IsRead) + .ToListAsync(); + + unread.ForEach(n => n.IsRead = true); + await _dbContext.SaveChangesAsync(); + + return Ok(new { message = $"Marked {unread.Count} notifications as read." }); + } + + // Helper to extract userId from JWT claim + private int? GetUserId() + { + var claim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User.FindFirst("sub")?.Value; + if (int.TryParse(claim, out var id)) return id; + return null; + } + } +} diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index fe159580..8f8225fe 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -39,11 +39,13 @@ public class ProjectController : ControllerBase private readonly ApplicationDbContext _dbContext; private readonly IConfiguration _configuration; + private readonly INotificationService _notificationService; - public ProjectController(ApplicationDbContext dbContext, IConfiguration configuration) + public ProjectController(ApplicationDbContext dbContext, IConfiguration configuration, INotificationService notificationService) { _dbContext = dbContext; _configuration = configuration; + _notificationService = notificationService; } #region GET REQUEST @@ -728,6 +730,19 @@ public async Task AddUser([FromForm] ProjectUserVM formdata) _dbContext.Entry(projectUser).Reference(pu => pu.User).Load(); + // Notify the invited user + var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); + if (project != null) + { + await _notificationService.SendNotificationAsync( + formdata.UserID, + "Project Invitation", + $"You have been added to the project \"{project.Name}\".", + NotificationType.ProjectInvitation, + $"/projects/{formdata.ProjectID}" + ); + } + // Return Ok Status return Ok(new { diff --git a/src/Analysim.Web/Extensions/ServiceExtensions.cs b/src/Analysim.Web/Extensions/ServiceExtensions.cs index 7fd1cb8d..3aaa2589 100644 --- a/src/Analysim.Web/Extensions/ServiceExtensions.cs +++ b/src/Analysim.Web/Extensions/ServiceExtensions.cs @@ -11,7 +11,10 @@ using Microsoft.IdentityModel.Tokens; using System; using System.Text; +using System.Threading.Tasks; using Core.Interfaces; +using Web.Services; +using Web.Hubs; namespace Web.Extensions { @@ -22,10 +25,19 @@ public static void ConfigureCors(this IServiceCollection services) { services.AddCors(options => { + // General REST API policy options.AddPolicy("CorsPolicy", builder => builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); + + // SignalR requires AllowCredentials which is incompatible with AllowAnyOrigin. + // This policy is applied on the hub endpoint and allows the same-site Angular dev server. + options.AddPolicy("SignalRPolicy", builder => + builder.SetIsOriginAllowed(_ => true) + .AllowAnyMethod() + .AllowAnyHeader() + .AllowCredentials()); }); } @@ -77,29 +89,45 @@ public static void ConfigureJWT(this IServiceCollection services, IConfiguration services.Configure(jwtSettings); - // Authentication Middleware services.AddAuthentication(o => - { - o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - o.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme; - o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - - }) - .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => - { - options.TokenValidationParameters = new TokenValidationParameters { - - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, - ValidateIssuerSigningKey = true, - - ValidIssuer = jwtSettings.GetSection("Issuer").Value, - ValidAudience = jwtSettings.GetSection("Audience").Value, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)) - }; - }); + o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + o.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme; + o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + ValidIssuer = jwtSettings.GetSection("Issuer").Value, + ValidAudience = jwtSettings.GetSection("Audience").Value, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey)), + + ClockSkew = TimeSpan.Zero + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + var accessToken = context.Request.Query["access_token"]; + var path = context.HttpContext.Request.Path; + + if (!string.IsNullOrEmpty(accessToken) && + path.StartsWithSegments("/hubs/notification")) + { + context.Token = accessToken; + } + + return Task.CompletedTask; + } + }; + }); } public static void ConfigureSpa(this IServiceCollection services) @@ -124,5 +152,15 @@ public static void ConfigureMailService(this IServiceCollection services, IConfi services.AddTransient(); } + public static void ConfigureSignalR(this IServiceCollection services) + { + services.AddSignalR(); + } + + public static void ConfigureNotificationService(this IServiceCollection services) + { + services.AddScoped(); + } + } } diff --git a/src/Analysim.Web/Hubs/NotificationHub.cs b/src/Analysim.Web/Hubs/NotificationHub.cs new file mode 100644 index 00000000..abceabae --- /dev/null +++ b/src/Analysim.Web/Hubs/NotificationHub.cs @@ -0,0 +1,38 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace Web.Hubs +{ + [Authorize] + public class NotificationHub : Hub + { + public override async Task OnConnectedAsync() + { + var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? Context.User?.FindFirst("sub")?.Value; + + if (!string.IsNullOrEmpty(userId)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, userId); + } + + await base.OnConnectedAsync(); + } + + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = Context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? Context.User?.FindFirst("sub")?.Value; + + if (!string.IsNullOrEmpty(userId)) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, userId); + } + + await base.OnDisconnectedAsync(exception); + } + } +} diff --git a/src/Analysim.Web/Services/NotificationService.cs b/src/Analysim.Web/Services/NotificationService.cs new file mode 100644 index 00000000..20f08ce6 --- /dev/null +++ b/src/Analysim.Web/Services/NotificationService.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading.Tasks; +using Core.Entities; +using Core.Interfaces; +using Infrastructure.Data; +using Microsoft.AspNetCore.SignalR; +using Web.Hubs; + +namespace Web.Services +{ + public class NotificationService : INotificationService + { + private readonly ApplicationDbContext _dbContext; + private readonly IHubContext _hubContext; + + public NotificationService(ApplicationDbContext dbContext, IHubContext hubContext) + { + _dbContext = dbContext; + _hubContext = hubContext; + } + + public async Task SendNotificationAsync(int userId, string title, string message, NotificationType type, string linkUrl) + { + // Persist notification to the database + var notification = new Notification + { + UserId = userId, + Title = title, + Message = message, + Type = type, + IsRead = false, + LinkUrl = linkUrl, + CreatedAt = DateTimeOffset.UtcNow + }; + + await _dbContext.Notifications.AddAsync(notification); + await _dbContext.SaveChangesAsync(); + + // Push notification to connected client(s) via SignalR + await _hubContext.Clients + .Group(userId.ToString()) + .SendAsync("ReceiveNotification", new + { + id = notification.Id, + userId = notification.UserId, + title = notification.Title, + message = notification.Message, + type = notification.Type.ToString(), + isRead = notification.IsRead, + linkUrl = notification.LinkUrl, + createdAt = notification.CreatedAt + }); + } + } +} diff --git a/src/Analysim.Web/Startup.cs b/src/Analysim.Web/Startup.cs index 028ed82d..20dfadd7 100644 --- a/src/Analysim.Web/Startup.cs +++ b/src/Analysim.Web/Startup.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Web.Extensions; +using Web.Hubs; namespace Web { @@ -76,6 +77,10 @@ public void ConfigureServices(IServiceCollection services) services.ConfigureMailService(Configuration); + services.ConfigureSignalR(); + + services.ConfigureNotificationService(); + services.AddAutoMapper(typeof(Startup)); services.AddControllers(config => @@ -138,6 +143,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerM name: "default", pattern: "{controller}/{action=Index}/{id?}" ); + endpoints.MapHub("/hubs/notification"); }); app.UseSpa(spa =>