diff --git a/src/Analysim.Core/Entities/Project.cs b/src/Analysim.Core/Entities/Project.cs index 3778c170..46c9e562 100644 --- a/src/Analysim.Core/Entities/Project.cs +++ b/src/Analysim.Core/Entities/Project.cs @@ -33,6 +33,18 @@ public class Project [Required(ErrorMessage = "Project Route is a required field.")] public string Route { get; set; } + // Member permissions are configured per project. Defaults preserve the + // existing platform behaviour for projects created before this feature. + public bool MembersCanEditProject { get; set; } = false; + public bool MembersCanManageMembers { get; set; } = false; + public bool MembersCanManageTags { get; set; } = false; + public bool MembersCanUploadFiles { get; set; } = true; + public bool MembersCanManageFiles { get; set; } = false; + public bool MembersCanUploadNotebooks { get; set; } = true; + public bool MembersCanManageNotebooks { get; set; } = false; + public bool MembersCanManagePublications { get; set; } = true; + public bool MembersCanManageProjectLogs { get; set; } = true; + public List ProjectTags { get; set; } public ICollection ProjectUsers { get; } = new List(); public ICollection BlobFiles { get; set; } = new List(); diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 375c1ba4..d0c0e5a9 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -141,6 +141,24 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasForeignKey(p => p.ProjectID) .OnDelete(DeleteBehavior.Cascade); + // Preserve the current member capabilities for existing and new projects. + // Project owners can change these values through the project permissions API. + modelBuilder.Entity() + .Property(p => p.MembersCanUploadFiles) + .HasDefaultValue(true); + + modelBuilder.Entity() + .Property(p => p.MembersCanUploadNotebooks) + .HasDefaultValue(true); + + modelBuilder.Entity() + .Property(p => p.MembersCanManagePublications) + .HasDefaultValue(true); + + modelBuilder.Entity() + .Property(p => p.MembersCanManageProjectLogs) + .HasDefaultValue(true); + // One To Many Relationship (Project -> Notebook) modelBuilder.Entity() diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/20260923172035_ConfigureProjectMemberPermissions.Designer.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260923172035_ConfigureProjectMemberPermissions.Designer.cs new file mode 100644 index 00000000..8a82b397 --- /dev/null +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260923172035_ConfigureProjectMemberPermissions.Designer.cs @@ -0,0 +1,1512 @@ +// +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 Analysim.Infrastructure.Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260923172035_ConfigureProjectMemberPermissions")] + partial class ConfigureProjectMemberPermissions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("BlobFileID"); + + b.ToTable("BlobFileContent"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.Property("NotebookID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("Author") + .IsRequired() + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("NotebookID", "Version"); + + b.ToTable("NotebookContent"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Property("BlobFileID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("BlobFileID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID"); + + b.ToTable("BlobFiles"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Property("NotebookID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotebookID")); + + b.Property("Container") + .IsRequired() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Directory") + .IsRequired() + .HasColumnType("text"); + + b.Property("Extension") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastModified") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("Uri") + .IsRequired() + .HasColumnType("text"); + + b.Property("type") + .HasColumnType("text"); + + b.HasKey("NotebookID"); + + b.HasIndex("Directory"); + + b.HasIndex("ProjectID"); + + b.ToTable("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.Notification", b => + { + b.Property("NotificationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("NotificationID")); + + b.Property("ActorUserID") + .HasColumnType("integer"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DataJson") + .HasColumnType("text"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Link") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ProjectLogID") + .HasColumnType("integer"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RecipientUserID") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("NotificationID"); + + b.HasIndex("ActorUserID"); + + b.HasIndex("CommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ProjectLogID"); + + b.HasIndex("Type"); + + b.HasIndex("RecipientUserID", "IsRead", "CreatedAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.Property("ID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("NotebookID") + .HasColumnType("integer"); + + b.Property("datasetName") + .HasColumnType("text"); + + b.Property("datasetURL") + .HasColumnType("text"); + + b.HasKey("ID"); + + b.HasIndex("NotebookID"); + + b.ToTable("ObservableNotebookDataset"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Property("ProjectID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectID")); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ForkedFromProjectID") + .HasColumnType("integer"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("MembersCanEditProject") + .HasColumnType("boolean"); + + b.Property("MembersCanManageFiles") + .HasColumnType("boolean"); + + b.Property("MembersCanManageMembers") + .HasColumnType("boolean"); + + b.Property("MembersCanManageNotebooks") + .HasColumnType("boolean"); + + b.Property("MembersCanManageProjectLogs") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MembersCanManagePublications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MembersCanManageTags") + .HasColumnType("boolean"); + + b.Property("MembersCanUploadFiles") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MembersCanUploadNotebooks") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("Name") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Route") + .IsRequired() + .HasColumnType("text"); + + b.Property("Visibility") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectID"); + + b.HasIndex("Route") + .IsUnique(); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Property("CommentID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("CommentID")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsPendingReview") + .HasColumnType("boolean"); + + b.Property("ParentCommentID") + .HasColumnType("integer"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ProjectLogID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("CommentID"); + + b.HasIndex("ParentCommentID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ProjectLogID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectComments"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.Property("FlagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("FlagID")); + + b.Property("CommentContentSnapshot") + .HasColumnType("text"); + + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("FlagID"); + + b.HasIndex("UserID"); + + b.HasIndex("CommentID", "UserID") + .IsUnique(); + + b.ToTable("ProjectCommentFlags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.Property("CommentID") + .HasColumnType("integer"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CommentID", "UserID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectCommentLikes"); + }); + + modelBuilder.Entity("Core.Entities.ProjectLog", b => + { + b.Property("LogID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LogID")); + + b.Property("BlobFileID") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("ReferencedNotebookID") + .HasColumnType("integer"); + + b.Property("ReferencedNotebookVersion") + .HasColumnType("integer"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("LogID"); + + b.HasIndex("BlobFileID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("ReferencedNotebookID"); + + b.HasIndex("UserID"); + + b.ToTable("ProjectLogs"); + }); + + modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => + { + b.Property("ProjectMembershipRequestID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectMembershipRequestID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserID") + .HasColumnType("integer"); + + b.Property("Message") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("RequesterUserID") + .HasColumnType("integer"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserID") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectMembershipRequestID"); + + b.HasIndex("CreatedByUserID"); + + b.HasIndex("RequesterUserID"); + + b.HasIndex("TargetUserID"); + + b.HasIndex("ProjectID", "TargetUserID", "Type", "Status"); + + b.ToTable("ProjectMembershipRequests"); + }); + + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => + { + b.Property("ProjectRecommendationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectRecommendationID")); + + b.Property("Comment") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("ProjectRecommendationID"); + + b.HasIndex("ProjectID"); + + b.HasIndex("UserID", "ProjectID") + .IsUnique(); + + b.ToTable("ProjectRecommendations"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("TagID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.HasKey("ProjectID", "TagID"); + + b.HasIndex("TagID"); + + b.ToTable("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("ProjectID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.Property("IsFollowing") + .HasColumnType("boolean"); + + b.Property("UserRole") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("UserID", "ProjectID"); + + b.HasIndex("ProjectID"); + + b.ToTable("ProjectUsers"); + }); + + modelBuilder.Entity("Core.Entities.Publication", b => + { + b.Property("PublicationID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("PublicationID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Doi") + .HasColumnType("text"); + + b.Property("Issue") + .HasColumnType("text"); + + b.Property("Journal") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Pages") + .HasColumnType("text"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("SourceAuthor") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("Volume") + .HasColumnType("text"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("PublicationID"); + + b.HasIndex("ProjectID"); + + b.ToTable("Publications"); + }); + + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.Property("RefreshTokenID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("RefreshTokenID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByIp") + .HasColumnType("text"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByIp") + .HasColumnType("text"); + + b.Property("RevokedReason") + .HasColumnType("text"); + + b.Property("TokenFamilyID") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserID") + .HasColumnType("integer"); + + b.HasKey("RefreshTokenID"); + + b.HasIndex("TokenFamilyID"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserID"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Property("TagID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TagID")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("TagID"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("Bio") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DateCreated") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LastOnline") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("ReceiveCommentReplyEmails") + .HasColumnType("boolean"); + + b.Property("RegistrationSurvey") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Core.Entities.UserNotificationPreference", b => + { + b.Property("UserID") + .HasColumnType("integer"); + + b.Property("NotificationType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("EmailEnabled") + .HasColumnType("boolean"); + + b.Property("InAppEnabled") + .HasColumnType("boolean"); + + b.HasKey("UserID", "NotificationType"); + + b.HasIndex("NotificationType"); + + b.ToTable("UserNotificationPreferences"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.Property("UserID") + .HasColumnType("integer") + .HasColumnOrder(1); + + b.Property("FollowerID") + .HasColumnType("integer") + .HasColumnOrder(2); + + b.HasKey("UserID", "FollowerID"); + + b.HasIndex("FollowerID"); + + b.ToTable("UserUsers"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + + b.HasData( + new + { + Id = 1, + ConcurrencyStamp = "d7c6278e-3f78-4db6-8832-aa41ea9fd62c", + Name = "Admin", + NormalizedName = "ADMIN" + }, + new + { + Id = 2, + ConcurrencyStamp = "7383e31e-a884-4cd6-82f7-a6287b4e30a5", + Name = "Customer", + NormalizedName = "CUSTOMER" + }, + new + { + Id = 3, + ConcurrencyStamp = "398ef1f2-6b82-45f2-9601-ed2129f197e9", + Name = "Moderator", + NormalizedName = "MODERATOR" + }); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Analysim.Core.Entities.BlobFileContent", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany("BlobFileContents") + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BlobFile"); + }); + + modelBuilder.Entity("Analysim.Core.Entities.NotebookContent", b => + { + b.HasOne("Core.Entities.Notebook", "Notebook") + .WithMany("NotebookContents") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notebook"); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("BlobFiles") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Core.Entities.User", "User") + .WithMany("BlobFiles") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Notebooks") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.Notification", b => + { + b.HasOne("Core.Entities.User", "ActorUser") + .WithMany() + .HasForeignKey("ActorUserID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany() + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany() + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.ProjectLog", "ProjectLog") + .WithMany() + .HasForeignKey("ProjectLogID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.User", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActorUser"); + + b.Navigation("Project"); + + b.Navigation("ProjectComment"); + + b.Navigation("ProjectLog"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("Core.Entities.ObservableNotebookDataset", b => + { + b.HasOne("Core.Entities.Notebook", "notebook") + .WithMany("observableNotebookDatasets") + .HasForeignKey("NotebookID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("notebook"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.HasOne("Core.Entities.ProjectComment", "ParentComment") + .WithMany("Replies") + .HasForeignKey("ParentCommentID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectComments") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.ProjectLog", "ProjectLog") + .WithMany("Comments") + .HasForeignKey("ProjectLogID") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectComments") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ParentComment"); + + b.Navigation("Project"); + + b.Navigation("ProjectLog"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentFlag", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentFlags") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentFlags") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectCommentLike", b => + { + b.HasOne("Core.Entities.ProjectComment", "ProjectComment") + .WithMany("CommentLikes") + .HasForeignKey("CommentID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("CommentLikes") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ProjectComment"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectLog", b => + { + b.HasOne("Core.Entities.BlobFile", "BlobFile") + .WithMany() + .HasForeignKey("BlobFileID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectLogs") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Notebook", "ReferencedNotebook") + .WithMany() + .HasForeignKey("ReferencedNotebookID") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectLogs") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("BlobFile"); + + b.Navigation("Project"); + + b.Navigation("ReferencedNotebook"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => + { + b.HasOne("Core.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectMembershipRequests") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "RequesterUser") + .WithMany() + .HasForeignKey("RequesterUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Core.Entities.User", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Project"); + + b.Navigation("RequesterUser"); + + b.Navigation("TargetUser"); + }); + + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectRecommendations") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectRecommendations") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.ProjectTag", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectTags") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.Tag", "Tag") + .WithMany("ProjectTags") + .HasForeignKey("TagID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("Tag"); + }); + + modelBuilder.Entity("Core.Entities.ProjectUser", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectUsers") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("ProjectUsers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.Publication", b => + { + b.HasOne("Core.Entities.Project", "Project") + .WithMany("Publications") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Core.Entities.RefreshToken", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserNotificationPreference", b => + { + b.HasOne("Core.Entities.User", "User") + .WithMany() + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Core.Entities.UserUser", b => + { + b.HasOne("Core.Entities.User", "Follower") + .WithMany("Following") + .HasForeignKey("FollowerID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "User") + .WithMany("Followers") + .HasForeignKey("UserID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Follower"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Core.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Core.Entities.BlobFile", b => + { + b.Navigation("BlobFileContents"); + }); + + modelBuilder.Entity("Core.Entities.Notebook", b => + { + b.Navigation("NotebookContents"); + + b.Navigation("observableNotebookDatasets"); + }); + + modelBuilder.Entity("Core.Entities.Project", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("Notebooks"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectLogs"); + + b.Navigation("ProjectMembershipRequests"); + + b.Navigation("ProjectRecommendations"); + + b.Navigation("ProjectTags"); + + b.Navigation("ProjectUsers"); + + b.Navigation("Publications"); + }); + + modelBuilder.Entity("Core.Entities.ProjectComment", b => + { + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Replies"); + }); + + modelBuilder.Entity("Core.Entities.ProjectLog", b => + { + b.Navigation("Comments"); + }); + + modelBuilder.Entity("Core.Entities.Tag", b => + { + b.Navigation("ProjectTags"); + }); + + modelBuilder.Entity("Core.Entities.User", b => + { + b.Navigation("BlobFiles"); + + b.Navigation("CommentFlags"); + + b.Navigation("CommentLikes"); + + b.Navigation("Followers"); + + b.Navigation("Following"); + + b.Navigation("ProjectComments"); + + b.Navigation("ProjectLogs"); + + b.Navigation("ProjectRecommendations"); + + b.Navigation("ProjectUsers"); + + b.Navigation("RefreshTokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/20260923172035_ConfigureProjectMemberPermissions.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260923172035_ConfigureProjectMemberPermissions.cs new file mode 100644 index 00000000..1d61bba6 --- /dev/null +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/20260923172035_ConfigureProjectMemberPermissions.cs @@ -0,0 +1,117 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Analysim.Infrastructure.Infrastructure.Migrations +{ + /// + public partial class ConfigureProjectMemberPermissions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MembersCanEditProject", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MembersCanManageFiles", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MembersCanManageMembers", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MembersCanManageNotebooks", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MembersCanManageProjectLogs", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "MembersCanManagePublications", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "MembersCanManageTags", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MembersCanUploadFiles", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: true); + + migrationBuilder.AddColumn( + name: "MembersCanUploadNotebooks", + table: "Projects", + type: "boolean", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MembersCanEditProject", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanManageFiles", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanManageMembers", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanManageNotebooks", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanManageProjectLogs", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanManagePublications", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanManageTags", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanUploadFiles", + table: "Projects"); + + migrationBuilder.DropColumn( + name: "MembersCanUploadNotebooks", + table: "Projects"); + } + } +} diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index cfadeaf8..0e8c1b75 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -295,6 +295,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("LastUpdated") .HasColumnType("timestamp with time zone"); + b.Property("MembersCanEditProject") + .HasColumnType("boolean"); + + b.Property("MembersCanManageFiles") + .HasColumnType("boolean"); + + b.Property("MembersCanManageMembers") + .HasColumnType("boolean"); + + b.Property("MembersCanManageNotebooks") + .HasColumnType("boolean"); + + b.Property("MembersCanManageProjectLogs") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MembersCanManagePublications") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MembersCanManageTags") + .HasColumnType("boolean"); + + b.Property("MembersCanUploadFiles") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("MembersCanUploadNotebooks") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + b.Property("Name") .IsRequired() .HasMaxLength(20) @@ -468,6 +503,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ProjectLogs"); }); + modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => + { + b.Property("ProjectMembershipRequestID") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectMembershipRequestID")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserID") + .HasColumnType("integer"); + + b.Property("Message") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ProjectID") + .HasColumnType("integer"); + + b.Property("RequesterUserID") + .HasColumnType("integer"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TargetUserID") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ProjectMembershipRequestID"); + + b.HasIndex("CreatedByUserID"); + + b.HasIndex("RequesterUserID"); + + b.HasIndex("TargetUserID"); + + b.HasIndex("ProjectID", "TargetUserID", "Type", "Status"); + + b.ToTable("ProjectMembershipRequests"); + }); + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => { b.Property("ProjectRecommendationID") @@ -520,57 +606,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("ProjectTags"); }); - modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => - { - b.Property("ProjectMembershipRequestID") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ProjectMembershipRequestID")); - - b.Property("CreatedByUserID") - .HasColumnType("integer"); - - b.Property("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("Message") - .HasMaxLength(500) - .HasColumnType("character varying(500)"); - - b.Property("ProjectID") - .HasColumnType("integer"); - - b.Property("RespondedAt") - .HasColumnType("timestamp with time zone"); - - b.Property("RequesterUserID") - .HasColumnType("integer"); - - b.Property("Status") - .IsRequired() - .HasColumnType("text"); - - b.Property("TargetUserID") - .HasColumnType("integer"); - - b.Property("Type") - .IsRequired() - .HasColumnType("text"); - - b.HasKey("ProjectMembershipRequestID"); - - b.HasIndex("CreatedByUserID"); - - b.HasIndex("RequesterUserID"); - - b.HasIndex("TargetUserID"); - - b.HasIndex("ProjectID", "TargetUserID", "Type", "Status"); - - b.ToTable("ProjectMembershipRequests"); - }); - modelBuilder.Entity("Core.Entities.ProjectUser", b => { b.Property("UserID") @@ -1199,6 +1234,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => + { + b.HasOne("Core.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Core.Entities.Project", "Project") + .WithMany("ProjectMembershipRequests") + .HasForeignKey("ProjectID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Entities.User", "RequesterUser") + .WithMany() + .HasForeignKey("RequesterUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Core.Entities.User", "TargetUser") + .WithMany() + .HasForeignKey("TargetUserID") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Project"); + + b.Navigation("RequesterUser"); + + b.Navigation("TargetUser"); + }); + modelBuilder.Entity("Core.Entities.ProjectRecommendation", b => { b.HasOne("Core.Entities.Project", "Project") @@ -1256,41 +1326,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); - modelBuilder.Entity("Core.Entities.ProjectMembershipRequest", b => - { - b.HasOne("Core.Entities.User", "CreatedByUser") - .WithMany() - .HasForeignKey("CreatedByUserID") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Core.Entities.Project", "Project") - .WithMany("ProjectMembershipRequests") - .HasForeignKey("ProjectID") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Core.Entities.User", "RequesterUser") - .WithMany() - .HasForeignKey("RequesterUserID") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.HasOne("Core.Entities.User", "TargetUser") - .WithMany() - .HasForeignKey("TargetUserID") - .OnDelete(DeleteBehavior.Restrict) - .IsRequired(); - - b.Navigation("CreatedByUser"); - - b.Navigation("Project"); - - b.Navigation("RequesterUser"); - - b.Navigation("TargetUser"); - }); - modelBuilder.Entity("Core.Entities.Publication", b => { b.HasOne("Core.Entities.Project", "Project") @@ -1416,10 +1451,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ProjectLogs"); - b.Navigation("ProjectRecommendations"); - b.Navigation("ProjectMembershipRequests"); + b.Navigation("ProjectRecommendations"); + b.Navigation("ProjectTags"); b.Navigation("ProjectUsers"); diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-member-permissions.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-member-permissions.ts new file mode 100644 index 00000000..d7a262cb --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-member-permissions.ts @@ -0,0 +1,79 @@ +export interface ProjectMemberPermissions { + membersCanEditProject: boolean; + membersCanManageMembers: boolean; + membersCanManageTags: boolean; + membersCanUploadFiles: boolean; + membersCanManageFiles: boolean; + membersCanUploadNotebooks: boolean; + membersCanManageNotebooks: boolean; + membersCanManagePublications: boolean; + membersCanManageProjectLogs: boolean; +} + +export interface ProjectMemberPermissionOption { + key: keyof ProjectMemberPermissions; + label: string; + description: string; +} + +export const PROJECT_MEMBER_PERMISSION_OPTIONS: ProjectMemberPermissionOption[] = [ + { + key: 'membersCanEditProject', + label: 'Edit project details', + description: 'Change the project name, description, and visibility.' + }, + { + key: 'membersCanManageMembers', + label: 'Manage members', + description: 'Invite or remove members and handle join requests.' + }, + { + key: 'membersCanManageTags', + label: 'Manage tags', + description: 'Add and remove project tags.' + }, + { + key: 'membersCanUploadFiles', + label: 'Upload files', + description: 'Upload datasets and create dataset folders.' + }, + { + key: 'membersCanManageFiles', + label: 'Manage files', + description: 'Rename, move, and delete project files.' + }, + { + key: 'membersCanUploadNotebooks', + label: 'Upload notebooks', + description: 'Upload notebooks, versions, and create notebook folders.' + }, + { + key: 'membersCanManageNotebooks', + label: 'Manage notebooks', + description: 'Rename or delete notebooks and manage their datasets.' + }, + { + key: 'membersCanManagePublications', + label: 'Manage publications', + description: 'Add, edit, and delete project publications.' + }, + { + key: 'membersCanManageProjectLogs', + label: 'Manage project logs', + description: 'Create, edit, delete, and repost project logs.' + } +]; + +export function createDefaultProjectMemberPermissions(): ProjectMemberPermissions { + return { + membersCanEditProject: false, + membersCanManageMembers: false, + membersCanManageTags: false, + membersCanUploadFiles: true, + membersCanManageFiles: false, + membersCanUploadNotebooks: true, + membersCanManageNotebooks: false, + membersCanManagePublications: true, + membersCanManageProjectLogs: true + }; +} diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts index ed474ae2..4490f454 100644 --- a/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project.ts @@ -3,6 +3,7 @@ import { ProjectTag } from 'src/app/interfaces/project-tag'; import { ProjectUser } from './project-user'; import { Notebook } from './notebook'; import { ProjectRecommendation } from './project-recommendation'; +import { ProjectMemberPermissions } from './project-member-permissions'; export interface Project { projectID: number; @@ -17,5 +18,6 @@ export interface Project { blobFiles: Array; projectTags: Array projectRecommendations: Array + memberPermissions: ProjectMemberPermissions; forkedFromProjectID: number; } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.html index c3183d84..7c94b6ad 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.html @@ -30,13 +30,32 @@ +
+ Member Permissions +

