| | | 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.Enums; |
| | | 6 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 7 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 8 | | using ArturRios.Output; |
| | | 9 | | using FluentValidation; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | using Application = ArturRios.Heimdall.Domain.Entities.Application; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="UpdateApplicationCommand" /> (UC-18, FR-AP-06): validates input, loads the |
| | | 17 | | /// application inside the addressed scope (AF-18a), enforces the acting role's rule — a System |
| | | 18 | | /// Admin may update any application, anyone else only the ones they own (AF-18c) — verifies a new |
| | | 19 | | /// owner satisfies FR-AP-03 when the owner actually changes (AF-18b), then applies the changes and |
| | | 20 | | /// stamps <c>UpdatedAt</c>. All failures are returned as errors on the output rather than thrown. |
| | | 21 | | /// </summary> |
| | 31 | 22 | | public class UpdateApplicationCommandHandler( |
| | 31 | 23 | | IValidator<UpdateApplicationCommand> validator, |
| | 31 | 24 | | IAsyncReadOnlyRepository<Application, long> applicationReader, |
| | 31 | 25 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 31 | 26 | | IAsyncRepository<Application, long> applicationWriter) |
| | | 27 | | : ICommandHandlerAsync<UpdateApplicationCommand, UpdateApplicationCommandOutput> |
| | | 28 | | { |
| | | 29 | | public async Task<DataOutput<UpdateApplicationCommandOutput?>> HandleAsync(UpdateApplicationCommand command, Cancell |
| | | 30 | | { |
| | 31 | 31 | | var output = DataOutput<UpdateApplicationCommandOutput?>.New; |
| | | 32 | | |
| | | 33 | | // UC-18 step 2: validate input shape. UC-18 defines no alternative flow for this, so the |
| | | 34 | | // validator's UC-16 messages carry their existing 400. |
| | 31 | 35 | | var validation = await validator.ValidateAsync(command); |
| | | 36 | | |
| | 31 | 37 | | if (!validation.IsValid) |
| | | 38 | | { |
| | 4 | 39 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 40 | | } |
| | | 41 | | |
| | | 42 | | // AF-18a: the application must exist inside the addressed scope and not be logically deleted. |
| | | 43 | | // The route's scopeId qualifies the lookup, as UC-17's by-id read does: an application that |
| | | 44 | | // lives in another scope is not the resource this path addresses. Checked before |
| | | 45 | | // authorization so AF-18a and AF-18c both stay observable. |
| | 29 | 46 | | var application = await applicationReader.Query() |
| | 29 | 47 | | .Include(x => x.Scope) |
| | 29 | 48 | | .Include(x => x.Owner) |
| | 29 | 49 | | .ThenInclude(owner => owner.ScopeOwnerships) |
| | 29 | 50 | | .FirstOrDefaultAsync(x => |
| | 29 | 51 | | x.PublicId == command.Id && x.Scope.PublicId == command.ScopeId && !x.IsDeleted); |
| | | 52 | | |
| | 29 | 53 | | if (application is null) |
| | | 54 | | { |
| | 7 | 55 | | return output.WithError(ApplicationMessages.ApplicationNotFound); |
| | | 56 | | } |
| | | 57 | | |
| | 22 | 58 | | var currentOwnerId = application.Owner.PublicId; |
| | | 59 | | |
| | | 60 | | // AF-18c (UC-18 step 3): a System Admin may update any application; anyone else must own it. |
| | | 61 | | // Owning the *scope* is not by itself grounds to modify another owner's application, so the |
| | | 62 | | // rule compares the owner rather than consulting IScopeOwnershipChecker — the same call |
| | | 63 | | // UC-17 makes for reads. |
| | | 64 | | // |
| | | 65 | | // An owner who no longer owns the application's scope (UC-22 removed them) is not a valid |
| | | 66 | | // owner under FR-AP-03, and is refused as UC-17 refuses them. |
| | 22 | 67 | | if (command.ActingRole != (int)Roles.SystemAdmin && |
| | 22 | 68 | | (currentOwnerId != command.ActingPersonId || |
| | 26 | 69 | | application.Owner.ScopeOwnerships.All(ownership => ownership.ScopeId != application.ScopeId))) |
| | | 70 | | { |
| | 4 | 71 | | return output.WithError(ApplicationMessages.NotAuthorizedToUpdateApplication); |
| | | 72 | | } |
| | | 73 | | |
| | 18 | 74 | | var newOwnerId = currentOwnerId; |
| | | 75 | | |
| | | 76 | | // AF-18b (UC-18 step 4): only *a change of* owner is verified. Verifying unconditionally |
| | | 77 | | // would refuse an ordinary rename whenever the existing owner had since been logically |
| | | 78 | | // deleted or lost their SCOPE_OWNER row — a refusal UC-18 does not define, and one that would |
| | | 79 | | // leave such an application uneditable. |
| | 18 | 80 | | if (command.OwnerId != currentOwnerId) |
| | | 81 | | { |
| | | 82 | | // FR-AP-03: the new owner must be an existing, non-logically-deleted administrator — a |
| | | 83 | | // ScopeAdmin or a SystemAdmin — who owns the application's scope. Same shape as UC-16's |
| | | 84 | | // owner check, against the scope the application already belongs to (FR-AP-02 fixes |
| | | 85 | | // that at creation time). |
| | 11 | 86 | | var newOwner = await personReader.Query().FirstOrDefaultAsync(person => |
| | 11 | 87 | | person.PublicId == command.OwnerId && !person.IsDeleted && |
| | 11 | 88 | | (person.RoleId == (long)Roles.ScopeAdmin || person.RoleId == (long)Roles.SystemAdmin) && |
| | 11 | 89 | | person.ScopeOwnerships.Any(ownership => ownership.ScopeId == application.ScopeId)); |
| | | 90 | | |
| | 11 | 91 | | if (newOwner is null) |
| | | 92 | | { |
| | 8 | 93 | | return output.WithError(ApplicationMessages.OwnerNotValidForScope); |
| | | 94 | | } |
| | | 95 | | |
| | | 96 | | // Both the foreign key and the navigation are set, so the tracked entity carries one |
| | | 97 | | // consistent answer whichever of the two the change tracker reads. |
| | 3 | 98 | | application.OwnerId = newOwner.Id; |
| | 3 | 99 | | application.Owner = newOwner; |
| | 3 | 100 | | newOwnerId = newOwner.PublicId; |
| | | 101 | | } |
| | | 102 | | |
| | | 103 | | // UC-18 step 5: apply the updates and stamp UpdatedAt (no DB trigger maintains it). |
| | 10 | 104 | | application.Name = command.Name; |
| | 10 | 105 | | application.UpdatedAt = DateTime.UtcNow; |
| | | 106 | | |
| | 10 | 107 | | var update = await applicationWriter.UpdateAsync(application); |
| | | 108 | | |
| | 10 | 109 | | if (!update.Success) |
| | | 110 | | { |
| | 0 | 111 | | return output.WithErrors(update.Errors); |
| | | 112 | | } |
| | | 113 | | |
| | | 114 | | // UC-18 step 6: return the updated application. |
| | 10 | 115 | | return output |
| | 10 | 116 | | .WithData(new UpdateApplicationCommandOutput |
| | 10 | 117 | | { |
| | 10 | 118 | | Id = application.PublicId, |
| | 10 | 119 | | Name = application.Name, |
| | 10 | 120 | | ScopeId = application.Scope.PublicId, |
| | 10 | 121 | | OwnerId = newOwnerId, |
| | 10 | 122 | | CreatedAt = application.CreatedAt, |
| | 10 | 123 | | UpdatedAt = application.UpdatedAt |
| | 10 | 124 | | }) |
| | 10 | 125 | | .WithMessage(ApplicationMessages.ApplicationUpdatedSuccessfully); |
| | 31 | 126 | | } |
| | | 127 | | } |