diff --git a/README.md b/README.md index 2c89b004..c81bf67a 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,13 @@ Then copy the file `index.html` under `dist\lab\index.html`. ### Connecting to databases and other services -Analysim requires two databases to operate: one SQL database (PostgreSQL) for relational data and one Azure BlobStorage database for keeping uploaded user files. In addition, an Outlook account is needed for the email functionality. All of these services are accessed via authentication information stored in the `appsettings.json` and `appsettings.Development.json` files under the `src/Analysim.Web` folder. The structure of the files are as follows (`XXX` means redacted): +Analysim currently requires a SQL database (PostgreSQL) for both relational data and manual blob storage (Azure BlobStorage is no longer used / required). + +In addition, an Outlook account is needed for email functionality. + +All of these services are accessed via authentication information stored in the `appsettings.json` and `appsettings.Development.json` files which should be added under the `src/Analysim.Web` folder. + +The structure of the files are as follows (`XXX` means redacted): ```json { @@ -67,11 +73,14 @@ Analysim requires two databases to operate: one SQL database (PostgreSQL) for re "Username": "XXX", "Password": "XXX" }, + "ClientSettings": { + "BaseUrl": "https://www.analysim.tech" + }, "JwtSettings": { "Issuer": "AnalySim", "Secret": "XXX", "ExpireTime": 60, - "Audience": "https://www.analysim.tech/" + "Audience": "https://www.analysim.tech" }, "UserQuota": 100000000, "registrationCodes": [ "123" ], @@ -87,7 +96,13 @@ Analysim requires two databases to operate: one SQL database (PostgreSQL) for re Admin access in Analysim is controlled through the AdminUsers section of the `appsettings.json` and `appsettings.Development.json`. Each entry in the list corresponds to the username of a registered Analysim user. Admin users will see an Admin link in the navigation bar and can access the /admin section of the platform. To add or remove admin privileges, simply update this list and restart the server. -⚠️ Important: The usernames must exactly match the usernames stored in the database (case-sensitive). +⚠️ Important: The usernames must exactly match the usernames stored in the database (full uppercase eg. "ADMIN"). + +#### Email account + +Outlook no longer allows simple email authentication, so you must use another service that provides password authentication (e.g. Gmail). You can either use an existing account or create a new one and then fill in the `XXX` values under the section `EmailSettings` in the above file. + +For email services, the correct BaseUrl is required within appsettings. For development, in `appsettings.Development.json` use: 'https://localhost:5001'. For deployment, in in `appsettings.json` the BaseUrl should match the necessary url. #### SQL database (also see Docker Compose option below) @@ -101,10 +116,6 @@ dotnet ef database update Blob storage is now replaced with the PostgreSQL database and no longer necessary. If you want to set it up regardless, follow these instructions. If you don't have an existing blob storage account, log into [Microsoft Azure](https://portal.azure.com), and create a ["Storage Account"](https://learn.microsoft.com/en-us/azure/storage/common/storage-account-overview) with "Blob service" enabled. Then, select "Access Keys" on the left sidebar menu and copy one of the keys and insert both to replace the `XXX` in the `AzureStorageConnectionString` entry above. You will also need to insert your storage account name. In the same section on Azure, you can see the formatting for the correct Connection String as a guide. Blob storage falls under the [free student services](https://azure.microsoft.com/en-us/free/students/). -#### Email account - -Outlook no longer allows simple email authentication, so you must use another service that provides password authentication (e.g. Gmail). You can either use an existing account or create a new one and then fill in the `XXX` values under the section `EmailSettings` in the above file. - ### Running the project In **Visual Studio**, open up the `AnalySim.sln` file. Click on diff --git a/src/Analysim.Core/Entities/User.cs b/src/Analysim.Core/Entities/User.cs index 53049acf..e8fb783d 100644 --- a/src/Analysim.Core/Entities/User.cs +++ b/src/Analysim.Core/Entities/User.cs @@ -25,6 +25,7 @@ public class User : IdentityUser public ICollection ProjectComments { get; set; } = new List(); public ICollection CommentLikes { get; set; } = new List(); public ICollection CommentFlags { get; set; } = new List(); + public bool ReceiveCommentReplyEmails { get; set; } = true; public string RegistrationSurvey {get; set;} diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index d1189288..2f7d84a9 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -490,6 +490,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PhoneNumberConfirmed") .HasColumnType("boolean"); + b.Property("ReceiveCommentReplyEmails") + .HasColumnType("boolean"); + b.Property("RegistrationSurvey") .HasColumnType("text"); @@ -564,21 +567,21 @@ protected override void BuildModel(ModelBuilder modelBuilder) new { Id = 1, - ConcurrencyStamp = "f1d80f82-74ad-4f0e-b39a-228c651b424a", + ConcurrencyStamp = "e7431394-d3f8-4e7a-828d-930d0ecfe1ac", Name = "Admin", NormalizedName = "ADMIN" }, new { Id = 2, - ConcurrencyStamp = "cfb8a477-eb7b-4e91-865d-122d17565ce0", + ConcurrencyStamp = "b325405e-6f19-4b1c-a845-9df31e386760", Name = "Customer", NormalizedName = "CUSTOMER" }, new { Id = 3, - ConcurrencyStamp = "ecaa6f71-1595-44f7-9337-57b461da675a", + ConcurrencyStamp = "f68ee1fe-5d74-4f56-ade1-5a8837a3e127", Name = "Moderator", NormalizedName = "MODERATOR" }); diff --git a/src/Analysim.Infrastructure/Migrations/20260508015045_AddRecieveCommentReplyEmailsToUser.Designer.cs b/src/Analysim.Infrastructure/Migrations/20260508015045_AddRecieveCommentReplyEmailsToUser.Designer.cs new file mode 100644 index 00000000..641c258f --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260508015045_AddRecieveCommentReplyEmailsToUser.Designer.cs @@ -0,0 +1,941 @@ +// +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("20260508015045_AddRecieveCommentReplyEmailsToUser")] + partial class AddRecieveCommentReplyEmailsToUser + { + 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.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.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPendingReview") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.Property("FlagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FlagID")); + + b.Property("CommentContentSnapshot") + .HasColumnType("text"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("FlagID"); + + b.HasIndex("UserID"); + + b.HasIndex("CommentID", "UserID") + .IsUnique(); + + b.ToTable("ProjectCommentFlags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CommentID", "UserID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectCommentLikes"); + }); + + modelBuilder.Entity("Core.Entities.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("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("ReceiveCommentReplyEmails") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "e7431394-d3f8-4e7a-828d-930d0ecfe1ac", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "b325405e-6f19-4b1c-a845-9df31e386760", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "f68ee1fe-5d74-4f56-ade1-5a8837a3e127", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentFlags") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentFlags") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentLikes") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.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("ProjectComments"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectUsers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Migrations/20260508015045_AddRecieveCommentReplyEmailsToUser.cs b/src/Analysim.Infrastructure/Migrations/20260508015045_AddRecieveCommentReplyEmailsToUser.cs new file mode 100644 index 00000000..344b40ba --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260508015045_AddRecieveCommentReplyEmailsToUser.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddRecieveCommentReplyEmailsToUser : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ReceiveCommentReplyEmails", + table: "AspNetUsers", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "e7431394-d3f8-4e7a-828d-930d0ecfe1ac"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "b325405e-6f19-4b1c-a845-9df31e386760"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "f68ee1fe-5d74-4f56-ade1-5a8837a3e127"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ReceiveCommentReplyEmails", + table: "AspNetUsers"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 1, + column: "ConcurrencyStamp", + value: "45fc9c92-801d-433f-ab00-280c2334bc89"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 2, + column: "ConcurrencyStamp", + value: "922f6db8-d335-4aa4-9331-12097412cf5e"); + + migrationBuilder.UpdateData( + table: "AspNetRoles", + keyColumn: "Id", + keyValue: 3, + column: "ConcurrencyStamp", + value: "cecf34c5-c9e0-4b99-9fb7-1441045209d8"); + } + } +} diff --git a/src/Analysim.Web/Analysim.Web.sln b/src/Analysim.Web/Analysim.Web.sln new file mode 100644 index 00000000..314bc50c --- /dev/null +++ b/src/Analysim.Web/Analysim.Web.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Analysim.Web", "Analysim.Web.csproj", "{ADC4F08B-F721-DC4C-9AC5-F673A3B7FA2B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {ADC4F08B-F721-DC4C-9AC5-F673A3B7FA2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADC4F08B-F721-DC4C-9AC5-F673A3B7FA2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADC4F08B-F721-DC4C-9AC5-F673A3B7FA2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADC4F08B-F721-DC4C-9AC5-F673A3B7FA2B}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {3F55E24C-EB1B-43AB-8116-6BA805404B75} + EndGlobalSection +EndGlobal diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/user.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/user.ts index d1cccb45..264956b0 100644 --- a/src/Analysim.Web/ClientApp/src/app/interfaces/user.ts +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/user.ts @@ -7,6 +7,7 @@ export interface User { userName: string email: string bio: string + receiveCommentReplyEmails: boolean dateCreated: Date lastOnline: Date followers: Array diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.html b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.html index 6983c726..63104b7f 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.html +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.html @@ -1,60 +1,290 @@ - -
- -
-
- -
-
- -
- -
- Profile Setting + +
+
+
+ +
+ +
+
+ Profile image + +
+

