| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Command.Services; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 7 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 8 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using ArturRios.Util.Hashing; |
| | | 11 | | using FluentValidation; |
| | | 12 | | using Microsoft.EntityFrameworkCore; |
| | | 13 | | |
| | | 14 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 15 | | |
| | | 16 | | /// <summary> |
| | | 17 | | /// Handles <see cref="CreateAdminCommand" /> (UC-06 path b): validates the request, verifies the |
| | | 18 | | /// email is unique among admin persons system-wide (AF-06a), hashes the password, and creates a |
| | | 19 | | /// <c>ScopeAdmin</c>/<c>SystemAdmin</c> with no scope association, then issues a verification |
| | | 20 | | /// token. AF-06c (non-System-Admin) is enforced by the controller's role requirement. |
| | | 21 | | /// </summary> |
| | 76 | 22 | | public class CreateAdminCommandHandler( |
| | 76 | 23 | | IValidator<CreateAdminCommand> validator, |
| | 76 | 24 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 76 | 25 | | IAsyncRepository<Person, long> personWriter, |
| | 76 | 26 | | IEmailVerificationService emailVerification) |
| | | 27 | | : ICommandHandlerAsync<CreateAdminCommand, CreatePersonCommandOutput> |
| | | 28 | | { |
| | | 29 | | public async Task<DataOutput<CreatePersonCommandOutput?>> HandleAsync(CreateAdminCommand command, CancellationToken |
| | | 30 | | { |
| | 76 | 31 | | var output = DataOutput<CreatePersonCommandOutput?>.New; |
| | | 32 | | |
| | | 33 | | // AF-06d: validate input shape. |
| | 76 | 34 | | var validation = await validator.ValidateAsync(command); |
| | | 35 | | |
| | 76 | 36 | | if (!validation.IsValid) |
| | | 37 | | { |
| | 457 | 38 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 39 | | } |
| | | 40 | | |
| | | 41 | | // AF-06a: admin emails are unique system-wide, compared case-insensitively (LOWER() in SQL). |
| | | 42 | | // Trimmed first, here and where it is stored: sign-in and recovery match the stored address |
| | | 43 | | // exactly, case aside, so surrounding whitespace would make it unreachable. |
| | 10 | 44 | | var address = command.Email.Trim(); |
| | 10 | 45 | | var email = address.ToLower(); |
| | | 46 | | |
| | 10 | 47 | | var emailTaken = await personReader.Query().AnyAsync(person => |
| | 10 | 48 | | !person.IsDeleted && person.Email.ToLower() == email && |
| | 10 | 49 | | (person.RoleId == (long)Roles.SystemAdmin || person.RoleId == (long)Roles.ScopeAdmin)); |
| | | 50 | | |
| | 10 | 51 | | if (emailTaken) |
| | | 52 | | { |
| | 4 | 53 | | return output.WithError(PersonMessages.EmailAlreadyExists); |
| | | 54 | | } |
| | | 55 | | |
| | | 56 | | // Create the admin person with no SCOPE_OWNER/SCOPE_USER row. |
| | 6 | 57 | | var (passwordHash, salt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.Password); |
| | | 58 | | |
| | 6 | 59 | | var newPerson = new Person |
| | 6 | 60 | | { |
| | 6 | 61 | | Name = command.Name, |
| | 6 | 62 | | Email = address, |
| | 6 | 63 | | PasswordHash = passwordHash, |
| | 6 | 64 | | Salt = salt, |
| | 6 | 65 | | RoleId = command.Role |
| | 6 | 66 | | }; |
| | | 67 | | |
| | | 68 | | // NFR-23. No scope: an administrator exists to operate Heimdall itself, which is Heimdall's |
| | | 69 | | // own purpose rather than a tenant's, so the deployment default applies. |
| | 6 | 70 | | LegalBasisRecorder.Record(newPerson, scope: null, DateTime.UtcNow); |
| | | 71 | | |
| | 6 | 72 | | var creation = await personWriter.CreateAsync(newPerson); |
| | | 73 | | |
| | 6 | 74 | | if (!creation.Success) |
| | | 75 | | { |
| | 0 | 76 | | return output.WithErrors(creation.Errors); |
| | | 77 | | } |
| | | 78 | | |
| | | 79 | | // FR-EV-01/02: issue and send the verification token. |
| | 6 | 80 | | await emailVerification.IssueAndSendAsync(newPerson); |
| | | 81 | | |
| | 6 | 82 | | return output |
| | 6 | 83 | | .WithData(new CreatePersonCommandOutput |
| | 6 | 84 | | { |
| | 6 | 85 | | Id = newPerson.PublicId, |
| | 6 | 86 | | Name = newPerson.Name, |
| | 6 | 87 | | Email = newPerson.Email, |
| | 6 | 88 | | Role = command.Role, |
| | 6 | 89 | | EmailVerified = newPerson.EmailVerified, |
| | 6 | 90 | | CreatedAt = newPerson.CreatedAt |
| | 6 | 91 | | }) |
| | 6 | 92 | | .WithMessage(PersonMessages.PersonCreatedSuccessfully); |
| | 76 | 93 | | } |
| | | 94 | | } |