diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs index cf6c21e1f..6c9fd3d08 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/AccountController.cs @@ -89,13 +89,13 @@ public async Task GetUserData() return Ok(aggregatedResult); } - [HttpPost("register")] - [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] - public async Task Register(RegisterViewModel model) - { - var result = await AuthServiceClient.Register(model); - return Ok(result); - } + // [HttpPost("register")] + // [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + // public async Task Register(RegisterViewModel model) + // { + // var result = await AuthServiceClient.Register(model); + // return Ok(result); + // } [HttpPost("login")] [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/RegistrationRequestsController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/RegistrationRequestsController.cs new file mode 100644 index 000000000..9ed818ca6 --- /dev/null +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/RegistrationRequestsController.cs @@ -0,0 +1,73 @@ +using System.Net; +using System.Threading.Tasks; +using HwProj.AuthService.Client; +using HwProj.CoursesService.Client; +using HwProj.Models.CoursesService.DTO; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.Result; +using HwProj.Models.Roles; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace HwProj.APIGateway.API.Controllers +{ + [Route("api/[controller]")] + [ApiController] + public class RegistrationRequestsController( + ICoursesServiceClient coursesClient, + IAuthServiceClient authServiceClient) + : AggregationController(authServiceClient) + { + [HttpPost("init")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Init([FromBody] InitRegistrationRequestViewModel model) + { + var result = await coursesClient.InitRegistrationRequest(model); + return Ok(result); + } + + [HttpPost("confirm")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Confirm([FromBody] ConfirmRegistrationRequestViewModel model) + { + var result = await coursesClient.ConfirmRegistrationRequest(model); + return Ok(result); + } + + [HttpGet("course/{courseId}")] + [Authorize(Roles = Roles.LecturerRole)] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task GetCourseRequests(long courseId) + { + var result = await coursesClient.GetCourseRegistrationRequests(courseId); + return Ok(result); + } + + [HttpGet("general")] + [Authorize(Roles = Roles.LecturerRole)] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task GetGeneralRequests() + { + var result = await coursesClient.GetGeneralRegistrationRequests(); + return Ok(result); + } + + [HttpPost("{requestId}/approve")] + [Authorize(Roles = Roles.LecturerRole)] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Approve(long requestId) + { + var result = await coursesClient.ApproveRegistrationRequest(requestId); + return Ok(result); + } + + [HttpPost("{requestId}/reject")] + [Authorize(Roles = Roles.LecturerRole)] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Reject(long requestId, [FromBody] ReviewRegistrationRequestViewModel model) + { + var result = await coursesClient.RejectRegistrationRequest(requestId, model); + return Ok(result); + } + } +} \ No newline at end of file diff --git a/HwProj.AuthService/HwProj.AuthService.API/Controllers/AccountController.cs b/HwProj.AuthService/HwProj.AuthService.API/Controllers/AccountController.cs index 06e2bf26f..9a62efb42 100644 --- a/HwProj.AuthService/HwProj.AuthService.API/Controllers/AccountController.cs +++ b/HwProj.AuthService/HwProj.AuthService.API/Controllers/AccountController.cs @@ -64,7 +64,25 @@ public async Task GetUserDataByEmail(string email) public async Task Register([FromBody] RegisterViewModel model) { var newModel = _mapper.Map(model); - var result = await _accountService.RegisterUserAsync(newModel); + var result = await _accountService.RegisterStudentAsync(newModel); + return Ok(result); + } + + [HttpPost("registerStudent")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task RegisterStudent([FromBody] RegisterViewModel model) + { + var newModel = _mapper.Map(model); + var result = await _accountService.RegisterStudentAsync(newModel); + return Ok(result); + } + + [HttpPost("registerLecturer")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task RegisterLecturer([FromBody] RegisterViewModel model) + { + var newModel = _mapper.Map(model); + var result = await _accountService.RegisterLecturerAsync(newModel); return Ok(result); } @@ -113,6 +131,11 @@ public async Task InviteNewLecturer(InviteLecturerViewModel model public async Task FindByEmail(string email) { var user = await _userManager.FindByEmailAsync(email); + if (user == null) + { + return Ok(null); + } + var roles = await _userManager.GetRolesAsync(user); return Ok(user.ToAccountDataDto(roles.First())); } diff --git a/HwProj.AuthService/HwProj.AuthService.API/Services/AccountService.cs b/HwProj.AuthService/HwProj.AuthService.API/Services/AccountService.cs index 706886b30..91da1fbcd 100644 --- a/HwProj.AuthService/HwProj.AuthService.API/Services/AccountService.cs +++ b/HwProj.AuthService/HwProj.AuthService.API/Services/AccountService.cs @@ -91,17 +91,24 @@ public async Task GetAccountDataByEmailAsync(string email) return await GetAccountDataAsync(user); } - public async Task> RegisterUserAsync(RegisterDataDTO model) + public async Task> RegisterStudentAsync(RegisterDataDTO model) { - model.Email = model.Email.Trim(); - model.Name = model.Name.Trim(); - model.Surname = model.Surname.Trim(); - model.MiddleName = model.MiddleName.Trim(); + NormalizeRegisterDataDTO(model); if (await _userManager.FindByEmailAsync(model.Email) != null) return Result.Failed("Пользователь уже зарегистрирован"); - return await RegisterUserAsyncInternal(model); + return await RegisterUserAsyncInternal(model, Roles.StudentRole); + } + + public async Task> RegisterLecturerAsync(RegisterDataDTO model) + { + NormalizeRegisterDataDTO(model); + + if (await _userManager.FindByEmailAsync(model.Email) != null) + return Result.Failed("Пользователь уже зарегистрирован"); + + return await RegisterUserAsyncInternal(model, Roles.LecturerRole); } public async Task[]> GetOrRegisterStudentsBatchAsync(IEnumerable models) @@ -116,7 +123,7 @@ public async Task[]> GetOrRegisterStudentsBatchAsync(IEnumerable< continue; } - var result = await RegisterUserAsyncInternal(model); + var result = await RegisterUserAsyncInternal(model, Roles.StudentRole); results.Add(result); } @@ -166,7 +173,7 @@ public async Task> RefreshToken(string userId) : await GetToken(user); } - private async Task> RegisterUserAsyncInternal(RegisterDataDTO model) + private async Task> RegisterUserAsyncInternal(RegisterDataDTO model, string role) { var user = _mapper.Map(model); user.UserName = user.Email; @@ -176,13 +183,13 @@ private async Task> RegisterUserAsyncInternal(RegisterDataDTO mod : _userManager.CreateAsync(user, Guid.NewGuid().ToString()); var result = await createUserTask - .Then(() => _userManager.AddToRoleAsync(user, Roles.StudentRole)); + .Then(() => _userManager.AddToRoleAsync(user, role)); if (result.Succeeded) { var newUser = await _userManager.FindByEmailAsync(model.Email); var changePasswordToken = await _aspUserManager.GeneratePasswordResetTokenAsync(user); - var registerEvent = new StudentRegisterEvent(newUser.Id, newUser.Email, newUser.Name, + var registerEvent = new AuthRegisterEvent(newUser.Id, newUser.Email, newUser.Name, newUser.Surname, newUser.MiddleName) { ChangePasswordToken = changePasswordToken @@ -377,5 +384,13 @@ private async Task> GetToken(User user) { return Result.Success(await _tokenService.GetTokenAsync(user).ConfigureAwait(false)); } + + private void NormalizeRegisterDataDTO(RegisterDataDTO model) + { + model.Email = model.Email.Trim(); + model.Name = model.Name.Trim(); + model.Surname = model.Surname.Trim(); + model.MiddleName = model.MiddleName.Trim(); + } } } diff --git a/HwProj.AuthService/HwProj.AuthService.API/Services/IAccountService.cs b/HwProj.AuthService/HwProj.AuthService.API/Services/IAccountService.cs index ca7d3c1d4..a85320113 100644 --- a/HwProj.AuthService/HwProj.AuthService.API/Services/IAccountService.cs +++ b/HwProj.AuthService/HwProj.AuthService.API/Services/IAccountService.cs @@ -13,7 +13,8 @@ public interface IAccountService Task GetAccountDataAsync(string userId); Task GetAccountsDataAsync(string[] userIds); Task GetAccountDataByEmailAsync(string email); - Task> RegisterUserAsync(RegisterDataDTO model); + Task> RegisterStudentAsync(RegisterDataDTO model); + Task> RegisterLecturerAsync(RegisterDataDTO model); Task EditAccountAsync(string accountId, EditDataDTO model); Task> LoginUserAsync(LoginViewModel model); Task> RefreshToken(string userId); diff --git a/HwProj.AuthService/HwProj.AuthService.Client/AuthServiceClient.cs b/HwProj.AuthService/HwProj.AuthService.Client/AuthServiceClient.cs index bf2d29112..b082b9a13 100644 --- a/HwProj.AuthService/HwProj.AuthService.Client/AuthServiceClient.cs +++ b/HwProj.AuthService/HwProj.AuthService.Client/AuthServiceClient.cs @@ -74,6 +74,38 @@ public async Task> Register(RegisterViewModel model) var response = await _httpClient.SendAsync(httpRequest); return await response.DeserializeAsync>(); } + + public async Task> RegisterStudent(RegisterViewModel model) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + _authServiceUri + "api/account/registerStudent") + { + Content = new StringContent( + JsonConvert.SerializeObject(model), + Encoding.UTF8, + "application/json") + }; + + var response = await _httpClient.SendAsync(httpRequest); + return await response.DeserializeAsync>(); + } + + public async Task> RegisterLecturer(RegisterViewModel model) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + _authServiceUri + "api/account/registerLecturer") + { + Content = new StringContent( + JsonConvert.SerializeObject(model), + Encoding.UTF8, + "application/json") + }; + + var response = await _httpClient.SendAsync(httpRequest); + return await response.DeserializeAsync>(); + } public async Task[]> GetOrRegisterStudentsBatchAsync( IEnumerable registrationModels) diff --git a/HwProj.AuthService/HwProj.AuthService.Client/IAuthServiceClient.cs b/HwProj.AuthService/HwProj.AuthService.Client/IAuthServiceClient.cs index 63d678991..c4769bb89 100644 --- a/HwProj.AuthService/HwProj.AuthService.Client/IAuthServiceClient.cs +++ b/HwProj.AuthService/HwProj.AuthService.Client/IAuthServiceClient.cs @@ -12,6 +12,8 @@ public interface IAuthServiceClient Task GetAccountDataByEmail(string email); Task GetAccountsData(string[] userId); Task> Register(RegisterViewModel model); + Task> RegisterStudent(RegisterViewModel model); + Task> RegisterLecturer(RegisterViewModel model); Task> Login(LoginViewModel model); Task> RefreshToken(string userId); Task Edit(EditAccountViewModel model, string userId); diff --git a/HwProj.Common/HwProj.Models/CoursesService/DTO/RegistrationRequestDTO.cs b/HwProj.Common/HwProj.Models/CoursesService/DTO/RegistrationRequestDTO.cs new file mode 100644 index 000000000..276695137 --- /dev/null +++ b/HwProj.Common/HwProj.Models/CoursesService/DTO/RegistrationRequestDTO.cs @@ -0,0 +1,39 @@ +using System; + +namespace HwProj.Models.CoursesService.DTO +{ + public class RegistrationRequestDto + { + public long Id { get; set; } + + public string? Description { get; set; } + + public string? PreferredLecturerEmail { get; set; } + + public long? CourseId { get; set; } + + public string RequestedRole { get; set; } + + public string Email { get; set; } + + public string Name { get; set; } + + public string Surname { get; set; } + + public string MiddleName { get; set; } + + public string Status { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public DateTime UpdatedAtUtc { get; set; } + + public DateTime? ReviewedAtUtc { get; set; } + + public string? ReviewedByUserId { get; set; } + + public string? RejectReason { get; set; } + + public string? ResolvedUserId { get; set; } + } +} \ No newline at end of file diff --git a/HwProj.Common/HwProj.Models/CoursesService/RequestedRole.cs b/HwProj.Common/HwProj.Models/CoursesService/RequestedRole.cs new file mode 100644 index 000000000..1fec5f2aa --- /dev/null +++ b/HwProj.Common/HwProj.Models/CoursesService/RequestedRole.cs @@ -0,0 +1,8 @@ +namespace HwProj.Models.CoursesService +{ + public enum RequestedRole + { + Student = 0, + Lecturer = 1, + } +} \ No newline at end of file diff --git a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/ConfirmRegistrationRequestViewModel.cs b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/ConfirmRegistrationRequestViewModel.cs new file mode 100644 index 000000000..4347fb160 --- /dev/null +++ b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/ConfirmRegistrationRequestViewModel.cs @@ -0,0 +1,10 @@ +using System.ComponentModel.DataAnnotations; + +namespace HwProj.Models.CoursesService.ViewModels +{ + public class ConfirmRegistrationRequestViewModel + { + [Required] + public string Token { get; set; } + } +} diff --git a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/InitRegistrationRequestViewModel.cs b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/InitRegistrationRequestViewModel.cs new file mode 100644 index 000000000..2339753ba --- /dev/null +++ b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/InitRegistrationRequestViewModel.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace HwProj.Models.CoursesService.ViewModels +{ + public class InitRegistrationRequestViewModel + { + [Required] + [EmailAddress] + public string Email { get; set; } + + [Required] + [RegularExpression(@"^\S+.*", ErrorMessage = "Name shouldn't start with white spaces.")] + public string Name { get; set; } + + [Required] + [RegularExpression(@"^\S+.*", ErrorMessage = "Surname shouldn't start with white spaces.")] + public string Surname { get; set; } + + [RegularExpression(@"^\S+.*", ErrorMessage = "MiddleName shouldn't start with white spaces.")] + public string MiddleName { get; set; } = string.Empty; + + public long? CourseId { get; set; } + + public RequestedRole RequestedRole { get; set; } = RequestedRole.Student; + + public string? Description { get; set; } + + [EmailAddress] + public string? PreferredLecturerEmail { get; set; } + } +} diff --git a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/ReviewRegistrationRequestViewModel.cs b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/ReviewRegistrationRequestViewModel.cs new file mode 100644 index 000000000..b5d16a4bd --- /dev/null +++ b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/ReviewRegistrationRequestViewModel.cs @@ -0,0 +1,9 @@ +using System.ComponentModel.DataAnnotations; + +namespace HwProj.Models.CoursesService.ViewModels +{ + public class ReviewRegistrationRequestViewModel + { + public string? RejectReason { get; set; } + } +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs b/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs index 43646b989..4a40bf8a9 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs @@ -21,10 +21,11 @@ public AutomapperProfile() CreateMap(); CreateMap(); - + + CreateMap(); + CreateMap().ReverseMap(); CreateMap(); - CreateMap().ReverseMap(); } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/RegistrationRequestsController.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/RegistrationRequestsController.cs new file mode 100644 index 000000000..41a327770 --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/RegistrationRequestsController.cs @@ -0,0 +1,96 @@ +using System.Net; +using System.Threading.Tasks; +using HwProj.Common.Net8; +using HwProj.CoursesService.API.Services; +using HwProj.Models.CoursesService.DTO; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.Result; +using Microsoft.AspNetCore.Mvc; + +namespace HwProj.CoursesService.API.Controllers +{ + [ApiController] + [Route("api/[controller]")] + public class RegistrationRequestsController : Controller + { + private readonly IRegistrationRequestsService _service; + + public RegistrationRequestsController(IRegistrationRequestsService service) + { + _service = service; + } + + [HttpPost("init")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Init([FromBody] InitRegistrationRequestViewModel model) + { + var result = await _service.InitRequestAsync(model); + return Ok(result); + } + + [HttpPost("confirm")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Confirm([FromBody] ConfirmRegistrationRequestViewModel model) + { + var result = await _service.ConfirmRequestAsync(model.Token); + return Ok(result); + } + + [HttpGet("course/{courseId}")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task GetCourseRequests(long courseId) + { + var reviewerId = Request.GetUserIdFromHeader(); + if (string.IsNullOrWhiteSpace(reviewerId)) + { + return Unauthorized(); + } + + var result = await _service.GetCourseRequestsAsync(courseId, reviewerId); + return Ok(result); + } + + [HttpGet("general")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task GetGeneralRequests() + { + var reviewerId = Request.GetUserIdFromHeader(); + if (string.IsNullOrWhiteSpace(reviewerId)) + { + return Unauthorized(); + } + + var result = await _service.GetGeneralRequestsAsync(reviewerId); + return Ok(result); + } + + [HttpPost("{requestId}/approve")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Approve(long requestId) + { + var reviewerId = Request.GetUserIdFromHeader(); + + if (string.IsNullOrWhiteSpace(reviewerId)) + { + return Unauthorized(); + } + + var result = await _service.ApproveAsync(requestId, reviewerId); + return Ok(result); + } + + [HttpPost("{requestId}/reject")] + [ProducesResponseType(typeof(Result), (int)HttpStatusCode.OK)] + public async Task Reject(long requestId, [FromBody] ReviewRegistrationRequestViewModel model) + { + var reviewerId = Request.GetUserIdFromHeader(); + if (string.IsNullOrWhiteSpace(reviewerId)) + { + return Unauthorized(); + } + + var result = await _service.RejectAsync(requestId, reviewerId, model?.RejectReason); + return Ok(result); + } + } +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20260510094007_RegistrationRequestsFlow.Designer.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20260510094007_RegistrationRequestsFlow.Designer.cs new file mode 100644 index 000000000..747481ffa --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20260510094007_RegistrationRequestsFlow.Designer.cs @@ -0,0 +1,603 @@ +// +using System; +using HwProj.CoursesService.API.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace HwProj.CoursesService.API.Migrations +{ + [DbContext(typeof(CourseContext))] + [Migration("20260510094007_RegistrationRequestsFlow")] + partial class RegistrationRequestsFlow + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Assignment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("MentorId") + .HasColumnType("nvarchar(max)"); + + b.Property("StudentId") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("Assignments"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GroupName") + .HasColumnType("nvarchar(max)"); + + b.Property("InviteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsOpen") + .HasColumnType("bit"); + + b.Property("MentorIds") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FilterJson") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("CourseFilters"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseMate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("IsAccepted") + .HasColumnType("bit"); + + b.Property("StudentId") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("CourseMates"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Criterion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("MaxPoints") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("TaskId") + .HasColumnType("bigint"); + + b.Property("Type") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("TaskId"); + + b.ToTable("Criteria"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.GroupMate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("StudentId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasAlternateKey("GroupId", "StudentId"); + + b.ToTable("GroupMates"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Homework", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("DeadlineDate") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("HasDeadline") + .HasColumnType("bit"); + + b.Property("IsDeadlineStrict") + .HasColumnType("bit"); + + b.Property("PublicationDate") + .HasColumnType("datetime2"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("Homeworks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.HomeworkTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeadlineDate") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("HasDeadline") + .HasColumnType("bit"); + + b.Property("HomeworkId") + .HasColumnType("bigint"); + + b.Property("IsBonusExplicit") + .HasColumnType("bit"); + + b.Property("IsDeadlineStrict") + .HasColumnType("bit"); + + b.Property("MaxRating") + .HasColumnType("int"); + + b.Property("PublicationDate") + .HasColumnType("datetime2"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("HomeworkId"); + + b.ToTable("Tasks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.RegistrationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(450)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredLecturerEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("RejectReason") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedRole") + .HasColumnType("int"); + + b.Property("ResolvedUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ReviewedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Surname") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Status] = 0"); + + b.HasIndex("CourseId", "Status"); + + b.ToTable("RegistrationRequests"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.RegistrationRequestDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfirmationToken") + .HasColumnType("nvarchar(450)"); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(450)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsConfirmed") + .HasColumnType("bit"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredLecturerEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedRole") + .HasColumnType("int"); + + b.Property("Surname") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConfirmationToken") + .IsUnique() + .HasFilter("[ConfirmationToken] IS NOT NULL"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[IsConfirmed] = 0"); + + b.ToTable("RegistrationRequestDrafts"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.StudentCharacteristics", b => + { + b.Property("CourseMateId") + .HasColumnType("bigint"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.HasKey("CourseMateId"); + + b.ToTable("StudentCharacteristics"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.TaskModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("GroupId") + .HasColumnType("bigint"); + + b.Property("TaskId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("TasksModels"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.TaskQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsPrivate") + .HasColumnType("bit"); + + b.Property("LecturerId") + .HasColumnType("nvarchar(max)"); + + b.Property("StudentId") + .HasColumnType("nvarchar(max)"); + + b.Property("TaskId") + .HasColumnType("bigint"); + + b.Property("Text") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.HasKey("Id"); + + b.HasIndex("TaskId"); + + b.ToTable("Questions"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.UserToCourseFilter", b => + { + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("CourseFilterId") + .HasColumnType("bigint"); + + b.HasKey("CourseId", "UserId"); + + b.HasIndex("CourseFilterId"); + + b.ToTable("UserToCourseFilters"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Assignment", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Course", null) + .WithMany("Assignments") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseMate", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Course", null) + .WithMany("CourseMates") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Criterion", b => + { + b.HasOne("HwProj.CoursesService.API.Models.HomeworkTask", "Task") + .WithMany("Criteria") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.GroupMate", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Group", null) + .WithMany("GroupMates") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Homework", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Course", null) + .WithMany("Homeworks") + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.HomeworkTask", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Homework", "Homework") + .WithMany("Tasks") + .HasForeignKey("HomeworkId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Homework"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.StudentCharacteristics", b => + { + b.HasOne("HwProj.CoursesService.API.Models.CourseMate", null) + .WithOne("Characteristics") + .HasForeignKey("HwProj.CoursesService.API.Models.StudentCharacteristics", "CourseMateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.TaskModel", b => + { + b.HasOne("HwProj.CoursesService.API.Models.Group", null) + .WithMany("Tasks") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.UserToCourseFilter", b => + { + b.HasOne("HwProj.CoursesService.API.Models.CourseFilter", "CourseFilter") + .WithMany() + .HasForeignKey("CourseFilterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CourseFilter"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Course", b => + { + b.Navigation("Assignments"); + + b.Navigation("CourseMates"); + + b.Navigation("Homeworks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.CourseMate", b => + { + b.Navigation("Characteristics"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Group", b => + { + b.Navigation("GroupMates"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.Homework", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.HomeworkTask", b => + { + b.Navigation("Criteria"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20260510094007_RegistrationRequestsFlow.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20260510094007_RegistrationRequestsFlow.cs new file mode 100644 index 000000000..acda838af --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/20260510094007_RegistrationRequestsFlow.cs @@ -0,0 +1,102 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HwProj.CoursesService.API.Migrations +{ + /// + public partial class RegistrationRequestsFlow : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RegistrationRequestDrafts", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Description = table.Column(type: "nvarchar(max)", nullable: true), + PreferredLecturerEmail = table.Column(type: "nvarchar(max)", nullable: true), + CourseId = table.Column(type: "bigint", nullable: true), + RequestedRole = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: true), + Surname = table.Column(type: "nvarchar(max)", nullable: true), + MiddleName = table.Column(type: "nvarchar(max)", nullable: true), + Email = table.Column(type: "nvarchar(450)", nullable: true), + ConfirmationToken = table.Column(type: "nvarchar(450)", nullable: true), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + ExpiresAtUtc = table.Column(type: "datetime2", nullable: false), + IsConfirmed = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RegistrationRequestDrafts", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "RegistrationRequests", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Description = table.Column(type: "nvarchar(max)", nullable: true), + PreferredLecturerEmail = table.Column(type: "nvarchar(max)", nullable: true), + CourseId = table.Column(type: "bigint", nullable: true), + RequestedRole = table.Column(type: "int", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: true), + Surname = table.Column(type: "nvarchar(max)", nullable: true), + MiddleName = table.Column(type: "nvarchar(max)", nullable: true), + Email = table.Column(type: "nvarchar(450)", nullable: true), + Status = table.Column(type: "int", nullable: false), + CreatedAtUtc = table.Column(type: "datetime2", nullable: false), + UpdatedAtUtc = table.Column(type: "datetime2", nullable: false), + ReviewedAtUtc = table.Column(type: "datetime2", nullable: true), + ReviewedByUserId = table.Column(type: "nvarchar(max)", nullable: true), + RejectReason = table.Column(type: "nvarchar(max)", nullable: true), + ResolvedUserId = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RegistrationRequests", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_RegistrationRequestDrafts_ConfirmationToken", + table: "RegistrationRequestDrafts", + column: "ConfirmationToken", + unique: true, + filter: "[ConfirmationToken] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_RegistrationRequestDrafts_Email", + table: "RegistrationRequestDrafts", + column: "Email", + unique: true, + filter: "[IsConfirmed] = 0"); + + migrationBuilder.CreateIndex( + name: "IX_RegistrationRequests_CourseId_Status", + table: "RegistrationRequests", + columns: new[] { "CourseId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_RegistrationRequests_Email", + table: "RegistrationRequests", + column: "Email", + unique: true, + filter: "[Status] = 0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RegistrationRequestDrafts"); + + migrationBuilder.DropTable( + name: "RegistrationRequests"); + } + } +} diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs index 199917cf8..5a292a761 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Migrations/CourseContextModelSnapshot.cs @@ -272,6 +272,127 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Tasks"); }); + modelBuilder.Entity("HwProj.CoursesService.API.Models.RegistrationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(450)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredLecturerEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("RejectReason") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedRole") + .HasColumnType("int"); + + b.Property("ResolvedUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAtUtc") + .HasColumnType("datetime2"); + + b.Property("ReviewedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("Surname") + .HasColumnType("nvarchar(max)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[Status] = 0"); + + b.HasIndex("CourseId", "Status"); + + b.ToTable("RegistrationRequests"); + }); + + modelBuilder.Entity("HwProj.CoursesService.API.Models.RegistrationRequestDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConfirmationToken") + .HasColumnType("nvarchar(450)"); + + b.Property("CourseId") + .HasColumnType("bigint"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasColumnType("nvarchar(450)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetime2"); + + b.Property("IsConfirmed") + .HasColumnType("bit"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredLecturerEmail") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedRole") + .HasColumnType("int"); + + b.Property("Surname") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ConfirmationToken") + .IsUnique() + .HasFilter("[ConfirmationToken] IS NOT NULL"); + + b.HasIndex("Email") + .IsUnique() + .HasFilter("[IsConfirmed] = 0"); + + b.ToTable("RegistrationRequestDrafts"); + }); + modelBuilder.Entity("HwProj.CoursesService.API.Models.StudentCharacteristics", b => { b.Property("CourseMateId") diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Models/CourseContext.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Models/CourseContext.cs index c32220254..a412effe9 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Models/CourseContext.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Models/CourseContext.cs @@ -18,6 +18,8 @@ public sealed class CourseContext : DbContext public DbSet UserToCourseFilters { get; set; } public DbSet Questions { get; set; } public DbSet Criteria { get; set; } + public DbSet RegistrationRequests { get; set; } + public DbSet RegistrationRequestDrafts { get; set; } public CourseContext(DbContextOptions options) : base(options) @@ -30,6 +32,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.Entity().HasIndex(a => a.CourseId); modelBuilder.Entity().HasKey(u => new { u.CourseId, u.Id }); modelBuilder.Entity().HasIndex(t => t.TaskId); + + modelBuilder.Entity() + .HasIndex(r => new { r.CourseId, r.Status }); + modelBuilder.Entity() + .HasIndex(r => r.Email); + modelBuilder.Entity() + .HasIndex(r => r.Email) + .HasFilter($"[{nameof(RegistrationRequest.Status)}] = {(int)RegistrationRequestStatus.Pending}") + .IsUnique(); + + modelBuilder.Entity() + .HasIndex(r => r.Email) + .HasFilter($"[{nameof(RegistrationRequestDraft.IsConfirmed)}] = 0") + .IsUnique(); + modelBuilder.Entity() + .HasIndex(r => r.ConfirmationToken) + .IsUnique(); } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Models/RegistrationRequest.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Models/RegistrationRequest.cs new file mode 100644 index 000000000..55e49b29a --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Models/RegistrationRequest.cs @@ -0,0 +1,53 @@ +using System; +using System.ComponentModel.DataAnnotations; +using HwProj.Models.CoursesService; +using HwProj.Repositories.Net8; + +namespace HwProj.CoursesService.API.Models +{ + public class RegistrationRequest : IEntity + { + [Key] + public long Id { get; set; } + + public string? Description { get; set; } + + public string? PreferredLecturerEmail { get; set; } + + public long? CourseId { get; set; } + + public RequestedRole RequestedRole { get; set; } + + public string Name { get; set; } + + public string Surname { get; set; } + + public string MiddleName { get; set; } + + public string Email { get; set; } + + // Status of the request to registration + public RegistrationRequestStatus Status { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public DateTime UpdatedAtUtc { get; set; } + + public DateTime? ReviewedAtUtc { get; set; } + + public string? ReviewedByUserId { get; set; } + + public string? RejectReason { get; set; } + + // Result userId + public string? ResolvedUserId { get; set; } + + } +} + +public enum RegistrationRequestStatus +{ + Pending = 0, + Approved = 1, + Rejected = 2, +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Models/RegistrationRequestDraft.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Models/RegistrationRequestDraft.cs new file mode 100644 index 000000000..a240520ea --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Models/RegistrationRequestDraft.cs @@ -0,0 +1,37 @@ +using System; +using System.ComponentModel.DataAnnotations; +using HwProj.Models.CoursesService; +using HwProj.Repositories.Net8; + +namespace HwProj.CoursesService.API.Models +{ + public class RegistrationRequestDraft : IEntity + { + [Key] + public long Id { get; set; } + + public string? Description { get; set; } + + public string? PreferredLecturerEmail { get; set; } + + public long? CourseId { get; set; } + + public RequestedRole RequestedRole { get; set; } + + public string Name { get; set; } + + public string Surname { get; set; } + + public string MiddleName { get; set; } = string.Empty; + + public string Email { get; set; } + + public string ConfirmationToken { get; set; } + + public DateTime CreatedAtUtc { get; set; } + + public DateTime ExpiresAtUtc { get; set; } + + public bool IsConfirmed { get; set; } + } +} diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IRegistrationRequestDraftsRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IRegistrationRequestDraftsRepository.cs new file mode 100644 index 000000000..06814e855 --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IRegistrationRequestDraftsRepository.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; +using HwProj.CoursesService.API.Models; +using HwProj.Repositories.Net8; + +namespace HwProj.CoursesService.API.Repositories +{ + public interface IRegistrationRequestDraftsRepository : ICrudRepository + { + Task GetUnconfirmedByEmailAsync(string email); + + Task GetByTokenAsync(string token); + } +} diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IRegistrationRequestRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IRegistrationRequestRepository.cs new file mode 100644 index 000000000..5f0c256d6 --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IRegistrationRequestRepository.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; +using HwProj.CoursesService.API.Models; +using HwProj.Repositories.Net8; + +namespace HwProj.CoursesService.API.Repositories +{ + public interface IRegistrationRequestsRepository : ICrudRepository + { + Task GetPendingByEmailAsync(string email); + } +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/RegistrationRequestDraftsRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/RegistrationRequestDraftsRepository.cs new file mode 100644 index 000000000..3203c57bd --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/RegistrationRequestDraftsRepository.cs @@ -0,0 +1,31 @@ +using System; +using System.Threading.Tasks; +using HwProj.CoursesService.API.Models; +using HwProj.Repositories.Net8; +using Microsoft.EntityFrameworkCore; + +namespace HwProj.CoursesService.API.Repositories +{ + public class RegistrationRequestDraftsRepository : CrudRepository, + IRegistrationRequestDraftsRepository + { + public RegistrationRequestDraftsRepository(CourseContext context) + : base(context) + { + } + + public async Task GetUnconfirmedByEmailAsync(string email) + { + return await Context.Set() + .FirstOrDefaultAsync(r => + r.Email == email && + !r.IsConfirmed); + } + + public async Task GetByTokenAsync(string token) + { + return await Context.Set() + .FirstOrDefaultAsync(r => r.ConfirmationToken == token); + } + } +} diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/RegistrationRequestRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/RegistrationRequestRepository.cs new file mode 100644 index 000000000..7817d1142 --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/RegistrationRequestRepository.cs @@ -0,0 +1,23 @@ +using System.Threading.Tasks; +using HwProj.CoursesService.API.Models; +using HwProj.Repositories.Net8; +using Microsoft.EntityFrameworkCore; + +namespace HwProj.CoursesService.API.Repositories +{ + public class RegistrationRequestsRepository : CrudRepository, IRegistrationRequestsRepository + { + public RegistrationRequestsRepository(CourseContext context) + : base(context) + { + } + + public async Task GetPendingByEmailAsync(string email) + { + return await Context.Set() + .FirstOrDefaultAsync(r => + r.Email == email && + r.Status == RegistrationRequestStatus.Pending); + } + } +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/IRegistrationRequestsService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/IRegistrationRequestsService.cs new file mode 100644 index 000000000..f6c7c8315 --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/IRegistrationRequestsService.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using HwProj.Models.CoursesService.DTO; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.Result; + +namespace HwProj.CoursesService.API.Services +{ + public interface IRegistrationRequestsService + { + Task InitRequestAsync(InitRegistrationRequestViewModel model); + Task> ConfirmRequestAsync(string token); + + Task> GetCourseRequestsAsync(long courseId, string reviewerId); + Task> GetGeneralRequestsAsync(string reviewerId); + + Task> ApproveAsync(long requestId, string reviewerId); + Task RejectAsync(long requestId, string reviewerId, string? rejectReason); + } +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/RegistrationRequestsService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/RegistrationRequestsService.cs new file mode 100644 index 000000000..2d8a5ca0a --- /dev/null +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/RegistrationRequestsService.cs @@ -0,0 +1,456 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using AutoMapper; +using HwProj.AuthService.Client; +using HwProj.CoursesService.API.Models; +using HwProj.CoursesService.API.Repositories; +using HwProj.EventBus.Client.Interfaces; +using HwProj.Models.AuthService.ViewModels; +using HwProj.Models.CoursesService; +using HwProj.Models.CoursesService.DTO; +using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.Result; +using HwProj.Models.Roles; +using HwProj.NotificationService.Events.CoursesService; +using Microsoft.EntityFrameworkCore; + +namespace HwProj.CoursesService.API.Services +{ + public class RegistrationRequestsService : IRegistrationRequestsService + { + private static readonly TimeSpan DraftLifetime = TimeSpan.FromHours(24); + + private readonly IRegistrationRequestsRepository _requestsRepository; + private readonly IRegistrationRequestDraftsRepository _draftsRepository; + private readonly ICoursesRepository _coursesRepository; + private readonly ICoursesService _coursesService; + private readonly IAuthServiceClient _authServiceClient; + private readonly IMapper _mapper; + private readonly IEventBus _eventBus; + + public RegistrationRequestsService( + IRegistrationRequestsRepository requestsRepository, + IRegistrationRequestDraftsRepository draftsRepository, + ICoursesRepository coursesRepository, + ICoursesService coursesService, + IAuthServiceClient authServiceClient, + IMapper mapper, + IEventBus eventBus) + { + _requestsRepository = requestsRepository; + _draftsRepository = draftsRepository; + _coursesRepository = coursesRepository; + _coursesService = coursesService; + _authServiceClient = authServiceClient; + _mapper = mapper; + _eventBus = eventBus; + } + + public async Task InitRequestAsync(InitRegistrationRequestViewModel model) + { + var email = model.Email.Trim(); + + if (model.RequestedRole == RequestedRole.Lecturer && model.CourseId != null) + { + return Result.Failed("Заявка преподавателя не может быть привязана к курсу"); + } + + var userId = await _authServiceClient.FindByEmailAsync(email).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(userId)) + { + return Result.Failed("Пользователь уже зарегистрирован, войдите в аккаунт"); + } + + var existingRequest = await _requestsRepository.GetPendingByEmailAsync(email).ConfigureAwait(false); + if (existingRequest != null) + { + return Result.Failed("У вас уже есть активная заявка"); + } + + if (model.CourseId != null) + { + var course = await _coursesRepository.GetAsync(model.CourseId!.Value).ConfigureAwait(false); + if (course == null) + { + return Result.Failed("Курс не найден"); + } + } + + var description = string.IsNullOrWhiteSpace(model.Description) + ? null + : model.Description.Trim(); + var preferredLecturerEmail = string.IsNullOrWhiteSpace(model.PreferredLecturerEmail) + ? null + : model.PreferredLecturerEmail.Trim(); + var now = DateTime.UtcNow; + var name = model.Name.Trim(); + var surname = model.Surname.Trim(); + var middleName = model.MiddleName.Trim(); + + var existingDraft = await _draftsRepository.GetUnconfirmedByEmailAsync(email).ConfigureAwait(false); + if (existingDraft != null) + { + if (existingDraft.ExpiresAtUtc > now) + { + return Result.Failed("Подтверждение уже отправлено на эту почту"); + } + + var newToken = Guid.NewGuid().ToString(); + + await _draftsRepository.UpdateAsync(existingDraft.Id, _ => new RegistrationRequestDraft + { + Description = description, + PreferredLecturerEmail = preferredLecturerEmail, + Email = email, + Name = name, + Surname = surname, + MiddleName = middleName, + CourseId = model.CourseId, + RequestedRole = model.RequestedRole, + ConfirmationToken = newToken, + CreatedAtUtc = now, + ExpiresAtUtc = now.Add(DraftLifetime), + IsConfirmed = false + }).ConfigureAwait(false); + + _eventBus.Publish(new RegistrationRequestConfirmationEvent + { + Email = email, + Name = name, + Surname = surname, + Token = newToken + }); + + return Result.Success(); + } + + var token = Guid.NewGuid().ToString(); + + var draft = new RegistrationRequestDraft + { + Description = description, + PreferredLecturerEmail = preferredLecturerEmail, + Email = email, + Name = name, + Surname = surname, + MiddleName = middleName, + CourseId = model.CourseId, + RequestedRole = model.RequestedRole, + ConfirmationToken = token, + CreatedAtUtc = now, + ExpiresAtUtc = now.Add(DraftLifetime), + IsConfirmed = false + }; + + await _draftsRepository.AddAsync(draft).ConfigureAwait(false); + + _eventBus.Publish(new RegistrationRequestConfirmationEvent + { + Email = draft.Email, + Name = draft.Name, + Surname = draft.Surname, + Token = draft.ConfirmationToken + }); + + return Result.Success(); + } + + public async Task> ConfirmRequestAsync(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return Result.Failed("Некорректный токен подтверждения"); + } + + var draft = await _draftsRepository.GetByTokenAsync(token.Trim()).ConfigureAwait(false); + if (draft == null) + { + return Result.Failed("Ссылка подтверждения недействительна"); + } + + if (draft.IsConfirmed) + { + return Result.Failed("Заявка уже подтверждена"); + } + + if (draft.ExpiresAtUtc <= DateTime.UtcNow) + { + return Result.Failed("Срок действия ссылки истёк"); + } + + if (draft.RequestedRole == RequestedRole.Lecturer && draft.CourseId != null) + { + return Result.Failed("Заявка преподавателя не может быть привязана к курсу"); + } + + var userId = await _authServiceClient.FindByEmailAsync(draft.Email).ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(userId)) + { + return Result.Failed("Пользователь уже зарегистрирован, войдите в аккаунт"); + } + + var existingRequest = await _requestsRepository.GetPendingByEmailAsync(draft.Email).ConfigureAwait(false); + if (existingRequest != null) + { + return Result.Failed("У вас уже есть активная заявка"); + } + + Course course = null; + if (draft.CourseId != null) + { + course = await _coursesRepository.GetAsync(draft.CourseId.Value).ConfigureAwait(false); + if (course == null) + { + return Result.Failed("Курс не найден"); + } + } + + var now = DateTime.UtcNow; + var request = new RegistrationRequest + { + Description = draft.Description, + PreferredLecturerEmail = draft.PreferredLecturerEmail, + Email = draft.Email, + Name = draft.Name, + Surname = draft.Surname, + MiddleName = draft.MiddleName, + CourseId = draft.CourseId, + RequestedRole = draft.RequestedRole, + Status = RegistrationRequestStatus.Pending, + CreatedAtUtc = now, + UpdatedAtUtc = now + }; + + var requestId = await _requestsRepository.AddAsync(request).ConfigureAwait(false); + + await _draftsRepository.UpdateAsync(draft.Id, _ => new RegistrationRequestDraft + { + IsConfirmed = true + }).ConfigureAwait(false); + + _eventBus.Publish(new RegistrationRequestCreatedEvent + { + RegistrationRequestId = requestId, + CourseId = request.CourseId, + Email = request.Email, + Name = request.Name, + Surname = request.Surname, + CourseName = course?.Name ?? string.Empty, + RequestedRole = request.RequestedRole.ToString(), + MentorIds = course?.MentorIds ?? string.Empty + + }); + + return Result.Success(requestId); + } + + public async Task> GetCourseRequestsAsync(long courseId, string reviewerId) + { + var validationResult = await EnsureCourseLecturerAsync(courseId, reviewerId).ConfigureAwait(false); + if (!validationResult.Succeeded) + { + return Result.Failed(validationResult.Errors.FirstOrDefault()!); + } + + var requests = await _requestsRepository.FindAll(r => + r.CourseId == courseId && + r.Status == RegistrationRequestStatus.Pending + ) + .OrderByDescending(r => r.CreatedAtUtc) + .ToArrayAsync() + .ConfigureAwait(false); + + var dtos = requests.Select(r => _mapper.Map(r)).ToArray(); + return Result.Success(dtos); + } + + public async Task> GetGeneralRequestsAsync(string reviewerId) + { + var reviewer = await _authServiceClient.GetAccountData(reviewerId).ConfigureAwait(false); + if (reviewer.Role != Roles.LecturerRole) + { + return Result.Failed("Нет прав просматривать общие заявки"); + } + + var requests = await _requestsRepository.FindAll(r => + r.Status == RegistrationRequestStatus.Pending && + r.CourseId == null) + .OrderByDescending(r => r.CreatedAtUtc) + .ToArrayAsync() + .ConfigureAwait(false); + + var dtos = requests.Select(r => _mapper.Map(r)).ToArray(); + return Result.Success(dtos); + } + + public async Task> ApproveAsync(long requestId, string reviewerId) + { + var request = await _requestsRepository.GetAsync(requestId).ConfigureAwait(false); + if (request == null) + { + return Result.Failed("Заявка не найдена"); + } + + if (request.RequestedRole == RequestedRole.Lecturer && request.CourseId != null) + { + return Result.Failed("Заявка преподавателя не может быть привязана к курсу"); + } + + if (request.Status != RegistrationRequestStatus.Pending) + { + return Result.Failed("Заявка уже обработана"); + } + + if (request.CourseId != null) + { + var validationResult = await EnsureCourseLecturerAsync(request.CourseId.Value, reviewerId).ConfigureAwait(false); + if (!validationResult.Succeeded) + { + return Result.Failed(validationResult.Errors.FirstOrDefault()); + } + } + else + { + var reviewer = await _authServiceClient.GetAccountData(reviewerId).ConfigureAwait(false); + if (reviewer.Role != Roles.LecturerRole) + { + return Result.Failed("Нет прав проверять общие заявки"); + } + } + + var userId = await _authServiceClient.FindByEmailAsync(request.Email).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId)) + { + Result registerResult; + + if (request.RequestedRole == RequestedRole.Student) + { + registerResult = await _authServiceClient.RegisterStudent(new RegisterViewModel + { + Email = request.Email, + Name = request.Name, + Surname = request.Surname, + MiddleName = request.MiddleName + }).ConfigureAwait(false); + + } + else + { + registerResult = await _authServiceClient.RegisterLecturer(new RegisterViewModel + { + Email = request.Email, + Name = request.Name, + Surname = request.Surname, + MiddleName = request.MiddleName + }).ConfigureAwait(false); + } + + if (!registerResult.Succeeded) + { + return Result.Failed(registerResult.Errors.FirstOrDefault() ?? + "Не удалось зарегистрировать пользователя"); + } + + userId = registerResult.Value; + } + + if (request.CourseId != null && request.RequestedRole == RequestedRole.Student) + { + var addResult = await _coursesService.AddStudentAsync( + request.CourseId.Value, + userId).ConfigureAwait(false); + if (!addResult) + { + return Result.Failed("Ошибка зачисления на курс"); + } + + var acceptResult = await _coursesService.AcceptCourseMateAsync(request.CourseId.Value, userId).ConfigureAwait(false); + if (!acceptResult) + { + return Result.Failed("Ошибка подтверждения зачисления на курс"); + } + } + + var now = DateTime.UtcNow; + await _requestsRepository.UpdateAsync(requestId, _ => new RegistrationRequest + { + Status = RegistrationRequestStatus.Approved, + ReviewedAtUtc = now, + ReviewedByUserId = reviewerId, + UpdatedAtUtc = now, + ResolvedUserId = userId, + RejectReason = null + }).ConfigureAwait(false); + + return Result.Success(userId); + } + + public async Task RejectAsync(long requestId, string reviewerId, string? rejectReason) + { + var request = await _requestsRepository.GetAsync(requestId).ConfigureAwait(false); + if (request == null) + { + return Result.Failed("Заявка не найдена"); + } + + if (request.Status != RegistrationRequestStatus.Pending) + { + return Result.Failed("Заявка уже обработана"); + } + + if (request.CourseId != null) + { + var validationResult = await EnsureCourseLecturerAsync(request.CourseId.Value, reviewerId).ConfigureAwait(false); + if (!validationResult.Succeeded) + { + return validationResult; + } + } + else + { + var reviewer = await _authServiceClient.GetAccountData(reviewerId).ConfigureAwait(false); + if (reviewer.Role != Roles.LecturerRole) + { + return Result.Failed("Нет прав проверять общие заявки"); + } + } + + var now = DateTime.UtcNow; + await _requestsRepository.UpdateAsync(requestId, _ => new RegistrationRequest + { + Status = RegistrationRequestStatus.Rejected, + ReviewedAtUtc = now, + ReviewedByUserId = reviewerId, + UpdatedAtUtc = now, + RejectReason = string.IsNullOrWhiteSpace(rejectReason) ? null : rejectReason.Trim() + }).ConfigureAwait(false); + + _eventBus.Publish(new RegistrationRequestRejectedEvent + { + Email = request.Email, + Name = request.Name, + Surname = request.Surname, + RejectReason = string.IsNullOrWhiteSpace(rejectReason) ? string.Empty : rejectReason.Trim() + }); + + return Result.Success(); + } + + private async Task EnsureCourseLecturerAsync(long courseId, string lecturerId) + { + var course = await _coursesRepository.GetAsync(courseId).ConfigureAwait(false); + if (course == null) + { + return Result.Failed("Курс с таким id не найден"); + } + + if (!course.MentorIds.Contains(lecturerId)) + { + return Result.Failed("Нет прав управлять заявками этого курса"); + } + + return Result.Success(); + } + } +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Startup.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Startup.cs index 62461edc6..59915d23c 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Startup.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Startup.cs @@ -44,7 +44,10 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddEventBus(Configuration); services.AddHttpClient(); diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/appsettings.json b/HwProj.CoursesService/HwProj.CoursesService.API/appsettings.json index b8f8a2600..e88017436 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/appsettings.json +++ b/HwProj.CoursesService/HwProj.CoursesService.API/appsettings.json @@ -1,7 +1,7 @@ { "ConnectionStrings": { "DefaultConnectionForWindows": "Server=(localdb)\\mssqllocaldb;Database=CoursesServiceDB;Trusted_Connection=True;TrustServerCertificate=true;", - "DefaultConnectionForLinux": "Server=localhost,1433;Database=CoursesServiceDB;User ID=SA;Password=password_1234;" + "DefaultConnectionForLinux": "Server=localhost,1433;Database=CoursesServiceDB;User ID=SA;Password=password_1234;TrustServerCertificate=True;Encrypt=False;" }, "Logging": { "LogLevel": { diff --git a/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs b/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs index c82dcdcbe..71bccf7ca 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs @@ -206,6 +206,120 @@ public async Task UpdateStudentCharacteristics(long courseId, string stu return response.IsSuccessStatusCode ? Result.Success() : Result.Failed(response.ReasonPhrase); } + public async Task InitRegistrationRequest(InitRegistrationRequestViewModel model) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + _coursesServiceUri + "api/RegistrationRequests/init") + { + Content = new StringContent( + JsonConvert.SerializeObject(model), + Encoding.UTF8, + "application/json") + }; + + var response = await _httpClient.SendAsync(httpRequest); + return response.StatusCode switch + { + HttpStatusCode.OK => await response.DeserializeAsync(), + _ => Result.Failed(response.ReasonPhrase), + }; + } + + public async Task> ConfirmRegistrationRequest(ConfirmRegistrationRequestViewModel model) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + _coursesServiceUri + "api/RegistrationRequests/confirm") + { + Content = new StringContent( + JsonConvert.SerializeObject(model), + Encoding.UTF8, + "application/json") + }; + + var response = await _httpClient.SendAsync(httpRequest); + return response.StatusCode switch + { + HttpStatusCode.OK => await response.DeserializeAsync>(), + _ => Result.Failed(response.ReasonPhrase), + }; + } + + public async Task> GetCourseRegistrationRequests(long courseId) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Get, + _coursesServiceUri + $"api/RegistrationRequests/course/{courseId}"); + + httpRequest.TryAddUserId(_httpContextAccessor); + var response = await _httpClient.SendAsync(httpRequest); + + return response.StatusCode switch + { + HttpStatusCode.OK => await response.DeserializeAsync>(), + HttpStatusCode.Unauthorized => Result.Failed("Пользователь не авторизован"), + _ => Result.Failed(response.ReasonPhrase), + }; + } + + public async Task> GetGeneralRegistrationRequests() + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Get, + _coursesServiceUri + "api/RegistrationRequests/general"); + + httpRequest.TryAddUserId(_httpContextAccessor); + var response = await _httpClient.SendAsync(httpRequest); + + return response.StatusCode switch + { + HttpStatusCode.OK => await response.DeserializeAsync>(), + HttpStatusCode.Unauthorized => Result.Failed("Пользователь не авторизован"), + _ => Result.Failed(response.ReasonPhrase), + }; + } + + public async Task> ApproveRegistrationRequest(long requestId) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + _coursesServiceUri + $"api/RegistrationRequests/{requestId}/approve"); + + httpRequest.TryAddUserId(_httpContextAccessor); + var response = await _httpClient.SendAsync(httpRequest); + + return response.StatusCode switch + { + HttpStatusCode.OK => await response.DeserializeAsync>(), + HttpStatusCode.Unauthorized => Result.Failed("Пользователь не авторизован"), + _ => Result.Failed(response.ReasonPhrase), + }; + } + + public async Task RejectRegistrationRequest(long requestId, ReviewRegistrationRequestViewModel model) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Post, + _coursesServiceUri + $"api/RegistrationRequests/{requestId}/reject") + { + Content = new StringContent( + JsonConvert.SerializeObject(model), + Encoding.UTF8, + "application/json") + }; + + httpRequest.TryAddUserId(_httpContextAccessor); + var response = await _httpClient.SendAsync(httpRequest); + + return response.StatusCode switch + { + HttpStatusCode.OK => await response.DeserializeAsync(), + HttpStatusCode.Unauthorized => Result.Failed("Пользователь не авторизован"), + _ => Result.Failed(response.ReasonPhrase), + }; + } + public async Task GetAllUserCourses() { var role = _httpContextAccessor.HttpContext.User.FindFirst(ClaimTypes.Role).Value; diff --git a/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs b/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs index 9bcb031c1..2842ece32 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs @@ -24,6 +24,13 @@ public interface ICoursesServiceClient Task UpdateStudentCharacteristics(long courseId, string studentId, StudentCharacteristicsDto characteristics); + Task InitRegistrationRequest(InitRegistrationRequestViewModel model); + Task> ConfirmRegistrationRequest(ConfirmRegistrationRequestViewModel model); + Task> GetCourseRegistrationRequests(long courseId); + Task> GetGeneralRegistrationRequests(); + Task> ApproveRegistrationRequest(long requestId); + Task RejectRegistrationRequest(long requestId, ReviewRegistrationRequestViewModel model); + Task GetAllUserCourses(); Task GetTaskDeadlines(); Task> AddHomeworkToCourse(CreateHomeworkViewModel model, long courseId); diff --git a/HwProj.NotificationsService/HwProj.NotificationService.Events/AuthService/StudentRegisterEvent.cs b/HwProj.NotificationsService/HwProj.NotificationService.Events/AuthService/AuthRegisterEvent.cs similarity index 54% rename from HwProj.NotificationsService/HwProj.NotificationService.Events/AuthService/StudentRegisterEvent.cs rename to HwProj.NotificationsService/HwProj.NotificationService.Events/AuthService/AuthRegisterEvent.cs index bc5b8ccd8..19f1f6788 100644 --- a/HwProj.NotificationsService/HwProj.NotificationService.Events/AuthService/StudentRegisterEvent.cs +++ b/HwProj.NotificationsService/HwProj.NotificationService.Events/AuthService/AuthRegisterEvent.cs @@ -1,8 +1,8 @@ namespace HwProj.NotificationService.Events.AuthService { - public class StudentRegisterEvent : RegisterEvent + public class AuthRegisterEvent : RegisterEvent { - public StudentRegisterEvent(string userId, string email, string name, string surname = "", string middleName = "") + public AuthRegisterEvent(string userId, string email, string name, string surname = "", string middleName = "") : base(userId, email, name, surname, middleName) { } diff --git a/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestConfirmationEvent.cs b/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestConfirmationEvent.cs new file mode 100644 index 000000000..8eca34125 --- /dev/null +++ b/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestConfirmationEvent.cs @@ -0,0 +1,15 @@ +using HwProj.EventBus.Client; + +namespace HwProj.NotificationService.Events.CoursesService +{ + public class RegistrationRequestConfirmationEvent : Event + { + public string Email { get; set; } + + public string Name { get; set; } + + public string Surname { get; set; } + + public string Token { get; set; } + } +} \ No newline at end of file diff --git a/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestCreatedEvent.cs b/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestCreatedEvent.cs new file mode 100644 index 000000000..2c5a1723c --- /dev/null +++ b/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestCreatedEvent.cs @@ -0,0 +1,24 @@ +using HwProj.EventBus.Client; +using HwProj.Models.CoursesService; + +namespace HwProj.NotificationService.Events.CoursesService +{ + public class RegistrationRequestCreatedEvent : Event + { + public long RegistrationRequestId { get; set; } + + public long? CourseId { get; set; } + + public string RequestedRole { get; set; } + + public string Email { get; set; } + + public string Name { get; set; } + + public string Surname { get; set; } + + public string MentorIds { get; set; } = string.Empty; + + public string CourseName { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestRejectedEvent.cs b/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestRejectedEvent.cs new file mode 100644 index 000000000..a5d314f38 --- /dev/null +++ b/HwProj.NotificationsService/HwProj.NotificationService.Events/CoursesService/RegistrationRequestRejectedEvent.cs @@ -0,0 +1,15 @@ +using HwProj.EventBus.Client; + +namespace HwProj.NotificationService.Events.CoursesService +{ + public class RegistrationRequestRejectedEvent : Event + { + public string Email { get; set; } + + public string Name { get; set; } + + public string Surname { get; set; } + + public string RejectReason { get; set; } + } +} \ No newline at end of file diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegisterEventHandler.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegisterEventHandler.cs index 322156d59..7e5bc37ef 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegisterEventHandler.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegisterEventHandler.cs @@ -11,7 +11,7 @@ namespace HwProj.NotificationsService.API.EventHandlers { - public class RegisterEventHandler : EventHandlerBase + public class RegisterEventHandler : EventHandlerBase { private readonly INotificationsRepository _notificationRepository; private readonly IEmailService _emailService; @@ -30,7 +30,7 @@ public RegisterEventHandler( _isDevelopmentEnv = env.IsDevelopment(); } - public override async Task HandleAsync(StudentRegisterEvent @event) + public override async Task HandleAsync(AuthRegisterEvent @event) { var frontendUrl = _configuration.GetSection("Notification")["Url"]; var recoveryLink = @@ -49,10 +49,7 @@ public override async Task HandleAsync(StudentRegisterEvent @event) }; if (_isDevelopmentEnv) Console.WriteLine(recoveryLink); - var addNotificationTask = _notificationRepository.AddAsync(notification); - var sendEmailTask = _emailService.SendEmailAsync(notification, @event.Email, "HwProj"); - - await Task.WhenAll(addNotificationTask, sendEmailTask); + await _emailService.SendEmailAsync(notification, @event.Email, "HwProj"); } } } diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestConfirmationEventHandler.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestConfirmationEventHandler.cs new file mode 100644 index 000000000..e202dbd28 --- /dev/null +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestConfirmationEventHandler.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading.Tasks; +using System.Web; +using HwProj.EventBus.Client.Interfaces; +using HwProj.NotificationService.Events.CoursesService; +using HwProj.NotificationsService.API.Models; +using HwProj.NotificationsService.API.Services; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; + +namespace HwProj.NotificationsService.API.EventHandlers +{ + public class RegistrationRequestConfirmationEventHandler : EventHandlerBase + { + private readonly IEmailService _emailService; + private readonly IConfiguration _configuration; + private readonly bool _isDevelopmentEnv; + + public RegistrationRequestConfirmationEventHandler( + IEmailService emailService, + IConfiguration configuration, + IHostingEnvironment env) + { + _emailService = emailService; + _configuration = configuration; + _isDevelopmentEnv = env.IsDevelopment(); + } + + public override async Task HandleAsync(RegistrationRequestConfirmationEvent @event) + { + var frontendUrl = _configuration.GetSection("Notification")["Url"]; + var confirmationLink = + $"{frontendUrl}/registrationRequests/confirm?token={HttpUtility.UrlEncode(@event.Token)}"; + + var email = new Notification + { + Sender = "CourseService", + Body = $"{@event.Name} {@event.Surname}, для подачи заявки подтвердите адрес электронной почты.

