| | | 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="HardDeletePersonCommand" /> (UC-10): locates the person in any deletion state |
| | | 17 | | /// (AF-10a), refuses a self-deletion (AF-10c), refuses to strip a scope of its last owner (AF-10b, |
| | | 18 | | /// NFR-12), then permanently deletes the applications the person owns (NFR-11) and their password |
| | | 19 | | /// reset and email verification tokens, and finally the person — whose <c>ON DELETE CASCADE</c> |
| | | 20 | | /// foreign keys remove the <c>SCOPE_USER</c>/<c>SCOPE_OWNER</c> join rows. The response reports the |
| | | 21 | | /// totals of the removed dependents, counted regardless of their individual deletion state. All |
| | | 22 | | /// failures are returned as errors on the <see cref="DataOutput{T}" /> rather than thrown. |
| | | 23 | | /// </summary> |
| | | 24 | | /// <remarks> |
| | | 25 | | /// Before anything is removed, the person's <c>PublicId</c> is tombstoned |
| | | 26 | | /// (<see cref="ErasedSubject" />, NFR-21, #195). Audit entries name their target by that bare id, |
| | | 27 | | /// so once the row is gone the tombstone is the only evidence the retention pass and the database |
| | | 28 | | /// have that those entries — this hard delete's own among them — name a person whose target must be |
| | | 29 | | /// cleared. Written first, a failure later leaves a tombstone for a live row, which nothing acts |
| | | 30 | | /// on; and a retry writes it again without error. |
| | | 31 | | /// </remarks> |
| | 19 | 32 | | public class HardDeletePersonCommandHandler( |
| | 19 | 33 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 19 | 34 | | IAsyncRepository<Person, long> personWriter, |
| | 19 | 35 | | IAsyncReadOnlyRepository<Application, long> applicationReader, |
| | 19 | 36 | | IAsyncRepository<Application, long> applicationWriter, |
| | 19 | 37 | | IAsyncReadOnlyRepository<PasswordResetToken, long> passwordResetTokenReader, |
| | 19 | 38 | | IAsyncRepository<PasswordResetToken, long> passwordResetTokenWriter, |
| | 19 | 39 | | IAsyncReadOnlyRepository<EmailVerificationToken, long> emailVerificationTokenReader, |
| | 19 | 40 | | IAsyncRepository<EmailVerificationToken, long> emailVerificationTokenWriter, |
| | 19 | 41 | | IErasedSubjects erasedSubjects) |
| | | 42 | | : ICommandHandlerAsync<HardDeletePersonCommand, HardDeletePersonCommandOutput> |
| | | 43 | | { |
| | | 44 | | public async Task<DataOutput<HardDeletePersonCommandOutput?>> HandleAsync(HardDeletePersonCommand command, Cancellat |
| | | 45 | | { |
| | 19 | 46 | | var output = DataOutput<HardDeletePersonCommandOutput?>.New; |
| | | 47 | | |
| | | 48 | | // AF-10a: the lookup omits an !IsDeleted filter — a logically deleted person is exactly what a |
| | | 49 | | // cleanup pass starts from, so it must still be hard-deletable. ScopeOwnerships is needed by |
| | | 50 | | // the last-owner guard below. |
| | 19 | 51 | | var person = await personReader.Query() |
| | 19 | 52 | | .Include(x => x.ScopeOwnerships) |
| | 19 | 53 | | .FirstOrDefaultAsync(x => x.PublicId == command.Id); |
| | | 54 | | |
| | 19 | 55 | | if (person is null) |
| | | 56 | | { |
| | 2 | 57 | | return output.WithError(PersonMessages.PersonNotFound); |
| | | 58 | | } |
| | | 59 | | |
| | | 60 | | // AF-10c: nobody hard-deletes their own record, System Admin included, so one call cannot |
| | | 61 | | // permanently destroy the caller's own account. Checked before the last-owner guard, so a |
| | | 62 | | // caller targeting themselves gets the reason that applies to them. |
| | 17 | 63 | | if (command.ActingPersonId == person.PublicId) |
| | | 64 | | { |
| | 2 | 65 | | return output.WithError(PersonMessages.CannotDeleteSelf); |
| | | 66 | | } |
| | | 67 | | |
| | | 68 | | // UC-10 step 3 (AF-10b, NFR-12). Unlike UC-09, this runs regardless of the person's own |
| | | 69 | | // deletion state: NFR-12 names hard-deleting the last owning person explicitly, and the guard |
| | | 70 | | // keeps every scope row backed by at least one SCOPE_OWNER row. |
| | 15 | 71 | | if (await WouldStripLastOwnerAsync(person)) |
| | | 72 | | { |
| | 4 | 73 | | return output.WithError(PersonMessages.ScopeWouldLoseLastOwner); |
| | | 74 | | } |
| | | 75 | | |
| | | 76 | | // UC-10 step 4 (NFR-21, #195): tombstone the id before anything irreversible happens, so the |
| | | 77 | | // trail's entries about this person can still be cleared once the row that said who they |
| | | 78 | | // were is gone. |
| | 11 | 79 | | await erasedSubjects.RecordAsync( |
| | 11 | 80 | | [ |
| | 11 | 81 | | new ErasedSubject |
| | 11 | 82 | | { |
| | 11 | 83 | | Id = person.PublicId, |
| | 11 | 84 | | Kind = (int)ErasedSubjectKinds.Person, |
| | 11 | 85 | | CreatedAt = DateTime.UtcNow |
| | 11 | 86 | | } |
| | 11 | 87 | | ]); |
| | | 88 | | |
| | | 89 | | // UC-10 steps 5-6: the dependents, counted regardless of individual deletion state. |
| | 11 | 90 | | var applications = await applicationReader.Query() |
| | 11 | 91 | | .Where(a => a.OwnerId == person.Id) |
| | 11 | 92 | | .ToListAsync(); |
| | 11 | 93 | | var passwordResetTokens = await passwordResetTokenReader.Query() |
| | 11 | 94 | | .Where(t => t.PersonId == person.Id) |
| | 11 | 95 | | .ToListAsync(); |
| | 11 | 96 | | var emailVerificationTokens = await emailVerificationTokenReader.Query() |
| | 11 | 97 | | .Where(t => t.PersonId == person.Id) |
| | 11 | 98 | | .ToListAsync(); |
| | | 99 | | |
| | | 100 | | // Applications and tokens reference the person, so they go first and no foreign key is ever |
| | | 101 | | // violated. |
| | 11 | 102 | | var deleteErrors = (await DeleteAllAsync(applications, applicationWriter)) |
| | 11 | 103 | | .Concat(await DeleteAllAsync(passwordResetTokens, passwordResetTokenWriter)) |
| | 11 | 104 | | .Concat(await DeleteAllAsync(emailVerificationTokens, emailVerificationTokenWriter)) |
| | 11 | 105 | | .ToList(); |
| | | 106 | | |
| | 11 | 107 | | if (deleteErrors.Count > 0) |
| | | 108 | | { |
| | 0 | 109 | | return output.WithErrors(deleteErrors); |
| | | 110 | | } |
| | | 111 | | |
| | | 112 | | // UC-10 steps 7-8: delete the person; its ON DELETE CASCADE foreign keys clear the SCOPE_USER |
| | | 113 | | // or SCOPE_OWNER join rows. |
| | 11 | 114 | | var personDelete = await personWriter.DeleteAsync(person); |
| | | 115 | | |
| | 11 | 116 | | if (!personDelete.Success) |
| | | 117 | | { |
| | 0 | 118 | | return output.WithErrors(personDelete.Errors); |
| | | 119 | | } |
| | | 120 | | |
| | | 121 | | // UC-10 step 9. |
| | 11 | 122 | | return output |
| | 11 | 123 | | .WithData(new HardDeletePersonCommandOutput |
| | 11 | 124 | | { |
| | 11 | 125 | | Id = person.PublicId, |
| | 11 | 126 | | DeletedApplicationCount = applications.Count, |
| | 11 | 127 | | DeletedTokenCount = passwordResetTokens.Count + emailVerificationTokens.Count |
| | 11 | 128 | | }) |
| | 11 | 129 | | .WithMessage(PersonMessages.PersonHardDeletedSuccessfully); |
| | 19 | 130 | | } |
| | | 131 | | |
| | | 132 | | /// <summary> |
| | | 133 | | /// NFR-12. Gathers the scopes somebody *other* than this person owns and reports whether any |
| | | 134 | | /// scope this person owns is missing from them — the same guard |
| | | 135 | | /// <see cref="DeletePersonCommandHandler" /> and <see cref="UpdatePersonCommandHandler" /> |
| | | 136 | | /// apply. Persons already logically deleted are excluded, since they no longer keep a scope |
| | | 137 | | /// owned. |
| | | 138 | | /// </summary> |
| | | 139 | | private async Task<bool> WouldStripLastOwnerAsync(Person person) |
| | | 140 | | { |
| | | 141 | | // Whatever the role: a SystemAdmin may own a scope too (FR-SC-08). |
| | 15 | 142 | | if (person.ScopeOwnerships.Count == 0) |
| | | 143 | | { |
| | 9 | 144 | | return false; |
| | | 145 | | } |
| | | 146 | | |
| | 12 | 147 | | var ownedScopeIds = person.ScopeOwnerships.Select(ownership => ownership.ScopeId).ToList(); |
| | | 148 | | |
| | 6 | 149 | | var coOwnedScopeIds = await personReader.Query() |
| | 6 | 150 | | .Where(other => other.Id != person.Id && !other.IsDeleted) |
| | 6 | 151 | | .SelectMany(other => other.ScopeOwnerships.Select(ownership => ownership.ScopeId)) |
| | 6 | 152 | | .Distinct() |
| | 6 | 153 | | .ToListAsync(); |
| | | 154 | | |
| | 12 | 155 | | return ownedScopeIds.Any(scopeId => !coOwnedScopeIds.Contains(scopeId)); |
| | 15 | 156 | | } |
| | | 157 | | |
| | | 158 | | /// <summary> |
| | | 159 | | /// Permanently removes every entity in <paramref name="dependents" /> by internal Id, or does |
| | | 160 | | /// nothing when the set is empty. Returns any persistence errors, or an empty sequence on |
| | | 161 | | /// success / no-op. |
| | | 162 | | /// </summary> |
| | | 163 | | private static async Task<IEnumerable<string>> DeleteAllAsync<T>( |
| | | 164 | | IReadOnlyCollection<T> dependents, |
| | | 165 | | IAsyncRepository<T, long> writer) where T : Entity<long> |
| | | 166 | | { |
| | 33 | 167 | | if (dependents.Count == 0) |
| | | 168 | | { |
| | 22 | 169 | | return []; |
| | | 170 | | } |
| | | 171 | | |
| | 22 | 172 | | var result = await writer.DeleteRangeAsync(dependents.Select(dependent => dependent.Id)); |
| | | 173 | | |
| | 11 | 174 | | return result.Success ? [] : result.Errors; |
| | 33 | 175 | | } |
| | | 176 | | } |