| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Query.Input; |
| | | 4 | | using ArturRios.Heimdall.Query.Output; |
| | | 5 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 6 | | using ArturRios.Heimdall.Shared.Services; |
| | | 7 | | using ArturRios.Mediator.Query.Interfaces; |
| | | 8 | | using ArturRios.Output; |
| | | 9 | | using FluentValidation; |
| | | 10 | | using Microsoft.EntityFrameworkCore; |
| | | 11 | | |
| | | 12 | | namespace ArturRios.Heimdall.Query.Handlers; |
| | | 13 | | |
| | | 14 | | /// <summary> |
| | | 15 | | /// Handles <see cref="ListScopeOwnersQuery" /> (UC-07, FR-PE-04): lists the <c>ScopeAdmin</c> |
| | | 16 | | /// owners of a scope with pagination and optional name/email filters, excluding logically deleted |
| | | 17 | | /// persons unless explicitly requested (FR-PE-08). A missing or logically deleted scope is AF-07a |
| | | 18 | | /// (<c>ScopeNotFound</c>); an actor who does not own the scope is AF-07b (<c>NotScopeOwner</c>). |
| | | 19 | | /// A System Admin bypasses the ownership check. |
| | | 20 | | /// </summary> |
| | 19 | 21 | | public class ListScopeOwnersQueryHandler( |
| | 19 | 22 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 19 | 23 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 19 | 24 | | IScopeOwnershipChecker scopeOwnership, |
| | 19 | 25 | | IValidator<ListScopeOwnersQuery> validator) |
| | | 26 | | : IPaginatedQueryHandlerAsync<ListScopeOwnersQuery, PersonOutput> |
| | | 27 | | { |
| | | 28 | | public async Task<PaginatedOutput<PersonOutput>> HandleAsync(ListScopeOwnersQuery query, CancellationToken cancellat |
| | | 29 | | { |
| | 19 | 30 | | var output = PaginatedOutput<PersonOutput>.New; |
| | | 31 | | |
| | | 32 | | // NFR-10: page number/size bounds and filter length, validated before any query runs. |
| | 19 | 33 | | var validation = await validator.ValidateAsync(query); |
| | | 34 | | |
| | 19 | 35 | | if (!validation.IsValid) |
| | | 36 | | { |
| | 0 | 37 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 38 | | } |
| | | 39 | | |
| | | 40 | | // AF-07a: the target scope must exist and not be logically deleted. |
| | 19 | 41 | | var scope = await scopeReader.Query() |
| | 19 | 42 | | .FirstOrDefaultAsync(x => x.PublicId == query.ScopeId && !x.IsDeleted); |
| | | 43 | | |
| | 19 | 44 | | if (scope is null) |
| | | 45 | | { |
| | 3 | 46 | | return output.WithError(PersonMessages.ScopeNotFound); |
| | | 47 | | } |
| | | 48 | | |
| | | 49 | | // AF-07b: a Scope Admin may only read a scope they own; a System Admin bypasses. |
| | 16 | 50 | | if (!await scopeOwnership.ActorMayManageScopeAsync(query.ActingRole, query.ActingPersonId, scope.Id)) |
| | | 51 | | { |
| | 2 | 52 | | return output.WithError(PersonMessages.NotScopeOwner); |
| | | 53 | | } |
| | | 54 | | |
| | 14 | 55 | | var owners = personReader.Query() |
| | 14 | 56 | | .Where(x => x.ScopeOwnerships.Any(ownership => ownership.ScopeId == scope.Id)); |
| | | 57 | | |
| | | 58 | | // NFR-24: a restricted identity is withheld from tenant-facing listings, whatever |
| | | 59 | | // IncludeDeleted says — the same rule ListScopePersons and ListScopeGoogleUsers apply. An |
| | | 60 | | // owner is no less a data subject than a User. |
| | 14 | 61 | | owners = owners.Where(x => x.ProcessingRestrictedAt == null); |
| | | 62 | | |
| | 14 | 63 | | if (!query.IncludeDeleted) |
| | | 64 | | { |
| | 12 | 65 | | owners = owners.Where(x => !x.IsDeleted); |
| | | 66 | | } |
| | | 67 | | |
| | 14 | 68 | | if (!string.IsNullOrWhiteSpace(query.Name)) |
| | | 69 | | { |
| | 2 | 70 | | var name = query.Name.ToLower(); |
| | 2 | 71 | | owners = owners.Where(x => x.Name.ToLower().Contains(name)); |
| | | 72 | | } |
| | | 73 | | |
| | 14 | 74 | | if (!string.IsNullOrWhiteSpace(query.Email)) |
| | | 75 | | { |
| | 1 | 76 | | var email = query.Email.ToLower(); |
| | 1 | 77 | | owners = owners.Where(x => x.Email.ToLower().Contains(email)); |
| | | 78 | | } |
| | | 79 | | |
| | | 80 | | // FR-PE-19: an owner's other scopes are other tenants'. A Scope Admin sees only the |
| | | 81 | | // ownerships of scopes they own too — which always includes this one — and a System Admin |
| | | 82 | | // sees them all. |
| | 14 | 83 | | var visibleScopeIds = await OwnedScopeVisibility.ScopesVisibleToAsync( |
| | 14 | 84 | | personReader, query.ActingRole, query.ActingPersonId); |
| | 14 | 85 | | var seesEveryOwnership = visibleScopeIds is null; |
| | 14 | 86 | | var visible = visibleScopeIds ?? []; |
| | | 87 | | |
| | 14 | 88 | | var projected = owners.Select(x => new PersonOutput |
| | 14 | 89 | | { |
| | 14 | 90 | | Id = x.PublicId, |
| | 14 | 91 | | Name = x.Name, |
| | 14 | 92 | | Email = x.Email, |
| | 14 | 93 | | Role = (int)x.RoleId, |
| | 14 | 94 | | EmailVerified = x.EmailVerified, |
| | 14 | 95 | | TwoFactorEnabled = x.TwoFactorAuth != null && x.TwoFactorAuth.IsActive, |
| | 14 | 96 | | IsDeleted = x.IsDeleted, |
| | 14 | 97 | | ScopeId = x.ScopeMembership != null ? x.ScopeMembership.Scope.PublicId : null, |
| | 14 | 98 | | OwnedScopeIds = x.ScopeOwnerships |
| | 14 | 99 | | .Where(ownership => seesEveryOwnership || visible.Contains(ownership.ScopeId)) |
| | 14 | 100 | | .Select(ownership => ownership.Scope.PublicId) |
| | 14 | 101 | | .ToList(), |
| | 14 | 102 | | CreatedAt = x.CreatedAt, |
| | 14 | 103 | | UpdatedAt = x.UpdatedAt |
| | 14 | 104 | | }); |
| | | 105 | | |
| | | 106 | | // Ordered by name with the public identifier as a tiebreaker, then paginated over that |
| | | 107 | | // ordering rather than handing PaginateAsync a sort key of its own. |
| | | 108 | | // |
| | | 109 | | // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key |
| | | 110 | | // ties — each page is a separate query, free to break the tie differently. Two people called |
| | | 111 | | // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody |
| | | 112 | | // else appeared on neither. The tiebreaker makes the total order deterministic, so paging |
| | | 113 | | // through a list sees every row exactly once. |
| | 14 | 114 | | var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id); |
| | | 115 | | |
| | 14 | 116 | | var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null); |
| | | 117 | | |
| | 14 | 118 | | return page.WithMessage(PersonMessages.PersonsRetrievedSuccessfully); |
| | 19 | 119 | | } |
| | | 120 | | } |