" + + $"Перейдите по ссылке
Подтвердить почту

" + + "Если вы не отправляли заявку, проигнорируйте это письмо.", + Category = CategoryState.Profile, + Date = DateTime.UtcNow, + HasSeen = false, + Owner = string.Empty + }; + + if (_isDevelopmentEnv) Console.WriteLine(confirmationLink); + + await _emailService.SendEmailAsync(email, @event.Email, "HwProj"); + } + } +} diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestCreatedEventHandler.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestCreatedEventHandler.cs new file mode 100644 index 000000000..8e8fbf8cc --- /dev/null +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestCreatedEventHandler.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using HwProj.AuthService.Client; +using HwProj.EventBus.Client.Interfaces; +using HwProj.NotificationService.Events.CoursesService; +using HwProj.NotificationsService.API.Models; +using HwProj.NotificationsService.API.Repositories; +using HwProj.NotificationsService.API.Services; +using Microsoft.Extensions.Configuration; + +namespace HwProj.NotificationsService.API.EventHandlers +{ + public class RegistrationRequestCreatedEventHandler : EventHandlerBase + { + private readonly INotificationsRepository _notificationsRepository; + private readonly INotificationSettingsService _settingsService; + private readonly IAuthServiceClient _authServiceClient; + private readonly IConfigurationSection _configuration; + private readonly IEmailService _emailService; + + public RegistrationRequestCreatedEventHandler( + INotificationsRepository notificationsRepository, + INotificationSettingsService settingsService, + IAuthServiceClient authServiceClient, + IConfiguration configuration, + IEmailService emailService + ) + { + _notificationsRepository = notificationsRepository; + _settingsService = settingsService; + _authServiceClient = authServiceClient; + _configuration = configuration.GetSection("Notification"); + _emailService = emailService; + } + + public override async Task HandleAsync(RegistrationRequestCreatedEvent @event) + { + var url = _configuration["Url"]; + var lecturers = string.IsNullOrWhiteSpace(@event.MentorIds) + ? await _authServiceClient.GetAllLecturers() + : await _authServiceClient.GetAccountsData(@event.MentorIds.Split('/')); + + var roleText = @event.RequestedRole == "Lecturer" + ? "преподавателя" + : "студента"; + + foreach (var lecturer in lecturers.Where(x => x != null)) + { + var setting = await _settingsService.GetAsync(lecturer.UserId, + NotificationsSettingCategory.NewCourseMateCategory); + if (!setting.IsEnabled) + { + continue; + } + + var body = @event.CourseId != null + ? $"Поступила новая заявка на регистрацию студента в курсе " + + $"{@event.CourseName} " + + $"от {@event.Surname} {@event.Name} ({@event.Email})." + : $"Поступила новая заявка на регистрацию {roleText} в общем пуле " + + $"от {@event.Surname} {@event.Name} ({@event.Email})."; + + var notification = new Notification + { + Sender = "CourseService", + Body = body, + Category = CategoryState.Courses, + Date = DateTime.UtcNow, + HasSeen = false, + Owner = lecturer.UserId + }; + + var subject = @event.CourseId != null + ? $"Новая заявка в курс {@event.CourseName}" + : "Новая заявка на регистрацию"; + + var addNotificationTask = _notificationsRepository.AddAsync(notification); + var sendEmailTask = _emailService.SendEmailAsync(notification, lecturer.Email, subject); + + await Task.WhenAll(addNotificationTask, sendEmailTask); + } + } + } +} \ No newline at end of file diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestRejectedEventHandler.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestRejectedEventHandler.cs new file mode 100644 index 000000000..24cf0b308 --- /dev/null +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/EventHandlers/RegistrationRequestRejectedEventHandler.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using HwProj.EventBus.Client.Interfaces; +using HwProj.NotificationService.Events.CoursesService; +using HwProj.NotificationsService.API.Models; +using HwProj.NotificationsService.API.Services; + +namespace HwProj.NotificationsService.API.EventHandlers +{ + public class RegistrationRequestRejectedEventHandler : EventHandlerBase + { + private readonly IEmailService _emailService; + + public RegistrationRequestRejectedEventHandler(IEmailService emailService) + { + _emailService = emailService; + } + + public override async Task HandleAsync(RegistrationRequestRejectedEvent @event) + { + var reasonBlock = string.IsNullOrWhiteSpace(@event.RejectReason) + ? string.Empty + : $"

