| | | 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.Shared.Messages; |
| | | 7 | | using ArturRios.Heimdall.Shared.Services; |
| | | 8 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | |
| | | 12 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Handles <see cref="SendPersonVerificationEmailCommand" /> (UC-51, FR-EV-06/07): an administrator |
| | | 16 | | /// has a fresh verification link mailed to a person's own address. |
| | | 17 | | /// </summary> |
| | | 18 | | /// <remarks> |
| | | 19 | | /// <para> |
| | | 20 | | /// Refusals, in order: not found (AF-51a); the caller may not act on this person (AF-51b); |
| | | 21 | | /// restricted, then deleted (AF-51c); already verified (AF-51d); the cooldown is running |
| | | 22 | | /// (AF-51e). Authorization comes before the state checks, as UC-09's does, so a Scope Admin |
| | | 23 | | /// learns nothing about persons outside their scopes. |
| | | 24 | | /// </para> |
| | | 25 | | /// <para> |
| | | 26 | | /// The send itself is UC-15's: every live link is retired, then |
| | | 27 | | /// <see cref="IEmailVerificationService" /> issues and mails a new one, so the link uses the |
| | | 28 | | /// scope's <c>EmailVerificationUrl</c> exactly as self-service does. The token never appears |
| | | 29 | | /// in the output or in a log. A Mailgun failure is swallowed by the sender as everywhere else, |
| | | 30 | | /// so success means the email was handed over, not delivered. |
| | | 31 | | /// </para> |
| | | 32 | | /// </remarks> |
| | 28 | 33 | | public class SendPersonVerificationEmailCommandHandler( |
| | 28 | 34 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 28 | 35 | | IAsyncReadOnlyRepository<EmailVerificationToken, long> tokenReader, |
| | 28 | 36 | | IAsyncRepository<EmailVerificationToken, long> tokenWriter, |
| | 28 | 37 | | IScopeOwnershipChecker scopeOwnership, |
| | 28 | 38 | | IEmailVerificationService emailVerification, |
| | 28 | 39 | | EmailVerificationOptions verificationOptions, |
| | 28 | 40 | | AdminEmailOptions adminEmailOptions) |
| | | 41 | | : ICommandHandlerAsync<SendPersonVerificationEmailCommand, SendPersonVerificationEmailCommandOutput> |
| | | 42 | | { |
| | | 43 | | public async Task<DataOutput<SendPersonVerificationEmailCommandOutput?>> HandleAsync( |
| | | 44 | | SendPersonVerificationEmailCommand command, CancellationToken cancellationToken = default) |
| | | 45 | | { |
| | 28 | 46 | | var output = DataOutput<SendPersonVerificationEmailCommandOutput?>.New; |
| | | 47 | | |
| | | 48 | | // AF-51a. No !IsDeleted filter: a logically deleted person is found and refused by AF-51c, |
| | | 49 | | // after authorization. A Google User's id misses — they are not persons. |
| | 28 | 50 | | var person = await personReader.Query() |
| | 28 | 51 | | .FirstOrDefaultAsync(x => x.PublicId == command.Id, cancellationToken); |
| | | 52 | | |
| | 28 | 53 | | if (person is null) |
| | | 54 | | { |
| | 3 | 55 | | return output.WithError(PersonMessages.PersonNotFound); |
| | | 56 | | } |
| | | 57 | | |
| | | 58 | | // AF-51b. |
| | 25 | 59 | | if (!await PersonEmailAuthorization.ActorMayEmailAsync( |
| | 25 | 60 | | scopeOwnership, command.ActingRole, command.ActingPersonId, person)) |
| | | 61 | | { |
| | 4 | 62 | | return output.WithError(PersonMessages.NotAuthorizedToEmailPerson); |
| | | 63 | | } |
| | | 64 | | |
| | | 65 | | // AF-51c. The restriction first, as UC-12/UC-52 check it (NFR-24). |
| | 21 | 66 | | if (person.ProcessingRestrictedAt is not null) |
| | | 67 | | { |
| | 3 | 68 | | return output.WithError(PersonMessages.PersonProcessingRestricted); |
| | | 69 | | } |
| | | 70 | | |
| | 18 | 71 | | if (person.IsDeleted) |
| | | 72 | | { |
| | 3 | 73 | | return output.WithError(PersonMessages.PersonIsDeleted); |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | // AF-51d. |
| | 15 | 77 | | if (person.EmailVerified) |
| | | 78 | | { |
| | 2 | 79 | | return output.WithError(AuthMessages.EmailAlreadyVerified); |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | // AF-51e. Any verification token issued for the person inside the window — used, superseded |
| | | 83 | | // or live, self-service or not — means one was sent recently. |
| | | 84 | | // Best-effort: concurrent requests for the same person can both pass this check (no lock). |
| | | 85 | | // Accepted, because only an authenticated administrator can trigger it. |
| | 13 | 86 | | var now = DateTime.UtcNow; |
| | | 87 | | |
| | 13 | 88 | | if (await adminEmailOptions.IsRunningAsync( |
| | 13 | 89 | | tokenReader, person.Id, verificationOptions.TokenLifetime, now, cancellationToken)) |
| | | 90 | | { |
| | 3 | 91 | | return output.WithError(PersonMessages.AccountEmailSentRecently); |
| | | 92 | | } |
| | | 93 | | |
| | | 94 | | // As UC-15 step 3: retire every live link, so the new one is the only one that works. |
| | 10 | 95 | | var retirement = await EmailVerificationTokenRetirement.RetireLiveAsync( |
| | 10 | 96 | | tokenReader, tokenWriter, person.Id, now); |
| | | 97 | | |
| | 10 | 98 | | if (retirement is not null) |
| | | 99 | | { |
| | 1 | 100 | | return output.WithErrors(retirement); |
| | | 101 | | } |
| | | 102 | | |
| | 9 | 103 | | await emailVerification.IssueAndSendAsync(person); |
| | | 104 | | |
| | 9 | 105 | | return output |
| | 9 | 106 | | .WithData(new SendPersonVerificationEmailCommandOutput { Id = person.PublicId }) |
| | 9 | 107 | | .WithMessage(PersonMessages.VerificationEmailSentToPerson); |
| | 28 | 108 | | } |
| | | 109 | | } |