| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 5 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 6 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 7 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 8 | | using ArturRios.Output; |
| | | 9 | | using Microsoft.EntityFrameworkCore; |
| | | 10 | | |
| | | 11 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Handles <see cref="DeleteScopeCommand" /> (UC-04): locates the scope (AF-04a), then logically |
| | | 15 | | /// deletes it and cascades <c>IsDeleted = true</c> to its Users (via <c>SCOPE_USER</c>), Google |
| | | 16 | | /// Users, and applications. Owners (<c>SCOPE_OWNER</c>) are never modified. An already-deleted |
| | | 17 | | /// scope is answered as deleted (AF-04b), its cascade re-run for any member still live. The |
| | | 18 | | /// response reports the totals of the scope's members, counted regardless of their individual |
| | | 19 | | /// deletion state. All failures are returned as errors on the <see cref="DataOutput{T}" /> or |
| | | 20 | | /// thrown, and none of them keeps a partial cascade. |
| | | 21 | | /// </summary> |
| | | 22 | | /// <remarks> |
| | | 23 | | /// <para> |
| | | 24 | | /// <b>All or nothing</b> (#204). The scope's flag and the cascade are written in one |
| | | 25 | | /// <see cref="IWriteTransactions" /> transaction, so a failure part-way leaves the scope and every |
| | | 26 | | /// member as they were, and the next attempt starts from the beginning. They used to be separate |
| | | 27 | | /// saves: a failure after the flag left a deleted scope with live members, and the retry, finding |
| | | 28 | | /// the scope deleted, skipped the cascade and answered <c>200</c>. |
| | | 29 | | /// </para> |
| | | 30 | | /// <para> |
| | | 31 | | /// <b>A retry finishes the cascade.</b> AF-04b leaves the scope as it is but runs the cascade |
| | | 32 | | /// again, which touches only members not yet deleted, so a scope left half-cascaded — by a |
| | | 33 | | /// version before the transaction — is repaired by deleting it again, and an intact one costs |
| | | 34 | | /// three statements that change nothing. |
| | | 35 | | /// </para> |
| | | 36 | | /// <para> |
| | | 37 | | /// <b>Set-based</b> (<see cref="IScopeCascades" />). The members are never loaded: the cascade is |
| | | 38 | | /// one conditional <c>UPDATE</c> per kind of member, and the totals are counts. A tenant of any |
| | | 39 | | /// size costs the request the same memory. |
| | | 40 | | /// </para> |
| | | 41 | | /// <para> |
| | | 42 | | /// <b>Under the scope's lock</b> (#202). The transaction first locks the scope's row |
| | | 43 | | /// (<see cref="IInvariantLocks" />), which every writer that can take an owner out of the scope |
| | | 44 | | /// also holds, and reads the scope again under it. So the deletion and an owner change of the same |
| | | 45 | | /// scope run one after the other: an owner removal that follows finds the scope gone (AF-22a), |
| | | 46 | | /// and the deletion never acts on a scope read before another request deleted it. |
| | | 47 | | /// </para> |
| | | 48 | | /// <para> |
| | | 49 | | /// <b>The flag alone</b> (#205). The scope is written by a targeted write of <c>IsDeleted</c> and |
| | | 50 | | /// <c>UpdatedAt</c>, not a save of the copy read before the lock, which would write the scope's |
| | | 51 | | /// name, description and settings back as they were read, over a change made in between. |
| | | 52 | | /// </para> |
| | | 53 | | /// </remarks> |
| | 16 | 54 | | public class DeleteScopeCommandHandler( |
| | 16 | 55 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 16 | 56 | | IAtomicWrites atomicWrites, |
| | 16 | 57 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 16 | 58 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 16 | 59 | | IAsyncReadOnlyRepository<Application, long> applicationReader, |
| | 16 | 60 | | IScopeCascades scopeCascades, |
| | 16 | 61 | | IWriteTransactions transactions, |
| | 16 | 62 | | IInvariantLocks locks) |
| | | 63 | | : ICommandHandlerAsync<DeleteScopeCommand, DeleteScopeCommandOutput> |
| | | 64 | | { |
| | | 65 | | public async Task<DataOutput<DeleteScopeCommandOutput?>> HandleAsync(DeleteScopeCommand command, CancellationToken c |
| | | 66 | | { |
| | 16 | 67 | | var output = DataOutput<DeleteScopeCommandOutput?>.New; |
| | | 68 | | |
| | | 69 | | // Step 2 (AF-04a): locate the scope in ANY deletion state, so an already-deleted scope is |
| | | 70 | | // handled idempotently (AF-04b) rather than reported as not found. |
| | 16 | 71 | | var scope = await scopeReader.Query().FirstOrDefaultAsync(x => x.PublicId == command.Id); |
| | | 72 | | |
| | 16 | 73 | | if (scope is null) |
| | | 74 | | { |
| | 2 | 75 | | return output.WithError(ScopeMessages.ScopeNotFound); |
| | | 76 | | } |
| | | 77 | | |
| | 14 | 78 | | var now = DateTime.UtcNow; |
| | | 79 | | |
| | | 80 | | // Steps 3-4 land together or not at all (#204): leaving this block by any return or exception |
| | | 81 | | // before the commit rolls both back. |
| | 14 | 82 | | await using var transaction = await transactions.BeginAsync(); |
| | 14 | 83 | | await locks.LockAsync([scope.Id]); |
| | | 84 | | |
| | | 85 | | // The scope's deletion state as it stands under the lock, not as read above: the tracked copy |
| | | 86 | | // keeps the values it was read with. Gone altogether is AF-04a's answer. |
| | 14 | 87 | | var isDeleted = await scopeReader.Query() |
| | 14 | 88 | | .Where(x => x.Id == scope.Id) |
| | 14 | 89 | | .Select(x => (bool?)x.IsDeleted) |
| | 14 | 90 | | .FirstOrDefaultAsync(); |
| | | 91 | | |
| | 14 | 92 | | if (isDeleted is null) |
| | | 93 | | { |
| | 0 | 94 | | return output.WithError(ScopeMessages.ScopeNotFound); |
| | | 95 | | } |
| | | 96 | | |
| | | 97 | | // Step 3: flip the scope itself, and nothing else of it. AF-04b: an already-deleted scope is left |
| | | 98 | | // as it is. Under the lock nothing else writes the row, so the write cannot find it deleted. |
| | 14 | 99 | | if (isDeleted == false) |
| | | 100 | | { |
| | 10 | 101 | | await atomicWrites.TryMarkScopeDeletedAsync(scope, now); |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | // Step 4, for a new deletion and for AF-04b alike: every member not yet deleted. It is an |
| | | 105 | | // administrative deletion like any other (NFR-20): nobody asked to be erased, so the identities |
| | | 106 | | // it sweeps up get the reversal window rather than the statutory deadline. Members already |
| | | 107 | | // deleted keep the time and kind of their own deletion (#237). |
| | 14 | 108 | | await scopeCascades.MarkMembersDeletedAsync(scope.Id, now); |
| | | 109 | | |
| | | 110 | | // Step 5's totals, counted regardless of individual deletion state. |
| | 13 | 111 | | var userCount = await personReader.Query() |
| | 13 | 112 | | .CountAsync(p => p.ScopeMembership != null && p.ScopeMembership.ScopeId == scope.Id); |
| | 13 | 113 | | var googleUserCount = await googleUserReader.Query().CountAsync(g => g.ScopeId == scope.Id); |
| | 13 | 114 | | var applicationCount = await applicationReader.Query().CountAsync(a => a.ScopeId == scope.Id); |
| | | 115 | | |
| | 13 | 116 | | await transaction.CommitAsync(); |
| | | 117 | | |
| | | 118 | | // Step 5: return the scope id and the member totals. |
| | 13 | 119 | | return output |
| | 13 | 120 | | .WithData(new DeleteScopeCommandOutput |
| | 13 | 121 | | { |
| | 13 | 122 | | Id = scope.PublicId, |
| | 13 | 123 | | UserCount = userCount, |
| | 13 | 124 | | GoogleUserCount = googleUserCount, |
| | 13 | 125 | | ApplicationCount = applicationCount |
| | 13 | 126 | | }) |
| | 13 | 127 | | .WithMessage(ScopeMessages.ScopeDeletedSuccessfully); |
| | 15 | 128 | | } |
| | | 129 | | } |