| | | 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.Mediator.Query.Interfaces; |
| | | 7 | | using ArturRios.Output; |
| | | 8 | | using FluentValidation; |
| | | 9 | | using Microsoft.EntityFrameworkCore; |
| | | 10 | | |
| | | 11 | | namespace ArturRios.Heimdall.Query.Handlers; |
| | | 12 | | |
| | | 13 | | /// <summary> |
| | | 14 | | /// Handles <see cref="ListErasureRequestsQuery" /> (UC-43): lists the erasure requests that have |
| | | 15 | | /// been made and not yet carried out, across both identity tables, soonest deadline first. |
| | | 16 | | /// </summary> |
| | | 17 | | /// <remarks> |
| | | 18 | | /// <para> |
| | | 19 | | /// Authorization is entirely the endpoint's: UC-43's only actor is the System Admin, and the |
| | | 20 | | /// <c>RoleRequirement</c> settles it, so there is no data-dependent rule left here. That is |
| | | 21 | | /// deliberate rather than an omission — a Scope Admin seeing the erasure requests of the |
| | | 22 | | /// users in their scope would learn which of them had asked to leave, which is not something |
| | | 23 | | /// the request itself entitles them to know. |
| | | 24 | | /// </para> |
| | | 25 | | /// <para> |
| | | 26 | | /// The two tables are read separately and combined in memory rather than joined. They have |
| | | 27 | | /// no relationship — the whole design keeps them apart (TH-21) — and the result set is |
| | | 28 | | /// bounded by what is outstanding at one moment, which is a queue of things needing human |
| | | 29 | | /// attention rather than a table scan. |
| | | 30 | | /// </para> |
| | | 31 | | /// </remarks> |
| | 9 | 32 | | public class ListErasureRequestsQueryHandler( |
| | 9 | 33 | | IAsyncReadOnlyRepository<Person, long> personReader, |
| | 9 | 34 | | IAsyncReadOnlyRepository<GoogleUser, long> googleUserReader, |
| | 9 | 35 | | IValidator<ListErasureRequestsQuery> validator) |
| | | 36 | | : IPaginatedQueryHandlerAsync<ListErasureRequestsQuery, ErasureRequestOutput> |
| | | 37 | | { |
| | | 38 | | public async Task<PaginatedOutput<ErasureRequestOutput>> HandleAsync(ListErasureRequestsQuery query, CancellationTok |
| | | 39 | | { |
| | 9 | 40 | | var output = PaginatedOutput<ErasureRequestOutput>.New; |
| | | 41 | | |
| | | 42 | | // NFR-10: page number and size bounds, validated before any query runs. |
| | 9 | 43 | | var validation = await validator.ValidateAsync(query); |
| | | 44 | | |
| | 9 | 45 | | if (!validation.IsValid) |
| | | 46 | | { |
| | 0 | 47 | | return output.WithErrors(validation.Errors.Select(failure => failure.ErrorMessage)); |
| | | 48 | | } |
| | | 49 | | |
| | 9 | 50 | | var now = DateTime.UtcNow; |
| | | 51 | | |
| | | 52 | | // Outstanding means asked for and not yet carried out. A record already anonymised is done, |
| | | 53 | | // whatever else is true of it. |
| | 9 | 54 | | var persons = await personReader.Query() |
| | 9 | 55 | | .Where(person => person.ErasureRequestedAt != null && person.AnonymisedAt == null) |
| | 9 | 56 | | .Select(person => new ErasureRequestOutput |
| | 9 | 57 | | { |
| | 9 | 58 | | SubjectId = person.PublicId, |
| | 9 | 59 | | IsGoogleUser = false, |
| | 9 | 60 | | RequestedAt = person.ErasureRequestedAt!.Value, |
| | 9 | 61 | | DueAt = person.ErasureDueAt!.Value, |
| | 9 | 62 | | BlockedReason = person.ErasureBlockedReason |
| | 9 | 63 | | }) |
| | 9 | 64 | | .ToListAsync(); |
| | | 65 | | |
| | 9 | 66 | | var googleUsers = await googleUserReader.Query() |
| | 9 | 67 | | .Where(googleUser => googleUser.ErasureRequestedAt != null && googleUser.AnonymisedAt == null) |
| | 9 | 68 | | .Select(googleUser => new ErasureRequestOutput |
| | 9 | 69 | | { |
| | 9 | 70 | | SubjectId = googleUser.PublicId, |
| | 9 | 71 | | IsGoogleUser = true, |
| | 9 | 72 | | RequestedAt = googleUser.ErasureRequestedAt!.Value, |
| | 9 | 73 | | DueAt = googleUser.ErasureDueAt!.Value, |
| | 9 | 74 | | BlockedReason = googleUser.ErasureBlockedReason |
| | 9 | 75 | | }) |
| | 9 | 76 | | .ToListAsync(); |
| | | 77 | | |
| | 9 | 78 | | var requests = persons.Concat(googleUsers).ToList(); |
| | | 79 | | |
| | | 80 | | // Computed here rather than in the projection: "overdue" is a comparison against the moment |
| | | 81 | | // the queue is read, not a stored fact, and computing it in SQL would have each of the two |
| | | 82 | | // queries take its own reading of the clock. |
| | 50 | 83 | | foreach (var request in requests) |
| | | 84 | | { |
| | 16 | 85 | | request.Overdue = request.DueAt <= now; |
| | | 86 | | } |
| | | 87 | | |
| | 9 | 88 | | if (query.OverdueOnly) |
| | | 89 | | { |
| | 3 | 90 | | requests = requests.Where(request => request.Overdue).ToList(); |
| | | 91 | | } |
| | | 92 | | |
| | | 93 | | // Soonest deadline first, with the subject's identifier as a tiebreaker so two requests |
| | | 94 | | // sharing a deadline cannot straddle a page boundary — the same reasoning the person |
| | | 95 | | // listings document. |
| | 9 | 96 | | var ordered = requests |
| | 15 | 97 | | .OrderBy(request => request.DueAt) |
| | 15 | 98 | | .ThenBy(request => request.SubjectId) |
| | 9 | 99 | | .AsQueryable(); |
| | | 100 | | |
| | 9 | 101 | | var page = await Task.FromResult( |
| | 9 | 102 | | ordered.Paginate(query.PageNumber, query.PageSize, orderBy: null)); |
| | | 103 | | |
| | 9 | 104 | | return page.WithMessage(ErasureMessages.ErasureRequestsRetrieved); |
| | 9 | 105 | | } |
| | | 106 | | } |