From 7229a10ad559b3619407ae4e005af72ee3671833 Mon Sep 17 00:00:00 2001 From: cuqmbr Date: Wed, 28 May 2025 15:40:30 +0300 Subject: [PATCH] add account management --- .../Commands/AddAccount/AddAccountCommand.cs | 5 +- .../AddAccount/AddAccountCommandAuthorizer.cs | 3 +- .../AddAccount/AddAccountCommandHandler.cs | 28 +- .../AddAccount/AddAccountCommandValidator.cs | 38 ++- .../DeleteAccount/DeleteAccountCommand.cs | 9 + .../DeleteAccountCommandAuthorizer.cs | 32 ++ .../DeleteAccountCommandHandler.cs | 34 ++ .../DeleteAccountCommandValidator.cs | 14 + .../UpdateAccount/UpdateAccountCommand.cs | 18 ++ .../UpdateAccountCommandAuthorizer.cs | 31 ++ .../UpdateAccountCommandHandler.cs | 109 +++++++ .../UpdateAccountCommandValidator.cs | 68 ++++ .../Queries/GetAccount/GetAccountQuery.cs | 8 + .../GetAccount/GetAccountQueryAuthorizer.cs | 31 ++ .../GetAccount/GetAccountQueryHandler.cs | 51 +++ .../GetAccount/GetAccountQueryValidator.cs | 14 + .../GetAccountsPage/GetAccountsPageQuery.cs | 18 ++ .../GetAccountsPageQueryAuthorizer.cs | 31 ++ .../GetAccountsPageQueryHandler.cs | 81 +++++ .../GetAccountsPageQueryValidator.cs | 43 +++ .../ViewModels/AddAccountViewModel.cs | 2 + .../GetAccountsPageFilterViewModel.cs | 6 + .../ViewModels/UpdateAccountViewModel.cs | 14 + src/HttpApi/Controllers/IdentityController.cs | 290 +++++++++--------- 24 files changed, 813 insertions(+), 165 deletions(-) create mode 100644 src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs create mode 100644 src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandAuthorizer.cs create mode 100644 src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandHandler.cs create mode 100644 src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandValidator.cs create mode 100644 src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommand.cs create mode 100644 src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandAuthorizer.cs create mode 100644 src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandHandler.cs create mode 100644 src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandValidator.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQuery.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryAuthorizer.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryHandler.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryValidator.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQuery.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryAuthorizer.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryHandler.cs create mode 100644 src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryValidator.cs create mode 100644 src/Application/Identity/Accounts/ViewModels/GetAccountsPageFilterViewModel.cs create mode 100644 src/Application/Identity/Accounts/ViewModels/UpdateAccountViewModel.cs diff --git a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommand.cs b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommand.cs index cf5ea4f..eff0d6d 100644 --- a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommand.cs +++ b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommand.cs @@ -1,10 +1,13 @@ using cuqmbr.TravelGuide.Domain.Enums; using MediatR; -namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; +namespace cuqmbr.TravelGuide.Application.Identity + .Accounts.Commands.AddAccount; public record AddAccountCommand : IRequest { + public string Username { get; set; } + public string Email { get; set; } public string Password { get; set; } diff --git a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandAuthorizer.cs b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandAuthorizer.cs index 07ff887..2e62d6b 100644 --- a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandAuthorizer.cs +++ b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandAuthorizer.cs @@ -3,7 +3,8 @@ using cuqmbr.TravelGuide.Application.Common.Services; using cuqmbr.TravelGuide.Domain.Enums; using MediatR.Behaviors.Authorization; -namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; +namespace cuqmbr.TravelGuide.Application + .Identity.Accounts.Commands.AddAccount; public class AddAccountCommandAuthorizer : AbstractRequestAuthorizer diff --git a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandHandler.cs b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandHandler.cs index 0d54557..149baca 100644 --- a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandHandler.cs +++ b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandHandler.cs @@ -7,34 +7,33 @@ using cuqmbr.TravelGuide.Application.Common.Services; using System.Security.Cryptography; using System.Text; -namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; +namespace cuqmbr.TravelGuide.Application + .Identity.Accounts.Commands.AddAccount; public class AddAccountCommandHandler : IRequestHandler { private readonly UnitOfWork _unitOfWork; private readonly IMapper _mapper; - private readonly PasswordHasherService _passwordHasherService; + private readonly PasswordHasherService _passwordHasher; - public AddAccountCommandHandler( - UnitOfWork unitOfWork, - IMapper mapper, - PasswordHasherService passwordHasherService) + public AddAccountCommandHandler(UnitOfWork unitOfWork, + IMapper mapper, PasswordHasherService passwordHasher) { _unitOfWork = unitOfWork; _mapper = mapper; - _passwordHasherService = passwordHasherService; + _passwordHasher = passwordHasher; } public async Task Handle( AddAccountCommand request, CancellationToken cancellationToken) { - var user = await _unitOfWork.AccountRepository.GetOneAsync( + var account = await _unitOfWork.AccountRepository.GetOneAsync( e => e.Email == request.Email, cancellationToken); - if (user != null) + if (account != null) { throw new DuplicateEntityException(); } @@ -47,15 +46,16 @@ public class AddAccountCommandHandler : .Items; var salt = RandomNumberGenerator.GetBytes(128 / 8); - var hash = await _passwordHasherService.HashAsync( + var hash = await _passwordHasher.HashAsync( Encoding.UTF8.GetBytes(request.Password), salt, cancellationToken); var saltBase64 = Convert.ToBase64String(salt); var hashBase64 = Convert.ToBase64String(hash); - user = new Account() + account = new Account() { + Username = request.Username, Email = request.Email, PasswordHash = hashBase64, PasswordSalt = saltBase64, @@ -66,12 +66,12 @@ public class AddAccountCommandHandler : .ToArray() }; - user = await _unitOfWork.AccountRepository.AddOneAsync( - user, cancellationToken); + account = await _unitOfWork.AccountRepository.AddOneAsync( + account, cancellationToken); await _unitOfWork.SaveAsync(cancellationToken); _unitOfWork.Dispose(); - return _mapper.Map(user); + return _mapper.Map(account); } } diff --git a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandValidator.cs b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandValidator.cs index 62586db..85e91c5 100644 --- a/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandValidator.cs +++ b/src/Application/Identity/Accounts/Commands/AddAccount/AddAccountCommandValidator.cs @@ -1,16 +1,37 @@ using cuqmbr.TravelGuide.Application.Common.FluentValidation; using cuqmbr.TravelGuide.Application.Common.Services; +using cuqmbr.TravelGuide.Domain.Enums; using FluentValidation; using Microsoft.Extensions.Localization; -namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; +namespace cuqmbr.TravelGuide.Application + .Identity.Accounts.Commands.AddAccount; -public class AddAccountCommandValidator : AbstractValidator +public class AddAccountCommandValidator : + AbstractValidator { public AddAccountCommandValidator( IStringLocalizer localizer, SessionCultureService cultureService) { + RuleFor(v => v.Username) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]) + .MinimumLength(1) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MinimumLength"], + 1)) + .MaximumLength(32) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MaximumLength"], + 32)) + .IsUsername() + .WithMessage(localizer["FluentValidation.IsUsername"]); + RuleFor(v => v.Email) .NotEmpty() .WithMessage(localizer["FluentValidation.NotEmpty"]) @@ -32,5 +53,18 @@ public class AddAccountCommandValidator : AbstractValidator cultureService.Culture, localizer["FluentValidation.MaximumLength"], 64)); + + RuleFor(v => v.Roles ?? new IdentityRole[0]) + .IsUnique(r => r) + .WithMessage(localizer["FluentValidation.IsUnique"]); + + RuleForEach(v => v.Roles) + .Must(r => IdentityRole.Enumerations.ContainsValue(r)) + .WithMessage( + String.Format( + localizer["FluentValidation.MustBeInEnum"], + String.Join( + ", ", + IdentityRole.Enumerations.Values.Select(e => e.Name)))); } } diff --git a/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs new file mode 100644 index 0000000..0d673af --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommand.cs @@ -0,0 +1,9 @@ +using MediatR; + +namespace cuqmbr.TravelGuide.Application.Identity + .Accounts.Commands.DeleteAccount; + +public record DeleteAccountCommand : IRequest +{ + public Guid Guid { get; set; } +} diff --git a/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandAuthorizer.cs b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandAuthorizer.cs new file mode 100644 index 0000000..4019940 --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandAuthorizer.cs @@ -0,0 +1,32 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.Identity + .Accounts.Commands.DeleteAccount; + +public class DeleteAccountCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public DeleteAccountCommandAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(DeleteAccountCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandHandler.cs b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandHandler.cs new file mode 100644 index 0000000..26dec51 --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandHandler.cs @@ -0,0 +1,34 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Persistence; +using cuqmbr.TravelGuide.Application.Common.Exceptions; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.DeleteAccount; + +public class DeleteAccountCommandHandler : IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + + public DeleteAccountCommandHandler(UnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task Handle( + DeleteAccountCommand request, + CancellationToken cancellationToken) + { + var account = await _unitOfWork.AccountRepository.GetOneAsync( + e => e.Guid == request.Guid, cancellationToken); + + if (account == null) + { + throw new NotFoundException(); + } + + await _unitOfWork.AccountRepository.DeleteOneAsync( + account, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + _unitOfWork.Dispose(); + } +} diff --git a/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandValidator.cs b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandValidator.cs new file mode 100644 index 0000000..1baf7f2 --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/DeleteAccount/DeleteAccountCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.DeleteAccount; + +public class DeleteAccountCommandValidator : AbstractValidator +{ + public DeleteAccountCommandValidator(IStringLocalizer localizer) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommand.cs b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommand.cs new file mode 100644 index 0000000..b5ed1e2 --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommand.cs @@ -0,0 +1,18 @@ +using MediatR; +using cuqmbr.TravelGuide.Domain.Enums; + +namespace cuqmbr.TravelGuide.Application + .Identity.Accounts.Commands.UpdateAccount; + +public record UpdateAccountCommand : IRequest +{ + public Guid Guid { get; set; } + + public string? Username { get; set; } + + public string? Email { get; set; } + + public string? Password { get; set; } + + public ICollection? Roles { get; set; } +} diff --git a/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandAuthorizer.cs b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandAuthorizer.cs new file mode 100644 index 0000000..ed54a5a --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandAuthorizer.cs @@ -0,0 +1,31 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.UpdateAccount; + +public class UpdateAccountCommandAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public UpdateAccountCommandAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(UpdateAccountCommand request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandHandler.cs b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandHandler.cs new file mode 100644 index 0000000..8a64931 --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandHandler.cs @@ -0,0 +1,109 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Persistence; +using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using System.Security.Cryptography; +using System.Text; +using cuqmbr.TravelGuide.Application.Common.Services; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application + .Identity.Accounts.Commands.UpdateAccount; + +public class UpdateAccountCommandHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + private readonly PasswordHasherService _passwordHasher; + + public UpdateAccountCommandHandler(UnitOfWork unitOfWork, + IMapper mapper, PasswordHasherService passwordHasher) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + _passwordHasher = passwordHasher; + } + + public async Task Handle( + UpdateAccountCommand request, + CancellationToken cancellationToken) + { + var account = await _unitOfWork.AccountRepository + .GetOneAsync(e => e.Guid == request.Guid, + e => e.AccountRoles, cancellationToken); + + if (account == null) + { + throw new NotFoundException(); + } + + + account.Username = request.Username ?? account.Username; + account.Email = request.Email ?? account.Email; + + if (request.Password != null) + { + var salt = RandomNumberGenerator.GetBytes(128 / 8); + var hash = await _passwordHasher.HashAsync( + Encoding.UTF8.GetBytes(request.Password), + salt, cancellationToken); + + var saltBase64 = Convert.ToBase64String(salt); + var hashBase64 = Convert.ToBase64String(hash); + + account.PasswordHash = hashBase64; + account.PasswordSalt = saltBase64; + } + + + if (request.Roles != null) + { + var requestRoleIds = (await _unitOfWork.RoleRepository + .GetPageAsync( + r => request.Roles.Contains(r.Value), + 1, request.Roles.Count, cancellationToken)) + .Items + .Select(r => r.Id); + + var accountRoles = account.AccountRoles; + var accountRoleIds = accountRoles.Select(ar => ar.RoleId); + + var commonRoleIds = requestRoleIds.Intersect(accountRoleIds); + + var newRoleIds = requestRoleIds.Except(accountRoleIds); + + var combinedRoleIds = commonRoleIds.Union(newRoleIds); + + account.AccountRoles = combinedRoleIds.Select(rId => + new AccountRole() + { + Id = accountRoles.FirstOrDefault(ar => + ar.RoleId == rId)?.Id ?? default, + RoleId = rId + }) + .ToList(); + } + else + { + var accountRoleIds = account.AccountRoles.Select(ar => ar.RoleId); + var accountRoles = (await _unitOfWork.AccountRoleRepository + .GetPageAsync( + ar => accountRoleIds.Contains(ar.RoleId), + ar => ar.Role, + 1, accountRoleIds.Count(), cancellationToken)) + .Items; + + account.AccountRoles = accountRoles.ToList(); + } + + + account = await _unitOfWork.AccountRepository.UpdateOneAsync( + account, cancellationToken); + + await _unitOfWork.SaveAsync(cancellationToken); + _unitOfWork.Dispose(); + + return _mapper.Map(account); + } +} diff --git a/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandValidator.cs b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandValidator.cs new file mode 100644 index 0000000..ab00393 --- /dev/null +++ b/src/Application/Identity/Accounts/Commands/UpdateAccount/UpdateAccountCommandValidator.cs @@ -0,0 +1,68 @@ +using cuqmbr.TravelGuide.Application.Common.FluentValidation; +using cuqmbr.TravelGuide.Application.Common.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application + .Identity.Accounts.Commands.UpdateAccount; + +public class UpdateAccountCommandValidator : + AbstractValidator +{ + public UpdateAccountCommandValidator( + IStringLocalizer localizer, + SessionCultureService cultureService) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + + RuleFor(v => v.Username) + .MinimumLength(1) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MinimumLength"], + 1)) + .MaximumLength(32) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MaximumLength"], + 32)) + .IsUsername() + .WithMessage(localizer["FluentValidation.IsUsername"]); + + RuleFor(v => v.Email) + .IsEmail() + .WithMessage(localizer["FluentValidation.IsEmail"]); + + RuleFor(v => v.Password) + .MinimumLength(8) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MinimumLength"], + 8)) + .MaximumLength(64) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MaximumLength"], + 64)); + + RuleFor(v => v.Roles ?? new IdentityRole[0]) + .IsUnique(r => r) + .WithMessage(localizer["FluentValidation.IsUnique"]); + + RuleForEach(v => v.Roles) + .Must(r => IdentityRole.Enumerations.ContainsValue(r)) + .WithMessage( + String.Format( + localizer["FluentValidation.MustBeInEnum"], + String.Join( + ", ", + IdentityRole.Enumerations.Values.Select(e => e.Name)))); + } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQuery.cs b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQuery.cs new file mode 100644 index 0000000..0031fcc --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQuery.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount; + +public record GetAccountQuery : IRequest +{ + public Guid Guid { get; set; } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryAuthorizer.cs b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryAuthorizer.cs new file mode 100644 index 0000000..56954d3 --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryAuthorizer.cs @@ -0,0 +1,31 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount; + +public class GetAccountQueryAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public GetAccountQueryAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(GetAccountQuery request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryHandler.cs b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryHandler.cs new file mode 100644 index 0000000..afbf47a --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryHandler.cs @@ -0,0 +1,51 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Persistence; +using cuqmbr.TravelGuide.Application.Common.Exceptions; +using AutoMapper; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount; + +public class GetAccountQueryHandler : + IRequestHandler +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetAccountQueryHandler( + UnitOfWork unitOfWork, + IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task Handle( + GetAccountQuery request, + CancellationToken cancellationToken) + { + var account = await _unitOfWork.AccountRepository.GetOneAsync( + e => e.Guid == request.Guid, e => e.AccountRoles, + cancellationToken); + + if (account == null) + { + throw new NotFoundException(); + } + + + var accountRoleIds = account.AccountRoles.Select(ar => ar.RoleId); + var accountRoles = (await _unitOfWork.AccountRoleRepository + .GetPageAsync( + ar => accountRoleIds.Contains(ar.RoleId), + ar => ar.Role, + 1, accountRoleIds.Count(), cancellationToken)) + .Items; + + account.AccountRoles = accountRoles.ToList(); + + + _unitOfWork.Dispose(); + + return _mapper.Map(account); + } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryValidator.cs b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryValidator.cs new file mode 100644 index 0000000..d0b26ab --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccount/GetAccountQueryValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount; + +public class GetAccountQueryValidator : AbstractValidator +{ + public GetAccountQueryValidator(IStringLocalizer localizer) + { + RuleFor(v => v.Guid) + .NotEmpty() + .WithMessage(localizer["FluentValidation.NotEmpty"]); + } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQuery.cs b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQuery.cs new file mode 100644 index 0000000..252f9c5 --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQuery.cs @@ -0,0 +1,18 @@ +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage; + +public record GetAccountsPageQuery : IRequest> +{ + public int PageNumber { get; set; } = 1; + + public int PageSize { get; set; } = 10; + + public string Search { get; set; } = String.Empty; + + public string Sort { get; set; } = String.Empty; + + public ICollection? Roles { get; set; } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryAuthorizer.cs b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryAuthorizer.cs new file mode 100644 index 0000000..79d158a --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryAuthorizer.cs @@ -0,0 +1,31 @@ +using cuqmbr.TravelGuide.Application.Common.Authorization; +using cuqmbr.TravelGuide.Application.Common.Services; +using cuqmbr.TravelGuide.Domain.Enums; +using MediatR.Behaviors.Authorization; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage; + +public class GetAccountsPageQueryAuthorizer : + AbstractRequestAuthorizer +{ + private readonly SessionUserService _sessionUserService; + + public GetAccountsPageQueryAuthorizer(SessionUserService sessionUserService) + { + _sessionUserService = sessionUserService; + } + + public override void BuildPolicy(GetAccountsPageQuery request) + { + UseRequirement(new MustBeAuthenticatedRequirement + { + IsAuthenticated= _sessionUserService.IsAuthenticated + }); + + UseRequirement(new MustBeInRolesRequirement + { + RequiredRoles = [IdentityRole.Administrator], + UserRoles = _sessionUserService.Roles + }); + } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryHandler.cs b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryHandler.cs new file mode 100644 index 0000000..dc5f975 --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryHandler.cs @@ -0,0 +1,81 @@ +using MediatR; +using cuqmbr.TravelGuide.Application.Common.Persistence; +using AutoMapper; +using cuqmbr.TravelGuide.Application.Common.Models; +using cuqmbr.TravelGuide.Application.Common.Extensions; +using cuqmbr.TravelGuide.Domain.Entities; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage; + +public class GetAccountsPageQueryHandler : + IRequestHandler> +{ + private readonly UnitOfWork _unitOfWork; + private readonly IMapper _mapper; + + public GetAccountsPageQueryHandler( + UnitOfWork unitOfWork, + IMapper mapper) + { + _unitOfWork = unitOfWork; + _mapper = mapper; + } + + public async Task> Handle( + GetAccountsPageQuery request, + CancellationToken cancellationToken) + { + var paginatedList = await _unitOfWork.AccountRepository.GetPageAsync( + a => + (a.Username.ToLower().Contains(request.Search.ToLower()) || + a.Email.ToLower().Contains(request.Search.ToLower())) && + (request.Roles != null + ? request.Roles.All(r => a.AccountRoles.Any(ar => ar.Role.Value == r)) + : true), + a => a.AccountRoles, + request.PageNumber, request.PageSize, cancellationToken); + + + var accounts = paginatedList.Items; + + var accountsRoleIds = accounts + .SelectMany(a => a.AccountRoles) + .Select(ar => ar.RoleId) + .Distinct(); + + var roles = (await _unitOfWork.RoleRepository + .GetPageAsync( + r => accountsRoleIds.Contains(r.Id), + 1, accountsRoleIds.Count(), cancellationToken)) + .Items; + + foreach (var account in accounts) + { + account.AccountRoles = account.AccountRoles.Select(ar => + new AccountRole() + { + RoleId = ar.RoleId, + Role = roles.Single(r => r.Id == ar.RoleId), + AccountId = account.Id, + Account = account + }) + .ToArray(); + } + + + var mappedItems = _mapper + .ProjectTo(accounts.AsQueryable()); + + mappedItems = QueryableExtension + .ApplySort(mappedItems, request.Sort); + + _unitOfWork.Dispose(); + + return new PaginatedList( + mappedItems.ToList(), + paginatedList.TotalCount, request.PageNumber, + request.PageSize); + + throw new NotImplementedException(); + } +} diff --git a/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryValidator.cs b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryValidator.cs new file mode 100644 index 0000000..d42a9ac --- /dev/null +++ b/src/Application/Identity/Accounts/Queries/GetAccountsPage/GetAccountsPageQueryValidator.cs @@ -0,0 +1,43 @@ +using cuqmbr.TravelGuide.Application.Common.Services; +using FluentValidation; +using Microsoft.Extensions.Localization; + +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage; + +public class GetAccountsPageQueryValidator : AbstractValidator +{ + public GetAccountsPageQueryValidator( + IStringLocalizer localizer, + SessionCultureService cultureService) + { + RuleFor(v => v.PageNumber) + .GreaterThanOrEqualTo(1) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 1)); + + RuleFor(v => v.PageSize) + .GreaterThanOrEqualTo(1) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.GreaterThanOrEqualTo"], + 1)) + .LessThanOrEqualTo(50) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.LessThanOrEqualTo"], + 50)); + + RuleFor(v => v.Search) + .MaximumLength(64) + .WithMessage( + String.Format( + cultureService.Culture, + localizer["FluentValidation.MaximumLength"], + 64)); + } +} diff --git a/src/Application/Identity/Accounts/ViewModels/AddAccountViewModel.cs b/src/Application/Identity/Accounts/ViewModels/AddAccountViewModel.cs index c8c8bc3..5ff18bc 100644 --- a/src/Application/Identity/Accounts/ViewModels/AddAccountViewModel.cs +++ b/src/Application/Identity/Accounts/ViewModels/AddAccountViewModel.cs @@ -2,6 +2,8 @@ namespace cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels; public sealed class AddAccountViewModel { + public string Username { get; set; } + public string Email { get; set; } public string Password { get; set; } diff --git a/src/Application/Identity/Accounts/ViewModels/GetAccountsPageFilterViewModel.cs b/src/Application/Identity/Accounts/ViewModels/GetAccountsPageFilterViewModel.cs new file mode 100644 index 0000000..d95a4a6 --- /dev/null +++ b/src/Application/Identity/Accounts/ViewModels/GetAccountsPageFilterViewModel.cs @@ -0,0 +1,6 @@ +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels; + +public sealed class GetAccountsPageFilterViewModel +{ + public ICollection? Roles { get; set; } +} diff --git a/src/Application/Identity/Accounts/ViewModels/UpdateAccountViewModel.cs b/src/Application/Identity/Accounts/ViewModels/UpdateAccountViewModel.cs new file mode 100644 index 0000000..4ae0732 --- /dev/null +++ b/src/Application/Identity/Accounts/ViewModels/UpdateAccountViewModel.cs @@ -0,0 +1,14 @@ +namespace cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels; + +public sealed class UpdateAccountViewModel +{ + public Guid Uuid { get; set; } + + public string? Username { get; set; } + + public string? Email { get; set; } + + public string? Password { get; set; } + + public ICollection? Roles { get; set; } +} diff --git a/src/HttpApi/Controllers/IdentityController.cs b/src/HttpApi/Controllers/IdentityController.cs index bacb072..967da73 100644 --- a/src/HttpApi/Controllers/IdentityController.cs +++ b/src/HttpApi/Controllers/IdentityController.cs @@ -7,12 +7,10 @@ using cuqmbr.TravelGuide.Application.Identity.Roles.Queries.GetRolesPage; using cuqmbr.TravelGuide.Application.Identity.Accounts; using cuqmbr.TravelGuide.Application.Identity.Accounts.ViewModels; using cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.AddAccount; -// using cuqmbr.TravelGuide.Application.Identity.Commands.AddIdentity; -// using cuqmbr.TravelGuide.Application.Identity.Queries.GetIdentityPage; -// using cuqmbr.TravelGuide.Application.Identity.Queries.GetIdentity; -// using cuqmbr.TravelGuide.Application.Identity.Commands.UpdateIdentity; -// using cuqmbr.TravelGuide.Application.Identity.Commands.DeleteIdentity; -// using cuqmbr.TravelGuide.Application.Identity.ViewModels; +using cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccountsPage; +using cuqmbr.TravelGuide.Application.Identity.Accounts.Queries.GetAccount; +using cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.UpdateAccount; +using cuqmbr.TravelGuide.Application.Identity.Accounts.Commands.DeleteAccount; namespace cuqmbr.TravelGuide.HttpApi.Controllers; @@ -78,7 +76,7 @@ public class IdentityController : ControllerBase [SwaggerResponse( StatusCodes.Status500InternalServerError, "Internal server error", typeof(ProblemDetails))] - public async Task> Add( + public async Task> AddAccount( [FromBody] AddAccountViewModel viewModel, CancellationToken cancellationToken) { @@ -87,6 +85,7 @@ public class IdentityController : ControllerBase await Mediator.Send( new AddAccountCommand() { + Username = viewModel.Username, Email = viewModel.Email, Password = viewModel.Password, Roles = viewModel.Roles @@ -96,147 +95,144 @@ public class IdentityController : ControllerBase cancellationToken)); } + [HttpGet("accounts")] + [SwaggerOperation("Get a list of all accounts")] + [SwaggerResponse( + StatusCodes.Status200OK, "Request successful", + typeof(PaginatedList))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task> GetAccountsPage( + [FromQuery] PageQuery pageQuery, [FromQuery] SearchQuery searchQuery, + [FromQuery] SortQuery sortQuery, + [FromQuery] GetAccountsPageFilterViewModel filterQuery, + CancellationToken cancellationToken) + { + return await Mediator.Send( + new GetAccountsPageQuery() + { + PageNumber = pageQuery.PageNumber, + PageSize = pageQuery.PageSize, + Search = searchQuery.Search, + Sort = sortQuery.Sort, + Roles = filterQuery.Roles == null ? null : + filterQuery.Roles + .Select(s => IdentityRole.FromName(s)) + .ToArray() + }, + cancellationToken); + } + [HttpGet("accounts/{uuid:guid}")] + [SwaggerOperation("Get an account by uuid")] + [SwaggerResponse( + StatusCodes.Status200OK, "Request successful", typeof(AccountDto))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Object not found", typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task GetAccount( + [FromRoute] Guid uuid, + CancellationToken cancellationToken) + { + return await Mediator.Send(new GetAccountQuery() { Guid = uuid }, + cancellationToken); + } + [HttpPut("accounts/{uuid:guid}")] + [SwaggerOperation("Update an account")] + [SwaggerResponse( + StatusCodes.Status200OK, "Request successful", typeof(AccountDto))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Object already exists", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Parent object not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task UpdateAccount( + [FromRoute] Guid uuid, + [FromBody] UpdateAccountViewModel viewModel, + CancellationToken cancellationToken) + { + return await Mediator.Send( + new UpdateAccountCommand() + { + Guid = uuid, + Username = viewModel.Username, + Email = viewModel.Email, + Password = viewModel.Password, + Roles = viewModel.Roles == null ? null : + viewModel.Roles + .Select(s => IdentityRole.FromName(s)) + .ToArray() + }, + cancellationToken); + } - // [HttpPost] - // [SwaggerOperation("Add an identity")] - // [SwaggerResponse( - // StatusCodes.Status201Created, "Object successfuly created", - // typeof(IdentityDto))] - // [SwaggerResponse( - // StatusCodes.Status400BadRequest, "Object already exists", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status400BadRequest, "Input data validation error", - // typeof(HttpValidationProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status403Forbidden, - // "Not enough privileges to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status404NotFound, "Parent object not found", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status500InternalServerError, "Internal server error", - // typeof(ProblemDetails))] - // public async Task> Add( - // [FromBody] AddIdentityViewModel viewModel, - // CancellationToken cancellationToken) - // { - // return StatusCode( - // StatusCodes.Status201Created, - // await Mediator.Send( - // new AddIdentityCommand() - // { - // Name = viewModel.Name, - // Longitude = viewModel.Longitude, - // Latitude = viewModel.Latitude, - // VehicleType = VehicleType.FromName(viewModel.VehicleType), - // CityGuid = viewModel.CityUuid - // }, - // cancellationToken)); - // } - // - // [HttpGet("{uuid:guid}")] - // [SwaggerOperation("Get an identity by uuid")] - // [SwaggerResponse( - // StatusCodes.Status200OK, "Request successful", typeof(IdentityDto))] - // [SwaggerResponse( - // StatusCodes.Status400BadRequest, "Input data validation error", - // typeof(HttpValidationProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status403Forbidden, - // "Not enough privileges to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status404NotFound, "Object not found", typeof(IdentityDto))] - // [SwaggerResponse( - // StatusCodes.Status500InternalServerError, "Internal server error", - // typeof(ProblemDetails))] - // public async Task Get( - // [FromRoute] Guid uuid, - // CancellationToken cancellationToken) - // { - // return await Mediator.Send(new GetIdentityQuery() { Guid = uuid }, - // cancellationToken); - // } - // - // [HttpPut("{uuid:guid}")] - // [SwaggerOperation("Update an identity")] - // [SwaggerResponse( - // StatusCodes.Status200OK, "Request successful", typeof(IdentityDto))] - // [SwaggerResponse( - // StatusCodes.Status400BadRequest, "Object already exists", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status400BadRequest, "Input data validation error", - // typeof(HttpValidationProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status403Forbidden, - // "Not enough privileges to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status404NotFound, "Object not found", typeof(IdentityDto))] - // [SwaggerResponse( - // StatusCodes.Status404NotFound, "Parent object not found", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status500InternalServerError, "Internal server error", - // typeof(ProblemDetails))] - // public async Task Update( - // [FromRoute] Guid uuid, - // [FromBody] UpdateIdentityViewModel viewModel, - // CancellationToken cancellationToken) - // { - // return await Mediator.Send( - // new UpdateIdentityCommand() - // { - // Guid = uuid, - // Name = viewModel.Name, - // Longitude = viewModel.Longitude, - // Latitude = viewModel.Latitude, - // VehicleType = VehicleType.FromName(viewModel.VehicleType), - // CityGuid = viewModel.CityUuid - // }, - // cancellationToken); - // } - // - // [HttpDelete("{uuid:guid}")] - // [SwaggerOperation("Delete an identity")] - // [SwaggerResponse(StatusCodes.Status204NoContent, "Request successful")] - // [SwaggerResponse( - // StatusCodes.Status400BadRequest, "Input data validation error", - // typeof(HttpValidationProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status403Forbidden, - // "Not enough privileges to perform an action", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status404NotFound, "Object not found", - // typeof(ProblemDetails))] - // [SwaggerResponse( - // StatusCodes.Status500InternalServerError, "Internal server error", - // typeof(ProblemDetails))] - // public async Task Delete( - // [FromRoute] Guid uuid, - // CancellationToken cancellationToken) - // { - // await Mediator.Send( - // new DeleteIdentityCommand() { Guid = uuid }, - // cancellationToken); - // return StatusCode(StatusCodes.Status204NoContent); - // } + [HttpDelete("accounts/{uuid:guid}")] + [SwaggerOperation("Delete an account")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Request successful")] + [SwaggerResponse( + StatusCodes.Status400BadRequest, "Input data validation error", + typeof(HttpValidationProblemDetails))] + [SwaggerResponse( + StatusCodes.Status401Unauthorized, "Unauthorized to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status403Forbidden, + "Not enough privileges to perform an action", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status404NotFound, "Object not found", + typeof(ProblemDetails))] + [SwaggerResponse( + StatusCodes.Status500InternalServerError, "Internal server error", + typeof(ProblemDetails))] + public async Task DeleteAccount( + [FromRoute] Guid uuid, + CancellationToken cancellationToken) + { + await Mediator.Send( + new DeleteAccountCommand() { Guid = uuid }, + cancellationToken); + return StatusCode(StatusCodes.Status204NoContent); + } }