| | | 1 | | using ArturRios.Data.Relational.Core.Interfaces; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Enums; |
| | | 4 | | using ArturRios.Heimdall.Query.Input; |
| | | 5 | | using ArturRios.Heimdall.Query.Output; |
| | | 6 | | using ArturRios.Heimdall.Shared.Messages; |
| | | 7 | | using ArturRios.Mediator.Query.Interfaces; |
| | | 8 | | using ArturRios.Output; |
| | | 9 | | using FluentValidation; |
| | | 10 | | |
| | | 11 | | namespace ArturRios.Heimdall.Query.Handlers; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Handles <see cref="ListScopesQuery" /> (UC-02 read b, FR-SC-03): lists scopes with pagination |
| | | 15 | | /// and an optional name filter, excluding logically deleted scopes unless explicitly requested |
| | | 16 | | /// (FR-SC-07). A System Admin lists every scope; a Scope Admin only the scopes they own. |
| | | 17 | | /// </summary> |
| | | 18 | | /// <remarks> |
| | | 19 | | /// Ownership is read from the <c>SCOPE_OWNER</c> rows, never from the token's owned-scope claim: |
| | | 20 | | /// that claim is a snapshot from sign-in, and an owner removed since (UC-22) must stop seeing the |
| | | 21 | | /// scope at once rather than when their token expires. The filter runs before pagination, so the |
| | | 22 | | /// total is the caller's own count — the number of other tenants' scopes stays out of reach, which |
| | | 23 | | /// is why the listing used to be System-Admin-only. |
| | | 24 | | /// </remarks> |
| | 16 | 25 | | public class ListScopesQueryHandler( |
| | 16 | 26 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 16 | 27 | | IValidator<ListScopesQuery> validator) |
| | | 28 | | : IPaginatedQueryHandlerAsync<ListScopesQuery, ScopeOutput> |
| | | 29 | | { |
| | | 30 | | public async Task<PaginatedOutput<ScopeOutput>> HandleAsync(ListScopesQuery query, CancellationToken cancellationTok |
| | | 31 | | { |
| | | 32 | | // NFR-10: page number/size bounds and filter length, validated before any query runs. |
| | 16 | 33 | | var validation = await validator.ValidateAsync(query); |
| | | 34 | | |
| | 16 | 35 | | if (!validation.IsValid) |
| | | 36 | | { |
| | 4 | 37 | | return PaginatedOutput<ScopeOutput>.New |
| | 8 | 38 | | .WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 39 | | } |
| | | 40 | | |
| | 12 | 41 | | var scopes = scopeReader.Query(); |
| | | 42 | | |
| | | 43 | | // AF-02b (read b). Any other role is refused here as well as at the controller, so a role |
| | | 44 | | // added later cannot inherit the whole collection by default. |
| | 12 | 45 | | switch (query.ActingRole) |
| | | 46 | | { |
| | | 47 | | case (int)Roles.SystemAdmin: |
| | | 48 | | break; |
| | | 49 | | case (int)Roles.ScopeAdmin: |
| | 4 | 50 | | scopes = scopes.Where(x => x.Owners.Any(owner => owner.Person.PublicId == query.ActingPersonId)); |
| | 4 | 51 | | break; |
| | | 52 | | default: |
| | 1 | 53 | | return PaginatedOutput<ScopeOutput>.New.WithError(ScopeMessages.NotAuthorizedToListScopes); |
| | | 54 | | } |
| | | 55 | | |
| | 11 | 56 | | if (!query.IncludeDeleted) |
| | | 57 | | { |
| | 9 | 58 | | scopes = scopes.Where(x => !x.IsDeleted); |
| | | 59 | | } |
| | | 60 | | |
| | | 61 | | // Compared case-insensitively (LOWER() in SQL), as the person listings do (FR-PE-04) and as |
| | | 62 | | // every name/email comparison in this codebase does. |
| | 11 | 63 | | if (!string.IsNullOrWhiteSpace(query.Name)) |
| | | 64 | | { |
| | 4 | 65 | | var name = query.Name.ToLower(); |
| | 4 | 66 | | scopes = scopes.Where(x => x.Name.ToLower().Contains(name)); |
| | | 67 | | } |
| | | 68 | | |
| | 11 | 69 | | var projected = scopes.Select(x => new ScopeOutput |
| | 11 | 70 | | { |
| | 11 | 71 | | Id = x.PublicId, |
| | 11 | 72 | | Name = x.Name, |
| | 11 | 73 | | Description = x.Description, |
| | 11 | 74 | | GoogleSignInEnabled = x.GoogleSignInEnabled, |
| | 11 | 75 | | SelfRegistrationEnabled = x.SelfRegistrationEnabled, |
| | 11 | 76 | | DefaultLegalBasis = x.DefaultLegalBasis, |
| | 11 | 77 | | PrivacyNoticeUri = x.PrivacyNoticeUri, |
| | 11 | 78 | | EmailVerificationUrl = x.EmailVerificationUrl, |
| | 11 | 79 | | PasswordResetUrl = x.PasswordResetUrl, |
| | 11 | 80 | | IsDeleted = x.IsDeleted, |
| | 11 | 81 | | OwnerIds = x.Owners.Select(owner => owner.Person.PublicId).ToList(), |
| | 11 | 82 | | CreatedAt = x.CreatedAt, |
| | 11 | 83 | | UpdatedAt = x.UpdatedAt |
| | 11 | 84 | | }); |
| | | 85 | | |
| | | 86 | | // Ordered by name with the public identifier as a tiebreaker, then paginated over that |
| | | 87 | | // ordering rather than handing PaginateAsync a sort key of its own. |
| | | 88 | | // |
| | | 89 | | // Name is not unique, and PostgreSQL gives no ordering guarantee between rows whose sort key |
| | | 90 | | // ties — each page is a separate query, free to break the tie differently. Two people called |
| | | 91 | | // "Ana Silva" straddling a page boundary could therefore appear on both pages while somebody |
| | | 92 | | // else appeared on neither. The tiebreaker makes the total order deterministic, so paging |
| | | 93 | | // through a list sees every row exactly once. |
| | 11 | 94 | | var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id); |
| | | 95 | | |
| | 11 | 96 | | var output = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null); |
| | | 97 | | |
| | 11 | 98 | | return output.WithMessage(ScopeMessages.ScopesRetrievedSuccessfully); |
| | 16 | 99 | | } |
| | | 100 | | } |