| | | 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="ListSystemAdminsQuery" /> (UC-07 read e, FR-PE-13): lists every live |
| | | 15 | | /// <c>SystemAdmin</c> with pagination and optional name/email filters, projected to identifier, |
| | | 16 | | /// name, and email only — who else holds full access to the deployment. |
| | | 17 | | /// </summary> |
| | | 18 | | /// <remarks> |
| | | 19 | | /// Authorization is entirely the endpoint's, as in <see cref="ListErasureRequestsQueryHandler" />: |
| | | 20 | | /// the only actor is the System Admin, and the <c>RoleRequirement</c> settles it, so there is no |
| | | 21 | | /// data-dependent rule left here. A Scope Admin is refused because a System Admin's address is |
| | | 22 | | /// not tenant-facing — the same reason <see cref="ListScopeAdminsQueryHandler" /> leaves System |
| | | 23 | | /// Admins out of a Scope Admin's owner picker. |
| | | 24 | | /// </remarks> |
| | 19 | 25 | | public class ListSystemAdminsQueryHandler( |
| | 19 | 26 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 19 | 27 | | IValidator<ListSystemAdminsQuery> validator) |
| | | 28 | | : IPaginatedQueryHandlerAsync<ListSystemAdminsQuery, PersonSummaryOutput> |
| | | 29 | | { |
| | | 30 | | public async Task<PaginatedOutput<PersonSummaryOutput>> HandleAsync(ListSystemAdminsQuery query, CancellationToken c |
| | | 31 | | { |
| | 19 | 32 | | var output = PaginatedOutput<PersonSummaryOutput>.New; |
| | | 33 | | |
| | | 34 | | // NFR-10: page number/size bounds and filter length, validated before any query runs. |
| | 19 | 35 | | var validation = await validator.ValidateAsync(query); |
| | | 36 | | |
| | 19 | 37 | | if (!validation.IsValid) |
| | | 38 | | { |
| | 14 | 39 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 40 | | } |
| | | 41 | | |
| | | 42 | | // A logically deleted administrator holds no access, so this listing has no include-deleted |
| | | 43 | | // mode at all — see ListSystemAdminsQuery. |
| | 12 | 44 | | var admins = personReader.Query() |
| | 12 | 45 | | .Where(x => x.RoleId == (long)Roles.SystemAdmin && !x.IsDeleted |
| | 12 | 46 | | // NFR-24: withheld from listings, as in ListScopeAdmins. A System Admin can |
| | 12 | 47 | | // still reach a restricted one by identifier (UC-07 read a). |
| | 12 | 48 | | && x.ProcessingRestrictedAt == null); |
| | | 49 | | |
| | 12 | 50 | | if (!string.IsNullOrWhiteSpace(query.Name)) |
| | | 51 | | { |
| | 3 | 52 | | var name = query.Name.ToLower(); |
| | 3 | 53 | | admins = admins.Where(x => x.Name.ToLower().Contains(name)); |
| | | 54 | | } |
| | | 55 | | |
| | 12 | 56 | | if (!string.IsNullOrWhiteSpace(query.Email)) |
| | | 57 | | { |
| | 2 | 58 | | var email = query.Email.ToLower(); |
| | 2 | 59 | | admins = admins.Where(x => x.Email.ToLower().Contains(email)); |
| | | 60 | | } |
| | | 61 | | |
| | 12 | 62 | | var projected = admins.Select(x => new PersonSummaryOutput |
| | 12 | 63 | | { |
| | 12 | 64 | | Id = x.PublicId, |
| | 12 | 65 | | Name = x.Name, |
| | 12 | 66 | | Email = x.Email |
| | 12 | 67 | | }); |
| | | 68 | | |
| | | 69 | | // Ordered by name with the public identifier as a tiebreaker, then paginated over that |
| | | 70 | | // ordering — the same reasoning ListScopePersonsQueryHandler documents: names are not |
| | | 71 | | // unique, PostgreSQL gives no ordering guarantee between tied sort keys, and each page is a |
| | | 72 | | // separate query, so without the tiebreaker two administrators sharing a name could straddle |
| | | 73 | | // a page boundary and appear on both pages while a third appeared on neither. |
| | 12 | 74 | | var ordered = projected.OrderBy(x => x.Name).ThenBy(x => x.Id); |
| | | 75 | | |
| | 12 | 76 | | var page = await ordered.PaginateAsync(query.PageNumber, query.PageSize, orderBy: null); |
| | | 77 | | |
| | 12 | 78 | | return page.WithMessage(PersonMessages.PersonsRetrievedSuccessfully); |
| | 19 | 79 | | } |
| | | 80 | | } |