From 0113235bd8ab7d3b953dcff1f496d389e3e7890b Mon Sep 17 00:00:00 2001 From: Mohab Date: Sun, 30 Aug 2026 04:37:18 +0300 Subject: [PATCH 1/2] Add project membership request and invitation backend --- src/Analysim.Core/Entities/Project.cs | 2 +- .../Entities/ProjectMembershipRequest.cs | 46 ++ .../Data/ApplicationDbContext.cs | 32 + .../ApplicationDbContextModelSnapshot.cs | 88 +++ ...0830040500_AddProjectMembershipRequests.cs | 85 +++ .../interfaces/project-membership-request.ts | 19 + .../project-create.component.html | 2 +- .../project-form-users.component.html | 4 +- .../project-form-users.component.ts | 7 +- .../project-edit/project-edit.component.ts | 5 - .../project-list-users.component.html | 4 +- .../project-list-users.component.ts | 13 - .../project-user/project-user.component.html | 8 +- .../project-user/project-user.component.ts | 15 - .../projects/project/project.component.html | 22 +- .../app/projects/project/project.component.ts | 67 +- .../src/app/services/project.service.ts | 134 +++- .../Controllers/ProjectController.cs | 657 ++++++++++++++---- ...serVM.cs => ProjectMembershipRequestVM.cs} | 13 +- 19 files changed, 956 insertions(+), 267 deletions(-) create mode 100644 src/Analysim.Core/Entities/ProjectMembershipRequest.cs create mode 100644 src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs create mode 100644 src/Analysim.Web/ClientApp/src/app/interfaces/project-membership-request.ts rename src/Analysim.Web/ViewModels/Project/{ProjectUserVM.cs => ProjectMembershipRequestVM.cs} (54%) diff --git a/src/Analysim.Core/Entities/Project.cs b/src/Analysim.Core/Entities/Project.cs index 06013a5d..1cbed8b2 100644 --- a/src/Analysim.Core/Entities/Project.cs +++ b/src/Analysim.Core/Entities/Project.cs @@ -45,8 +45,8 @@ public class Project // Logs public ICollection ProjectLogs { get; set; } = new List(); public ICollection Publications { get; set; } = new List(); + public ICollection ProjectMembershipRequests { get; set; } = new List(); public int ForkedFromProjectID { get; set; } } } - diff --git a/src/Analysim.Core/Entities/ProjectMembershipRequest.cs b/src/Analysim.Core/Entities/ProjectMembershipRequest.cs new file mode 100644 index 00000000..3765e424 --- /dev/null +++ b/src/Analysim.Core/Entities/ProjectMembershipRequest.cs @@ -0,0 +1,46 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Core.Entities +{ + public class ProjectMembershipRequest + { + [Key] + public int ProjectMembershipRequestID { get; set; } + + [Required(ErrorMessage = "Project ID is a required field.")] + [ForeignKey("Project")] + public int ProjectID { get; set; } + public Project Project { get; set; } + + [Required(ErrorMessage = "Requester User ID is a required field.")] + [ForeignKey("RequesterUser")] + public int RequesterUserID { get; set; } + public User RequesterUser { get; set; } + + [Required(ErrorMessage = "Target User ID is a required field.")] + [ForeignKey("TargetUser")] + public int TargetUserID { get; set; } + public User TargetUser { get; set; } + + [Required(ErrorMessage = "Created By User ID is a required field.")] + [ForeignKey("CreatedByUser")] + public int CreatedByUserID { get; set; } + public User CreatedByUser { get; set; } + + [Required(ErrorMessage = "Request Type is a required field.")] + public string Type { get; set; } + + [Required(ErrorMessage = "Request Status is a required field.")] + public string Status { get; set; } + + [MaxLength(500, ErrorMessage = "Maximum length for Message is 500 Character.")] + public string Message { get; set; } + + [Required(ErrorMessage = "Created At is a required field.")] + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? RespondedAt { get; set; } + } +} diff --git a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs index 79751278..3de7857e 100644 --- a/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/Analysim.Infrastructure/Data/ApplicationDbContext.cs @@ -258,6 +258,37 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .WithOne(pp => pp.Project) .HasForeignKey(pp => pp.ProjectID) .OnDelete(DeleteBehavior.Cascade); + + // One To Many Relationship (Project -> ProjectMembershipRequest) + modelBuilder.Entity() + .HasMany(p => p.ProjectMembershipRequests) + .WithOne(pmr => pmr.Project) + .HasForeignKey(pmr => pmr.ProjectID) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(pmr => pmr.RequesterUser) + .WithMany() + .HasForeignKey(pmr => pmr.RequesterUserID) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(pmr => pmr.TargetUser) + .WithMany() + .HasForeignKey(pmr => pmr.TargetUserID) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(pmr => pmr.CreatedByUser) + .WithMany() + .HasForeignKey(pmr => pmr.CreatedByUserID) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasIndex(pmr => new { pmr.ProjectID, pmr.TargetUserID, pmr.Type, pmr.Status }); + + modelBuilder.Entity() + .HasIndex(pmr => pmr.CreatedByUserID); } @@ -279,5 +310,6 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) public DbSet ProjectLogs { get; set; } public DbSet Publications { get; set; } + public DbSet ProjectMembershipRequests { get; set; } } } diff --git a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 91c60939..815f97a9 100644 --- a/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Analysim.Infrastructure/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -414,6 +414,57 @@ 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") @@ -1019,6 +1070,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.Publication", b => { b.HasOne("Core.Entities.Project", "Project") @@ -1133,6 +1219,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("ProjectLogs"); + b.Navigation("ProjectMembershipRequests"); + b.Navigation("ProjectTags"); b.Navigation("ProjectUsers"); diff --git a/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs b/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs new file mode 100644 index 00000000..df56ddea --- /dev/null +++ b/src/Analysim.Infrastructure/Migrations/20260830040500_AddProjectMembershipRequests.cs @@ -0,0 +1,85 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Infrastructure.Migrations +{ + public partial class AddProjectMembershipRequests : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProjectMembershipRequests", + columns: table => new + { + ProjectMembershipRequestID = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ProjectID = table.Column(type: "integer", nullable: false), + RequesterUserID = table.Column(type: "integer", nullable: false), + TargetUserID = table.Column(type: "integer", nullable: false), + CreatedByUserID = table.Column(type: "integer", nullable: false), + Type = table.Column(type: "text", nullable: false), + Status = table.Column(type: "text", nullable: false), + Message = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + RespondedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ProjectMembershipRequests", x => x.ProjectMembershipRequestID); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_AspNetUsers_CreatedByUserID", + column: x => x.CreatedByUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_AspNetUsers_RequesterUserID", + column: x => x.RequesterUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_AspNetUsers_TargetUserID", + column: x => x.TargetUserID, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_ProjectMembershipRequests_Projects_ProjectID", + column: x => x.ProjectID, + principalTable: "Projects", + principalColumn: "ProjectID", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_CreatedByUserID", + table: "ProjectMembershipRequests", + column: "CreatedByUserID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_ProjectID_TargetUserID_Type_Status", + table: "ProjectMembershipRequests", + columns: new[] { "ProjectID", "TargetUserID", "Type", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_RequesterUserID", + table: "ProjectMembershipRequests", + column: "RequesterUserID"); + + migrationBuilder.CreateIndex( + name: "IX_ProjectMembershipRequests_TargetUserID", + table: "ProjectMembershipRequests", + column: "TargetUserID"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProjectMembershipRequests"); + } + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/interfaces/project-membership-request.ts b/src/Analysim.Web/ClientApp/src/app/interfaces/project-membership-request.ts new file mode 100644 index 00000000..20f82e2a --- /dev/null +++ b/src/Analysim.Web/ClientApp/src/app/interfaces/project-membership-request.ts @@ -0,0 +1,19 @@ +import { Project } from './project'; +import { User } from './user'; + +export interface ProjectMembershipRequest { + projectMembershipRequestID: number + projectID: number + project: Project + requesterUserID: number + requesterUser: User + targetUserID: number + targetUser: User + createdByUserID: number + createdByUser: User + type: string + status: string + message: string + createdAt: Date + respondedAt: Date +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html index d064ddb3..ac13429d 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html @@ -21,7 +21,7 @@