Причина отклонения: {@event.RejectReason}"; + + var email = new Notification + { + Sender = "CourseService", + Body = $"{@event.Name} {@event.Surname}, ваша заявка на регистрацию была отклонена. {reasonBlock}", + Category = CategoryState.Profile + }; + + await _emailService.SendEmailAsync(email, @event.Email, "HwProj"); + } + } +} \ No newline at end of file diff --git a/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs b/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs index f15b72ff4..c1dac0787 100644 --- a/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs +++ b/HwProj.NotificationsService/HwProj.NotificationsService.API/Startup.cs @@ -35,7 +35,7 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddEventBus(Configuration); - services.AddTransient, RegisterEventHandler>(); + services.AddTransient, RegisterEventHandler>(); services.AddTransient, RateEventHandler>(); services.AddTransient, StudentPassTaskEventHandler>(); services.AddTransient, UpdateHomeworkEventHandler>(); @@ -48,6 +48,9 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient, InviteLecturerEventHandler>(); services.AddTransient, NewCourseMateHandler>(); services.AddTransient, PasswordRecoveryEventHandler>(); + services.AddTransient, RegistrationRequestConfirmationEventHandler>(); + services.AddTransient, RegistrationRequestRejectedEventHandler>(); + services.AddTransient, RegistrationRequestCreatedEventHandler>(); services.AddSingleton(); services.AddHttpClient(); @@ -67,7 +70,7 @@ public void Configure(IApplicationBuilder app, IHostEnvironment env, IEventBus e { using (var eventBustSubscriber = eventBus.CreateSubscriber()) { - eventBustSubscriber.Subscribe(); + eventBustSubscriber.Subscribe(); eventBustSubscriber.Subscribe(); eventBustSubscriber.Subscribe(); eventBustSubscriber.Subscribe(); @@ -80,6 +83,9 @@ public void Configure(IApplicationBuilder app, IHostEnvironment env, IEventBus e eventBustSubscriber.Subscribe(); eventBustSubscriber.Subscribe(); eventBustSubscriber.Subscribe(); + eventBustSubscriber.Subscribe(); + eventBustSubscriber.Subscribe(); + eventBustSubscriber.Subscribe(); } if (env.IsDevelopment()) app.UseDeveloperExceptionPage(); diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/Properties/launchSettings.json b/HwProj.SolutionsService/HwProj.SolutionsService.API/Properties/launchSettings.json index b4956d025..6e3ca1631 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/Properties/launchSettings.json +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/Properties/launchSettings.json @@ -4,7 +4,9 @@ "HwProj.SolutionsService.API": { "commandName": "Project", "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" + "ASPNETCORE_ENVIRONMENT": "Development", + "OPENSSL_CONF": "", + "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT": "1" }, "applicationUrl": "http://localhost:5007" }, diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/appsettings.json b/HwProj.SolutionsService/HwProj.SolutionsService.API/appsettings.json index e00816874..288bcf061 100644 --- a/HwProj.SolutionsService/HwProj.SolutionsService.API/appsettings.json +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/appsettings.json @@ -1,7 +1,7 @@ { "ConnectionStrings": { "DefaultConnectionForWindows": "Server=(localdb)\\mssqllocaldb;Database=SolutionsServiceDB;Trusted_Connection=True;", - "DefaultConnectionForLinux": "Server=localhost,1433;Database=SolutionsServiceDB;User ID=SA;Password=password_1234;" + "DefaultConnectionForLinux": "Server=localhost,1433;Database=SolutionsServiceDB;User ID=SA;Password=password_1234;TrustServerCertificate=True;Encrypt=False;" }, "Logging": { "LogLevel": { diff --git a/HwProj.SolutionsService/HwProj.SolutionsService.API/runtimeconfig.template.json b/HwProj.SolutionsService/HwProj.SolutionsService.API/runtimeconfig.template.json new file mode 100644 index 000000000..657711ad7 --- /dev/null +++ b/HwProj.SolutionsService/HwProj.SolutionsService.API/runtimeconfig.template.json @@ -0,0 +1,7 @@ +{ + "runtimeOptions": { + "configProperties": { + "System.Globalization.Invariant": true + } + } +} diff --git a/hwproj.front/package-lock.json b/hwproj.front/package-lock.json index 1c7b32e30..b6f4352cf 100644 --- a/hwproj.front/package-lock.json +++ b/hwproj.front/package-lock.json @@ -4977,6 +4977,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/addons/-/addons-6.5.16.tgz", "integrity": "sha512-p3DqQi+8QRL5k7jXhXmJZLsE/GqHqyY6PcoA1oNTJr0try48uhTGUOYkgzmqtDaa/qPFO5LP+xCPzZXckGtquQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/api": "6.5.16", @@ -5004,12 +5005,14 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/api": { "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/api/-/api-6.5.16.tgz", "integrity": "sha512-HOsuT8iomqeTMQJrRx5U8nsC7lJTwRr1DhdD0SzlqL4c80S/7uuCy4IZvOt4sYQjOzW5fOo/kamcoBXyLproTA==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/channels": "6.5.16", @@ -5043,6 +5046,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/builder-webpack4": { @@ -5441,6 +5445,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/channels/-/channels-6.5.16.tgz", "integrity": "sha512-VylzaWQZaMozEwZPJdyJoz+0jpDa8GRyaqu9TGG6QGv+KU5POoZaGLDkRE7TzWkyyP0KQLo80K99MssZCpgSeg==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2", @@ -5500,6 +5505,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.5.16.tgz", "integrity": "sha512-pxcNaCj3ItDdicPTXTtmYJE3YC1SjxFrBmHcyrN+nffeNyiMuViJdOOZzzzucTUG0wcOOX8jaSyak+nnHg5H1Q==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2", @@ -5514,6 +5520,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/components/-/components-6.5.16.tgz", "integrity": "sha512-LzBOFJKITLtDcbW9jXl0/PaG+4xAz25PK8JxPZpIALbmOpYWOAPcO6V9C2heX6e6NgWFMUxjplkULEk9RCQMNA==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -5538,6 +5545,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/core": { @@ -5704,6 +5712,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.5.16.tgz", "integrity": "sha512-qMZQwmvzpH5F2uwNUllTPg6eZXr2OaYZQRRN8VZJiuorZzDNdAFmiVWMWdkThwmyLEJuQKXxqCL8lMj/7PPM+g==", + "dev": true, "license": "MIT", "dependencies": { "core-js": "^3.8.2" @@ -5804,6 +5813,7 @@ "version": "0.0.2--canary.4566f4d.1", "resolved": "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.4566f4d.1.tgz", "integrity": "sha512-9OVvMVh3t9znYZwb0Svf/YQoxX2gVOeQTGe2bses2yj+a3+OJnCrUF3/hGv6Em7KujtOdL2LL+JnG49oMVGFgQ==", + "dev": true, "license": "MIT", "dependencies": { "lodash": "^4.17.15" @@ -6276,6 +6286,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/router/-/router-6.5.16.tgz", "integrity": "sha512-ZgeP8a5YV/iuKbv31V8DjPxlV4AzorRiR8OuSt/KqaiYXNXlOoQDz/qMmiNcrshrfLpmkzoq7fSo4T8lWo2UwQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -6297,12 +6308,14 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz", "integrity": "sha512-SWeszlsiPsMI0Ps0jVNtH64cI5c0UF3f7KgjVKJoNP30crQ6wUSddY2hsdeczZXEKVJGEn50Q60flcGsQGIcrg==", + "dev": true, "license": "ISC", "dependencies": { "core-js": "^3.6.5", @@ -6319,6 +6332,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -6332,6 +6346,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -6344,6 +6359,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -6359,6 +6375,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -6442,6 +6459,7 @@ "version": "6.5.16", "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-6.5.16.tgz", "integrity": "sha512-hNLctkjaYLRdk1+xYTkC1mg4dYz2wSv6SqbLpcKMbkPHTE0ElhddGPHQqB362md/w9emYXNkt1LSMD8Xk9JzVQ==", + "dev": true, "license": "MIT", "dependencies": { "@storybook/client-logger": "6.5.16", @@ -6462,6 +6480,7 @@ "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, "license": "MIT" }, "node_modules/@storybook/ui": { @@ -7088,6 +7107,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/is-function/-/is-function-1.0.3.tgz", "integrity": "sha512-/CLhCW79JUeLKznI6mbVieGbl4QU5Hfn+6udw1YHZoofASjbQ5zaP5LzAUZYDpRYEjS4/P+DhEgyJ/PQmGGTWw==", + "dev": true, "license": "MIT" }, "node_modules/@types/isomorphic-fetch": { @@ -7456,6 +7476,7 @@ "version": "1.18.8", "resolved": "https://registry.npmjs.org/@types/webpack-env/-/webpack-env-1.18.8.tgz", "integrity": "sha512-G9eAoJRMLjcvN4I08wB5I7YofOb/kaJNd5uoCMX+LbKXTPCF+ZIHuqTnFaK9Jz1rgs035f9JUPUhNFtqgucy/A==", + "dev": true, "license": "MIT" }, "node_modules/@types/webpack-sources": { @@ -18027,6 +18048,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.2.tgz", "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "dev": true, "license": "MIT" }, "node_modules/is-generator-function": { @@ -18149,6 +18171,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -18226,6 +18249,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -18697,13 +18721,6 @@ "node": ">= 10.13.0" } }, - "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT", - "peer": true - }, "node_modules/js-sha3": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", @@ -19343,6 +19360,7 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", + "dev": true, "license": "MIT" }, "node_modules/map-visit": { @@ -20535,6 +20553,7 @@ "version": "1.11.3", "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", + "dev": true, "license": "MIT", "dependencies": { "map-or-similar": "^1.5.0" @@ -22501,6 +22520,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -22703,6 +22723,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -22855,18 +22876,6 @@ "node": ">=6" } }, - "node_modules/popper.js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", - "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/portable-fetch": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/portable-fetch/-/portable-fetch-3.0.0.tgz", @@ -27164,6 +27173,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -28040,6 +28050,7 @@ "version": "2.14.4", "resolved": "https://registry.npmjs.org/store2/-/store2-2.14.4.tgz", "integrity": "sha512-srTItn1GOvyvOycgxjAnPA63FZNwy0PTyUBFMHRM+hVFltAeoh0LmNBz9SZqUS9mMqGk8rfyWyXn3GH5ReJ8Zw==", + "dev": true, "license": "MIT" }, "node_modules/stream-browserify": { @@ -28673,6 +28684,7 @@ "version": "6.0.8", "resolved": "https://registry.npmjs.org/telejson/-/telejson-6.0.8.tgz", "integrity": "sha512-nerNXi+j8NK1QEfBHtZUN/aLdDcyupA//9kAboYLrtzZlPLpUfqbVGWb9zz91f/mIjRbAYhbgtnJHY8I1b5MBg==", + "dev": true, "license": "MIT", "dependencies": { "@types/is-function": "^1.0.0", @@ -28689,6 +28701,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/isobject/-/isobject-4.0.0.tgz", "integrity": "sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -29210,6 +29223,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -30102,6 +30116,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/util.promisify": { diff --git a/hwproj.front/src/App.tsx b/hwproj.front/src/App.tsx index 359f29fa3..71132b3dc 100644 --- a/hwproj.front/src/App.tsx +++ b/hwproj.front/src/App.tsx @@ -12,7 +12,6 @@ import TaskSolutionsPage from "./components/Solutions/TaskSolutionsPage"; import {AppBarContextAction, appBarStateManager, Header} from "./components/AppBar"; import Login from "./components/Auth/Login"; import EditCourse from "./components/Courses/EditCourse"; -import Register from "./components/Auth/Register"; import ExpertsNotebook from "./components/Experts/Notebook"; import StudentSolutionsPage from "./components/Solutions/StudentSolutionsPage"; import EditProfile from "./components/EditProfile"; @@ -24,6 +23,9 @@ import PasswordRecovery from "components/Auth/PasswordRecovery"; import AuthLayout from "./AuthLayout"; import ExpertAuthLayout from "./components/Experts/AuthLayout"; import TrackPageChanges from "TrackPageChanges"; +import RegistrationRequestForm from "@/components/RegistrationRequests/RegistrationRequestForm"; +import RegistrationRequestConfirm from "./components/RegistrationRequests/RegistrationRequestConfirm"; +import GeneralRegistrationRequests from "./components/RegistrationRequests/GeneralRegistrationRequests"; // TODO: add flux @@ -119,13 +121,15 @@ class App extends Component<{ navigate: any }, AppState> { }/> }/> }/> + }/> }/> }/> }/> - }/> + }/> }/> }/> + }/> }/> }/> diff --git a/hwproj.front/src/api/ApiSingleton.ts b/hwproj.front/src/api/ApiSingleton.ts index 1886ef4ce..b7c01d885 100644 --- a/hwproj.front/src/api/ApiSingleton.ts +++ b/hwproj.front/src/api/ApiSingleton.ts @@ -9,6 +9,7 @@ import { StatisticsApi, SystemApi, FilesApi, + RegistrationRequestsApi, CourseGroupsApi } from "."; import AuthService from "../services/AuthService"; @@ -29,6 +30,7 @@ class Api { readonly authService: AuthService; readonly customFilesApi: CustomFilesApi; readonly filesApi: FilesApi; + readonly registrationRequestsApi: RegistrationRequestsApi; constructor( accountApi: AccountApi, @@ -43,7 +45,8 @@ class Api { systemApi: SystemApi, authService: AuthService, customFilesApi: CustomFilesApi, - filesApi: FilesApi + filesApi: FilesApi, + registrationRequestsApi: RegistrationRequestsApi, ) { this.accountApi = accountApi; this.expertsApi = expertsApi; @@ -58,6 +61,7 @@ class Api { this.authService = authService; this.customFilesApi = customFilesApi; this.filesApi = filesApi; + this.registrationRequestsApi = registrationRequestsApi; } } @@ -91,6 +95,7 @@ ApiSingleton = new Api( new SystemApi({basePath: basePath}), authService, new CustomFilesApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), - new FilesApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}) + new FilesApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), + new RegistrationRequestsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), ); export default ApiSingleton; \ No newline at end of file diff --git a/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index e42934f1c..a81cbe120 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -299,6 +299,19 @@ export enum CategoryState { NUMBER_2 = 2, NUMBER_3 = 3 } +/** + * + * @export + * @interface ConfirmRegistrationRequestViewModel + */ +export interface ConfirmRegistrationRequestViewModel { + /** + * + * @type {string} + * @memberof ConfirmRegistrationRequestViewModel + */ + token: string; +} /** * * @export @@ -1435,6 +1448,86 @@ export interface HomeworksGroupUserTaskSolutions { */ homeworkSolutions?: Array; } +/** + * + * @export + * @interface InitRegistrationRequestViewModel + */ +export interface InitRegistrationRequestViewModel { + /** + * + * @type {string} + * @memberof InitRegistrationRequestViewModel + */ + email: string; + /** + * + * @type {string} + * @memberof InitRegistrationRequestViewModel + */ + name: string; + /** + * + * @type {string} + * @memberof InitRegistrationRequestViewModel + */ + surname: string; + /** + * + * @type {string} + * @memberof InitRegistrationRequestViewModel + */ + middleName?: string; + /** + * + * @type {number} + * @memberof InitRegistrationRequestViewModel + */ + courseId?: number; + /** + * + * @type {RequestedRole} + * @memberof InitRegistrationRequestViewModel + */ + requestedRole?: RequestedRole; + /** + * + * @type {string} + * @memberof InitRegistrationRequestViewModel + */ + description?: string; + /** + * + * @type {string} + * @memberof InitRegistrationRequestViewModel + */ + preferredLecturerEmail?: string; +} +/** + * + * @export + * @interface Int64Result + */ +export interface Int64Result { + /** + * + * @type {number} + * @memberof Int64Result + */ + value?: number; + /** + * + * @type {boolean} + * @memberof Int64Result + */ + succeeded?: boolean; + /** + * + * @type {Array} + * @memberof Int64Result + */ + errors?: Array; +} /** * * @export @@ -1732,6 +1825,15 @@ export interface PostTaskViewModel { */ criteria?: Array; } +/** + * + * @export + * @interface ProblemDetails + */ +export interface ProblemDetails { + [key: string]: any; + +} /** * * @export @@ -1841,33 +1943,130 @@ export interface RegisterExpertViewModel { /** * * @export - * @interface RegisterViewModel + * @interface RegistrationRequestDto */ -export interface RegisterViewModel { +export interface RegistrationRequestDto { + /** + * + * @type {number} + * @memberof RegistrationRequestDto + */ + id?: number; /** * * @type {string} - * @memberof RegisterViewModel + * @memberof RegistrationRequestDto */ - name: string; + description?: string; /** * * @type {string} - * @memberof RegisterViewModel + * @memberof RegistrationRequestDto */ - surname: string; + preferredLecturerEmail?: string; + /** + * + * @type {number} + * @memberof RegistrationRequestDto + */ + courseId?: number; + /** + * + * @type {string} + * @memberof RegistrationRequestDto + */ + requestedRole?: string; + /** + * + * @type {string} + * @memberof RegistrationRequestDto + */ + email?: string; + /** + * + * @type {string} + * @memberof RegistrationRequestDto + */ + name?: string; /** * * @type {string} - * @memberof RegisterViewModel + * @memberof RegistrationRequestDto + */ + surname?: string; + /** + * + * @type {string} + * @memberof RegistrationRequestDto */ middleName?: string; /** * * @type {string} - * @memberof RegisterViewModel + * @memberof RegistrationRequestDto */ - email: string; + status?: string; + /** + * + * @type {Date} + * @memberof RegistrationRequestDto + */ + createdAtUtc?: Date; + /** + * + * @type {Date} + * @memberof RegistrationRequestDto + */ + updatedAtUtc?: Date; + /** + * + * @type {Date} + * @memberof RegistrationRequestDto + */ + reviewedAtUtc?: Date; + /** + * + * @type {string} + * @memberof RegistrationRequestDto + */ + reviewedByUserId?: string; + /** + * + * @type {string} + * @memberof RegistrationRequestDto + */ + rejectReason?: string; + /** + * + * @type {string} + * @memberof RegistrationRequestDto + */ + resolvedUserId?: string; +} +/** + * + * @export + * @interface RegistrationRequestDtoArrayResult + */ +export interface RegistrationRequestDtoArrayResult { + /** + * + * @type {Array} + * @memberof RegistrationRequestDtoArrayResult + */ + value?: Array; + /** + * + * @type {boolean} + * @memberof RegistrationRequestDtoArrayResult + */ + succeeded?: boolean; + /** + * + * @type {Array} + * @memberof RegistrationRequestDtoArrayResult + */ + errors?: Array; } /** * @@ -1882,6 +2081,15 @@ export interface RequestPasswordRecoveryViewModel { */ email: string; } +/** + * + * @export + * @enum {string} + */ +export enum RequestedRole { + NUMBER_0 = 0, + NUMBER_1 = 1 +} /** * * @export @@ -1932,6 +2140,19 @@ export interface Result { */ errors?: Array; } +/** + * + * @export + * @interface ReviewRegistrationRequestViewModel + */ +export interface ReviewRegistrationRequestViewModel { + /** + * + * @type {string} + * @memberof ReviewRegistrationRequestViewModel + */ + rejectReason?: string; +} /** * * @export @@ -2323,6 +2544,31 @@ export interface StatisticsLecturersModel { */ numberOfCheckedUniqueSolutions?: number; } +/** + * + * @export + * @interface StringResult + */ +export interface StringResult { + /** + * + * @type {string} + * @memberof StringResult + */ + value?: string; + /** + * + * @type {boolean} + * @memberof StringResult + */ + succeeded?: boolean; + /** + * + * @type {Array} + * @memberof StringResult + */ + errors?: Array; +} /** * * @export @@ -3247,41 +3493,6 @@ export const AccountApiFetchParamCreator = function (configuration?: Configurati options: localVarRequestOptions, }; }, - /** - * - * @param {RegisterViewModel} [body] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - accountRegister(body?: RegisterViewModel, options: any = {}): FetchArgs { - const localVarPath = `/api/Account/register`; - const localVarUrlObj = url.parse(localVarPath, true); - const localVarRequestOptions = Object.assign({ method: 'POST' }, options); - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication Bearer required - if (configuration && configuration.apiKey) { - const localVarApiKeyValue = typeof configuration.apiKey === 'function' - ? configuration.apiKey("Authorization") - : configuration.apiKey; - localVarHeaderParameter["Authorization"] = localVarApiKeyValue; - } - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); - // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 - localVarUrlObj.search = null; - localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); - const needsSerialization = ("RegisterViewModel" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; - localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); - - return { - url: url.format(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * * @param {RequestPasswordRecoveryViewModel} [body] @@ -3520,24 +3731,6 @@ export const AccountApiFp = function(configuration?: Configuration) { }); }; }, - /** - * - * @param {RegisterViewModel} [body] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - accountRegister(body?: RegisterViewModel, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { - const localVarFetchArgs = AccountApiFetchParamCreator(configuration).accountRegister(body, options); - return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { - return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { - if (response.status >= 200 && response.status < 300) { - return response.json(); - } else { - throw response; - } - }); - }; - }, /** * * @param {RequestPasswordRecoveryViewModel} [body] @@ -3661,15 +3854,6 @@ export const AccountApiFactory = function (configuration?: Configuration, fetch? accountRefreshToken(options?: any) { return AccountApiFp(configuration).accountRefreshToken(options)(fetch, basePath); }, - /** - * - * @param {RegisterViewModel} [body] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - accountRegister(body?: RegisterViewModel, options?: any) { - return AccountApiFp(configuration).accountRegister(body, options)(fetch, basePath); - }, /** * * @param {RequestPasswordRecoveryViewModel} [body] @@ -3794,17 +3978,6 @@ export class AccountApi extends BaseAPI { return AccountApiFp(this.configuration).accountRefreshToken(options)(this.fetch, this.basePath); } - /** - * - * @param {RegisterViewModel} [body] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AccountApi - */ - public accountRegister(body?: RegisterViewModel, options?: any) { - return AccountApiFp(this.configuration).accountRegister(body, options)(this.fetch, this.basePath); - } - /** * * @param {RequestPasswordRecoveryViewModel} [body] @@ -7697,6 +7870,482 @@ export class NotificationsApi extends BaseAPI { return NotificationsApiFp(this.configuration).notificationsMarkAsSeen(body, options)(this.fetch, this.basePath); } +} +/** + * RegistrationRequestsApi - fetch parameter creator + * @export + */ +export const RegistrationRequestsApiFetchParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {number} requestId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsApprove(requestId: number, options: any = {}): FetchArgs { + // verify required parameter 'requestId' is not null or undefined + if (requestId === null || requestId === undefined) { + throw new RequiredError('requestId','Required parameter requestId was null or undefined when calling registrationRequestsApprove.'); + } + const localVarPath = `/api/RegistrationRequests/{requestId}/approve` + .replace(`{${"requestId"}}`, encodeURIComponent(String(requestId))); + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {ConfirmRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsConfirm(body?: ConfirmRegistrationRequestViewModel, options: any = {}): FetchArgs { + const localVarPath = `/api/RegistrationRequests/confirm`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + const needsSerialization = ("ConfirmRegistrationRequestViewModel" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; + localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsGetCourseRequests(courseId: number, options: any = {}): FetchArgs { + // verify required parameter 'courseId' is not null or undefined + if (courseId === null || courseId === undefined) { + throw new RequiredError('courseId','Required parameter courseId was null or undefined when calling registrationRequestsGetCourseRequests.'); + } + const localVarPath = `/api/RegistrationRequests/course/{courseId}` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsGetGeneralRequests(options: any = {}): FetchArgs { + const localVarPath = `/api/RegistrationRequests/general`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {InitRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsInit(body?: InitRegistrationRequestViewModel, options: any = {}): FetchArgs { + const localVarPath = `/api/RegistrationRequests/init`; + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + const needsSerialization = ("InitRegistrationRequestViewModel" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; + localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {number} requestId + * @param {ReviewRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsReject(requestId: number, body?: ReviewRegistrationRequestViewModel, options: any = {}): FetchArgs { + // verify required parameter 'requestId' is not null or undefined + if (requestId === null || requestId === undefined) { + throw new RequiredError('requestId','Required parameter requestId was null or undefined when calling registrationRequestsReject.'); + } + const localVarPath = `/api/RegistrationRequests/{requestId}/reject` + .replace(`{${"requestId"}}`, encodeURIComponent(String(requestId))); + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'POST' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + const needsSerialization = ("ReviewRegistrationRequestViewModel" !== "string") || localVarRequestOptions.headers['Content-Type'] === 'application/json'; + localVarRequestOptions.body = needsSerialization ? JSON.stringify(body || {}) : (body || ""); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * RegistrationRequestsApi - functional programming interface + * @export + */ +export const RegistrationRequestsApiFp = function(configuration?: Configuration) { + return { + /** + * + * @param {number} requestId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsApprove(requestId: number, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = RegistrationRequestsApiFetchParamCreator(configuration).registrationRequestsApprove(requestId, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + /** + * + * @param {ConfirmRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsConfirm(body?: ConfirmRegistrationRequestViewModel, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = RegistrationRequestsApiFetchParamCreator(configuration).registrationRequestsConfirm(body, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsGetCourseRequests(courseId: number, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = RegistrationRequestsApiFetchParamCreator(configuration).registrationRequestsGetCourseRequests(courseId, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsGetGeneralRequests(options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = RegistrationRequestsApiFetchParamCreator(configuration).registrationRequestsGetGeneralRequests(options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + /** + * + * @param {InitRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsInit(body?: InitRegistrationRequestViewModel, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = RegistrationRequestsApiFetchParamCreator(configuration).registrationRequestsInit(body, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + /** + * + * @param {number} requestId + * @param {ReviewRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsReject(requestId: number, body?: ReviewRegistrationRequestViewModel, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise { + const localVarFetchArgs = RegistrationRequestsApiFetchParamCreator(configuration).registrationRequestsReject(requestId, body, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, + } +}; + +/** + * RegistrationRequestsApi - factory interface + * @export + */ +export const RegistrationRequestsApiFactory = function (configuration?: Configuration, fetch?: FetchAPI, basePath?: string) { + return { + /** + * + * @param {number} requestId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsApprove(requestId: number, options?: any) { + return RegistrationRequestsApiFp(configuration).registrationRequestsApprove(requestId, options)(fetch, basePath); + }, + /** + * + * @param {ConfirmRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsConfirm(body?: ConfirmRegistrationRequestViewModel, options?: any) { + return RegistrationRequestsApiFp(configuration).registrationRequestsConfirm(body, options)(fetch, basePath); + }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsGetCourseRequests(courseId: number, options?: any) { + return RegistrationRequestsApiFp(configuration).registrationRequestsGetCourseRequests(courseId, options)(fetch, basePath); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsGetGeneralRequests(options?: any) { + return RegistrationRequestsApiFp(configuration).registrationRequestsGetGeneralRequests(options)(fetch, basePath); + }, + /** + * + * @param {InitRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsInit(body?: InitRegistrationRequestViewModel, options?: any) { + return RegistrationRequestsApiFp(configuration).registrationRequestsInit(body, options)(fetch, basePath); + }, + /** + * + * @param {number} requestId + * @param {ReviewRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + registrationRequestsReject(requestId: number, body?: ReviewRegistrationRequestViewModel, options?: any) { + return RegistrationRequestsApiFp(configuration).registrationRequestsReject(requestId, body, options)(fetch, basePath); + }, + }; +}; + +/** + * RegistrationRequestsApi - object-oriented interface + * @export + * @class RegistrationRequestsApi + * @extends {BaseAPI} + */ +export class RegistrationRequestsApi extends BaseAPI { + /** + * + * @param {number} requestId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof RegistrationRequestsApi + */ + public registrationRequestsApprove(requestId: number, options?: any) { + return RegistrationRequestsApiFp(this.configuration).registrationRequestsApprove(requestId, options)(this.fetch, this.basePath); + } + + /** + * + * @param {ConfirmRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof RegistrationRequestsApi + */ + public registrationRequestsConfirm(body?: ConfirmRegistrationRequestViewModel, options?: any) { + return RegistrationRequestsApiFp(this.configuration).registrationRequestsConfirm(body, options)(this.fetch, this.basePath); + } + + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof RegistrationRequestsApi + */ + public registrationRequestsGetCourseRequests(courseId: number, options?: any) { + return RegistrationRequestsApiFp(this.configuration).registrationRequestsGetCourseRequests(courseId, options)(this.fetch, this.basePath); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof RegistrationRequestsApi + */ + public registrationRequestsGetGeneralRequests(options?: any) { + return RegistrationRequestsApiFp(this.configuration).registrationRequestsGetGeneralRequests(options)(this.fetch, this.basePath); + } + + /** + * + * @param {InitRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof RegistrationRequestsApi + */ + public registrationRequestsInit(body?: InitRegistrationRequestViewModel, options?: any) { + return RegistrationRequestsApiFp(this.configuration).registrationRequestsInit(body, options)(this.fetch, this.basePath); + } + + /** + * + * @param {number} requestId + * @param {ReviewRegistrationRequestViewModel} [body] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof RegistrationRequestsApi + */ + public registrationRequestsReject(requestId: number, body?: ReviewRegistrationRequestViewModel, options?: any) { + return RegistrationRequestsApiFp(this.configuration).registrationRequestsReject(requestId, body, options)(this.fetch, this.basePath); + } + } /** * SolutionsApi - fetch parameter creator diff --git a/hwproj.front/src/components/AppBar.tsx b/hwproj.front/src/components/AppBar.tsx index bbd1e9e99..ac7e295af 100644 --- a/hwproj.front/src/components/AppBar.tsx +++ b/hwproj.front/src/components/AppBar.tsx @@ -163,6 +163,13 @@ export const Header: React.FC = (props: AppBarProps) => { К списку экспертов + + + Заявки на регистрацию + + void; + onLogin(returnUrl: string | null): void; } interface ILoginState { @@ -46,6 +46,22 @@ const useStyles = makeStyles((theme) => ({ const Login: FC = (props) => { const [searchParams] = useSearchParams() const returnUrl = searchParams.get("returnUrl") + + const getCourseIdFromReturnUrl = (url: string | null) => { + if (!url) return undefined; + + const match = url.match(/^\/courses\/(\d+)(?:\/|$)/); + if (!match) return undefined; + + const parsed = Number(match[1]); + return Number.isNaN(parsed) ? undefined : parsed; + }; + + const courseIdFromReturnUrl = getCourseIdFromReturnUrl(returnUrl); + const isCourseBoundEntry = courseIdFromReturnUrl !== undefined; + const registerLink = isCourseBoundEntry + ? `/register?courseId=${courseIdFromReturnUrl}` + : "/register"; const classes = useStyles() const [loginState, setLoginState] = useState({ email: '', @@ -84,7 +100,7 @@ const Login: FC = (props) => { isLogin: result.isLogin, })) } - } catch (e) { + } catch { setLoginState(prevState => ({ ...prevState, error: ['Сервис недоступен'], @@ -94,7 +110,6 @@ const Login: FC = (props) => { } const handleChangeEmail = (e: React.ChangeEvent) => { - e.persist() setLoginState((prevState) => ({ ...prevState, email: e.target.value @@ -104,7 +119,6 @@ const Login: FC = (props) => { } const handleChangePassword = (e: React.ChangeEvent) => { - e.persist() setLoginState((prevState) => ({ ...prevState, password: e.target.value @@ -114,7 +128,7 @@ const Login: FC = (props) => { const headerStyles: React.CSSProperties = {marginRight: "9.5rem"}; if (loginState.isLogin) { - return ; + return ; } if (loginState.error) { @@ -123,10 +137,11 @@ const Login: FC = (props) => { return ( - + + + + + @@ -139,6 +154,13 @@ const Login: FC = (props) => { {loginState.error} } + {isCourseBoundEntry && ( + + + Для доступа к курсу сначала войдите в систему или подайте заявку на регистрацию. + + + )}
handleSubmit(e)} className={classes.form}> @@ -149,7 +171,7 @@ const Login: FC = (props) => { label="Электронная почта" variant="outlined" margin="normal" - name={loginState.email} + value={loginState.email} onChange={handleChangeEmail} error={emailError !== ""} helperText={emailError} @@ -166,7 +188,7 @@ const Login: FC = (props) => { value={loginState.password} onChange={handleChangePassword} /> - + Забыли пароль? @@ -189,11 +211,11 @@ const Login: FC = (props) => { style={{paddingTop: 15}} spacing={1}> - Впервые тут? + {isCourseBoundEntry ? "Нет аккаунта?" : "Впервые тут?"} - + - Регистрация + {isCourseBoundEntry ? "Подать заявку на вступление" : "Регистрация"} diff --git a/hwproj.front/src/components/Auth/Register.tsx b/hwproj.front/src/components/Auth/Register.tsx deleted file mode 100644 index fc27eaff2..000000000 --- a/hwproj.front/src/components/Auth/Register.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React, {FC, useState} from "react"; -import TextField from "@material-ui/core/TextField"; -import Button from "@material-ui/core/Button"; -import Typography from "@material-ui/core/Typography"; -import {Navigate} from "react-router-dom"; -import ApiSingleton from "../../api/ApiSingleton"; -import {RegisterViewModel} from "../../api/"; -import "./Styles/Register.css"; -import Container from "@material-ui/core/Container"; -import Grid from "@material-ui/core/Grid"; -import {makeStyles} from '@material-ui/core/styles'; -import LockOutlinedIcon from "@material-ui/icons/LockOutlined"; -import Avatar from "@material-ui/core/Avatar"; -import ValidationUtils from "../Utils/ValidationUtils"; -import {Alert, AlertTitle} from "@mui/material"; - -interface ICommonState { - error: string[]; - isRegistered: boolean; -} - -const useStyles = makeStyles((theme) => ({ - paper: { - marginTop: theme.spacing(3), - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - }, - avatar: { - margin: theme.spacing(1), - }, - form: { - marginTop: theme.spacing(3), - width: '100%' - }, - button: { - marginTop: theme.spacing(1) - }, -})) - -const Register: FC = () => { - - const classes = useStyles() - const [registerState, setRegisterState] = useState({ - name: "", - surname: "", - email: "", - middleName: "", - }) - - const [commonState, setCommonState] = useState({ - error: [], - isRegistered: false, - }) - const [emailError, setEmailError] = useState(""); // Состояние для ошибки электронной почты - const [isRegisterButtonDisabled, setIsRegisterButtonDisabled] = useState(false); // Состояние для блокировки кнопки - - const handleSubmit = async (e: any) => { - e.preventDefault(); - if (!ValidationUtils.isCorrectEmail(registerState.email)) { - setEmailError("Некорректный адрес электронной почты"); - setIsRegisterButtonDisabled(true); - return; - } - e.preventDefault() - try { - const registerModel: RegisterViewModel = - { - email: registerState.email.trim(), - name: registerState.name.trim(), - surname: registerState.surname.trim(), - middleName: registerState.middleName?.trim() || "" - } - const result = await ApiSingleton.authService.register(registerModel) - setCommonState((prevState) => ({ - ...prevState, - error: result!.error!, - isRegistered: result.isRegistered! - })) - } catch (e) { - setCommonState((prevState) => ({ - ...prevState, - error: ['Сервис недоступен'], - loggedIn: false - })) - } - } - - if (commonState.isRegistered) { - return -
- - Подтведите почту - Ссылка для подтверждение профиля отправлена на указанную при регистрации почту - -
-
- } - - return ( - -
- - - - - Регистрация - - {commonState.error.length > 0 && ( -

{commonState.error}

- )} - - - - { - e.persist() - setRegisterState((prevState) => ({ - ...prevState, - name: e.target.value - })) - }} - /> - - - { - e.persist() - setRegisterState((prevState) => ({ - ...prevState, - surname: e.target.value - })) - }} - /> - - - { - e.persist() - setRegisterState((prevState) => ({ - ...prevState, - middleName: e.target.value - })) - }} - /> - - - { - e.persist() - setRegisterState((prevState) => ({ - ...prevState, - email: e.target.value - })) - setEmailError(""); - setIsRegisterButtonDisabled(false); - }} - error={emailError !== ""} - helperText={emailError} - /> - - - - -
-
- ) -} - -export default Register diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index c33122808..dfc2e9582 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import {FC, useEffect, useState, useMemo} from "react"; import {useNavigate, useParams, useSearchParams} from "react-router-dom"; -import {AccountDataDto, CourseViewModel, GroupViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; +import {AccountDataDto, CourseViewModel, GroupViewModel, HomeworkViewModel, RegistrationRequestDto, StatisticsCourseMatesModel} from "@/api"; import StudentStats from "./StudentStats"; import NewCourseStudents from "./NewCourseStudents"; import ApiSingleton from "../../api/ApiSingleton"; @@ -32,11 +32,12 @@ import AssessmentIcon from '@mui/icons-material/Assessment'; import NameBuilder from "../Utils/NameBuilder"; import {QRCodeSVG} from 'qrcode.react'; import QrCode2Icon from '@mui/icons-material/QrCode2'; -import GroupIcon from '@mui/icons-material/Group'; import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import CourseRegistrationRequests from "@/components/RegistrationRequests/CourseRegistrationRequests"; +import GroupIcon from '@mui/icons-material/Group'; import Utils from "@/services/Utils"; type TabValue = "homeworks" | "stats" | "applications" @@ -55,6 +56,8 @@ interface ICourseState { newStudents: AccountDataDto[]; studentSolutions: StatisticsCourseMatesModel[]; showQrCode: boolean; + courseRegistrationRequests: RegistrationRequestDto[]; + courseRegistrationRequestsError: string[] } interface IPageState { @@ -75,7 +78,9 @@ const Course: React.FC = () => { acceptedStudents: [], newStudents: [], studentSolutions: [], - showQrCode: false + showQrCode: false, + courseRegistrationRequests: [], + courseRegistrationRequestsError: [], }) const [studentSolutions, setStudentSolutions] = useState(undefined) @@ -90,7 +95,9 @@ const Course: React.FC = () => { newStudents, acceptedStudents, courseHomeworks, - groups + courseRegistrationRequests, + courseRegistrationRequestsError, + groups, } = courseState const loadGroups = async () => { @@ -109,6 +116,11 @@ const Course: React.FC = () => { const isCourseMentor = mentors.some(t => t.userId === userId) const isSignedInCourse = newStudents!.some(cm => cm.userId === userId) + const [applicationsTabValue, setApplicationsTabValue] = useState(0) + + const hasRegisteredApplications = newStudents.length > 0 + const hasRegistrationRequestApplications = courseRegistrationRequests.length > 0 + const { courseFilesState, updateCourseUnitFiles, @@ -145,6 +157,28 @@ const Course: React.FC = () => { newToken.value && ApiSingleton.authService.refreshToken(newToken.value.accessToken!) return } + + const isActualCourseMentor = course.mentors!.some(t => t.userId === userId); + + + let loadedCourseRegistrationRequests: RegistrationRequestDto[] = []; + let loadedCourseRegistrationRequestsError: string[] = []; + + if (isActualCourseMentor) { + try { + const registrationRequestsResult = + await ApiSingleton.registrationRequestsApi.registrationRequestsGetCourseRequests(+courseId!); + + if (registrationRequestsResult.succeeded) { + loadedCourseRegistrationRequests = registrationRequestsResult.value ?? []; + } else { + loadedCourseRegistrationRequestsError = + registrationRequestsResult.errors ?? ["Не удалось загрузить заявки на регистрацию"]; + } + } catch { + loadedCourseRegistrationRequestsError = ["Сервис недоступен"]; + } + } setCourseState(prevState => ({ ...prevState, @@ -156,12 +190,14 @@ const Course: React.FC = () => { groups: course.groups || [], acceptedStudents: course.acceptedStudents!, newStudents: course.newStudents!, + courseRegistrationRequests: loadedCourseRegistrationRequests, + courseRegistrationRequestsError: loadedCourseRegistrationRequestsError, })) } useEffect(() => { setCurrentState() - }, []) + }, [courseId]) useEffect(() => { ApiSingleton.statisticsApi.statisticsGetCourseStatistics(+courseId!) @@ -358,7 +394,7 @@ const Course: React.FC = () => {
Заявки
+ label={newStudents.length + courseRegistrationRequests.length}/>
}/>} {tabValue === "homeworks" && {
} {tabValue === "applications" && showApplicationsTab && - setCurrentState()} - course={courseState.course} - students={courseState.newStudents} - courseId={courseId!} - /> + + {courseRegistrationRequestsError.length > 0 && ( + + + Ошибка + {courseRegistrationRequestsError.join(", ")} + + + )} + + + { + setApplicationsTabValue(value); + }} + > + +
Зарегистрированные
+ + + } + /> + +
Новые регистрации
+ + + } + /> +
+
+ + {applicationsTabValue === 0 && ( + + {hasRegisteredApplications ? ( + setCurrentState()} + course={courseState.course} + students={courseState.newStudents} + courseId={courseId!} + /> + ) : ( + + Нет новых заявок + Нет заявок от зарегистрированных пользователей. + + )} + + )} + + {applicationsTabValue === 1 && ( + + {courseRegistrationRequestsError.length > 0 ? ( + + Список недоступен + Заявки на регистрацию для вступления в курс сейчас не отображаются. + + ) : hasRegistrationRequestApplications ? ( + + ) : ( + + Нет новых заявок + Нет заявок на регистрацию для вступления в курс. + + )} + + )} +
} diff --git a/hwproj.front/src/components/RegistrationRequests/CourseRegistrationRequests.tsx b/hwproj.front/src/components/RegistrationRequests/CourseRegistrationRequests.tsx new file mode 100644 index 000000000..312d25155 --- /dev/null +++ b/hwproj.front/src/components/RegistrationRequests/CourseRegistrationRequests.tsx @@ -0,0 +1,156 @@ +import {RegistrationRequestDto} from "@/api"; +import {FC, useState} from "react"; +import ApiSingleton from "@/api/ApiSingleton"; +import {Alert, AlertTitle, Grid} from "@mui/material"; +import RegistrationRequestCard from "@/components/RegistrationRequests/RegistrationRequestCard"; +import RejectRegistrationRequestModal from "@/components/RegistrationRequests/RejectRegistrationRequestModal"; + +interface ICourseRegistrationRequestsProps { + requests: RegistrationRequestDto[]; + onUpdate: () => Promise | void; +} + +const CourseRegistrationRequests: FC = (props) => { + const [error, setError] = useState([]); + const [rejectingRequest, setRejectingRequest] = useState(undefined); + const [rejectError, setRejectError] = useState([]); + const [isRejectSubmitting, setIsRejectSubmitting] = useState(false); + const [processingRequestId, setProcessingRequestId] = useState(undefined); + + const currentLecturerEmail = ApiSingleton.authService.getUserEmail().toLowerCase(); + + const isPreferredForCurrentLecturer = (preferredLecturerEmail?: string) => { + return !!preferredLecturerEmail && + preferredLecturerEmail.toLowerCase() === currentLecturerEmail; + }; + + const sortedRequests = [...props.requests].sort((leftRequest, rightRequest) => { + const leftPriority = Number(isPreferredForCurrentLecturer(leftRequest.preferredLecturerEmail)); + const rightPriority = Number(isPreferredForCurrentLecturer(rightRequest.preferredLecturerEmail)); + + return rightPriority - leftPriority; + }); + + const approveRequest = async (requestId: number) => { + if (processingRequestId !== undefined || isRejectSubmitting) { + return; + } + + setProcessingRequestId(requestId); + setError([]); + + try { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsApprove(requestId); + + if (!result.succeeded) { + setError(result.errors ?? ["Не удалось принять заявку"]); + return; + } + + try { + await props.onUpdate(); + setError([]); + } catch { + setError(["Заявка принята, но не удалось обновить список"]); + } + } catch { + setError(["Сервис недоступен"]); + } finally { + setProcessingRequestId(undefined); + } + }; + + const rejectRequest = async (rejectReason?: string) => { + if (!rejectingRequest?.id || processingRequestId !== undefined || isRejectSubmitting) { + return; + } + + setProcessingRequestId(rejectingRequest.id); + setIsRejectSubmitting(true); + setRejectError([]); + + try { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsReject( + rejectingRequest.id, + {rejectReason}, + ); + + if (!result.succeeded) { + setRejectError(result.errors ?? ["Не удалось отклонить заявку"]); + return; + } + + setRejectingRequest(undefined); + + try { + await props.onUpdate(); + setError([]); + } catch { + setError(["Заявка отклонена, но не удалось обновить список"]); + } + } catch { + setRejectError(["Сервис недоступен"]); + } finally { + setIsRejectSubmitting(false); + setProcessingRequestId(undefined); + } + }; + + if (props.requests.length === 0) { + return ( + + Нет новых заявок + На данный момент все заявки на регистрацию для вступления в курс обработаны. + + ); + } + + return ( + <> + + {error.length > 0 && ( + + + Ошибка + {error.join(", ")} + + + )} + + + {sortedRequests.map((request) => ( + { + setRejectingRequest(selectedRequest); + setRejectError([]); + }} + /> + ))} + + + + { + setRejectingRequest(undefined); + setRejectError([]); + }} + onReject={rejectRequest} + /> + + ) +} + +export default CourseRegistrationRequests; \ No newline at end of file diff --git a/hwproj.front/src/components/RegistrationRequests/GeneralRegistrationRequests.tsx b/hwproj.front/src/components/RegistrationRequests/GeneralRegistrationRequests.tsx new file mode 100644 index 000000000..26a5f3c39 --- /dev/null +++ b/hwproj.front/src/components/RegistrationRequests/GeneralRegistrationRequests.tsx @@ -0,0 +1,295 @@ +import {FC, useEffect, useState} from "react"; +import {RegistrationRequestDto} from "@/api"; +import ApiSingleton from "@/api/ApiSingleton"; +import { + Alert, + AlertTitle, + Chip, + Grid, + Stack, + Typography, + Tabs, + Tab +} from "@mui/material"; +import RejectRegistrationRequestModal from "./RejectRegistrationRequestModal"; +import RegistrationRequestCard from "./RegistrationRequestCard"; +import {DotLottieReact} from "@lottiefiles/dotlottie-react"; + +const GeneralRegistrationRequests: FC = () => { + const [requests, setRequests] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState([]); + const [tabValue, setTabValue] = useState(0); + + const [rejectingRequest, setRejectingRequest] = useState(undefined); + const [rejectError, setRejectError] = useState([]); + const [isRejectSubmitting, setIsRejectSubmitting] = useState(false); + + const [processingRequestId, setProcessingRequestId] = useState(undefined); + + const currentLecturerEmail = ApiSingleton.authService.getUserEmail().toLowerCase(); + + const isPreferredForCurrentLecturer = (preferredLecturerEmail?: string) => { + return !!preferredLecturerEmail && + preferredLecturerEmail.toLowerCase() === currentLecturerEmail; + }; + + const sortRequestsByPriority = (items: RegistrationRequestDto[]) => { + return [...items].sort((leftRequest, rightRequest) => { + const leftPriority = Number(isPreferredForCurrentLecturer(leftRequest.preferredLecturerEmail)); + const rightPriority = Number(isPreferredForCurrentLecturer(rightRequest.preferredLecturerEmail)); + return rightPriority - leftPriority; + }) + } + + const studentRequests = sortRequestsByPriority( + requests.filter((request) => request.requestedRole === "Student"), + ) + const lecturerRequests = requests.filter((request) => request.requestedRole === "Lecturer"); + + const loadRequests = async () => { + setIsLoading(true); + setError([]); + + try { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsGetGeneralRequests(); + + if (result.succeeded) { + setRequests(result.value ?? []); + } else { + setRequests([]); + setError(result.errors ?? ["Не удалось загрузить заявки"]); + } + } catch { + setRequests([]); + setError(["Сервис недоступен"]); + } finally { + setIsLoading(false); + } + }; + + const refreshRequests = async () => { + try { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsGetGeneralRequests(); + if (!result.succeeded) { + return false; + } + + setRequests(result.value ?? []); + return true; + } catch { + return false; + } + } + + useEffect(() => { + if (tabValue === 0 && studentRequests.length === 0 && lecturerRequests.length > 0) { + setTabValue(1); + } + }, [tabValue, studentRequests.length, lecturerRequests.length]) + + const isLecturer = ApiSingleton.authService.isLecturer(); + useEffect(() => { + if (!isLecturer) { + setIsLoading(false); + return; + } + + loadRequests(); + }, [isLecturer]); + + const approveRequest = async (requestId: number) => { + if (processingRequestId !== undefined || isRejectSubmitting) { + return; + } + + setProcessingRequestId(requestId); + setError([]); + + try { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsApprove(requestId); + if (!result.succeeded) { + setError(result.errors ?? ["Не удалось принять заявку"]); + return; + } + + const refreshed = await refreshRequests(); + if (!refreshed) { + setError(["Заявка принята, но не удалось обновить список"]); + } else { + setError([]); + } + } catch { + setError(["Сервис недоступен"]); + } finally { + setProcessingRequestId(undefined); + } + }; + + const rejectRequest = async (rejectReason?: string) => { + if (!rejectingRequest?.id || processingRequestId !== undefined || isRejectSubmitting) { + return; + } + + setProcessingRequestId(rejectingRequest.id); + setIsRejectSubmitting(true); + setRejectError([]); + + try { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsReject( + rejectingRequest.id, + {rejectReason} + ); + + if (!result.succeeded) { + setRejectError(result.errors ?? ["Не удалось отклонить заявку"]); + return; + } + + setRejectingRequest(undefined); + + const refreshed = await refreshRequests(); + if (!refreshed) { + setError(["Заявка отклонена, но не удалось обновить список"]) + } else { + setError([]); + } + } catch { + setRejectError(["Сервис недоступен"]); + } finally { + setIsRejectSubmitting(false); + setProcessingRequestId(undefined); + } + }; + + const renderRequests = (items: RegistrationRequestDto[], emptyText: string) => { + if (items.length === 0) { + return ( + + + Нет новых заявок + {emptyText} + + + ) + } + + return items.map((request) => ( + { + setRejectingRequest(selectedRequest); + setRejectError([]); + }} + /> + )) + } + + if (!isLecturer) { + return ( +
+ + Страница недоступна + Доступ только для преподавателей. + +
+ ) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + return ( +
+ + + + Общие заявки на регистрацию + + + + {error.length > 0 && ( + + + Ошибка + {error.join(", ")} + + + )} + + + { + setTabValue(value); + }} + > + +
Заявки студентов
+ + + } + /> + +
Заявки преподавателей
+ + + } + /> +
+
+ + {tabValue === 0 && ( + + {renderRequests(studentRequests, "Нет новых заявок от студентов.")} + + )} + + {tabValue === 1 && ( + + {renderRequests(lecturerRequests, "Нет новых заявок от преподавателей.")} + + )} +
+ + { + setRejectingRequest(undefined); + setRejectError([]); + }} + onReject={rejectRequest} + /> +
+ ); +} + +export default GeneralRegistrationRequests \ No newline at end of file diff --git a/hwproj.front/src/components/RegistrationRequests/RegistrationRequestCard.tsx b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestCard.tsx new file mode 100644 index 000000000..3221b894a --- /dev/null +++ b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestCard.tsx @@ -0,0 +1,95 @@ +import {RegistrationRequestDto} from "@/api"; +import {FC} from "react"; +import {Button, Card, CardActions, CardContent, Chip, Grid, Stack, Typography} from "@mui/material"; +import Utils from "@/services/Utils"; + +interface IRegistrationRequestCardProps { + request: RegistrationRequestDto; + isProcessing?: boolean; + isPreferredForCurrentLecturer?: boolean; + onApprove: (requestId: number) => void; + onReject: (request: RegistrationRequestDto) => void; +} + +const RegistrationRequestCard: FC = (props) => { + const {request, isProcessing, isPreferredForCurrentLecturer, onApprove, onReject} = props; + + return ( + + + + + + {request.surname} {request.name} + + + + + {isPreferredForCurrentLecturer && ( + + )} + + + {request.middleName && ( + + {request.middleName} + + )} + + + {request.email} + + + {request.createdAtUtc && ( + + {Utils.renderDateWithoutSeconds(request.createdAtUtc)} + + )} + + {request.description && ( + + {request.description} + + )} + + {request.preferredLecturerEmail && ( + + Предпочитаемый преподаватель: {request.preferredLecturerEmail} + + )} + + + + + + + + + ); +} + +export default RegistrationRequestCard; \ No newline at end of file diff --git a/hwproj.front/src/components/RegistrationRequests/RegistrationRequestConfirm.tsx b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestConfirm.tsx new file mode 100644 index 000000000..6be80e474 --- /dev/null +++ b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestConfirm.tsx @@ -0,0 +1,110 @@ +import ApiSingleton from "@/api/ApiSingleton"; +import {makeStyles} from "@material-ui/core/styles"; +import {FC, useEffect, useState} from "react"; +import {useSearchParams} from "react-router-dom"; +import {Alert, AlertTitle, Container, Typography} from "@mui/material"; +import Avatar from "@mui/material/Avatar"; +import MarkEmailReadIcon from "@mui/icons-material/MarkEmailRead"; +import {DotLottieReact} from "@lottiefiles/dotlottie-react"; + +interface IConfirmState { + isLoading: boolean; + isConfirmed: boolean; + error: string[]; +} + +const useStyles = makeStyles((theme) => ({ + paper: { + marginTop: theme.spacing(3), + display: "flex", + flexDirection: "column", + alignItems: "center", + }, + avatar: { + margin: theme.spacing(1), + } +})); + +const RegistrationRequestConfirm: FC = () => { + const classes = useStyles(); + const [searchParams] = useSearchParams(); + + const [state, setState] = useState({ + isLoading: true, + isConfirmed: false, + error: [], + }) + + useEffect(() => { + const token = searchParams.get("token"); + if (!token) { + setState({ + isLoading: false, + isConfirmed: false, + error: ["Некорректная ссылка подтверждения"], + }); + return; + } + + const confirm = async () => { + try { + const result = await ApiSingleton.authService.confirmRegistrationRequest({token}); + + const isConfirmed = result.isConfirmed ?? false; + setState({ + isLoading: false, + isConfirmed, + error: isConfirmed + ? [] + : result.error && result.error.length > 0 + ? result.error + : ["Не удалось подтвердить заявку"], + }); + } catch { + setState({ + isLoading: false, + isConfirmed: false, + error: ["Сервис недоступен"], + }) + } + }; + + confirm(); + }, [searchParams]); + + return ( + +
+ + + + + + Подтверждение заявки + + + {state.isLoading && } + + {!state.isLoading && state.isConfirmed && ( + + Почта подтверждена + Ваша заявка успешно подтверждена и отправлена на рассмотрение. + + )} + + {!state.isLoading && !state.isConfirmed && state.error.length > 0 && ( + + Не удалось подтвердить заявку + {state.error.join(", ")} + + )} +
+
+ ) +} + +export default RegistrationRequestConfirm; \ No newline at end of file diff --git a/hwproj.front/src/components/Auth/Register.stories.tsx b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestForm.stories.tsx similarity index 52% rename from hwproj.front/src/components/Auth/Register.stories.tsx rename to hwproj.front/src/components/RegistrationRequests/RegistrationRequestForm.stories.tsx index 517794011..c6f1c1363 100644 --- a/hwproj.front/src/components/Auth/Register.stories.tsx +++ b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestForm.stories.tsx @@ -1,8 +1,8 @@ import * as React from "react"; import { storiesOf } from "@storybook/react"; -import Register from "./Register"; +import RegistrationRequestForm from "@/components/RegistrationRequests/RegistrationRequestForm"; storiesOf("Register page", module) .add("simple", () => - + ); \ No newline at end of file diff --git a/hwproj.front/src/components/RegistrationRequests/RegistrationRequestForm.tsx b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestForm.tsx new file mode 100644 index 000000000..1bbe546e8 --- /dev/null +++ b/hwproj.front/src/components/RegistrationRequests/RegistrationRequestForm.tsx @@ -0,0 +1,319 @@ +import React, {FC, useState} from "react"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import Typography from "@material-ui/core/Typography"; +import {useSearchParams} from "react-router-dom"; +import ApiSingleton from "../../api/ApiSingleton"; +import {InitRegistrationRequestViewModel, RequestedRole} from "@/api"; +import "../Auth/Styles/Register.css"; +import Container from "@material-ui/core/Container"; +import Grid from "@material-ui/core/Grid"; +import {makeStyles} from '@material-ui/core/styles'; +import LockOutlinedIcon from "@material-ui/icons/LockOutlined"; +import Avatar from "@material-ui/core/Avatar"; +import ValidationUtils from "../Utils/ValidationUtils"; +import {Alert, AlertTitle, FormControl, InputLabel, MenuItem, Select, SelectChangeEvent} from "@mui/material"; + +interface IRegistrationRequestState { + name: string; + surname: string; + middleName: string; + email: string; + requestedRole: RequestedRole; + description: string; + preferredLecturerEmail: string; +} + +interface ICommonState { + error: string[]; + isConfirmationSent: boolean; +} + +const useStyles = makeStyles((theme) => ({ + paper: { + marginTop: theme.spacing(3), + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + avatar: { + margin: theme.spacing(1), + }, + form: { + marginTop: theme.spacing(3), + width: '100%' + }, + button: { + marginTop: theme.spacing(1) + }, +})) + +const RegistrationRequestForm: FC = () => { + const classes = useStyles(); + const [searchParams] = useSearchParams(); + + const courseIdFromQuery = searchParams.get("courseId"); + const parsedCourseId = courseIdFromQuery ? Number(courseIdFromQuery) : undefined; + const isCourseBound = parsedCourseId !== undefined && !Number.isNaN(parsedCourseId); + + const [isSubmitting, setIsSubmitting] = useState(false); + + const [requestState, setRequestState] = useState({ + name: "", + surname: "", + middleName: "", + email: "", + requestedRole: RequestedRole.NUMBER_0, + description: "", + preferredLecturerEmail: "" + }) + + const [commonState, setCommonState] = useState({ + error: [], + isConfirmationSent: false, + }) + + const [emailError, setEmailError] = useState(""); // Состояние для ошибки электронной почты + const [isSubmitButtonDisabled, setIsSubmitButtonDisabled] = useState(false); // Состояние для блокировки кнопки + const [preferredLecturerEmailError, setPreferredLecturerEmailError] = useState(""); // Состояние для ошибки электронной почты выбранного для проверки заявки преподавателя + + const effectiveRequestedRole = isCourseBound + ? RequestedRole.NUMBER_0 + : requestState.requestedRole; + + const isStudentRequest = effectiveRequestedRole === RequestedRole.NUMBER_0; + + const handleSubmit = async (e: any) => { + e.preventDefault(); + + if (isSubmitting) { + return; + } + + if (!ValidationUtils.isCorrectEmail(requestState.email)) { + setEmailError("Некорректный адрес электронной почты"); + setIsSubmitButtonDisabled(true); + return; + } + + if (isStudentRequest && requestState.preferredLecturerEmail.trim() && !ValidationUtils.isCorrectEmail(requestState.preferredLecturerEmail)) { + setPreferredLecturerEmailError("Некорректный адрес электронной почты"); + setIsSubmitButtonDisabled(true); + return; + } + + setIsSubmitting(true); + try { + const requestModel: InitRegistrationRequestViewModel = + { + email: requestState.email.trim(), + name: requestState.name.trim(), + surname: requestState.surname.trim(), + middleName: requestState.middleName?.trim() || "", + requestedRole: effectiveRequestedRole, + description: requestState.description.trim() || undefined, + preferredLecturerEmail: isStudentRequest + ? requestState.preferredLecturerEmail.trim() || undefined + : undefined, + courseId: isCourseBound ? parsedCourseId : undefined, + }; + + const result = await ApiSingleton.authService.initRegistrationRequest(requestModel); + setCommonState(_ => ({ + error: result.error ?? [], + isConfirmationSent: result.isConfirmationSent ?? false, + })) + } catch { + setCommonState((prevState) => ({ + ...prevState, + error: ['Сервис недоступен'], + isConfirmationSent: false + })) + } finally { + setIsSubmitting(false); + } + } + + if (commonState.isConfirmationSent) { + return +
+ + Подтвердите почту + {isCourseBound + ? "Ссылка для подтверждения регистрации и заявки на вступление в курс отправлена на указанную почту." + : "Ссылка для подтверждения заявки отправлена на указанную почту."} + + +
+
+ } + + return ( + +
+ + + + + + {isCourseBound ? "Регистрация и заявка на курс" : "Регистрация"} + + + {commonState.error.length > 0 && ( + + {commonState.error.join(", ")} + + )} +
+ + {!isCourseBound && ( + + + Тип заявки + + + + )} + + { + setRequestState((prevState) => ({ + ...prevState, + name: e.target.value + })) + }} + /> + + + { + setRequestState((prevState) => ({ + ...prevState, + surname: e.target.value + })) + }} + /> + + + { + setRequestState((prevState) => ({ + ...prevState, + middleName: e.target.value + })) + }} + /> + + + { + setRequestState((prevState) => ({ + ...prevState, + email: e.target.value + })) + setEmailError(""); + setIsSubmitButtonDisabled(false); + }} + error={emailError !== ""} + helperText={emailError} + /> + + + { + setRequestState((prevState) => ({ + ...prevState, + description: e.target.value + })) + }} + /> + + {!isCourseBound && isStudentRequest && ( + + { + setRequestState((prevState) => ({ + ...prevState, + preferredLecturerEmail: e.target.value, + })); + setPreferredLecturerEmailError(""); + setIsSubmitButtonDisabled(false); + }} + error={preferredLecturerEmailError !== ""} + helperText={ + preferredLecturerEmailError || + "Необязательно. Если указать преподавателя, заявку будет для него выделена" + } + /> + + )} + + +
+
+
+ ) +} + +export default RegistrationRequestForm; diff --git a/hwproj.front/src/components/RegistrationRequests/RejectRegistrationRequestModal.tsx b/hwproj.front/src/components/RegistrationRequests/RejectRegistrationRequestModal.tsx new file mode 100644 index 000000000..a537b5cc7 --- /dev/null +++ b/hwproj.front/src/components/RegistrationRequests/RejectRegistrationRequestModal.tsx @@ -0,0 +1,103 @@ +import {FC, useEffect, useState} from "react"; +import {DialogActions, Grid, TextField, Button, DialogTitle, Typography, Dialog, DialogContent} from "@mui/material"; + +interface IRejectRegistrationRequestModalProps { + isOpen: boolean; + applicantName?: string; + isSubmitting?: boolean; + error?: string[]; + onClose: () => void; + onReject: (rejectReason?: string) => void; +} + +const RejectRegistrationRequestModal: FC = (props) => { + const [rejectReason, setRejectReason] = useState(""); + + useEffect(() => { + if (!props.isOpen) { + setRejectReason(""); + } + }, [props.isOpen]); + + const handleClose = () => { + setRejectReason(""); + props.onClose(); + } + + const handleReject = () => { + props.onReject(rejectReason.trim() || undefined); + } + + return ( + { + if (props.isSubmitting || reason === "backdropClick") { + return; + } + handleClose(); + }} + aria-labelledby="reject-registration-request-dialog-title" + fullWidth + maxWidth="sm" + > + + Отклонить заявку + + + + {props.applicantName && ( + + + Заявка: {props.applicantName} + + + )} + + {props.error && props.error.length > 0 && ( + + + {props.error.join(", ")} + + + )} + + + { + setRejectReason(e.target.value); + }} + helperText="Необязательно. Комментарий увидит пользователь." + /> + + + + + + + + + ) +} + +export default RejectRegistrationRequestModal \ No newline at end of file diff --git a/hwproj.front/src/services/AuthService.ts b/hwproj.front/src/services/AuthService.ts index be0770392..fc09797c1 100644 --- a/hwproj.front/src/services/AuthService.ts +++ b/hwproj.front/src/services/AuthService.ts @@ -1,4 +1,4 @@ -import {LoginViewModel, AccountApi, RegisterViewModel} from '../api'; +import {LoginViewModel, InitRegistrationRequestViewModel, ConfirmRegistrationRequestViewModel} from '@/api'; import ApiSingleton from "../api/ApiSingleton"; import decode from "jwt-decode"; @@ -13,8 +13,6 @@ interface TokenPayload { } export default class AuthService { - client = new AccountApi(); - constructor() { this.login = this.login.bind(this); this.getProfile = this.getProfile.bind(this); @@ -35,11 +33,20 @@ export default class AuthService { } } - async register(user: RegisterViewModel) { - const result = await ApiSingleton.accountApi.accountRegister(user) + async initRegistrationRequest(user: InitRegistrationRequestViewModel) { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsInit(user); + return { + isConfirmationSent: result.succeeded, + error: result.errors, + } + } + + async confirmRegistrationRequest(user: ConfirmRegistrationRequestViewModel) { + const result = await ApiSingleton.registrationRequestsApi.registrationRequestsConfirm(user); return { - isRegistered: result.succeeded, - error: result.errors + isConfirmed: result.succeeded, + error: result.errors, + requestId: result.value, } }