Choose what project members can do. As the owner, you always keep full access.

+ +
+
+ + +
+ {{ permission.description }} +
+
+
-
- diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.scss index 92bec986..903348a4 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.scss @@ -15,4 +15,25 @@ .create-button { background-color: #2a6a8a; color: #01234a !important; - } \ No newline at end of file + } + + .member-permissions { + border-top: 1px solid #dee2e6; + margin: 1.5rem 0; + padding-top: 1rem; + } + + .member-permissions legend { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: .25rem; + } + + .member-permission-option { + border-bottom: 1px solid #e9ecef; + padding: .65rem 0; + } + + .member-permission-option:last-child { + border-bottom: 0; + } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.ts index 0b02cd59..f9cfb875 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-create/project-form-create.component.ts @@ -6,6 +6,11 @@ import { AccountService } from 'src/app/services/account.service'; import { User } from 'src/app/interfaces/user'; import { Router } from '@angular/router'; import { Observable } from 'rxjs'; +import { + createDefaultProjectMemberPermissions, + PROJECT_MEMBER_PERMISSION_OPTIONS, + ProjectMemberPermissions +} from 'src/app/interfaces/project-member-permissions'; @Component({ selector: 'app-project-form-create', @@ -25,6 +30,8 @@ export class ProjectFormCreateComponent implements OnInit { name: FormControl description: FormControl visibility: FormControl + memberPermissions: FormGroup + permissionOptions = PROJECT_MEMBER_PERMISSION_OPTIONS currentUser$ : Observable currentUser : User @@ -47,12 +54,14 @@ export class ProjectFormCreateComponent implements OnInit { this.name = new FormControl('', [Validators.required, Validators.minLength(5), Validators.maxLength(20), this.noSpaceSpecial()]) this.description = new FormControl('') this.visibility = new FormControl('public', [Validators.required]) + this.memberPermissions = this.formBuilder.group(createDefaultProjectMemberPermissions()) // Initialize FormGroup using FormBuilder this.projectForm = this.formBuilder.group({ name : this.name, description : this.description, - visibility : this.visibility + visibility : this.visibility, + memberPermissions: this.memberPermissions }) } @@ -85,7 +94,13 @@ export class ProjectFormCreateComponent implements OnInit { this.submitErrorMessage = ''; this.isLoading = true; - this.projectService.createProject(this.currentUser, projectForm.name, projectForm.visibility, projectForm.description).subscribe( + this.projectService.createProject( + this.currentUser, + projectForm.name, + projectForm.visibility, + projectForm.description, + projectForm.memberPermissions as ProjectMemberPermissions + ).subscribe( result =>{ this.isLoading = false; this.setProject.emit(result) diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html index e092e010..591d1648 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.html @@ -14,7 +14,7 @@ (setProject)="setProject($event)"> -
+

Project Tags
Project Tags [projectTags]="project.projectTags" (updateProjectTags)="updateProjectTags($event)"> +
+

Project Members
this.currentUser = user); + this.route.params.subscribe(params => { this.owner = params['owner']; this.projectname = params['projectname']; @@ -34,7 +41,26 @@ export class ProjectEditComponent implements OnInit { setProject(project : Project){ this.project = project - this.loadMembershipRequests() + if (this.canManageMembers) this.loadMembershipRequests() + } + + get projectUser(): ProjectUser { + if (this.project == null || this.currentUser == null) return null + return this.project.projectUsers.find(projectUser => projectUser.userID == this.currentUser.id) || null + } + + get isOwner(): boolean { + return this.projectUser?.userRole == 'owner' + } + + get canManageTags(): boolean { + return this.isOwner || (this.projectUser?.userRole == 'member' && + this.project?.memberPermissions?.membersCanManageTags == true) + } + + get canManageMembers(): boolean { + return this.isOwner || (this.projectUser?.userRole == 'member' && + this.project?.memberPermissions?.membersCanManageMembers == true) } updateProjectTags(projectTags : ProjectTag[]){ diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-form-edit/project-form-edit.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-form-edit/project-form-edit.component.html index 2044d197..91a27815 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-form-edit/project-form-edit.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-form-edit/project-form-edit.component.html @@ -1,43 +1,64 @@
-

Edit Project

- -
-
- - -
-
- - -
-
+ +

Edit Project

+ +
+
+ + +
+
+ + +
+
- -
- - -
-

No Space or Special Character Except Hyphen

-

Minimum 5 Character

-

maximum 20 Character

+ +
+ + +
+

No Space or Special Character Except Hyphen

+

Minimum 5 Character

+

maximum 20 Character

+
-
- -
- - -
+ +
+ + +
+ + +
+ Member Permissions +

Choose what project members can do. These settings do not limit your owner access.

+ +
+
+ + +
+ {{ permission.description }} +
+
{{ submitErrorMessage }}
-
- @@ -20,7 +20,8 @@ + [canUploadNotebooks]="canUploadNotebooks" + [canManageNotebooks]="canManageNotebooks">
@@ -35,7 +36,7 @@ - diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-content.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-content.component.ts index 132223c0..57a3222b 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-content.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-content.component.ts @@ -33,7 +33,8 @@ export class ProjectContentComponent implements OnInit, AfterViewInit { validDirectory: boolean = true - @Input() isMember: boolean + @Input() canUploadNotebooks: boolean + @Input() canManageNotebooks: boolean stackDirectories: string[] = ["notebook/"]; diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item.component.html index 81f9a1b0..60f14189 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-overview/project-overview-view/project-content/project-notebook-item/project-notebook-item.component.html @@ -58,7 +58,7 @@
-
+
@@ -319,7 +321,7 @@

diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.ts index 0c7d0e97..147f883f 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.ts @@ -15,6 +15,10 @@ import hljs from 'highlight.js'; import { Notebook } from 'src/app/interfaces/notebook'; import { ProjectRecommendation } from 'src/app/interfaces/project-recommendation'; import { ProjectMembershipRequest } from 'src/app/interfaces/project-membership-request'; +import { + createDefaultProjectMemberPermissions, + ProjectMemberPermissions +} from 'src/app/interfaces/project-member-permissions'; @Component({ @@ -65,6 +69,7 @@ export class ProjectComponent implements OnInit { toggleNotebookExpand: boolean = true toggleView: string = "File" showFiles: boolean = false; + private readonly defaultMemberPermissions = createDefaultProjectMemberPermissions(); activeView: string = 'File'; @@ -304,6 +309,54 @@ export class ProjectComponent implements OnInit { return false; } + private hasMemberPermission(permission: keyof ProjectMemberPermissions): boolean { + if (this.isOwner) return true + if (this.projectUser == null || this.projectUser.userRole != "member") return false + + const permissions = this.project?.memberPermissions || this.defaultMemberPermissions + return permissions[permission] + } + + get canEditProject(): boolean { + return this.hasMemberPermission('membersCanEditProject') + } + + get canManageMembers(): boolean { + return this.hasMemberPermission('membersCanManageMembers') + } + + get canManageTags(): boolean { + return this.hasMemberPermission('membersCanManageTags') + } + + get canUploadFiles(): boolean { + return this.hasMemberPermission('membersCanUploadFiles') + } + + get canManageFiles(): boolean { + return this.hasMemberPermission('membersCanManageFiles') + } + + get canUploadNotebooks(): boolean { + return this.hasMemberPermission('membersCanUploadNotebooks') + } + + get canManageNotebooks(): boolean { + return this.hasMemberPermission('membersCanManageNotebooks') + } + + get canManagePublications(): boolean { + return this.hasMemberPermission('membersCanManagePublications') + } + + get canManageProjectLogs(): boolean { + return this.hasMemberPermission('membersCanManageProjectLogs') + } + + get canAccessProjectSettings(): boolean { + return this.canEditProject || this.canManageMembers || this.canManageTags + } + get currentRecommendation(): ProjectRecommendation { if (this.currentUser == null) return null if (this.project == null || this.project.projectRecommendations == null) return null diff --git a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts index 9d9472e1..ceb0377c 100644 --- a/src/Analysim.Web/ClientApp/src/app/services/project.service.ts +++ b/src/Analysim.Web/ClientApp/src/app/services/project.service.ts @@ -20,6 +20,7 @@ import { ExpiredProjectLog } from '../interfaces/expired-project-log'; import { Publication } from '../interfaces/publication'; import { ProjectRecommendation } from '../interfaces/project-recommendation'; import { ProjectMembershipRequest } from '../interfaces/project-membership-request'; +import { ProjectMemberPermissions } from '../interfaces/project-member-permissions'; @Injectable({ providedIn: 'root' @@ -55,6 +56,7 @@ export class ProjectService { private urlGetProjectNotebookRefs: string = this.baseUrl + "getprojectnotebookreferences/"; private urlGetProjectMembershipRequests: string = this.baseUrl + "getprojectmembershiprequests/"; private urlGetMyMembershipRequests: string = this.baseUrl + "getmymembershiprequests"; + private urlGetProjectMemberPermissions: string = this.baseUrl + "getprojectmemberpermissions/"; // Post private urlCreateProject: string = this.baseUrl + "createproject" @@ -91,6 +93,7 @@ export class ProjectService { private urlRepostProjectLog: string = this.baseUrl + "repostlog/"; private urlUpdatePublication: string = this.baseUrl + "updatePublication/"; private urlUpdateRecommendation: string = this.baseUrl + "updaterecommendation/"; + private urlUpdateProjectMemberPermissions: string = this.baseUrl + "updateprojectmemberpermissions/"; // Delete private urlDeleteProject: string = this.baseUrl + "deleteproject/" @@ -281,12 +284,25 @@ export class ProjectService { ) } - createProject(currentUser: User, projectName: string, visibility: string, description: string): Observable { + private appendMemberPermissions(body: FormData, permissions: ProjectMemberPermissions): void { + body.append('membersCanEditProject', permissions.membersCanEditProject.toString()) + body.append('membersCanManageMembers', permissions.membersCanManageMembers.toString()) + body.append('membersCanManageTags', permissions.membersCanManageTags.toString()) + body.append('membersCanUploadFiles', permissions.membersCanUploadFiles.toString()) + body.append('membersCanManageFiles', permissions.membersCanManageFiles.toString()) + body.append('membersCanUploadNotebooks', permissions.membersCanUploadNotebooks.toString()) + body.append('membersCanManageNotebooks', permissions.membersCanManageNotebooks.toString()) + body.append('membersCanManagePublications', permissions.membersCanManagePublications.toString()) + body.append('membersCanManageProjectLogs', permissions.membersCanManageProjectLogs.toString()) + } + + createProject(currentUser: User, projectName: string, visibility: string, description: string, memberPermissions: ProjectMemberPermissions): Observable { let body = new FormData() body.append('name', projectName) body.append('visibility', visibility) body.append('description', description) body.append('route', currentUser.userName + "/" + projectName) + this.appendMemberPermissions(body, memberPermissions) return this.http.post(this.urlCreateProject, body) .pipe( map(body => { @@ -699,11 +715,36 @@ export class ProjectService { ) } - updateProject(updateProject: Project): Observable { + getProjectMemberPermissions(projectID: number): Observable { + return this.http.get(this.urlGetProjectMemberPermissions + projectID) + .pipe( + map(body => body.result), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + updateProjectMemberPermissions(projectID: number, permissions: ProjectMemberPermissions): Observable { + return this.http.put(this.urlUpdateProjectMemberPermissions + projectID, permissions) + .pipe( + map(body => body.result), + catchError(error => { + console.log(error) + return throwError(error) + }) + ) + } + + updateProject(updateProject: Project, memberPermissions?: ProjectMemberPermissions): Observable { let body = new FormData() body.append('name', updateProject.name) body.append('visibility', updateProject.visibility) body.append('description', updateProject.description) + if (memberPermissions != null) { + this.appendMemberPermissions(body, memberPermissions) + } return this.http.put(this.urlUpdateProject + updateProject.projectID, body) .pipe( map(body => { diff --git a/src/Analysim.Web/Controllers/ProjectController.cs b/src/Analysim.Web/Controllers/ProjectController.cs index 2504b202..16b60978 100644 --- a/src/Analysim.Web/Controllers/ProjectController.cs +++ b/src/Analysim.Web/Controllers/ProjectController.cs @@ -70,6 +70,19 @@ IMailNetService mailNetService private const string MembershipRequestStatusRejected = "rejected"; private const string MembershipRequestStatusCancelled = "cancelled"; + private enum ProjectPermission + { + EditProject, + ManageMembers, + ManageTags, + UploadFiles, + ManageFiles, + UploadNotebooks, + ManageNotebooks, + ManagePublications, + ManageProjectLogs + } + private bool IsCurrentUserAdmin() { var currentUsername = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.Name); @@ -176,6 +189,47 @@ private async Task IsProjectOwnerAsync(int projectID, int userID) pu.UserRole == ProjectOwnerRole); } + private async Task HasProjectPermissionAsync( + int projectID, + int userID, + ProjectPermission permission) + { + var access = await _dbContext.ProjectUsers + .Where(pu => pu.ProjectID == projectID && pu.UserID == userID) + .Select(pu => new + { + pu.UserRole, + pu.Project.MembersCanEditProject, + pu.Project.MembersCanManageMembers, + pu.Project.MembersCanManageTags, + pu.Project.MembersCanUploadFiles, + pu.Project.MembersCanManageFiles, + pu.Project.MembersCanUploadNotebooks, + pu.Project.MembersCanManageNotebooks, + pu.Project.MembersCanManagePublications, + pu.Project.MembersCanManageProjectLogs + }) + .SingleOrDefaultAsync(); + + if (access == null) return false; + if (string.Equals(access.UserRole, ProjectOwnerRole, StringComparison.OrdinalIgnoreCase)) return true; + if (!string.Equals(access.UserRole, ProjectMemberRole, StringComparison.OrdinalIgnoreCase)) return false; + + return permission switch + { + ProjectPermission.EditProject => access.MembersCanEditProject, + ProjectPermission.ManageMembers => access.MembersCanManageMembers, + ProjectPermission.ManageTags => access.MembersCanManageTags, + ProjectPermission.UploadFiles => access.MembersCanUploadFiles, + ProjectPermission.ManageFiles => access.MembersCanManageFiles, + ProjectPermission.UploadNotebooks => access.MembersCanUploadNotebooks, + ProjectPermission.ManageNotebooks => access.MembersCanManageNotebooks, + ProjectPermission.ManagePublications => access.MembersCanManagePublications, + ProjectPermission.ManageProjectLogs => access.MembersCanManageProjectLogs, + _ => false + }; + } + private IQueryable ProjectMembershipRequestsWithUsers() { return _dbContext.ProjectMembershipRequests @@ -293,7 +347,24 @@ private static object ToProjectResponse(Project project) ProjectUsers = project.ProjectUsers.Select(ToProjectUserResponse).ToList(), BlobFiles = project.BlobFiles.Select(ToBlobFileResponse).ToList(), Notebooks = project.Notebooks.Select(ToNotebookResponse).ToList(), - ProjectTags = project.ProjectTags?.Select(ToProjectTagResponse).ToList() + ProjectTags = project.ProjectTags?.Select(ToProjectTagResponse).ToList(), + MemberPermissions = ToMemberPermissionsResponse(project) + }; + } + + private static object ToMemberPermissionsResponse(Project project) + { + return new + { + project.MembersCanEditProject, + project.MembersCanManageMembers, + project.MembersCanManageTags, + project.MembersCanUploadFiles, + project.MembersCanManageFiles, + project.MembersCanUploadNotebooks, + project.MembersCanManageNotebooks, + project.MembersCanManagePublications, + project.MembersCanManageProjectLogs }; } @@ -1169,10 +1240,10 @@ public async Task AddPublication([FromForm] CreatePublicationVM f var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); if (project == null) return NotFound(new { message = "Project Not Found" }); - var canManagePublications = await _dbContext.ProjectUsers.AnyAsync(pu => - pu.ProjectID == project.ProjectID && - pu.UserID == user.Id && - (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + var canManagePublications = await HasProjectPermissionAsync( + project.ProjectID, + user.Id, + ProjectPermission.ManagePublications); if (!canManagePublications) return Forbid(); // Validate Fields @@ -1245,15 +1316,14 @@ public async Task AddProjectLog([FromForm] CreateProjectLogVM for var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); if (project == null) return NotFound(new { message = "Project Not Found." }); - bool hasProjectLogAccess = await _dbContext.ProjectUsers - .AnyAsync(aup => - aup.UserID == user.Id && - aup.ProjectID == project.ProjectID && - (aup.UserRole == ProjectOwnerRole || aup.UserRole == ProjectMemberRole)); + bool hasProjectLogAccess = await HasProjectPermissionAsync( + project.ProjectID, + user.Id, + ProjectPermission.ManageProjectLogs); if (!hasProjectLogAccess) { - return StatusCode(StatusCodes.Status403Forbidden, new { message = "You must be an owner or member of the project" }); + return StatusCode(StatusCodes.Status403Forbidden, new { message = "You do not have permission to manage project logs." }); } // Validate Content @@ -2171,7 +2241,16 @@ public async Task CreateProject([FromForm] ProjectVM formdata) Description = formdata.Description, DateCreated = DateTimeOffset.UtcNow, LastUpdated = DateTimeOffset.UtcNow, - Route = user.UserName + "/" + formdata.Name + Route = user.UserName + "/" + formdata.Name, + MembersCanEditProject = formdata.MembersCanEditProject ?? false, + MembersCanManageMembers = formdata.MembersCanManageMembers ?? false, + MembersCanManageTags = formdata.MembersCanManageTags ?? false, + MembersCanUploadFiles = formdata.MembersCanUploadFiles ?? true, + MembersCanManageFiles = formdata.MembersCanManageFiles ?? false, + MembersCanUploadNotebooks = formdata.MembersCanUploadNotebooks ?? true, + MembersCanManageNotebooks = formdata.MembersCanManageNotebooks ?? false, + MembersCanManagePublications = formdata.MembersCanManagePublications ?? true, + MembersCanManageProjectLogs = formdata.MembersCanManageProjectLogs ?? true }; // Add Project And Save Change @@ -2458,10 +2537,39 @@ public async Task UnfollowProject([FromRoute] int projectID) }); } + /* + * Type : GET + * URL : /api/project/getprojectmemberpermissions/{projectID} + * Description: Get the member permissions configured for a project. + */ + [Authorize] + [HttpGet("[action]/{projectID}")] + public async Task GetProjectMemberPermissions([FromRoute] int projectID) + { + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + var project = await _dbContext.Projects.FindAsync(projectID); + if (project == null) return NotFound(new { message = "Project Not Found" }); + + var isCollaborator = await _dbContext.ProjectUsers.AnyAsync(pu => + pu.ProjectID == projectID && + pu.UserID == userId && + (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + + if (!isCollaborator) return Forbid(); + + return Ok(new + { + result = ToMemberPermissionsResponse(project), + message = "Project member permissions received." + }); + } + /* * Type : GET * URL : /api/project/getprojectmembershiprequests/{projectID} - * Description: Get all membership requests for a project. Owner only. + * Description: Get all membership requests for a project. */ [Authorize] [HttpGet("[action]/{projectID}")] @@ -2479,8 +2587,11 @@ public async Task GetProjectMembershipRequests([FromRoute] int pr var project = await _dbContext.Projects.FindAsync(projectID); if (project == null) return NotFound(new { message = "Project Not Found" }); - var isOwner = await IsProjectOwnerAsync(projectID, user.Id); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + var canManageMembers = await HasProjectPermissionAsync( + projectID, + user.Id, + ProjectPermission.ManageMembers); + if (!canManageMembers) return Forbid(); var requests = await ProjectMembershipRequestsWithUsers() .Where(pmr => pmr.ProjectID == projectID) @@ -2627,7 +2738,7 @@ await _notificationService.PublishAsync(new NotificationEvent * Type : POST * URL : /api/project/inviteprojectmember * Param : ProjectMembershipInvitationVM - * Description: Create a pending project invitation. Owner only. + * Description: Create a pending project invitation. */ [Authorize] [HttpPost("[action]")] @@ -2644,8 +2755,11 @@ public async Task InviteProjectMember([FromForm] ProjectMembershi var project = await _dbContext.Projects.FindAsync(formdata.ProjectID); if (project == null) return NotFound(new { message = "Project Not Found" }); - var isOwner = await IsProjectOwnerAsync(formdata.ProjectID, owner.Id); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + var canManageMembers = await HasProjectPermissionAsync( + formdata.ProjectID, + owner.Id, + ProjectPermission.ManageMembers); + if (!canManageMembers) return Forbid(); var targetUser = await _dbContext.Users.FindAsync(formdata.UserID); if (targetUser == null) return NotFound(new { message = "Target user not found." }); @@ -2758,8 +2872,11 @@ public async Task AcceptProjectMembershipRequest([FromRoute] int if (membershipRequest.Type == MembershipRequestTypeJoin) { - var isOwner = await IsProjectOwnerAsync(membershipRequest.ProjectID, user.Id); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + var canManageMembers = await HasProjectPermissionAsync( + membershipRequest.ProjectID, + user.Id, + ProjectPermission.ManageMembers); + if (!canManageMembers) return Forbid(); } else if (membershipRequest.Type == MembershipRequestTypeInvitation) { @@ -2835,8 +2952,11 @@ public async Task RejectProjectMembershipRequest([FromRoute] int if (membershipRequest.Type == MembershipRequestTypeJoin) { - var isOwner = await IsProjectOwnerAsync(membershipRequest.ProjectID, user.Id); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + var canManageMembers = await HasProjectPermissionAsync( + membershipRequest.ProjectID, + user.Id, + ProjectPermission.ManageMembers); + if (!canManageMembers) return Forbid(); } else if (membershipRequest.Type == MembershipRequestTypeInvitation) { @@ -2889,8 +3009,11 @@ public async Task CancelProjectMembershipRequest([FromRoute] int } else if (membershipRequest.Type == MembershipRequestTypeInvitation) { - var isOwner = await IsProjectOwnerAsync(membershipRequest.ProjectID, user.Id); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + var canManageMembers = await HasProjectPermissionAsync( + membershipRequest.ProjectID, + user.Id, + ProjectPermission.ManageMembers); + if (!canManageMembers) return Forbid(); } else { @@ -2976,16 +3099,12 @@ public async Task AddTag([FromForm] ProjectTagVM formdata) if (user == null) return NotFound(new { message = "User Not Found" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == formdata.ProjectID && - aup.UserRole == "owner")); + bool canManageTags = await HasProjectPermissionAsync( + formdata.ProjectID, + user.Id, + ProjectPermission.ManageTags); - if (!isOwner) - { - return Unauthorized(new { message = "You are not the owner of the project" }); - } + if (!canManageTags) return Forbid(); var tagName = formdata.TagName?.Trim(); if (string.IsNullOrWhiteSpace(tagName)) return BadRequest(new { message = "Tag Name is required" }); @@ -3055,15 +3174,14 @@ public async Task UploadFile([FromForm] ProjectFileUploadVM formd var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); if (user == null) return NotFound(new { message = "User Not Found" }); - bool hasUploadAccess = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.UserID == user.Id && - aup.ProjectID == formdata.ProjectID && - (aup.UserRole == "owner" || aup.UserRole == "member"))); + bool hasUploadAccess = await HasProjectPermissionAsync( + formdata.ProjectID, + user.Id, + ProjectPermission.UploadFiles); if (!hasUploadAccess) { - return StatusCode(StatusCodes.Status403Forbidden, new { message = "You must be an owner or member of the project" }); + return StatusCode(StatusCodes.Status403Forbidden, new { message = "You do not have permission to upload project files." }); } @@ -3174,15 +3292,14 @@ public async Task UploadNotebook([FromForm] ProjectNotebookUpload if (user == null) return NotFound(new { message = "User Not Found" }); - bool hasNotebookUploadAccess = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.UserID == user.Id && - aup.ProjectID == noteBookData.ProjectID && - (aup.UserRole == "owner" || aup.UserRole == "member"))); + bool hasNotebookUploadAccess = await HasProjectPermissionAsync( + noteBookData.ProjectID, + user.Id, + ProjectPermission.UploadNotebooks); if (!hasNotebookUploadAccess) { - return StatusCode(StatusCodes.Status403Forbidden, new { message = "You must be an owner or member of the project" }); + return StatusCode(StatusCodes.Status403Forbidden, new { message = "You do not have permission to upload project notebooks." }); } @@ -3274,14 +3391,13 @@ public async Task UploadNotebookNewVersion([FromForm] ProjectNote var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); if (user == null) return NotFound(new { message = "User Not Found" }); - bool hasNotebookUploadAccess = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.UserID == user.Id && - aup.ProjectID == noteBookData.ProjectID && - (aup.UserRole == "owner" || aup.UserRole == "member"))); + bool hasNotebookUploadAccess = await HasProjectPermissionAsync( + noteBookData.ProjectID, + user.Id, + ProjectPermission.UploadNotebooks); if (!hasNotebookUploadAccess) { - return StatusCode(StatusCodes.Status403Forbidden, new { message = "You must be an owner or member of the project" }); + return StatusCode(StatusCodes.Status403Forbidden, new { message = "You do not have permission to upload project notebooks." }); } @@ -3360,15 +3476,14 @@ public async Task UploadExistingNotebook([FromForm] ExistingProje var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); if (user == null) return NotFound(new { message = "User Not Found" }); - bool hasNotebookUploadAccess = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.UserID == user.Id && - aup.ProjectID == noteBookData.ProjectID && - (aup.UserRole == "owner" || aup.UserRole == "member"))); + bool hasNotebookUploadAccess = await HasProjectPermissionAsync( + noteBookData.ProjectID, + user.Id, + ProjectPermission.UploadNotebooks); if (!hasNotebookUploadAccess) { - return StatusCode(StatusCodes.Status403Forbidden, new { message = "You must be an owner or member of the project" }); + return StatusCode(StatusCodes.Status403Forbidden, new { message = "You do not have permission to upload project notebooks." }); } @@ -3500,15 +3615,14 @@ public async Task CreateFolder([FromForm] FolderUploadProfileView var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); if (user == null) return NotFound(new { message = "User Not Found" }); - bool hasFolderAccess = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.UserID == user.Id && - aup.ProjectID == formdata.ProjectID && - (aup.UserRole == "owner" || aup.UserRole == "member"))); + bool hasFolderAccess = await HasProjectPermissionAsync( + formdata.ProjectID, + user.Id, + ProjectPermission.UploadFiles); if (!hasFolderAccess) { - return StatusCode(StatusCodes.Status403Forbidden, new { message = "You must be an owner or member of the project" }); + return StatusCode(StatusCodes.Status403Forbidden, new { message = "You do not have permission to upload project files." }); } @@ -3575,15 +3689,14 @@ public async Task CreateNotebookFolder([FromForm] FolderUploadPro var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); if (user == null) return NotFound(new { message = "User Not Found" }); - bool hasFolderAccess = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.UserID == user.Id && - aup.ProjectID == formdata.ProjectID && - (aup.UserRole == "owner" || aup.UserRole == "member"))); + bool hasFolderAccess = await HasProjectPermissionAsync( + formdata.ProjectID, + user.Id, + ProjectPermission.UploadNotebooks); if (!hasFolderAccess) { - return StatusCode(StatusCodes.Status403Forbidden, new { message = "You must be an owner or member of the project" }); + return StatusCode(StatusCodes.Status403Forbidden, new { message = "You do not have permission to upload project notebooks." }); } @@ -3658,13 +3771,14 @@ public async Task AddDatasetToNotebook([FromForm] int notebookID, if (user == null) return NotFound(new { message = "User Not Found" }); var notebook = _dbContext.Notebook.SingleOrDefault(n => n.NotebookID == notebookID); - - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == notebook.ProjectID && - aup.UserRole == "owner")); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + if (notebook == null) return NotFound(new { message = "Notebook Not Found" }); + if (!notebook.ProjectID.HasValue) return BadRequest(new { message = "Notebook does not belong to a project" }); + + bool canManageNotebooks = await HasProjectPermissionAsync( + notebook.ProjectID.Value, + user.Id, + ProjectPermission.ManageNotebooks); + if (!canManageNotebooks) return Forbid(); var dataset = await _dbContext.ObservableNotebookDataset @@ -3733,10 +3847,10 @@ public async Task UpdatePublication([FromRoute] int publicationID if (publication == null) return NotFound(new { message = "Publication Not Found" }); - var canManagePublications = await _dbContext.ProjectUsers.AnyAsync(pu => - pu.ProjectID == publication.ProjectID && - pu.UserID == user.Id && - (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + var canManagePublications = await HasProjectPermissionAsync( + publication.ProjectID, + user.Id, + ProjectPermission.ManagePublications); if (!canManagePublications) return Forbid(); if (publication.ProjectID != formdata.ProjectID) @@ -3815,11 +3929,11 @@ public async Task UpdateProjectLog([FromRoute] int logId, [FromFo if (projectLog == null) return NotFound(new { message = "Project Log Not Found." }); - var isProjectMember = await _dbContext.ProjectUsers.AnyAsync(pu => - pu.ProjectID == projectLog.ProjectID && - pu.UserID == user.Id && - (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); - if (!isProjectMember) return Forbid(); + var canManageProjectLogs = await HasProjectPermissionAsync( + projectLog.ProjectID, + user.Id, + ProjectPermission.ManageProjectLogs); + if (!canManageProjectLogs) return Forbid(); // Validate this log belongs to current user if (projectLog.UserID != user.Id) @@ -4048,11 +4162,11 @@ public async Task DeleteLog([FromRoute] int projectLogID) if (!isAdmin) { - var isProjectMember = await _dbContext.ProjectUsers.AnyAsync(pu => - pu.ProjectID == projectLog.ProjectID && - pu.UserID == user.Id && - (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); - if (!isProjectMember) return Forbid(); + var canManageProjectLogs = await HasProjectPermissionAsync( + projectLog.ProjectID, + user.Id, + ProjectPermission.ManageProjectLogs); + if (!canManageProjectLogs) return Forbid(); if (projectLog.UserID != user.Id) return Unauthorized(new { message = "Project log does not belong to current user." }); @@ -4110,11 +4224,11 @@ public async Task RepostLog([FromRoute] int projectLogID) if (!isAdmin) { - var isProjectMember = await _dbContext.ProjectUsers.AnyAsync(pu => - pu.ProjectID == projectLog.ProjectID && - pu.UserID == user.Id && - (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); - if (!isProjectMember) return Forbid(); + var canManageProjectLogs = await HasProjectPermissionAsync( + projectLog.ProjectID, + user.Id, + ProjectPermission.ManageProjectLogs); + if (!canManageProjectLogs) return Forbid(); if (projectLog.UserID != user.Id) return Unauthorized(new { message = "Project log does not belong to current user." }); @@ -4286,6 +4400,46 @@ public async Task DeleteComment([FromRoute] int commentID) return Ok(new { message = "Comment deleted successfully."}); } + /* + * Type : PUT + * URL : /api/project/updateprojectmemberpermissions/{projectID} + * Description: Update member permissions for a project. Owner only. + */ + [Authorize] + [HttpPut("[action]/{projectID}")] + public async Task UpdateProjectMemberPermissions( + [FromRoute] int projectID, + [FromBody] ProjectMemberPermissionsVM permissions) + { + if (!TryGetCurrentUserId(out var userId)) + return Unauthorized(new { message = "Invalid user identifier." }); + + if (!await IsProjectOwnerAsync(projectID, userId)) + return Forbid(); + + var project = await _dbContext.Projects.FindAsync(projectID); + if (project == null) return NotFound(new { message = "Project Not Found" }); + + project.MembersCanEditProject = permissions.MembersCanEditProject; + project.MembersCanManageMembers = permissions.MembersCanManageMembers; + project.MembersCanManageTags = permissions.MembersCanManageTags; + project.MembersCanUploadFiles = permissions.MembersCanUploadFiles; + project.MembersCanManageFiles = permissions.MembersCanManageFiles; + project.MembersCanUploadNotebooks = permissions.MembersCanUploadNotebooks; + project.MembersCanManageNotebooks = permissions.MembersCanManageNotebooks; + project.MembersCanManagePublications = permissions.MembersCanManagePublications; + project.MembersCanManageProjectLogs = permissions.MembersCanManageProjectLogs; + project.LastUpdated = DateTimeOffset.UtcNow; + + await _dbContext.SaveChangesAsync(); + + return Ok(new + { + result = ToMemberPermissionsResponse(project), + message = "Project member permissions updated successfully." + }); + } + /* * Type : PUT * URL : /api/project/updateproject/ @@ -4305,13 +4459,26 @@ public async Task UpdateProject([FromRoute] int projectID, [FromF var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); if (user == null) return NotFound(new { message = "User Not Found" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == projectID && - aup.UserRole == "owner")); - - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + bool canEditProject = await HasProjectPermissionAsync( + projectID, + user.Id, + ProjectPermission.EditProject); + + if (!canEditProject) return Forbid(); + + bool includesMemberPermissions = + formdata.MembersCanEditProject.HasValue || + formdata.MembersCanManageMembers.HasValue || + formdata.MembersCanManageTags.HasValue || + formdata.MembersCanUploadFiles.HasValue || + formdata.MembersCanManageFiles.HasValue || + formdata.MembersCanUploadNotebooks.HasValue || + formdata.MembersCanManageNotebooks.HasValue || + formdata.MembersCanManagePublications.HasValue || + formdata.MembersCanManageProjectLogs.HasValue; + + if (includesMemberPermissions && !await IsProjectOwnerAsync(projectID, user.Id)) + return Forbid(); // Check Model State @@ -4323,12 +4490,19 @@ public async Task UpdateProject([FromRoute] int projectID, [FromF if (project == null) return NotFound(new { message = "Project Not Found" }); + var projectOwner = await _dbContext.ProjectUsers + .Where(pu => pu.ProjectID == projectID && pu.UserRole == ProjectOwnerRole) + .Select(pu => pu.User) + .FirstOrDefaultAsync(); + if (projectOwner == null) + return Conflict(new { message = "Project owner not found." }); + // Check if the owner already has another project with the requested name. var duplicateProject = await _dbContext.Projects.AnyAsync(p => p.ProjectID != projectID && p.Name == formdata.Name && p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && + aup.UserID == projectOwner.Id && aup.UserRole == "owner")); if (duplicateProject) return BadRequest(new { message = "Project Already Exist" }); @@ -4337,7 +4511,20 @@ public async Task UpdateProject([FromRoute] int projectID, [FromF project.Visibility = formdata.Visibility; project.Description = formdata.Description; project.LastUpdated = DateTime.UtcNow; - project.Route = user.UserName + "/" + formdata.Name; + project.Route = projectOwner.UserName + "/" + formdata.Name; + + if (includesMemberPermissions) + { + project.MembersCanEditProject = formdata.MembersCanEditProject ?? project.MembersCanEditProject; + project.MembersCanManageMembers = formdata.MembersCanManageMembers ?? project.MembersCanManageMembers; + project.MembersCanManageTags = formdata.MembersCanManageTags ?? project.MembersCanManageTags; + project.MembersCanUploadFiles = formdata.MembersCanUploadFiles ?? project.MembersCanUploadFiles; + project.MembersCanManageFiles = formdata.MembersCanManageFiles ?? project.MembersCanManageFiles; + project.MembersCanUploadNotebooks = formdata.MembersCanUploadNotebooks ?? project.MembersCanUploadNotebooks; + project.MembersCanManageNotebooks = formdata.MembersCanManageNotebooks ?? project.MembersCanManageNotebooks; + project.MembersCanManagePublications = formdata.MembersCanManagePublications ?? project.MembersCanManagePublications; + project.MembersCanManageProjectLogs = formdata.MembersCanManageProjectLogs ?? project.MembersCanManageProjectLogs; + } // Set Entity State _dbContext.Entry(project).State = EntityState.Modified; @@ -4376,14 +4563,15 @@ public async Task DeleteDatasetFromNotebook([FromForm] int notebo if (user == null) return NotFound(new { message = "User Not Found" }); var notebook = _dbContext.Notebook.SingleOrDefault(n => n.NotebookID == notebookID); + if (notebook == null) return NotFound(new { message = "Notebook Not Found" }); + if (!notebook.ProjectID.HasValue) return BadRequest(new { message = "Notebook does not belong to a project" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == notebook.ProjectID && - aup.UserRole == "owner")); + bool canManageNotebooks = await HasProjectPermissionAsync( + notebook.ProjectID.Value, + user.Id, + ProjectPermission.ManageNotebooks); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + if (!canManageNotebooks) return Forbid(); var dataset = await _dbContext.ObservableNotebookDataset @@ -4423,14 +4611,14 @@ public async Task RenameNotebook([FromForm] NotebookNameChangeVM var notebookE = await _dbContext.Notebook.FindAsync(notebookNameChangeVM.NotebookID); if (notebookE == null) return NotFound(new { message = "File Not Found" }); + if (!notebookE.ProjectID.HasValue) return BadRequest(new { message = "Notebook does not belong to a project" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == notebookE.ProjectID && - aup.UserRole == "owner")); + bool canManageNotebooks = await HasProjectPermissionAsync( + notebookE.ProjectID.Value, + user.Id, + ProjectPermission.ManageNotebooks); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + if (!canManageNotebooks) return Forbid(); Notebook notebook = await _dbContext.Notebook.FindAsync(notebookNameChangeVM.NotebookID); @@ -4473,10 +4661,10 @@ public async Task DeletePublication([FromRoute] int publicationId .FirstOrDefaultAsync(p => p.PublicationID == publicationId); if (publication == null) return NotFound(new { message = "Publication not found." }); - var canManagePublications = await _dbContext.ProjectUsers.AnyAsync(pu => - pu.ProjectID == publication.ProjectID && - pu.UserID == userId && - (pu.UserRole == ProjectOwnerRole || pu.UserRole == ProjectMemberRole)); + var canManagePublications = await HasProjectPermissionAsync( + publication.ProjectID, + userId, + ProjectPermission.ManagePublications); if (!canManagePublications) return Forbid(); // Remove publication @@ -4977,13 +5165,12 @@ public async Task RemoveUser([FromRoute] int projectID, int userI if (user == null) return NotFound(new { message = "User Not Found" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == projectID && - aup.UserRole == "owner")); + bool canManageMembers = await HasProjectPermissionAsync( + projectID, + user.Id, + ProjectPermission.ManageMembers); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + if (!canManageMembers) return Forbid(); // Find Many To Many var projectUser = await _dbContext.ProjectUsers.FindAsync(userID, projectID); @@ -5025,13 +5212,12 @@ public async Task RemoveTag([FromRoute] int projectID, [FromRoute var user = await _dbContext.Users.SingleOrDefaultAsync(u => u.Id == userId); if (user == null) return NotFound(new { message = "User Not Found" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == projectID && - aup.UserRole == "owner")); + bool canManageTags = await HasProjectPermissionAsync( + projectID, + user.Id, + ProjectPermission.ManageTags); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + if (!canManageTags) return Forbid(); // Find ProjectTag In Database ProjectTag projectTag = _dbContext.ProjectTags @@ -5094,11 +5280,11 @@ public async Task DeleteFile([FromRoute] int fileID, [FromRoute] var blobFile = await _dbContext.BlobFiles.FindAsync(fileID); if (blobFile == null) return NotFound(new { message = "File Not Found" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == blobFile.ProjectID && - aup.UserRole == "owner")); + bool canManageFiles = blobFile.ProjectID.HasValue && + await HasProjectPermissionAsync( + blobFile.ProjectID.Value, + user.Id, + ProjectPermission.ManageFiles); var admins = _configuration .GetSection("AdminUsers") @@ -5107,45 +5293,26 @@ public async Task DeleteFile([FromRoute] int fileID, [FromRoute] bool isAdmin = admins .Any(u => string.Equals(u, user.UserName, StringComparison.OrdinalIgnoreCase)); - if (!isAdmin && !isOwner) + if (!isAdmin && !canManageFiles) return Forbid(); - if (isMember) + if (blobFile.Extension != ".$$") { - if (blobFile.Extension != ".$$") + var dataset = await _dbContext.ObservableNotebookDataset.FirstOrDefaultAsync(data => data.BlobFileID == blobFile.BlobFileID); + if (dataset != null) { - var dataset = await _dbContext.ObservableNotebookDataset.FirstOrDefaultAsync(data => data.BlobFileID == blobFile.BlobFileID); - // Console.Write(blobFile.Uri); - - if (dataset != null) - { - // Console.Write("deleting the dataset from observable"); - _dbContext.ObservableNotebookDataset.Remove(dataset); - } + _dbContext.ObservableNotebookDataset.Remove(dataset); } + } - //await _blobService.DeleteBlobAsync(blobFile); - - // Delete Blob Files From Database - _dbContext.BlobFiles.Remove(blobFile); - - // Save Change to Database - await _dbContext.SaveChangesAsync(); + _dbContext.BlobFiles.Remove(blobFile); + await _dbContext.SaveChangesAsync(); - // Return Ok Status - return Ok(new - { - result = ToBlobFileResponse(blobFile), - message = "File Successfully Deleted" - }); - } - else + return Ok(new { - return BadRequest(new - { - error = "You do not have permission to delete this file" - }); - } + result = ToBlobFileResponse(blobFile), + message = "File Successfully Deleted" + }); } catch (Exception e) { @@ -5175,64 +5342,47 @@ public async Task DeleteNotebook([FromRoute] int notebookID, [Fro var notebook = await _dbContext.Notebook.FindAsync(notebookID); if (notebook == null) return NotFound(new { message = "File Not Found" }); + if (!notebook.ProjectID.HasValue) return BadRequest(new { message = "Notebook does not belong to a project" }); - bool isOwner = await _dbContext.Projects - .AnyAsync(p => p.ProjectUsers.Any(aup => - aup.User.Id == user.Id && - aup.Project.ProjectID == notebook.ProjectID && - aup.UserRole == "owner")); + bool canManageNotebooks = await HasProjectPermissionAsync( + notebook.ProjectID.Value, + user.Id, + ProjectPermission.ManageNotebooks); + + if (!canManageNotebooks) return Forbid(); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); - if (isMember) + if (notebook.type == "observable") + { + _dbContext.Notebook.Remove(notebook); + await _dbContext.SaveChangesAsync(); + } + else { - //await _blobService.DeleteNotebookAsync(notebook); + var noteContent = await _dbContext.NotebookContent + .Where(n => n.NotebookID == notebookID && n.Version == version) + .FirstOrDefaultAsync(); + if (noteContent == null) return NotFound(new { message = "Notebook Content Not Found" }); - // Delete Blob Files From Database - //_dbContext.Notebook.Remove(notebook); + _dbContext.NotebookContent.Remove(noteContent); + await _dbContext.SaveChangesAsync(); + + var notebookContents = await _dbContext.NotebookContent + .Where(n => n.NotebookID == notebookID) + .OrderByDescending(n => n.Version) + .FirstOrDefaultAsync(); - if (notebook.type == "observable") + if (notebookContents == null) { _dbContext.Notebook.Remove(notebook); await _dbContext.SaveChangesAsync(); } - else - { - var noteContent = await _dbContext.NotebookContent - .Where(n => n.NotebookID == notebookID && n.Version == version) - .FirstOrDefaultAsync(); - if (noteContent == null) return NotFound(new { message = "Notebook Content Not Found" }); - - _dbContext.NotebookContent.Remove(noteContent); - - // Save Change to Database - await _dbContext.SaveChangesAsync(); - - var notebookContents = await _dbContext.NotebookContent - .Where(n => n.NotebookID == notebookID) - .OrderByDescending(n => n.Version) - .FirstOrDefaultAsync(); - - if (notebookContents == null) - { - _dbContext.Notebook.Remove(notebook); - await _dbContext.SaveChangesAsync(); - } - } - - // Return Ok Status - return Ok(new - { - result = ToNotebookResponse(notebook), - message = "Notebook Successfully Deleted" - }); } - else + + return Ok(new { - return BadRequest(new - { - error = "You do not have permission to delete this notebook" - }); - } + result = ToNotebookResponse(notebook), + message = "Notebook Successfully Deleted" + }); } catch (Exception e) { @@ -5461,8 +5611,11 @@ public async Task MoveFile([FromForm] ProjectFileMoveVM formdata) return BadRequest(new { message = "File does not belong to a project" }); } - var isOwner = await IsProjectOwnerAsync(blobFile.ProjectID.Value, userId); - if (!isOwner) return Unauthorized(new { message = "You are not the owner of the project" }); + var canManageFiles = await HasProjectPermissionAsync( + blobFile.ProjectID.Value, + userId, + ProjectPermission.ManageFiles); + if (!canManageFiles) return Forbid(); var filePath = formdata.SubDirectory + blobFile.Name + blobFile.Extension; diff --git a/src/Analysim.Web/ViewModels/Project/ProjectMemberPermissionsVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectMemberPermissionsVM.cs new file mode 100644 index 00000000..34d13cc2 --- /dev/null +++ b/src/Analysim.Web/ViewModels/Project/ProjectMemberPermissionsVM.cs @@ -0,0 +1,15 @@ +namespace Analysim.Web.ViewModels.Project +{ + public class ProjectMemberPermissionsVM + { + public bool MembersCanEditProject { get; set; } + public bool MembersCanManageMembers { get; set; } + public bool MembersCanManageTags { get; set; } + public bool MembersCanUploadFiles { get; set; } + public bool MembersCanManageFiles { get; set; } + public bool MembersCanUploadNotebooks { get; set; } + public bool MembersCanManageNotebooks { get; set; } + public bool MembersCanManagePublications { get; set; } + public bool MembersCanManageProjectLogs { get; set; } + } +} diff --git a/src/Analysim.Web/ViewModels/Project/ProjectVM.cs b/src/Analysim.Web/ViewModels/Project/ProjectVM.cs index 1825b739..cc6008d3 100644 --- a/src/Analysim.Web/ViewModels/Project/ProjectVM.cs +++ b/src/Analysim.Web/ViewModels/Project/ProjectVM.cs @@ -13,6 +13,18 @@ public class ProjectVM public string Description { get; set; } + // Nullable so older clients can continue creating and updating projects + // without changing the existing member-permission defaults. + public bool? MembersCanEditProject { get; set; } + public bool? MembersCanManageMembers { get; set; } + public bool? MembersCanManageTags { get; set; } + public bool? MembersCanUploadFiles { get; set; } + public bool? MembersCanManageFiles { get; set; } + public bool? MembersCanUploadNotebooks { get; set; } + public bool? MembersCanManageNotebooks { get; set; } + public bool? MembersCanManagePublications { get; set; } + public bool? MembersCanManageProjectLogs { get; set; } + [Required] public int UserID { get; set; } }