| | | 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.Shared.Messages; |
| | | 6 | | using ArturRios.Heimdall.Shared.Services; |
| | | 7 | | using ArturRios.Mediator.Command.Interfaces; |
| | | 8 | | using ArturRios.Output; |
| | | 9 | | using FluentValidation; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | |
| | | 12 | | namespace ArturRios.Heimdall.Command.Handlers; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Handles <see cref="SetGoogleSignInCommand" /> (UC-24, FR-GO-01/FR-GO-02): validates that the |
| | | 16 | | /// request said which value to set (AF-24c), verifies the target scope exists and is active (AF-24a), |
| | | 17 | | /// enforces scope ownership for a Scope Admin actor (AF-24b), then writes the scope's |
| | | 18 | | /// <c>GoogleSignInEnabled</c> flag and returns the updated scope. A System Admin actor bypasses |
| | | 19 | | /// the ownership check. All failures are returned as errors on the <see cref="DataOutput{T}" /> |
| | | 20 | | /// rather than thrown, using the canonical <see cref="ScopeMessages" /> so the response resolver |
| | | 21 | | /// can pick the matching status code. |
| | | 22 | | /// </summary> |
| | 19 | 23 | | public class SetGoogleSignInCommandHandler( |
| | 19 | 24 | | IValidator<SetGoogleSignInCommand> validator, |
| | 19 | 25 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 19 | 26 | | IAsyncRepository<Scope, long> scopeWriter, |
| | 19 | 27 | | IScopeOwnershipChecker scopeOwnership) |
| | | 28 | | : ICommandHandlerAsync<SetGoogleSignInCommand, SetGoogleSignInCommandOutput> |
| | | 29 | | { |
| | | 30 | | public async Task<DataOutput<SetGoogleSignInCommandOutput?>> HandleAsync(SetGoogleSignInCommand command, Cancellatio |
| | | 31 | | { |
| | 19 | 32 | | var output = DataOutput<SetGoogleSignInCommandOutput?>.New; |
| | | 33 | | |
| | | 34 | | // AF-24c (UC-24 step 2, NFR-10): the request must state which value to set. Enabled is |
| | | 35 | | // nullable precisely so this check is possible — a plain bool would bind a body omitting the |
| | | 36 | | // field to false, turning a malformed request into the destructive half of the toggle. |
| | 19 | 37 | | var validation = await validator.ValidateAsync(command); |
| | | 38 | | |
| | 19 | 39 | | if (!validation.IsValid) |
| | | 40 | | { |
| | 4 | 41 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 42 | | } |
| | | 43 | | |
| | | 44 | | // AF-24a (UC-24 step 3): the target scope must exist and not be logically deleted — the |
| | | 45 | | // alternative flow names both conditions as one outcome, so the filter answers for both. |
| | 17 | 46 | | var scope = await scopeReader.Query() |
| | 17 | 47 | | .FirstOrDefaultAsync(x => x.PublicId == command.Id && !x.IsDeleted); |
| | | 48 | | |
| | 17 | 49 | | if (scope is null) |
| | | 50 | | { |
| | 4 | 51 | | return output.WithError(ScopeMessages.ScopeNotFound); |
| | | 52 | | } |
| | | 53 | | |
| | | 54 | | // AF-24b (UC-24 step 4): a Scope Admin actor may only act on a scope they own; a System Admin |
| | | 55 | | // bypasses. Checked before anything is written, so a refused caller leaves no trace. |
| | 13 | 56 | | if (!await scopeOwnership.ActorMayManageScopeAsync(command.ActingRole, command.ActingPersonId, scope.Id)) |
| | | 57 | | { |
| | 3 | 58 | | return output.WithError(ScopeMessages.NotScopeOwner); |
| | | 59 | | } |
| | | 60 | | |
| | | 61 | | // Owner PublicIds for the response. A projection over the navigation (no Include needed), |
| | | 62 | | // which EF translates to a join and the in-memory fake evaluates directly. |
| | 10 | 63 | | var ownerIds = await scopeReader.Query() |
| | 10 | 64 | | .Where(x => x.PublicId == command.Id) |
| | 10 | 65 | | .SelectMany(x => x.Owners.Select(owner => owner.Person.PublicId)) |
| | 10 | 66 | | .ToListAsync(); |
| | | 67 | | |
| | | 68 | | // UC-24 step 5: write the flag and stamp UpdatedAt (no DB trigger maintains it). Written |
| | | 69 | | // unconditionally: PUT is idempotent by contract and UC-24 defines no alternative flow for a |
| | | 70 | | // request that asks for the value the scope already holds. |
| | 10 | 71 | | scope.GoogleSignInEnabled = command.Enabled!.Value; |
| | 10 | 72 | | scope.UpdatedAt = DateTime.UtcNow; |
| | | 73 | | |
| | 10 | 74 | | var update = await scopeWriter.UpdateAsync(scope); |
| | | 75 | | |
| | 10 | 76 | | if (!update.Success) |
| | | 77 | | { |
| | 0 | 78 | | return output.WithErrors(update.Errors); |
| | | 79 | | } |
| | | 80 | | |
| | | 81 | | // UC-24 step 6: return the updated scope. |
| | 10 | 82 | | return output |
| | 10 | 83 | | .WithData(new SetGoogleSignInCommandOutput |
| | 10 | 84 | | { |
| | 10 | 85 | | Id = scope.PublicId, |
| | 10 | 86 | | Name = scope.Name, |
| | 10 | 87 | | Description = scope.Description, |
| | 10 | 88 | | GoogleSignInEnabled = scope.GoogleSignInEnabled, |
| | 10 | 89 | | EmailVerificationUrl = scope.EmailVerificationUrl, |
| | 10 | 90 | | PasswordResetUrl = scope.PasswordResetUrl, |
| | 10 | 91 | | OwnerIds = ownerIds, |
| | 10 | 92 | | CreatedAt = scope.CreatedAt, |
| | 10 | 93 | | UpdatedAt = scope.UpdatedAt |
| | 10 | 94 | | }) |
| | 10 | 95 | | .WithMessage(ScopeMessages.GoogleSignInUpdatedSuccessfully); |
| | 19 | 96 | | } |
| | | 97 | | } |