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