| | | 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 Microsoft.EntityFrameworkCore; |
| | | 11 | | |
| | | 12 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Handles <see cref="LiftProcessingRestrictionCommand" /> (UC-45): lifts a restriction, after |
| | | 16 | | /// informing the subject where the law requires it. All failures are returned as errors on the |
| | | 17 | | /// <see cref="DataOutput{T}" /> rather than thrown. |
| | | 18 | | /// </summary> |
| | | 19 | | /// <remarks> |
| | | 20 | | /// <para> |
| | | 21 | | /// <b>Art. 18(3) is a precondition, not a courtesy.</b> The subject "shall be informed |
| | | 22 | | /// before the restriction is lifted", so a failed notification stops the lift. That is the |
| | | 23 | | /// opposite of how every other delivery in this API behaves — elsewhere a failure is |
| | | 24 | | /// deliberately not the caller's problem — and the difference is deliberate: a lift that |
| | | 25 | | /// proceeded after a failed send would be unlawful and would look identical to one that |
| | | 26 | | /// worked. |
| | | 27 | | /// </para> |
| | | 28 | | /// <para> |
| | | 29 | | /// <b>A subject lifting their own restriction needs no notification.</b> They are the person |
| | | 30 | | /// Art. 18(3) exists to inform. Requiring an email to be delivered to somebody who is |
| | | 31 | | /// standing in front of you, and refusing their request when it bounces, would be the |
| | | 32 | | /// article's letter against its purpose. |
| | | 33 | | /// </para> |
| | | 34 | | /// </remarks> |
| | 15 | 35 | | public class LiftProcessingRestrictionCommandHandler( |
| | 15 | 36 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 15 | 37 | | IAsyncRepository<Person, long> personWriter, |
| | 15 | 38 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 15 | 39 | | IAsyncRepository<GoogleUser, long> googleUserWriter, |
| | 15 | 40 | | IRestrictionLiftNotifier notifier) |
| | | 41 | | : ICommandHandlerAsync<LiftProcessingRestrictionCommand, LiftProcessingRestrictionCommandOutput> |
| | | 42 | | { |
| | | 43 | | public async Task<DataOutput<LiftProcessingRestrictionCommandOutput?>> HandleAsync( |
| | | 44 | | LiftProcessingRestrictionCommand command, CancellationToken cancellationToken = default) |
| | | 45 | | { |
| | 15 | 46 | | var output = DataOutput<LiftProcessingRestrictionCommandOutput?>.New; |
| | | 47 | | |
| | 15 | 48 | | var subjectId = command.SubjectId ?? command.ActingPersonId; |
| | 15 | 49 | | var liftingOwn = subjectId == command.ActingPersonId; |
| | | 50 | | |
| | | 51 | | // Only a System Admin may lift somebody else's. The endpoint carries no role requirement, |
| | | 52 | | // because a subject of any role may lift their own, so the rule is data-dependent and lives |
| | | 53 | | // here. |
| | | 54 | | // |
| | | 55 | | // A subject-rights-only token acts as a subject and never as an administrator (AF-45c), |
| | | 56 | | // including after its own restriction was lifted: it was issued to a restricted identity, |
| | | 57 | | // and that is all it ever stands for. |
| | 15 | 58 | | if (!liftingOwn && (command.ActingRole != (int)Roles.SystemAdmin || command.ActingSubjectRightsOnly)) |
| | | 59 | | { |
| | 4 | 60 | | return output.WithError(ErasureMessages.NotEligible); |
| | | 61 | | } |
| | | 62 | | |
| | | 63 | | // The endpoint admits a restricted or suspended caller, because lifting their own restriction |
| | | 64 | | // is a right of theirs (NFR-24). Lifting somebody else's is not: an administrator who is |
| | | 65 | | // restricted or suspended themselves acts here as a subject, not as an administrator. |
| | 11 | 66 | | if (!liftingOwn && await personReader.Query().AnyAsync(x => |
| | 11 | 67 | | x.PublicId == command.ActingPersonId && (x.IsDeleted || x.ProcessingRestrictedAt != null))) |
| | | 68 | | { |
| | 2 | 69 | | return output.WithError(ErasureMessages.NotEligible); |
| | | 70 | | } |
| | | 71 | | |
| | 9 | 72 | | var person = await personReader.Query().FirstOrDefaultAsync(x => x.PublicId == subjectId); |
| | | 73 | | |
| | 9 | 74 | | if (person is not null) |
| | | 75 | | { |
| | 8 | 76 | | return await LiftAsync( |
| | 8 | 77 | | output, person.ProcessingRestrictedAt, person.Email, liftingOwn, |
| | 8 | 78 | | notifiedAt => |
| | 8 | 79 | | { |
| | 6 | 80 | | person.ProcessingRestrictedAt = null; |
| | 6 | 81 | | person.RestrictionGround = null; |
| | 6 | 82 | | person.RestrictionLiftNotifiedAt = notifiedAt; |
| | 6 | 83 | | person.UpdatedAt = DateTime.UtcNow; |
| | 6 | 84 | | }, |
| | 6 | 85 | | () => personWriter.UpdateAsync(person), |
| | 8 | 86 | | person.PublicId); |
| | | 87 | | } |
| | | 88 | | |
| | 1 | 89 | | var googleUser = await googleUserReader.Query().FirstOrDefaultAsync(x => x.PublicId == subjectId); |
| | | 90 | | |
| | 1 | 91 | | if (googleUser is null) |
| | | 92 | | { |
| | 0 | 93 | | return output.WithError(ErasureMessages.NotEligible); |
| | | 94 | | } |
| | | 95 | | |
| | 1 | 96 | | return await LiftAsync( |
| | 1 | 97 | | output, googleUser.ProcessingRestrictedAt, googleUser.Email, liftingOwn, |
| | 1 | 98 | | notifiedAt => |
| | 1 | 99 | | { |
| | 1 | 100 | | googleUser.ProcessingRestrictedAt = null; |
| | 1 | 101 | | googleUser.RestrictionGround = null; |
| | 1 | 102 | | googleUser.RestrictionLiftNotifiedAt = notifiedAt; |
| | 1 | 103 | | googleUser.UpdatedAt = DateTime.UtcNow; |
| | 1 | 104 | | }, |
| | 1 | 105 | | () => googleUserWriter.UpdateAsync(googleUser), |
| | 1 | 106 | | googleUser.PublicId); |
| | 15 | 107 | | } |
| | | 108 | | |
| | | 109 | | private async Task<DataOutput<LiftProcessingRestrictionCommandOutput?>> LiftAsync<T>( |
| | | 110 | | DataOutput<LiftProcessingRestrictionCommandOutput?> output, |
| | | 111 | | DateTime? restrictedAt, |
| | | 112 | | string email, |
| | | 113 | | bool liftingOwn, |
| | | 114 | | Action<DateTime?> clear, |
| | | 115 | | Func<Task<DataOutput<T>>> save, |
| | | 116 | | Guid subjectPublicId) |
| | | 117 | | { |
| | | 118 | | // AF-45a. |
| | 9 | 119 | | if (restrictedAt is null) |
| | | 120 | | { |
| | 1 | 121 | | return output.WithError(ErasureMessages.NotRestricted); |
| | | 122 | | } |
| | | 123 | | |
| | 8 | 124 | | DateTime? notifiedAt = null; |
| | | 125 | | |
| | 8 | 126 | | if (!liftingOwn) |
| | | 127 | | { |
| | | 128 | | // AF-45b. Informing comes first and the lift does not proceed without it. |
| | 4 | 129 | | if (!await notifier.NotifyAsync(email)) |
| | | 130 | | { |
| | 1 | 131 | | return output.WithError(ErasureMessages.RestrictionLiftNotificationFailed); |
| | | 132 | | } |
| | | 133 | | |
| | 3 | 134 | | notifiedAt = DateTime.UtcNow; |
| | | 135 | | } |
| | | 136 | | |
| | 7 | 137 | | clear(notifiedAt); |
| | | 138 | | |
| | 7 | 139 | | var update = await save(); |
| | | 140 | | |
| | 7 | 141 | | return update.Success |
| | 7 | 142 | | ? output |
| | 7 | 143 | | .WithData(new LiftProcessingRestrictionCommandOutput |
| | 7 | 144 | | { |
| | 7 | 145 | | Id = subjectPublicId, |
| | 7 | 146 | | LiftedAt = DateTime.UtcNow, |
| | 7 | 147 | | SubjectNotified = notifiedAt is not null |
| | 7 | 148 | | }) |
| | 7 | 149 | | .WithMessage(ErasureMessages.RestrictionLifted) |
| | 7 | 150 | | : output.WithErrors(update.Errors); |
| | 9 | 151 | | } |
| | | 152 | | } |