| | | 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.Heimdall.Shared.Services; |
| | | 9 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 10 | | using ArturRios.Output; |
| | | 11 | | using ArturRios.Util.Hashing; |
| | | 12 | | using FluentValidation; |
| | | 13 | | using Microsoft.EntityFrameworkCore; |
| | | 14 | | |
| | | 15 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 16 | | |
| | | 17 | | /// <summary> |
| | | 18 | | /// Handles <see cref="CreateScopeOwnerCommand" /> (UC-06 path c, FR-SC-12): validates input, |
| | | 19 | | /// verifies the target scope exists and is active (AF-06b), enforces scope ownership for a Scope |
| | | 20 | | /// Admin actor (AF-06e), checks the email is unique among admin persons system-wide (AF-06a), then |
| | | 21 | | /// creates a <c>ScopeAdmin</c> with a <c>SCOPE_OWNER</c> row making them a co-owner, and issues a |
| | | 22 | | /// verification token. A System Admin actor bypasses the ownership check. |
| | | 23 | | /// </summary> |
| | 11 | 24 | | public class CreateScopeOwnerCommandHandler( |
| | 11 | 25 | | IValidator<CreateScopeOwnerCommand> validator, |
| | 11 | 26 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 11 | 27 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 11 | 28 | | IAsyncRepository<Person, long> personWriter, |
| | 11 | 29 | | IScopeOwnershipChecker scopeOwnership, |
| | 11 | 30 | | IEmailVerificationService emailVerification) |
| | | 31 | | : ICommandHandlerAsync<CreateScopeOwnerCommand, CreatePersonCommandOutput> |
| | | 32 | | { |
| | | 33 | | public async Task<DataOutput<CreatePersonCommandOutput?>> HandleAsync(CreateScopeOwnerCommand command, CancellationT |
| | | 34 | | { |
| | 11 | 35 | | var output = DataOutput<CreatePersonCommandOutput?>.New; |
| | | 36 | | |
| | | 37 | | // AF-06d: validate input shape. |
| | 11 | 38 | | var validation = await validator.ValidateAsync(command); |
| | | 39 | | |
| | 11 | 40 | | if (!validation.IsValid) |
| | | 41 | | { |
| | 0 | 42 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 43 | | } |
| | | 44 | | |
| | | 45 | | // AF-06b: the target scope must exist and not be logically deleted. |
| | 11 | 46 | | var scope = await scopeReader.Query() |
| | 11 | 47 | | .FirstOrDefaultAsync(x => x.PublicId == command.ScopeId && !x.IsDeleted); |
| | | 48 | | |
| | 11 | 49 | | if (scope is null) |
| | | 50 | | { |
| | 2 | 51 | | return output.WithError(PersonMessages.ScopeNotFound); |
| | | 52 | | } |
| | | 53 | | |
| | | 54 | | // AF-06e: a Scope Admin actor may only act on a scope they own; a System Admin bypasses. |
| | 9 | 55 | | if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id)) |
| | | 56 | | { |
| | 2 | 57 | | return output.WithError(PersonMessages.NotScopeOwner); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | // AF-06a: admin emails are unique system-wide, compared case-insensitively (LOWER() in SQL). |
| | | 61 | | // Trimmed first, here and where it is stored: sign-in and recovery match the stored address |
| | | 62 | | // exactly, case aside, so surrounding whitespace would make it unreachable. |
| | 7 | 63 | | var address = command.Email.Trim(); |
| | 7 | 64 | | var email = address.ToLower(); |
| | | 65 | | |
| | 7 | 66 | | var emailTaken = await personReader.Query().AnyAsync(person => |
| | 7 | 67 | | !person.IsDeleted && person.Email.ToLower() == email && |
| | 7 | 68 | | (person.RoleId == (long)Roles.SystemAdmin || person.RoleId == (long)Roles.ScopeAdmin)); |
| | | 69 | | |
| | 7 | 70 | | if (emailTaken) |
| | | 71 | | { |
| | 3 | 72 | | return output.WithError(PersonMessages.EmailAlreadyExists); |
| | | 73 | | } |
| | | 74 | | |
| | | 75 | | // Create the ScopeAdmin with a SCOPE_OWNER row linking them to the scope as a co-owner. |
| | 4 | 76 | | var (passwordHash, salt) = await PasswordHashGate.Shared.EncodeWithRandomSaltAsync(command.Password); |
| | | 77 | | |
| | 4 | 78 | | var newPerson = new Person |
| | 4 | 79 | | { |
| | 4 | 80 | | Name = command.Name, |
| | 4 | 81 | | Email = address, |
| | 4 | 82 | | PasswordHash = passwordHash, |
| | 4 | 83 | | Salt = salt, |
| | 4 | 84 | | RoleId = (long)Roles.ScopeAdmin, |
| | 4 | 85 | | ScopeOwnerships = [new ScopeOwner { ScopeId = scope.Id }] |
| | 4 | 86 | | }; |
| | | 87 | | |
| | | 88 | | // NFR-23: recorded against the scope they will own. |
| | 4 | 89 | | LegalBasisRecorder.Record(newPerson, scope, DateTime.UtcNow); |
| | | 90 | | |
| | 4 | 91 | | var creation = await personWriter.CreateAsync(newPerson); |
| | | 92 | | |
| | 4 | 93 | | if (!creation.Success) |
| | | 94 | | { |
| | 0 | 95 | | return output.WithErrors(creation.Errors); |
| | | 96 | | } |
| | | 97 | | |
| | | 98 | | // FR-EV-01/02: issue and send the verification token. |
| | 4 | 99 | | await emailVerification.IssueAndSendAsync(newPerson); |
| | | 100 | | |
| | 4 | 101 | | return output |
| | 4 | 102 | | .WithData(new CreatePersonCommandOutput |
| | 4 | 103 | | { |
| | 4 | 104 | | Id = newPerson.PublicId, |
| | 4 | 105 | | Name = newPerson.Name, |
| | 4 | 106 | | Email = newPerson.Email, |
| | 4 | 107 | | Role = (int)Roles.ScopeAdmin, |
| | 4 | 108 | | EmailVerified = newPerson.EmailVerified, |
| | 4 | 109 | | ScopeId = scope.PublicId, |
| | 4 | 110 | | CreatedAt = newPerson.CreatedAt |
| | 4 | 111 | | }) |
| | 4 | 112 | | .WithMessage(PersonMessages.PersonCreatedSuccessfully); |
| | 11 | 113 | | } |
| | | 114 | | } |