| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Command.Input; |
| | | 3 | | using ArturRios.Heimdall.Command.Output; |
| | | 4 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 5 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 6 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 7 | | using ArturRios.Output; |
| | | 8 | | using Microsoft.EntityFrameworkCore; |
| | | 9 | | using Application = ArturRios.Heimdall.Domain.Entities.Application; |
| | | 10 | | |
| | | 11 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Handles <see cref="DeleteApplicationCommand" /> (UC-19, FR-AP-07): locates the application |
| | | 15 | | /// inside the addressed scope (AF-19a), enforces the acting role's rule — a System Admin may |
| | | 16 | | /// delete any application, anyone else only the ones they own (AF-19c) — serves an |
| | | 17 | | /// already-deleted application as an idempotent no-op (AF-19b), and otherwise sets |
| | | 18 | | /// <c>IsDeleted = true</c> and stamps <c>UpdatedAt</c>. Nothing cascades: an application owns no |
| | | 19 | | /// dependent row. All failures are returned as errors on the <see cref="DataOutput{T}" /> rather |
| | | 20 | | /// than thrown. |
| | | 21 | | /// </summary> |
| | 22 | 22 | | public class DeleteApplicationCommandHandler( |
| | 22 | 23 | | IAsyncReadOnlyRepository<Application, long> applicationReader, |
| | 22 | 24 | | IAsyncRepository<Application, long> applicationWriter) |
| | | 25 | | : ICommandHandlerAsync<DeleteApplicationCommand, DeleteApplicationCommandOutput> |
| | | 26 | | { |
| | | 27 | | public async Task<DataOutput<DeleteApplicationCommandOutput?>> HandleAsync(DeleteApplicationCommand command, Cancell |
| | | 28 | | { |
| | 22 | 29 | | var output = DataOutput<DeleteApplicationCommandOutput?>.New; |
| | | 30 | | |
| | | 31 | | // AF-19a: the application must exist inside the addressed scope. The lookup deliberately omits |
| | | 32 | | // the !IsDeleted filter UC-18 applies, so an already-deleted application is found and served |
| | | 33 | | // idempotently by AF-19b below rather than reported as not found. The route's scopeId |
| | | 34 | | // qualifies it: an application that lives in another scope is not the resource this path |
| | | 35 | | // addresses. Checked before authorization so AF-19a and AF-19c both stay observable. |
| | 22 | 36 | | var application = await applicationReader.Query() |
| | 22 | 37 | | .Include(x => x.Owner) |
| | 22 | 38 | | .ThenInclude(owner => owner.ScopeOwnerships) |
| | 22 | 39 | | .FirstOrDefaultAsync(x => x.PublicId == command.Id && x.Scope.PublicId == command.ScopeId); |
| | | 40 | | |
| | 22 | 41 | | if (application is null) |
| | | 42 | | { |
| | 5 | 43 | | return output.WithError(ApplicationMessages.ApplicationNotFound); |
| | | 44 | | } |
| | | 45 | | |
| | | 46 | | // AF-19c (UC-19 step 2): a System Admin may delete any application; anyone else must own it. |
| | | 47 | | // Owning the *scope* is not by itself grounds to delete another owner's application, so the |
| | | 48 | | // rule compares the owner rather than consulting IScopeOwnershipChecker — the same call UC-17 |
| | | 49 | | // makes for reads and UC-18 for updates. Runs before AF-19b so an already-deleted application |
| | | 50 | | // cannot be used to probe for applications outside the caller's reach. |
| | | 51 | | // |
| | | 52 | | // An owner who no longer owns the application's scope (UC-22 removed them) is not a valid |
| | | 53 | | // owner under FR-AP-03, and is refused as UC-17 refuses them. |
| | 17 | 54 | | if (command.ActingRole != (int)Roles.SystemAdmin && |
| | 17 | 55 | | (application.Owner.PublicId != command.ActingPersonId || |
| | 22 | 56 | | application.Owner.ScopeOwnerships.All(ownership => ownership.ScopeId != application.ScopeId))) |
| | | 57 | | { |
| | 5 | 58 | | return output.WithError(ApplicationMessages.NotAuthorizedToDeleteApplication); |
| | | 59 | | } |
| | | 60 | | |
| | | 61 | | // AF-19b: already deleted — whether directly or by UC-04's cascade from its scope — so there |
| | | 62 | | // is nothing to write. UpdatedAt is left alone: the row already carries the requested state, |
| | | 63 | | // and re-stamping it would misreport when the deletion happened. |
| | 12 | 64 | | if (application.IsDeleted) |
| | | 65 | | { |
| | 5 | 66 | | return output |
| | 5 | 67 | | .WithData(new DeleteApplicationCommandOutput { Id = application.PublicId, AlreadyDeleted = true }) |
| | 5 | 68 | | .WithMessage(ApplicationMessages.ApplicationDeletedSuccessfully); |
| | | 69 | | } |
| | | 70 | | |
| | | 71 | | // UC-19 step 3: flip the flag and stamp UpdatedAt (no DB trigger maintains it). |
| | 7 | 72 | | application.IsDeleted = true; |
| | 7 | 73 | | application.UpdatedAt = DateTime.UtcNow; |
| | | 74 | | |
| | 7 | 75 | | var update = await applicationWriter.UpdateAsync(application); |
| | | 76 | | |
| | 7 | 77 | | if (!update.Success) |
| | | 78 | | { |
| | 0 | 79 | | return output.WithErrors(update.Errors); |
| | | 80 | | } |
| | | 81 | | |
| | | 82 | | // UC-19 step 4. |
| | 7 | 83 | | return output |
| | 7 | 84 | | .WithData(new DeleteApplicationCommandOutput { Id = application.PublicId, AlreadyDeleted = false }) |
| | 7 | 85 | | .WithMessage(ApplicationMessages.ApplicationDeletedSuccessfully); |
| | 22 | 86 | | } |
| | | 87 | | } |