diff --git a/mws.api/Controllers/AccountsController.cs b/mws.api/Controllers/AccountsController.cs index 2416612..f49eb87 100644 --- a/mws.api/Controllers/AccountsController.cs +++ b/mws.api/Controllers/AccountsController.cs @@ -1,3 +1,4 @@ +using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Mws.Application.Accounts; @@ -7,37 +8,37 @@ namespace Mws.Api.Controllers; [ApiController] [Route("api/accounts")] [Authorize] -public class AccountsController(IAccountService accountService) : ControllerBase +public class AccountsController(ISender sender) : ControllerBase { [HttpGet] public async Task>> GetAll([FromQuery] string? q, CancellationToken ct) { - return Ok(await accountService.GetAccountsAsync(User.GetUserId(), q, ct)); + return Ok(await sender.Send(new GetAccountsQuery(User.GetUserId(), q), ct)); } [HttpPost] public async Task> Create([FromBody] CreateAccountRequest request, CancellationToken ct) { - return Ok(await accountService.CreateAccountAsync(User.GetUserId(), request, ct)); + return Ok(await sender.Send(new CreateAccountCommand(User.GetUserId(), request), ct)); } [HttpPut("{id:guid}")] public async Task> Update(Guid id, [FromBody] UpdateAccountRequest request, CancellationToken ct) { - return Ok(await accountService.UpdateAccountAsync(User.GetUserId(), id, request, ct)); + return Ok(await sender.Send(new UpdateAccountCommand(User.GetUserId(), id, request), ct)); } [HttpDelete("{id:guid}")] public async Task Delete(Guid id, CancellationToken ct) { - await accountService.DeleteAccountAsync(User.GetUserId(), id, ct); + await sender.Send(new DeleteAccountCommand(User.GetUserId(), id), ct); return NoContent(); } [HttpPost("{id:guid}/reset-password")] public async Task ResetPassword(Guid id, [FromBody] ResetPasswordRequest request, CancellationToken ct) { - await accountService.ResetPasswordAsync(User.GetUserId(), id, request, ct); + await sender.Send(new ResetPasswordCommand(User.GetUserId(), id, request), ct); return NoContent(); } } diff --git a/mws.application/Accounts/AccountService.cs b/mws.application/Accounts/AccountService.cs deleted file mode 100644 index ed5af07..0000000 --- a/mws.application/Accounts/AccountService.cs +++ /dev/null @@ -1,116 +0,0 @@ -using AutoMapper; -using Mws.Application.Auth; -using Mws.Application.Common; -using Mws.Application.Permissions; -using Mws.Domain.Users; - -namespace Mws.Application.Accounts; - -public class AccountService(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) : IAccountService -{ - private const string Screen = "accounts"; - - public async Task> GetAccountsAsync(Guid actorUserId, string? term, CancellationToken ct = default) - { - await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.View, ct); - - var users = await uow.Users.SearchWithRoleAsync(term, null, ct); - return mapper.Map>(users); - } - - public async Task CreateAccountAsync(Guid actorUserId, CreateAccountRequest request, CancellationToken ct = default) - { - await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Create, ct); - - var username = request.Username.Trim(); - if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) - { - throw new BadRequestException("Username and password are required"); - } - - if (await uow.Users.ExistsByUsernameAsync(username, ct)) - { - throw new BadRequestException("Username already exists"); - } - - var role = await uow.Roles.GetByIdAsync(request.RoleId, ct) - ?? throw new BadRequestException("Role not found"); - - var now = DateTime.UtcNow; - var user = new User - { - Id = Guid.NewGuid(), - Username = username, - PasswordHash = passwordHasher.Hash(request.Password), - DisplayName = request.DisplayName.Trim(), - RoleId = role.Id, - IsActive = true, - CreatedAt = now, - UpdatedAt = now, - }; - - uow.Users.Add(user); - await uow.SaveChangesAsync(ct); - user.Role = role; - return mapper.Map(user); - } - - public async Task UpdateAccountAsync(Guid actorUserId, Guid id, UpdateAccountRequest request, CancellationToken ct = default) - { - await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Edit, ct); - - var user = await uow.Users.GetByIdWithRoleAsync(id, ct) - ?? throw new NotFoundException("Account not found"); - - var role = await uow.Roles.GetByIdAsync(request.RoleId, ct) - ?? throw new BadRequestException("Role not found"); - - user.DisplayName = request.DisplayName.Trim(); - user.RoleId = role.Id; - user.Role = role; - user.IsActive = request.IsActive; - user.UpdatedAt = DateTime.UtcNow; - - await uow.SaveChangesAsync(ct); - return mapper.Map(user); - } - - public async Task DeleteAccountAsync(Guid actorUserId, Guid id, CancellationToken ct = default) - { - await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Delete, ct); - - var user = await uow.Users.GetByIdAsync(id, ct) - ?? throw new NotFoundException("Account not found"); - - var soleOwnerProjectIds = await uow.Projects.GetOwnedProjectIdsAsync(id, ct); - - foreach (var projectId in soleOwnerProjectIds) - { - var ownerCount = await uow.Projects.CountOwnersAsync(projectId, ct); - if (ownerCount <= 1) - { - throw new BadRequestException("Cannot delete an account that is the sole owner of a project"); - } - } - - uow.Users.Remove(user); - await uow.SaveChangesAsync(ct); - } - - public async Task ResetPasswordAsync(Guid actorUserId, Guid id, ResetPasswordRequest request, CancellationToken ct = default) - { - await permissions.EnsureAsync(actorUserId, Screen, PermissionAction.Edit, ct); - - if (string.IsNullOrWhiteSpace(request.NewPassword)) - { - throw new BadRequestException("New password is required"); - } - - var user = await uow.Users.GetByIdAsync(id, ct) - ?? throw new NotFoundException("Account not found"); - - user.PasswordHash = passwordHasher.Hash(request.NewPassword); - user.UpdatedAt = DateTime.UtcNow; - await uow.SaveChangesAsync(ct); - } -} diff --git a/mws.application/Accounts/Commands/CreateAccount.cs b/mws.application/Accounts/Commands/CreateAccount.cs new file mode 100644 index 0000000..68cf461 --- /dev/null +++ b/mws.application/Accounts/Commands/CreateAccount.cs @@ -0,0 +1,54 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Auth; +using Mws.Application.Common; +using Mws.Application.Permissions; +using Mws.Domain.Users; + +namespace Mws.Application.Accounts; + +public record CreateAccountCommand(Guid ActorUserId, CreateAccountRequest Request) : IRequest; + +public class CreateAccountHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions, IMapper mapper) + : IRequestHandler +{ + private const string Screen = "accounts"; + + public async Task Handle(CreateAccountCommand command, CancellationToken ct) + { + await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Create, ct); + + var request = command.Request; + var username = request.Username.Trim(); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) + { + throw new BadRequestException("Username and password are required"); + } + + if (await uow.Users.ExistsByUsernameAsync(username, ct)) + { + throw new BadRequestException("Username already exists"); + } + + var role = await uow.Roles.GetByIdAsync(request.RoleId, ct) + ?? throw new BadRequestException("Role not found"); + + var now = DateTime.UtcNow; + var user = new User + { + Id = Guid.NewGuid(), + Username = username, + PasswordHash = passwordHasher.Hash(request.Password), + DisplayName = request.DisplayName.Trim(), + RoleId = role.Id, + IsActive = true, + CreatedAt = now, + UpdatedAt = now, + }; + + uow.Users.Add(user); + await uow.SaveChangesAsync(ct); + user.Role = role; + return mapper.Map(user); + } +} diff --git a/mws.application/Accounts/Commands/DeleteAccount.cs b/mws.application/Accounts/Commands/DeleteAccount.cs new file mode 100644 index 0000000..0e0d488 --- /dev/null +++ b/mws.application/Accounts/Commands/DeleteAccount.cs @@ -0,0 +1,34 @@ +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; + +namespace Mws.Application.Accounts; + +public record DeleteAccountCommand(Guid ActorUserId, Guid Id) : IRequest; + +public class DeleteAccountHandler(IUnitOfWork uow, IPermissionService permissions) : IRequestHandler +{ + private const string Screen = "accounts"; + + public async Task Handle(DeleteAccountCommand command, CancellationToken ct) + { + await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Delete, ct); + + var user = await uow.Users.GetByIdAsync(command.Id, ct) + ?? throw new NotFoundException("Account not found"); + + var soleOwnerProjectIds = await uow.Projects.GetOwnedProjectIdsAsync(command.Id, ct); + + foreach (var projectId in soleOwnerProjectIds) + { + var ownerCount = await uow.Projects.CountOwnersAsync(projectId, ct); + if (ownerCount <= 1) + { + throw new BadRequestException("Cannot delete an account that is the sole owner of a project"); + } + } + + uow.Users.Remove(user); + await uow.SaveChangesAsync(ct); + } +} diff --git a/mws.application/Accounts/Commands/ResetPassword.cs b/mws.application/Accounts/Commands/ResetPassword.cs new file mode 100644 index 0000000..6251e35 --- /dev/null +++ b/mws.application/Accounts/Commands/ResetPassword.cs @@ -0,0 +1,31 @@ +using MediatR; +using Mws.Application.Auth; +using Mws.Application.Common; +using Mws.Application.Permissions; + +namespace Mws.Application.Accounts; + +public record ResetPasswordCommand(Guid ActorUserId, Guid Id, ResetPasswordRequest Request) : IRequest; + +public class ResetPasswordHandler(IUnitOfWork uow, IPasswordHasher passwordHasher, IPermissionService permissions) + : IRequestHandler +{ + private const string Screen = "accounts"; + + public async Task Handle(ResetPasswordCommand command, CancellationToken ct) + { + await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct); + + if (string.IsNullOrWhiteSpace(command.Request.NewPassword)) + { + throw new BadRequestException("New password is required"); + } + + var user = await uow.Users.GetByIdAsync(command.Id, ct) + ?? throw new NotFoundException("Account not found"); + + user.PasswordHash = passwordHasher.Hash(command.Request.NewPassword); + user.UpdatedAt = DateTime.UtcNow; + await uow.SaveChangesAsync(ct); + } +} diff --git a/mws.application/Accounts/Commands/UpdateAccount.cs b/mws.application/Accounts/Commands/UpdateAccount.cs new file mode 100644 index 0000000..34fdcf1 --- /dev/null +++ b/mws.application/Accounts/Commands/UpdateAccount.cs @@ -0,0 +1,35 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; + +namespace Mws.Application.Accounts; + +public record UpdateAccountCommand(Guid ActorUserId, Guid Id, UpdateAccountRequest Request) : IRequest; + +public class UpdateAccountHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) + : IRequestHandler +{ + private const string Screen = "accounts"; + + public async Task Handle(UpdateAccountCommand command, CancellationToken ct) + { + await permissions.EnsureAsync(command.ActorUserId, Screen, PermissionAction.Edit, ct); + + var user = await uow.Users.GetByIdWithRoleAsync(command.Id, ct) + ?? throw new NotFoundException("Account not found"); + + var request = command.Request; + var role = await uow.Roles.GetByIdAsync(request.RoleId, ct) + ?? throw new BadRequestException("Role not found"); + + user.DisplayName = request.DisplayName.Trim(); + user.RoleId = role.Id; + user.Role = role; + user.IsActive = request.IsActive; + user.UpdatedAt = DateTime.UtcNow; + + await uow.SaveChangesAsync(ct); + return mapper.Map(user); + } +} diff --git a/mws.application/Accounts/IAccountService.cs b/mws.application/Accounts/IAccountService.cs deleted file mode 100644 index ca982fe..0000000 --- a/mws.application/Accounts/IAccountService.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Mws.Application.Accounts; - -public interface IAccountService -{ - Task> GetAccountsAsync(Guid actorUserId, string? term, CancellationToken ct = default); - Task CreateAccountAsync(Guid actorUserId, CreateAccountRequest request, CancellationToken ct = default); - Task UpdateAccountAsync(Guid actorUserId, Guid id, UpdateAccountRequest request, CancellationToken ct = default); - Task DeleteAccountAsync(Guid actorUserId, Guid id, CancellationToken ct = default); - Task ResetPasswordAsync(Guid actorUserId, Guid id, ResetPasswordRequest request, CancellationToken ct = default); -} diff --git a/mws.application/Accounts/Queries/GetAccounts.cs b/mws.application/Accounts/Queries/GetAccounts.cs new file mode 100644 index 0000000..d4af4b4 --- /dev/null +++ b/mws.application/Accounts/Queries/GetAccounts.cs @@ -0,0 +1,22 @@ +using AutoMapper; +using MediatR; +using Mws.Application.Common; +using Mws.Application.Permissions; + +namespace Mws.Application.Accounts; + +public record GetAccountsQuery(Guid ActorUserId, string? Term) : IRequest>; + +public class GetAccountsHandler(IUnitOfWork uow, IPermissionService permissions, IMapper mapper) + : IRequestHandler> +{ + private const string Screen = "accounts"; + + public async Task> Handle(GetAccountsQuery query, CancellationToken ct) + { + await permissions.EnsureAsync(query.ActorUserId, Screen, PermissionAction.View, ct); + + var users = await uow.Users.SearchWithRoleAsync(query.Term, null, ct); + return mapper.Map>(users); + } +} diff --git a/mws.infrastructure/DependencyInjection.cs b/mws.infrastructure/DependencyInjection.cs index f0232e4..033aaa6 100644 --- a/mws.infrastructure/DependencyInjection.cs +++ b/mws.infrastructure/DependencyInjection.cs @@ -1,7 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Mws.Application.Accounts; using Mws.Application.Auth; using Mws.Application.Common; using Mws.Application.Documents; @@ -40,7 +39,6 @@ public static class DependencyInjection services.AddSingleton(jwtOptions); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped();