| | | 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.Heimdall.Shared.Services; |
| | | 8 | | using ArturRios.Mediator.Query.Interfaces; |
| | | 9 | | using ArturRios.Output; |
| | | 10 | | using FluentValidation; |
| | | 11 | | using Microsoft.EntityFrameworkCore; |
| | | 12 | | |
| | | 13 | | namespace ArturRios.Heimdall.Query.Handlers; |
| | | 14 | | |
| | | 15 | | /// <summary> |
| | | 16 | | /// Handles <see cref="ListScopeAdminsQuery" /> (UC-07 read d, FR-PE-12): lists every live |
| | | 17 | | /// administrator who may own a scope — every <c>ScopeAdmin</c>, and for a System Admin caller |
| | | 18 | | /// every <c>SystemAdmin</c> too — with pagination and optional name/email filters, projected to |
| | | 19 | | /// identifier, name, and email only. Backs UI-11's owner selector and UI-14's "add an existing |
| | | 20 | | /// Scope Admin". |
| | | 21 | | /// </summary> |
| | | 22 | | /// <remarks> |
| | | 23 | | /// <para> |
| | | 24 | | /// Both administrator roles may call it, which is wider than UC-07's per-person visibility |
| | | 25 | | /// rule allows for a Scope Admin. That widening is deliberate and is what makes UI-14 step 3 |
| | | 26 | | /// possible at all — a co-owner being added does not yet own the scope, so the existing rule |
| | | 27 | | /// could never surface them. The three-field projection is what keeps it safe; see |
| | | 28 | | /// <see cref="PersonSummaryOutput" />. |
| | | 29 | | /// </para> |
| | | 30 | | /// <para> |
| | | 31 | | /// <c>ExcludeOwnersOfScopeId</c> is gated on scope ownership even though the projection is |
| | | 32 | | /// minimal, because the parameter is not a projection question: calling the endpoint twice, |
| | | 33 | | /// once with it and once without, and diffing the results enumerates the owners of whatever |
| | | 34 | | /// scope was named. The gate makes that possible only for a scope the caller already manages. |
| | | 35 | | /// </para> |
| | | 36 | | /// </remarks> |
| | 22 | 37 | | public class ListScopeAdminsQueryHandler( |
| | 22 | 38 | | IAsyncReadOnlyRepository<Scope, long> scopeReader, |
| | 22 | 39 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 22 | 40 | | IScopeOwnershipChecker scopeOwnership, |
| | 22 | 41 | | IValidator<ListScopeAdminsQuery> validator) |
| | | 42 | | : IPaginatedQueryHandlerAsync<ListScopeAdminsQuery, PersonSummaryOutput> |
| | | 43 | | { |
| | | 44 | | public async Task<PaginatedOutput<PersonSummaryOutput>> HandleAsync(ListScopeAdminsQuery query, CancellationToken ca |
| | | 45 | | { |
| | 22 | 46 | | var output = PaginatedOutput<PersonSummaryOutput>.New; |
| | | 47 | | |
| | | 48 | | // NFR-10: page number/size bounds and filter length, validated before any query runs. |
| | 22 | 49 | | var validation = await validator.ValidateAsync(query); |
| | | 50 | | |
| | 22 | 51 | | if (!validation.IsValid) |
| | | 52 | | { |
| | 2 | 53 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 54 | | } |
| | | 55 | | |
| | 21 | 56 | | long? excludedScopeId = null; |
| | | 57 | | |
| | 21 | 58 | | if (query.ExcludeOwnersOfScopeId is not null) |
| | | 59 | | { |
| | | 60 | | // AF-07a: the named scope must exist and not be logically deleted. |
| | 8 | 61 | | var scope = await scopeReader.Query() |
| | 8 | 62 | | .FirstOrDefaultAsync(x => x.PublicId == query.ExcludeOwnersOfScopeId.Value && !x.IsDeleted); |
| | | 63 | | |
| | 8 | 64 | | if (scope is null) |
| | | 65 | | { |
| | 3 | 66 | | return output.WithError(PersonMessages.ScopeNotFound); |
| | | 67 | | } |
| | | 68 | | |
| | | 69 | | // AF-07b: only an owner of the named scope (or a System Admin) may subtract its owners, |
| | | 70 | | // since a with/without diff would otherwise reveal them. |
| | 5 | 71 | | if (!await scopeOwnership.ActorMayManageScopeAsync(query.ActingRole, query.ActingPersonId, scope.Id)) |
| | | 72 | | { |
| | 2 | 73 | | return output.WithError(PersonMessages.NotScopeOwner); |
| | | 74 | | } |
| | | 75 | | |
| | 3 | 76 | | excludedScopeId = scope.Id; |
| | 3 | 77 | | } |
| | | 78 | | |
| | | 79 | | // Who may own a scope (FR-SC-08): a ScopeAdmin, or a SystemAdmin. System Admins are offered |
| | | 80 | | // only to a System Admin — their addresses are not tenant-facing, and a Scope Admin choosing |
| | | 81 | | // co-owners for their scope has no need of them. |
| | 16 | 82 | | var ownerRoles = query.ActingRole == (int)Roles.SystemAdmin |
| | 16 | 83 | | ? new[] { (long)Roles.ScopeAdmin, (long)Roles.SystemAdmin } |
| | 16 | 84 | | : [(long)Roles.ScopeAdmin]; |
| | | 85 | | |
| | | 86 | | // A logically deleted administrator is never a valid owner, so this listing has no |
| | | 87 | | // include-deleted mode at all — see ListScopeAdminsQuery. |
| | 16 | 88 | | var admins = personReader.Query() |
| | 16 | 89 | | .Where(x => ownerRoles.Contains(x.RoleId) && !x.IsDeleted |
| | 16 | 90 | | // NFR-24: withheld from tenant-facing listings, as in ListScopePersons. |
| | 16 | 91 | | && x.ProcessingRestrictedAt == null); |
| | | 92 | | |
| | 16 | 93 | | if (excludedScopeId is not null) |
| | | 94 | | { |
| | | 95 | | // Before pagination, so a page of the requested size comes back full (UI-14 AF-14c). |
| | 3 | 96 | | admins = admins.Where(x => x.ScopeOwnerships.All(ownership => ownership.ScopeId != excludedScopeId.Value)); |
| | | 97 | | } |
| | | 98 | | |
| | 16 | 99 | | if (!string.IsNullOrWhiteSpace(query.Name)) |
| | | 100 | | { |
| | 6 | 101 | | var name = query.Name.ToLower(); |
| | 6 | 102 | | admins = admins.Where(x => x.Name.ToLower().Contains(name)); |
| | | 103 | | } |
| | | 104 | | |
| | 16 | 105 | | if (!string.IsNullOrWhiteSpace(query.Email)) |
| | | 106 | | { |
| | 2 | 107 | | var email = query.Email.ToLower(); |
| | 2 | 108 | | admins = admins.Where(x => x.Email.ToLower().Contains(email)); |
| | | 109 | | } |
| | | 110 | | |
| | 16 | 111 | | var projected = admins.Select(x => new PersonSummaryOutput |
| | 16 | 112 | | { |
| | 16 | 113 | | Id = x.PublicId, |
| | 16 | 114 | | Name = x.Name, |
| | 16 | 115 | | Email = x.Email |
| | 16 | 116 | | }); |
| | | 117 | | |
| | | 118 | | // Ordered by name with the public identifier as a tiebreaker, then paginated over that |
| | | 119 | | // ordering — the same reasoning ListScopePersonsQueryHandler documents: names are not |
| | | 120 | | // unique, PostgreSQL gives no ordering guarantee between tied sort keys, and each page is a |
| | | 121 | | // separate query, so without the tiebreaker two administrators sharing a name could straddle |
| | | 122 | | // a page boundary and appear on both pages while a third appeared on neither. |
| | 16 | 123 | | var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id); |
| | | 124 | | |
| | 16 | 125 | | var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null); |
| | | 126 | | |
| | 16 | 127 | | return page.WithMessage(PersonMessages.PersonsRetrievedSuccessfully); |
| | 22 | 128 | | } |
| | | 129 | | } |