Profile Settings

+ +
+ {{ currentUser.userName }} +
+
+
+ +
+
+ Email + {{ currentUser.email }} +
+
+
+ + +
+
+ Profile + Public Info +
+ +
+
+ +
+
+
+
Bio
+ +
+
+ + + +
+
+ +
+ +
+
+
+
+ + +
+
+
Profile Image
+ +
+
+
+ Profile image +
+ +

+ Upload or clear your current profile image. +

+
+ + +
+ +
+ + + +
+
+
+
+
+
+ + +
+
+ Account Settings +
+ +
+
+ +
+
+
+
Change Email
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + +
+
+
+
Change Password
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+
+
+
+ + +
+
+ Notification Preferences +
+ +
+
+
-
-
-
-
\ No newline at end of file + + + + +
+ +
+
+ +
+
+
+
+ + +
diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.scss b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.scss index 2852a284..3fa389f0 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.scss @@ -1,13 +1,300 @@ -.card { - background-color: var(--background-color-tertiary); +.profile-settings-page { + .profile-settings-shell { + background: var(--background-color-tertiary); + border: var(--border-w-1) var(--border-style) var(--border-color); + padding: var(--space-3); + } + + /* HEADER */ + .settings-profile-header { + display: flex; + justify-content: space-between; + gap: var(--space-4); - .btn { - background-color: var(--button-background-color); - color:var(--primary-color); + padding: var(--space-2) var(--space-1) var(--space-4); + border-bottom: var(--border-w-1) var(--border-style) var(--border-color); + margin-bottom: var(--space-3); + } + + .settings-profile-main { + display: flex; + align-items: flex-start; + gap: var(--space-3); + } + + .settings-avatar { + width: 54px; + height: 54px; + object-fit: cover; + border-radius: 50%; + border: var(--border-w-1) var(--border-style) var(--border-color); + background: var(--surface-0); + } + + .settings-title { + font-size: 24px; + font-weight: 600; + margin: 0 0 var(--space-1); } -} -.card-header { - font-size:20px; - font-weight:500; + .settings-user-line { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--fs-14); + margin-bottom: var(--space-2); + } + + .settings-profile-stats { + width: 245px; + padding-top: var(--space-1); + } + + .settings-stat-row { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: var(--space-2); + + font-size: var(--fs-14); + color: var(--button-background-color); + margin-bottom: var(--space-1); + + span:first-child { + white-space: nowrap; + } + + span:last-child { + text-align: right; + border-left: var(--border-w-1) var(--border-style) var(--border-color); + padding-left: var(--space-2); + } + } + + /* CARDS */ + .settings-card { + background: var(--surface-0); + border: var(--border-w-1) var(--border-style) var(--border-color); + border-radius: var(--border-radius); + margin-bottom: var(--space-3); + } + + .settings-card-header { + min-height: 40px; + padding: var(--space-2) var(--space-3); + border-bottom: var(--border-w-1) var(--border-style) var(--border-color); + + display: flex; + align-items: center; + justify-content: space-between; + + font-size: var(--fs-14); + font-weight: 600; + } + + .settings-header-action { + color: var(--button-background-color); + font-weight: 500; + } + + .settings-card-body { + padding: var(--space-3); + } + + .settings-card-actions { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + + padding-top: var(--space-3); + margin-top: auto; + border-top: var(--border-w-1) var(--border-style) var(--border-color); + } + + /* SHARED INNER PANELS */ + .settings-card-body > .row > [class*="col-"] { + display: flex; + } + + .settings-card-body > .row > [class*="col-"] > form { + display: flex; + width: 100%; + } + + .settings-inner-box { + width: 100%; + height: 100%; + min-height: 100%; + + display: flex; + flex-direction: column; + + background: var(--surface-0); + border: var(--border-w-1) var(--border-style) var(--border-color); + padding: var(--space-3); + } + + .settings-inner-header { + font-size: var(--fs-14); + font-weight: 600; + + padding-bottom: var(--space-2); + margin-bottom: var(--space-3); + border-bottom: var(--border-w-1) var(--border-style) var(--border-color); + } + + /* PROFILE SPLIT */ + .profile-split-row { + display: flex; + align-items: stretch; + } + + .profile-split-col { + display: flex; + } + + .settings-profile-form { + display: flex; + width: 100%; + } + + .profile-panel { + min-height: 340px; + } + + .profile-panel-body { + flex: 1; + min-height: 0; + + display: flex; + flex-direction: column; + } + + .profile-bio-group { + height: 100%; + display: flex; + flex-direction: column; + margin-bottom: 0; + } + + .profile-bio-textarea { + flex: 1; + min-height: 0; + resize: none; + } + + .profile-image-panel-body { + align-items: center; + justify-content: center; + } + + .settings-image-box { + width: 100%; + height: 100%; + + display: grid; + grid-template-rows: 1fr auto; + align-items: center; + justify-items: center; + gap: var(--space-3); + } + + .settings-image-frame { + width: 100%; + display: flex; + justify-content: center; + align-items: center; + } + + .settings-image-preview { + width: min(180px, 100%); + aspect-ratio: 1 / 1; + object-fit: cover; + display: block; + } + + .settings-image-note { + margin: 0; + color: var(--text-muted); + font-size: var(--fs-14); + text-align: center; + } + + .profile-panel-actions { + min-height: 48px; + align-items: center; + } + + /* NOTIFICATIONS */ + .settings-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + + padding: var(--space-3); + border: var(--border-w-1) var(--border-style) var(--border-color); + margin-bottom: var(--space-2); + cursor: pointer; + } + + .settings-option-title { + font-weight: 600; + font-size: var(--fs-14); + margin-bottom: 2px; + } + + .settings-option-text { + font-size: var(--fs-14); + color: var(--text-muted); + } + + /* FORM DEFAULTS */ + label { + font-size: var(--fs-14); + font-weight: 600; + } + + .form-control { + font-size: var(--fs-14); + } } + +@media (max-width: 768px) { + .profile-settings-page { + .settings-profile-header, + .profile-split-row { + flex-direction: column; + } + + .settings-profile-stats { + width: 100%; + } + + .settings-card-body > .row > [class*="col-"], + .profile-split-col { + display: block; + } + + .settings-card-actions { + flex-direction: column; + align-items: stretch; + } + + .settings-card-actions .btn { + width: 100%; + } + + .settings-inner-box { + margin-bottom: var(--space-3); + } + + .profile-panel { + min-height: auto; + } + + .profile-bio-textarea { + min-height: 160px; + } + } +} \ No newline at end of file diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts index 35ab67ef..6cdd120f 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile-setting/profile-setting.component.ts @@ -14,11 +14,9 @@ import { SafeUrl, DomSanitizer } from '@angular/platform-browser'; styleUrls: ['./profile-setting.component.scss'] }) export class ProfileSettingComponent implements OnInit { + currentUser$: Observable; + currentUser: User = null; - currentUser$: Observable - currentUser: User = null - profileForm: FormGroup - bio: FormControl profileImageUrl: SafeUrl; constructor( @@ -27,28 +25,23 @@ export class ProfileSettingComponent implements OnInit { private router: Router, private formBuilder: FormBuilder, private sanitizer: DomSanitizer, - private notif: NotificationService + private notif: NotificationService, ) { } async ngOnInit(): Promise { if (!this.accountService.checkLoginStatus()) - this.router.navigate(['/login'], { queryParams: { returnUrl: this.router.url } }) - - await this.accountService.currentUser.then((x) => this.currentUser$ = x) - this.currentUser$.subscribe(x => { - this.currentUser = x - this.profileImage(); - }) - - // Make Form Control - this.bio = new FormControl(this.currentUser.bio) - - // Initialize FormGroup using FormBuilder - this.profileForm = this.formBuilder.group({ - bio: this.bio - }) - - this.profileImage(); + this.router.navigate(['/login'], { + queryParams: { returnUrl: this.router.url }, + }); + + await this.accountService.currentUser.then((x) => (this.currentUser$ = x)); + this.currentUser$.subscribe((x) => { + this.currentUser = x; + + this.getProfileImage(); + this.buildProfileBioForm(); + this.buildNotificationsPrefForm(); + }); } public useFileInput() { @@ -58,68 +51,137 @@ export class ProfileSettingComponent implements OnInit { // Add FormControl to FormGroup for file input public fileEvent($event) { // Get Target File - let file = $event.target.files[0] + let file = $event.target.files[0]; // Upload File Or Replace If Already Exist this.accountService.uploadProfileImage(file, this.currentUser.id).subscribe( - result => { - let index = this.currentUser.blobFiles.findIndex(x => x.blobFileID == result.blobFileID) - if (index > -1) - this.currentUser.blobFiles[index] = result - else - this.currentUser.blobFiles.push(result) + (result) => { + let index = this.currentUser.blobFiles.findIndex( + (x) => x.blobFileID == result.blobFileID, + ); + if (index > -1) this.currentUser.blobFiles[index] = result; + else this.currentUser.blobFiles.push(result); // this.profileImage(); this.accountService.setCurrentUser(this.currentUser); - }, error => { - console.log(error) - } - ) + }, + (error) => { + console.log(error); + }, + ); } - profileImage() { + getProfileImage() { if (this.currentUser.blobFiles.length != 0) { - var blobFile = this.currentUser.blobFiles.find(x => x.container == 'profile') + var blobFile = this.currentUser.blobFiles.find( + (x) => x.container == 'profile', + ); if (blobFile != null) { this.projectService.downloadFile(blobFile.blobFileID).subscribe( - imageBlob => { - if (imageBlob == null) this.profileImageUrl = "../../assets/img/default-profile.png"; + (imageBlob) => { + if (imageBlob == null) + this.profileImageUrl = '../../assets/img/default-profile.png'; const objectURL = URL.createObjectURL(imageBlob); - this.profileImageUrl = this.sanitizer.bypassSecurityTrustUrl(objectURL); - }, error => { - console.log(error) - } - ) - } - else this.profileImageUrl = "../../assets/img/default-profile.png"; - } + this.profileImageUrl = + this.sanitizer.bypassSecurityTrustUrl(objectURL); + }, + (error) => { + console.log(error); + }, + ); + } else this.profileImageUrl = '../../assets/img/default-profile.png'; + } else this.profileImageUrl = '../../assets/img/default-profile.png'; } - clearProfile() { - let imageFileID = this.currentUser.blobFiles.find(x => x.container == 'profile').blobFileID + onRemoveProfileImage() { + let imageFileID = this.currentUser.blobFiles.find( + (x) => x.container == 'profile', + ).blobFileID; if (imageFileID != undefined) { this.accountService.deleteProfileImage(imageFileID).subscribe( - result => { + (result) => { // Remove Item From Project File - let index = this.currentUser.blobFiles.indexOf(result, 0) + let index = this.currentUser.blobFiles.indexOf(result, 0); this.currentUser.blobFiles.splice(index, 1); - }, error => { - console.log(error) - } - ) + this.accountService.setCurrentUser(this.currentUser); + }, + (error) => { + console.log(error); + }, + ); } } - onSubmit() { - let form = this.profileForm.value + // BIO FORM + profileBioForm: FormGroup; + bio: FormControl; + + buildProfileBioForm() { + this.bio = new FormControl(this.currentUser.bio); + this.profileBioForm = this.formBuilder.group({ + bio: this.bio, + }); + } + + onUpdateBio() { + let form = this.profileBioForm.value; this.accountService.updateUser(form.bio, this.currentUser.id).subscribe( - result => { - this.currentUser = result - this.notif.showSuccess("Account has been successfully updated", "Account Update") - }, error => { - console.log(error) - } - ) + (result) => { + this.currentUser = result; + this.notif.showSuccess( + 'Account has been successfully updated', + 'Account Update', + ); + }, + (error) => { + console.log(error); + }, + ); } + // EMAIL FORM + emailForm: FormGroup; + onChangeEmail() { + // will need to revalidate email + } + + // PASSWORD FORM + passwordForm: FormGroup; + onChangePassword() { + // call to change password + } + + // NOTIFICATION FORM + notificationForm: FormGroup; + sendCommentNotifications: FormControl; + + buildNotificationsPrefForm() { + this.sendCommentNotifications = new FormControl( + this.currentUser.receiveCommentReplyEmails, + ); + this.notificationForm = this.formBuilder.group({ + sendCommentNotifications: this.sendCommentNotifications, + }); + } + + onSaveNotificationPreferences() { + let form = this.notificationForm.value; + + this.accountService.updateNotificationPreferences(form.sendCommentNotifications, this.currentUser.id).subscribe( + (result) => { + this.currentUser = result; + this.notif.showInfo( + 'Notification Preferences successfully updated', + 'Account Update', + ); + }, + (error) => { + console.log(error); + this.notif.showMessage( + 'Notification Preferences failed to update', + 'Account Update', + ); + }, + ); + } } 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..4bbc27c6 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/account.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/account.service.ts @@ -42,6 +42,7 @@ export class AccountService { private urlResetPassword: string = this.baseUrl + "resetPassword?" private urlChangePassword: string = this.baseUrl + "changePassword" private urlReSendVerification: string = this.baseUrl + "sendConfirmationEmail" + private urlUpdateNotificationPreferences: string = this.baseUrl + "updatenotificationpreferences/" // Delete private urlUnfollow: string = this.baseUrl + "unfollow/" @@ -346,6 +347,29 @@ export class AccountService { ) } + updateNotificationPreferences(receiveCommentReplyEmails: boolean, userID: number): Observable { + const body = { + receiveCommentReplyEmails: receiveCommentReplyEmails + }; + + const headers = new HttpHeaders().set( + 'Authorization', + `Bearer ${localStorage.getItem('jwt')}` + ); + + return this.http.put(this.urlUpdateNotificationPreferences + userID, body, {headers}) + .pipe( + map(body => { + console.log(body.message) + return body.result + }), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + unfollow(userID: number, followerID: number): Observable { const headers = 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 d782b51b..99f10e29 100644 --- a/src/Analysim.Web/Controllers/AccountController.cs +++ b/src/Analysim.Web/Controllers/AccountController.cs @@ -906,6 +906,48 @@ public async Task UpdateUser([FromRoute] int userID, [FromForm] A }); } + + /* + * Type : PUT + * URL : /api/account/updatenotificationpreferences + * Param : NotificationPreferencesUpdateVM + * Description: Update user notification email preferences + * Response Status: 200 Ok, 400 Bad Request, 401 Unauthorized, 404 Not Found + */ + [Authorize] + [HttpPut("[action]/{userID}")] + public async Task UpdateNotificationPreferences( + [FromRoute] int userID, + [FromBody] NotificationPreferencesUpdateVM formdata + ) + { + // Validate VM + if (!ModelState.IsValid) return BadRequest(ModelState); + + // Get User + var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userIdClaim) || !int.TryParse(userIdClaim, out var userId)) + { + return Unauthorized(new { message = "Invalid user identifier." }); + } + + // Validate User + var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); + if (user == null) return NotFound(new { message = "User Not Found" }); + + // Update Notification Preferences + user.ReceiveCommentReplyEmails = formdata.ReceiveCommentReplyEmails; + + // Save Changes + await _dbContext.SaveChangesAsync(); + + // Return updated user + return Ok(new + { + result = user, + message = "Notification preferences have been updated" + }); + } #endregion #region DELETE REQUEST diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index b7aa7b39..3d53e08c 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -39,11 +39,17 @@ public class ProjectController : ControllerBase private readonly ApplicationDbContext _dbContext; private readonly IConfiguration _configuration; + private readonly IMailNetService _mailNetService; - public ProjectController(ApplicationDbContext dbContext, IConfiguration configuration) + public ProjectController( + ApplicationDbContext dbContext, + IConfiguration configuration, + IMailNetService mailNetService + ) { _dbContext = dbContext; _configuration = configuration; + _mailNetService = mailNetService; } #region GET REQUEST @@ -645,6 +651,115 @@ public async Task PostComment([FromForm] CreateProjectCommentVM f _dbContext.ProjectComments.Add(newComment); await _dbContext.SaveChangesAsync(); + // Send email notification if this comment is a reply + if (parentComment != null && parentComment.UserID != userId) + { + try + { + // Get the user who wrote the parent comment + var parentCommentUser = await _dbContext.Users + .SingleOrDefaultAsync(u => u.Id == parentComment.UserID); + + if (parentCommentUser != null + && parentCommentUser.ReceiveCommentReplyEmails + && !string.IsNullOrWhiteSpace(parentCommentUser.Email) + ) + { + // Collect comment information for email + var recipientName = string.IsNullOrWhiteSpace(parentCommentUser.UserName) + ? "there" + : parentCommentUser.UserName; + + var replyingUserName = string.IsNullOrWhiteSpace(user.UserName) + ? "Someone" + : user.UserName; + + var projectName = string.IsNullOrWhiteSpace(project.Name) + ? $"Project #{project.ProjectID}" + : project.Name; + + var parentCommentPreview = parentComment.Content.Length > 200 + ? parentComment.Content.Substring(0, 200) + "..." + : parentComment.Content; + + var replyPreview = trimmedContent.Length > 200 + ? trimmedContent.Substring(0, 200) + "..." + : trimmedContent; + + var projectRoute = string.IsNullOrWhiteSpace(project.Route) + ? project.ProjectID.ToString() + : project.Route.Trim('/'); + + // Handle link to comment + var clientBaseUrl = _configuration["ClientSettings:BaseUrl"]?.TrimEnd('/'); + + var projectCommentUrl = string.IsNullOrWhiteSpace(clientBaseUrl) + ? null + : $"{clientBaseUrl}/project/{projectRoute}/comment"; + + var linkHtml = string.IsNullOrWhiteSpace(projectCommentUrl) + ? "" + : $@" +

+ View the conversation +

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

Hi {safeRecipientName},

+ +

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

+ +

Your comment:

+
+ {safeParentCommentPreview} +
+ +

Their reply:

+
+ {safeReplyPreview} +
+ + {linkHtml} + "; + + var bodyText = + $"Hi {recipientName},\n\n" + + $"{replyingUserName} replied to your comment on {projectName}.\n\n" + + $"Your comment:\n\"{parentCommentPreview}\"\n\n" + + $"Their reply:\n\"{replyPreview}\"" + + linkText; + + + await _mailNetService.SendEmail( + parentCommentUser.Email, + recipientName, + subject, + bodyHtml, + bodyText + ); + } + } + catch (Exception ex){} + } + // Return return Ok(new { diff --git a/src/Analysim.Web/ViewModels/Account/NotificationPreferencesUpdateVM.cs b/src/Analysim.Web/ViewModels/Account/NotificationPreferencesUpdateVM.cs new file mode 100644 index 00000000..0a765616 --- /dev/null +++ b/src/Analysim.Web/ViewModels/Account/NotificationPreferencesUpdateVM.cs @@ -0,0 +1,7 @@ +namespace Web.ViewModels.Account +{ + public class NotificationPreferencesUpdateVM + { + public bool ReceiveCommentReplyEmails { get; set; } + } +} \ No newline at end of file