| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Auditing; |
| | | 3 | | using ArturRios.Heimdall.Command.Input; |
| | | 4 | | using ArturRios.Heimdall.Command.Output; |
| | | 5 | | using ArturRios.Heimdall.Command.Services; |
| | | 6 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 7 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 8 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 9 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 10 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 11 | | using ArturRios.Output; |
| | | 12 | | using Microsoft.EntityFrameworkCore; |
| | | 13 | | using Microsoft.Extensions.Logging; |
| | | 14 | | |
| | | 15 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 16 | | |
| | | 17 | | /// <summary> |
| | | 18 | | /// Handles <see cref="AcceptScopeOwnerInvitationCommand" /> (UC-60, FR-SC-17): the invitee, signed in as |
| | | 19 | | /// themselves, accepts an open invitation, which ends it as accepted and makes them an owner of the scope |
| | | 20 | | /// in one transaction; the inviter is then told. All failures are returned as errors on the |
| | | 21 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 22 | | /// </summary> |
| | | 23 | | /// <remarks> |
| | | 24 | | /// <para> |
| | | 25 | | /// <b>One answer for every refusal (AF-60a).</b> An unknown id, somebody else's invitation, an ended or |
| | | 26 | | /// expired one, and one that a withdrawal or another answer ended between the read and the write all |
| | | 27 | | /// answer "The invitation was not found or is no longer open." The inviter accepting their own |
| | | 28 | | /// invitation is told the same as an outsider. |
| | | 29 | | /// </para> |
| | | 30 | | /// <para> |
| | | 31 | | /// <b>An invitation that can no longer be honoured is voided (AF-60b).</b> The scope must still be live |
| | | 32 | | /// and the invitee <see cref="ScopeOwnerInvitation.EligibleInvitee" />. If not, the invitation ends as |
| | | 33 | | /// voided, the void is audited with the invitation as target, and the answer is AF-60a's. The |
| | | 34 | | /// acceptance's own write re-checks both conditions, so a change landing after this check cannot slip |
| | | 35 | | /// through; when that write is refused, the invitation is read again and, if it is still open but no |
| | | 36 | | /// longer honourable, voided the same way. |
| | | 37 | | /// </para> |
| | | 38 | | /// <para> |
| | | 39 | | /// The acceptance (<see cref="IScopeOwnerInvitations.TryAcceptAsync" />) opens its own transaction, so |
| | | 40 | | /// this handler opens none around it. |
| | | 41 | | /// </para> |
| | | 42 | | /// </remarks> |
| | 25 | 43 | | public class AcceptScopeOwnerInvitationCommandHandler( |
| | 25 | 44 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 25 | 45 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 25 | 46 | | IAsyncReadOnlyRepository<ScopeOwnerInvitation, long> invitationReader, |
| | 25 | 47 | | IScopeOwnerInvitations invitations, |
| | 25 | 48 | | IScopeInvitationNoticeSender notices, |
| | 25 | 49 | | IAuditLogWriter auditLog, |
| | 25 | 50 | | ILogger<AcceptScopeOwnerInvitationCommandHandler> logger) |
| | | 51 | | : ICommandHandlerAsync<AcceptScopeOwnerInvitationCommand, AcceptScopeOwnerInvitationCommandOutput> |
| | | 52 | | { |
| | 2 | 53 | | private static readonly Func<Person, bool> EligibleInvitee = ScopeOwnerInvitation.EligibleInvitee.Compile(); |
| | | 54 | | |
| | | 55 | | public async Task<DataOutput<AcceptScopeOwnerInvitationCommandOutput?>> HandleAsync( |
| | | 56 | | AcceptScopeOwnerInvitationCommand command, CancellationToken cancellationToken = default) |
| | | 57 | | { |
| | 25 | 58 | | var output = DataOutput<AcceptScopeOwnerInvitationCommandOutput?>.New; |
| | 25 | 59 | | var now = DateTime.UtcNow; |
| | | 60 | | |
| | | 61 | | // AF-60a. |
| | 25 | 62 | | var found = await InviteeInvitationLookup.FindAsync( |
| | 25 | 63 | | personReader, invitationReader, command.ActingPersonId, command.InvitationId, now, cancellationToken); |
| | | 64 | | |
| | 25 | 65 | | if (found is null) |
| | | 66 | | { |
| | 5 | 67 | | return output.WithError(ScopeInvitationMessages.InvitationNotOpen); |
| | | 68 | | } |
| | | 69 | | |
| | 20 | 70 | | var (invitee, invitation) = found.Value; |
| | | 71 | | |
| | 20 | 72 | | var scope = await scopeReader.Query() |
| | 20 | 73 | | .FirstOrDefaultAsync(x => x.Id == invitation.ScopeId, cancellationToken); |
| | | 74 | | |
| | | 75 | | // AF-60b. EligibleInvitee covers the person only; the scope's liveness is checked beside it. |
| | 20 | 76 | | if (scope is null || scope.IsDeleted || !EligibleInvitee(invitee)) |
| | | 77 | | { |
| | 4 | 78 | | await VoidAsync(invitation, now); |
| | | 79 | | |
| | 4 | 80 | | return output.WithError(ScopeInvitationMessages.InvitationNotOpen); |
| | | 81 | | } |
| | | 82 | | |
| | | 83 | | // UC-60 step 3: ends the invitation and inserts the ownership together, or neither (AF-60a). |
| | 16 | 84 | | if (!await invitations.TryAcceptAsync(invitation, now)) |
| | | 85 | | { |
| | | 86 | | // AF-60b landing between the check above and the write: void it if it is still open. |
| | 6 | 87 | | if (await NoLongerHonourableAsync(invitation, now, cancellationToken)) |
| | | 88 | | { |
| | 3 | 89 | | await VoidAsync(invitation, now); |
| | | 90 | | } |
| | | 91 | | |
| | 6 | 92 | | return output.WithError(ScopeInvitationMessages.InvitationNotOpen); |
| | | 93 | | } |
| | | 94 | | |
| | 10 | 95 | | if (await InviteeInvitationLookup.InviterToTellAsync(personReader, invitation, cancellationToken) is { } inviter |
| | | 96 | | { |
| | 3 | 97 | | await notices.SendAnswerAsync(inviter.Email, invitee.Name, scope.Name, accepted: true); |
| | | 98 | | } |
| | | 99 | | |
| | 10 | 100 | | return output |
| | 10 | 101 | | .WithData(new AcceptScopeOwnerInvitationCommandOutput { Id = invitation.PublicId, ScopeId = scope.PublicId } |
| | 10 | 102 | | .WithMessage(ScopeInvitationMessages.InvitationAccepted); |
| | 25 | 103 | | } |
| | | 104 | | |
| | | 105 | | /// <summary> |
| | | 106 | | /// After a refused acceptance, reads the invitation again: <c>true</c> when it is still open and its scope |
| | | 107 | | /// is no longer live or its invitee no longer <see cref="ScopeOwnerInvitation.EligibleInvitee" />; |
| | | 108 | | /// <c>false</c> when something else ended it, a withdrawal or another answer, which stands. The checks |
| | | 109 | | /// run in the query, so they see the stored rows rather than the instances read earlier. |
| | | 110 | | /// </summary> |
| | | 111 | | private async Task<bool> NoLongerHonourableAsync( |
| | | 112 | | ScopeOwnerInvitation invitation, DateTime now, CancellationToken cancellationToken) |
| | | 113 | | { |
| | 6 | 114 | | var stillOpen = await invitationReader.Query() |
| | 6 | 115 | | .Where(x => x.Id == invitation.Id) |
| | 6 | 116 | | .Where(ScopeOwnerInvitation.OpenAt(now)) |
| | 6 | 117 | | .AnyAsync(cancellationToken); |
| | | 118 | | |
| | 6 | 119 | | if (!stillOpen) |
| | | 120 | | { |
| | 3 | 121 | | return false; |
| | | 122 | | } |
| | | 123 | | |
| | 3 | 124 | | var scopeLive = await scopeReader.Query() |
| | 3 | 125 | | .AnyAsync(x => x.Id == invitation.ScopeId && !x.IsDeleted, cancellationToken); |
| | 3 | 126 | | var inviteeEligible = await personReader.Query() |
| | 3 | 127 | | .Where(x => x.Id == invitation.InviteePersonId) |
| | 3 | 128 | | .Where(ScopeOwnerInvitation.EligibleInvitee) |
| | 3 | 129 | | .AnyAsync(cancellationToken); |
| | | 130 | | |
| | 3 | 131 | | return !scopeLive || !inviteeEligible; |
| | 6 | 132 | | } |
| | | 133 | | |
| | | 134 | | /// <summary> |
| | | 135 | | /// Ends the invitation as voided and, only if that write took effect, records the void (NFR-09). The |
| | | 136 | | /// decorator records this request's own refusal; the void is a second event, with the invitation as |
| | | 137 | | /// target. Like the decorator, a failed audit write is logged and the answer stands. |
| | | 138 | | /// </summary> |
| | | 139 | | private async Task VoidAsync(ScopeOwnerInvitation invitation, DateTime now) |
| | | 140 | | { |
| | 7 | 141 | | if (!await invitations.TryEndAsync(invitation, ScopeOwnerInvitationOutcomes.Voided, now)) |
| | | 142 | | { |
| | 0 | 143 | | return; |
| | | 144 | | } |
| | | 145 | | |
| | | 146 | | try |
| | | 147 | | { |
| | 7 | 148 | | await auditLog.WriteAsync(ScopeOwnerInvitationAuditActions.Voided, invitation.PublicId, true, null); |
| | 6 | 149 | | } |
| | 1 | 150 | | catch (Exception exception) |
| | | 151 | | { |
| | 1 | 152 | | logger.LogWarning(exception, "Failed to audit the void of invitation {InvitationId}", invitation.PublicId); |
| | 1 | 153 | | } |
| | 7 | 154 | | } |
| | | 155 | | } |