Project Creation

- + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html index 40aa8b7b..acbf479b 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.html @@ -11,7 +11,7 @@ - @@ -22,4 +22,4 @@ -
\ No newline at end of file + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts index 322c7ab7..6e8e7b26 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-form-users/project-form-users.component.ts @@ -76,11 +76,10 @@ export class ProjectFormUsersComponent implements OnInit { } - public addUser(user : User){ - this.projectService.addUser(this.project.projectID, user.id, "member", false).subscribe( + public inviteUser(user : User){ + this.projectService.inviteProjectMember(this.project.projectID, user.id).subscribe( result => { - this.project.projectUsers.push(result) - this.updateProject.emit(this.project) + console.log(result) }, error =>{ console.log(error) } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts index a11c7a19..e21409b4 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts @@ -1,7 +1,6 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Project } from 'src/app/interfaces/project'; -import { ProjectUser } from 'src/app/interfaces/project-user'; import { ProjectTag } from 'src/app/interfaces/project-tag'; @Component({ @@ -33,8 +32,4 @@ export class ProjectEditComponent implements OnInit { this.project.projectTags = projectTags } - updateProjectUsers(projectUsers : ProjectUser[]){ - this.project.projectUsers = projectUsers - } - } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html index 344d9a77..262b1d0a 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html @@ -3,7 +3,7 @@
    - +
- \ No newline at end of file + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts index f33e7e48..25b28cdb 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.ts @@ -11,24 +11,11 @@ export class ProjectListUsersComponent implements OnInit { constructor() { } @Input() projectUsers : ProjectUser[] - @Output() updateProjectUsers = new EventEmitter() @Output() removeProjectUsers = new EventEmitter() ngOnInit(): void { } - updateUser(projectUsers : ProjectUser[]){ - this.projectUsers = projectUsers - } - - updateProjectUser(projectUser : ProjectUser){ - let index = this.projectUsers.findIndex(x => x.userID == projectUser.userID) - if (index > -1) { - this.projectUsers[index] = projectUser - } - this.updateProjectUsers.emit(this.projectUsers) - } - removeProjectUser(projectUser : ProjectUser){ let index = this.projectUsers.indexOf(projectUser,0) if (index > -1) { diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html index 195908ab..1b4a01e6 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.html @@ -9,11 +9,9 @@
{{projectUser.user.userName}}
- +
- + {{projectUser.userRole | titlecase}}
@@ -22,4 +20,4 @@
- \ No newline at end of file + diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts index 1ead5d00..fd691ee5 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-user/project-user.component.ts @@ -12,26 +12,11 @@ export class ProjectUserComponent implements OnInit { constructor(private projectService : ProjectService) { } @Input() projectUser : ProjectUser - @Output() updatedUser = new EventEmitter() @Output() removedUser = new EventEmitter() - roles: any[] = ['member', 'admin']; - ngOnInit(): void { } - updateUser(event : Event){ - var role = (event.target as HTMLSelectElement).value; - this.projectUser.userRole = role.toLowerCase(); - this.projectService.updateUser(this.projectUser).subscribe( - result => { - this.updatedUser.emit(this.projectUser) - }, error =>{ - console.log(error) - } - ) - } - deleteUser(){ this.projectService.removeUser(this.projectUser.projectID, this.projectUser.userID).subscribe( result => { diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html index 5a0e9dc0..63dedd0f 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project/project.component.html @@ -114,8 +114,26 @@

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

{{ profile.userName }}
+ + @@ -106,4 +154,4 @@
{{ profile.userName }}
- \ No newline at end of file + diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss index dceeb4de..db07220d 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.scss @@ -20,4 +20,43 @@ .nav-item:hover { cursor: pointer; -} \ No newline at end of file +} + +.profile-invite-row { + align-items: center; + border: 1px solid #dee2e6; + display: flex; + gap: 12px; + justify-content: space-between; + padding: 14px; +} + +.profile-invite-main { + min-width: 0; +} + +.profile-invite-title { + font-weight: 500; +} + +.profile-invite-title a { + color: var(--link-on-light); +} + +.profile-invite-actions { + display: flex; + flex-shrink: 0; + gap: 8px; +} + +.profile-invite-actions .btn-outline-danger { + background-color: transparent; + color: #dc3545; +} + +@media (max-width: 576px) { + .profile-invite-row { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts index 1a4330ba..9a5206c9 100644 --- a/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/profile/profile.component.ts @@ -4,10 +4,10 @@ import { AccountService } from '../services/account.service'; import { User } from '../interfaces/user'; import { Project } from '../interfaces/project'; import { ProjectService } from '../services/project.service'; -import { of, pipe, Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { UserUser } from '../interfaces/user-user'; +import { Observable } from 'rxjs'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { ProjectMembershipRequest } from '../interfaces/project-membership-request'; +import { ProjectUser } from '../interfaces/project-user'; @Component({ selector: 'app-profile', @@ -29,9 +29,11 @@ export class ProfileComponent implements OnInit { projects: Project[] followings: User[] followers: User[] + membershipRequests: ProjectMembershipRequest[] = [] - tabActive: boolean[] = [true, false, false] + tabActive: boolean[] = [true, false, false, false] showError: boolean + requestActionID: number = null currentUser: User = null @@ -39,7 +41,10 @@ export class ProfileComponent implements OnInit { async ngOnInit() { if (this.accountService.checkLoginStatus()) { await this.accountService.currentUser.then((x) => this.currentUser$ = x) - this.currentUser$.subscribe(x => this.currentUser = x) + this.currentUser$.subscribe(x => { + this.currentUser = x + this.loadMembershipRequests() + }) } this.route.params.subscribe(params => { @@ -47,7 +52,10 @@ export class ProfileComponent implements OnInit { this.projects = [] this.followings = [] this.followers = [] + this.membershipRequests = [] this.showError = false + this.requestActionID = null + this.tabActive = [true, false, false, false] let username = params["username"] @@ -58,6 +66,7 @@ export class ProfileComponent implements OnInit { this.loadProject(result) this.loadFollowing(result) this.loadFollower(result) + this.loadMembershipRequests() this.profileImage() }, error => { this.showError = true @@ -86,6 +95,8 @@ export class ProfileComponent implements OnInit { } changeTab(num: number) { + if (num == 3 && !this.isOwnProfile) return + this.tabActive.forEach((t, i) => { if (num != i) this.tabActive[i] = false @@ -126,6 +137,92 @@ export class ProfileComponent implements OnInit { ) } + get isOwnProfile(): boolean { + return this.profile != null && + this.currentUser != null && + this.profile.id == this.currentUser.id + } + + get pendingInvitations(): ProjectMembershipRequest[] { + if (this.currentUser == null) return [] + + return this.membershipRequests.filter(request => + request.type == "invitation" && + request.status == "pending" && + request.targetUserID == this.currentUser.id) + } + + loadMembershipRequests() { + if (!this.isOwnProfile) return + + this.projectService.getMyMembershipRequests().subscribe( + result => { + this.membershipRequests = result.filter(request => + request.type == "invitation" && + request.status == "pending" && + request.targetUserID == this.currentUser.id) + }, error => { + console.log(error) + } + ) + } + + acceptInvitation(request: ProjectMembershipRequest) { + if (this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.acceptProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.removeMembershipRequest(request.projectMembershipRequestID) + this.addProjectUser(result) + this.loadAcceptedProject(result) + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + rejectInvitation(request: ProjectMembershipRequest) { + if (this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.rejectProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.removeMembershipRequest(result.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + private removeMembershipRequest(requestID: number) { + this.membershipRequests = this.membershipRequests + .filter(request => request.projectMembershipRequestID != requestID) + } + + private addProjectUser(projectUser: ProjectUser) { + let index = this.profile.projectUsers.findIndex(item => item.projectID == projectUser.projectID) + if(index > -1) this.profile.projectUsers[index] = projectUser + else this.profile.projectUsers.push(projectUser) + } + + private loadAcceptedProject(projectUser: ProjectUser) { + this.projectService.getProjectByID(projectUser.projectID).subscribe( + project => { + let index = this.projects.findIndex(item => item.projectID == project.projectID) + if(index > -1) this.projects[index] = project + else this.projects.push(project) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + loadProject(profile: User) { let projectIDs: number[] = profile.projectUsers.map(pu => pu.projectID) diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html index ac13429d..d16cc416 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-create/project-create.component.html @@ -20,7 +20,11 @@

Project Creation

- + + + + + + + +

No pending join requests.

+ +
Pending Invitations
+
    +
  • +
    +
    + {{request.targetUser?.userName}} + {{request.createdAt | date:'medium'}} +
    + +
    +
  • +
+

No pending invitations.

+ diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss index e69de29b..9fdd0956 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.scss @@ -0,0 +1,19 @@ +.membership-request-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.membership-request-actions { + display: flex; + align-items: center; + gap: 8px; +} + +@media (max-width: 576px) { + .membership-request-row { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts index e21409b4..6c5d945d 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-edit/project-edit.component.ts @@ -2,6 +2,9 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Project } from 'src/app/interfaces/project'; import { ProjectTag } from 'src/app/interfaces/project-tag'; +import { ProjectUser } from 'src/app/interfaces/project-user'; +import { ProjectMembershipRequest } from 'src/app/interfaces/project-membership-request'; +import { ProjectService } from 'src/app/services/project.service'; @Component({ selector: 'app-project-edit', @@ -13,8 +16,13 @@ export class ProjectEditComponent implements OnInit { project : Project = null; owner: string = null; projectname: string = null; + membershipRequests: ProjectMembershipRequest[] = []; + requestActionID: number = null; - constructor(private route: ActivatedRoute) { } + constructor( + private route: ActivatedRoute, + private projectService: ProjectService + ) { } ngOnInit(): void { @@ -26,10 +34,88 @@ export class ProjectEditComponent implements OnInit { setProject(project : Project){ this.project = project + this.loadMembershipRequests() } updateProjectTags(projectTags : ProjectTag[]){ this.project.projectTags = projectTags } + updateProjectUsers(projectUsers : ProjectUser[]){ + this.project.projectUsers = projectUsers + } + + get pendingJoinRequests(): ProjectMembershipRequest[] { + return this.membershipRequests.filter(request => + request.type == "join_request" && + request.status == "pending") + } + + get pendingInvitations(): ProjectMembershipRequest[] { + return this.membershipRequests.filter(request => + request.type == "invitation" && + request.status == "pending") + } + + loadMembershipRequests(){ + if(this.project == null) return + + this.projectService.getProjectMembershipRequests(this.project.projectID).subscribe( + result => { + this.membershipRequests = result + }, error => { + console.log(error) + } + ) + } + + acceptRequest(request: ProjectMembershipRequest){ + if(this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.acceptProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + let index = this.project.projectUsers.findIndex(projectUser => projectUser.userID == result.userID) + if(index > -1) this.project.projectUsers[index] = result + else this.project.projectUsers.push(result) + + this.membershipRequests = this.membershipRequests.filter(item => item.projectMembershipRequestID != request.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + rejectRequest(request: ProjectMembershipRequest){ + if(this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.rejectProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.membershipRequests = this.membershipRequests.filter(item => item.projectMembershipRequestID != result.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + + cancelInvitation(request: ProjectMembershipRequest){ + if(this.requestActionID != null) return + this.requestActionID = request.projectMembershipRequestID + + this.projectService.cancelProjectMembershipRequest(request.projectMembershipRequestID).subscribe( + result => { + this.membershipRequests = this.membershipRequests.filter(item => item.projectMembershipRequestID != result.projectMembershipRequestID) + this.requestActionID = null + }, error => { + console.log(error) + this.requestActionID = null + } + ) + } + } diff --git a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html index 262b1d0a..c41964ab 100644 --- a/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html +++ b/src/Analysim.Web/ClientApp/src/app/projects/project-list-users/project-list-users.component.html @@ -1,5 +1,5 @@ -
+