| | | 1 | | using ArturRios.Data.Relational.Core.Entities; |
| | | 2 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 3 | | using ArturRios.Heimdall.Command.Input; |
| | | 4 | | using ArturRios.Heimdall.Command.Output; |
| | | 5 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 6 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 7 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 8 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 9 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 10 | | using ArturRios.Output; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="HardDeleteScopeCommand" /> (UC-05): locates the scope (AF-05a), then |
| | | 17 | | /// permanently deletes its Users (via <c>SCOPE_USER</c>), Google Users, and applications, and |
| | | 18 | | /// finally the scope itself — whose <c>ON DELETE CASCADE</c> foreign keys remove the |
| | | 19 | | /// <c>SCOPE_OWNER</c>/<c>SCOPE_USER</c> join rows and any remaining scope permissions. Owner |
| | | 20 | | /// person records (<c>ScopeAdmin</c>s) are never removed. The response reports the totals of |
| | | 21 | | /// the scope's members and permissions, counted regardless of their individual deletion state. |
| | | 22 | | /// All failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <remarks> |
| | | 25 | | /// Before anything is removed, the <c>PublicId</c> of every member person and Google User about |
| | | 26 | | /// to go is tombstoned (<see cref="ErasedSubject" />, NFR-21, #195), one per row, in one write. |
| | | 27 | | /// Audit entries name their target by that bare id, so without the tombstone nothing would say, |
| | | 28 | | /// once the rows are gone, that those entries name people whose targets must be cleared. The |
| | | 29 | | /// scope's own id is not tombstoned: a scope is not a person, and its entries stay as recorded. |
| | | 30 | | /// Written first, a failure later leaves tombstones for live rows, which nothing acts on; and a |
| | | 31 | | /// retry writes them again without error. |
| | | 32 | | /// </remarks> |
| | 11 | 33 | | public class HardDeleteScopeCommandHandler( |
| | 11 | 34 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 11 | 35 | | IAsyncRepository<Scope, long> scopeWriter, |
| | 11 | 36 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 11 | 37 | | IAsyncRepository<Person, long> personWriter, |
| | 11 | 38 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 11 | 39 | | IAsyncRepository<GoogleUser, long> googleUserWriter, |
| | 11 | 40 | | IAsyncReadOnlyRepository<Application, long> applicationReader, |
| | 11 | 41 | | IAsyncRepository<Application, long> applicationWriter, |
| | 11 | 42 | | IAsyncReadOnlyRepository<ScopePermission, long> scopePermissionReader, |
| | 11 | 43 | | IErasedSubjects erasedSubjects) |
| | | 44 | | : ICommandHandlerAsync<HardDeleteScopeCommand, HardDeleteScopeCommandOutput> |
| | | 45 | | { |
| | | 46 | | public async Task<DataOutput<HardDeleteScopeCommandOutput?>> HandleAsync(HardDeleteScopeCommand command, Cancellatio |
| | | 47 | | { |
| | 11 | 48 | | var output = DataOutput<HardDeleteScopeCommandOutput?>.New; |
| | | 49 | | |
| | | 50 | | // Step 2 (AF-05a): locate the scope in ANY deletion state — an already logically-deleted |
| | | 51 | | // scope can still be hard-deleted. |
| | 11 | 52 | | var scope = await scopeReader.Query().FirstOrDefaultAsync(x => x.PublicId == command.Id); |
| | | 53 | | |
| | 11 | 54 | | if (scope is null) |
| | | 55 | | { |
| | 2 | 56 | | return output.WithError(ScopeMessages.ScopeNotFound); |
| | | 57 | | } |
| | | 58 | | |
| | | 59 | | // The scope's members, counted regardless of individual deletion state: the rows steps 3 |
| | | 60 | | // and 4 act on, and the totals step 7 reports. |
| | 9 | 61 | | var users = await personReader.Query() |
| | 9 | 62 | | .Where(p => p.ScopeMembership != null && p.ScopeMembership.ScopeId == scope.Id) |
| | 9 | 63 | | .ToListAsync(); |
| | 9 | 64 | | var googleUsers = await googleUserReader.Query() |
| | 9 | 65 | | .Where(g => g.ScopeId == scope.Id) |
| | 9 | 66 | | .ToListAsync(); |
| | 9 | 67 | | var applications = await applicationReader.Query() |
| | 9 | 68 | | .Where(a => a.ScopeId == scope.Id) |
| | 9 | 69 | | .ToListAsync(); |
| | 9 | 70 | | var scopePermissionCount = await scopePermissionReader.Query() |
| | 9 | 71 | | .CountAsync(p => p.ScopeId == scope.Id); |
| | | 72 | | |
| | | 73 | | // Step 3 (NFR-21, #195): tombstone exactly the persons and Google Users step 4 removes, |
| | | 74 | | // before any of it happens. Owners are not members and are not removed, so they are not |
| | | 75 | | // tombstoned. |
| | 9 | 76 | | var erased = users |
| | 8 | 77 | | .Select(user => new ErasedSubject |
| | 8 | 78 | | { |
| | 8 | 79 | | Id = user.PublicId, Kind = (int)ErasedSubjectKinds.Person, CreatedAt = DateTime.UtcNow |
| | 8 | 80 | | }) |
| | 4 | 81 | | .Concat(googleUsers.Select(googleUser => new ErasedSubject |
| | 4 | 82 | | { |
| | 4 | 83 | | Id = googleUser.PublicId, Kind = (int)ErasedSubjectKinds.GoogleUser, CreatedAt = DateTime.UtcNow |
| | 4 | 84 | | })) |
| | 9 | 85 | | .ToList(); |
| | | 86 | | |
| | 9 | 87 | | if (erased.Count > 0) |
| | | 88 | | { |
| | 5 | 89 | | await erasedSubjects.RecordAsync(erased); |
| | | 90 | | } |
| | | 91 | | |
| | | 92 | | // Step 4: delete the members explicitly, in an order that never violates a foreign key |
| | | 93 | | // (applications reference their owning person, so they go first). |
| | 9 | 94 | | var deleteErrors = (await DeleteAllAsync(applications, applicationWriter)) |
| | 9 | 95 | | .Concat(await DeleteAllAsync(googleUsers, googleUserWriter)) |
| | 9 | 96 | | .Concat(await DeleteAllAsync(users, personWriter)) |
| | 9 | 97 | | .ToList(); |
| | | 98 | | |
| | 9 | 99 | | if (deleteErrors.Count > 0) |
| | | 100 | | { |
| | 0 | 101 | | return output.WithErrors(deleteErrors); |
| | | 102 | | } |
| | | 103 | | |
| | | 104 | | // Steps 5-6: delete the scope; its ON DELETE CASCADE foreign keys clear the SCOPE_OWNER and any |
| | | 105 | | // remaining SCOPE_USER join rows, as well as any remaining scope permissions. Owner person |
| | | 106 | | // records are untouched. |
| | 9 | 107 | | var scopeDelete = await scopeWriter.DeleteAsync(scope); |
| | | 108 | | |
| | 9 | 109 | | if (!scopeDelete.Success) |
| | | 110 | | { |
| | 0 | 111 | | return output.WithErrors(scopeDelete.Errors); |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | // Step 7: return the scope id and the member/permission totals. |
| | 9 | 115 | | return output |
| | 9 | 116 | | .WithData(new HardDeleteScopeCommandOutput |
| | 9 | 117 | | { |
| | 9 | 118 | | Id = scope.PublicId, |
| | 9 | 119 | | UserCount = users.Count, |
| | 9 | 120 | | GoogleUserCount = googleUsers.Count, |
| | 9 | 121 | | ApplicationCount = applications.Count, |
| | 9 | 122 | | ScopePermissionCount = scopePermissionCount |
| | 9 | 123 | | }) |
| | 9 | 124 | | .WithMessage(ScopeMessages.ScopeHardDeletedSuccessfully); |
| | 11 | 125 | | } |
| | | 126 | | |
| | | 127 | | /// <summary> |
| | | 128 | | /// Permanently removes every entity in <paramref name="members" /> by internal Id, or does |
| | | 129 | | /// nothing when the set is empty. Returns any persistence errors, or an empty sequence on |
| | | 130 | | /// success / no-op. |
| | | 131 | | /// </summary> |
| | | 132 | | private static async Task<IEnumerable<string>> DeleteAllAsync<T>( |
| | | 133 | | IReadOnlyCollection<T> members, |
| | | 134 | | IAsyncRepository<T, long> writer) where T : Entity<long> |
| | | 135 | | { |
| | 27 | 136 | | if (members.Count == 0) |
| | | 137 | | { |
| | 12 | 138 | | return []; |
| | | 139 | | } |
| | | 140 | | |
| | 33 | 141 | | var result = await writer.DeleteRangeAsync(members.Select(member => member.Id)); |
| | | 142 | | |
| | 15 | 143 | | return result.Success ? [] : result.Errors; |
| | 27 | 144 | | } |
| | | 145 | | } |