diff --git a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs index 5c398cf..b5427f5 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftQueueReadTests.cs @@ -164,6 +164,59 @@ public sealed class UpliftQueueReadTests new DateTime(2026, 3, 10)); } + [Fact] + public async Task List_RejectedStatus_OrdersMostRecentlyRejectedFirst() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + // Request order (3/1, 3/2, 3/3) deliberately disagrees with decision order. + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Rejected", new DateTime(2026, 3, 1), decided: new DateTime(2026, 3, 10)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 20)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 3), decided: new DateTime(2026, 3, 15))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 20), + new DateTime(2026, 3, 15), + new DateTime(2026, 3, 10)); + } + + [Fact] + public async Task List_RejectedStatus_ReturnsOnlyRejectedRequests_IncludingLegacyDenied() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-1"); + context.DispatchUpliftRequests.AddRange( + Request(dispatch, "Pending", new DateTime(2026, 3, 1)), + Request(dispatch, "Approved", new DateTime(2026, 3, 2), decided: new DateTime(2026, 3, 3)), + Request(dispatch, "Revoked", new DateTime(2026, 3, 4), decided: new DateTime(2026, 3, 5)), + Request(dispatch, "Withdrawn", new DateTime(2026, 3, 6)), + Request(dispatch, "Rejected", new DateTime(2026, 3, 7), decided: new DateTime(2026, 3, 8)), + Request(dispatch, "Denied", new DateTime(2026, 2, 1), decided: new DateTime(2026, 2, 2))); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + result.Total.Should().Be(2); + result.Items.Select(i => i.Status).Should().Equal("Rejected", "Rejected"); + result.Items.Select(i => i.DecidedAt).Should().Equal( + new DateTime(2026, 3, 8), + new DateTime(2026, 2, 2)); + } + [Fact] public async Task List_WithoutStatusFilter_KeepsHistoricalNewestRequestFirstOrder() { @@ -558,6 +611,40 @@ public sealed class UpliftQueueReadTests result.Items.Single().DecidedByName.Should().Be("Grace Hopper"); } + [Fact] + public async Task List_RejectedRow_CarriesRejecterRequesterDecisionTimeAndReason() + { + using var context = NewContext(); + var (vendor, workOrder) = await SeedWorkOrderAsync(context, "WO-R", "SITE-R", "Plumbing"); + var dispatch = await SeedDispatchAsync(context, vendor, workOrder, "DIS-R"); + context.Users.Add(new ApplicationUser { Id = "user-7", FirstName = "Ada", LastName = "Lovelace" }); + await context.SaveChangesAsync(); + context.DispatchUpliftRequests.Add(new DispatchUpliftRequest + { + DispatchId = dispatch.Id, + Status = "Rejected", + CreatedDate = new DateTime(2026, 3, 1), + DecidedAt = new DateTime(2026, 3, 2, 16, 40, 0), + DecidedByUserId = "user-7", + DecisionNote = "Outside this work order's scope", + RequestedByVendorName = "Gateway", + RequiredTier = 1, + RequestedNTE = 250m, + NotificationStatus = "Pending" + }); + await context.SaveChangesAsync(); + var service = NewService(context); + + var result = await service.ListAsync(UserWithRoles("Approver"), "Rejected", null, 1, 25, CancellationToken.None); + + var row = result.Items.Single(); + row.DecidedByName.Should().Be("Ada Lovelace"); + row.RequestedByName.Should().Be("Gateway"); + row.DecidedAt.Should().Be(new DateTime(2026, 3, 2, 16, 40, 0)); + row.DecisionNote.Should().Be("Outside this work order's scope"); + row.WorkOrderNumber.Should().Be("WO-R"); + } + [Fact] public async Task List_PendingExposureTotal_SumsEachPendingRequestsIncreaseAcrossTheQueue() { diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs index 09566f3..0c3706c 100644 --- a/SeaHaven.DataServices/Implementation/UpliftDataService.cs +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -11,6 +11,9 @@ namespace SeaHaven.DataServices.Implementation public class UpliftDataService : IUpliftDataService { private static readonly ConcurrentDictionary WorkOrderGates = new(); + + // The Rejected queue also surfaces the legacy "Denied" spelling, which reads as Rejected. + private static readonly string[] RejectedStatuses = { "Rejected", "Denied" }; private readonly ApplicationDbContext _context; public UpliftDataService(ApplicationDbContext context) @@ -46,7 +49,9 @@ namespace SeaHaven.DataServices.Implementation && (d.IsDeleted == null || d.IsDeleted == false) select new { u, d, v, ev, effectiveWorkOrderId, workOrder, reqUser, decUser }; - if (!string.IsNullOrWhiteSpace(status)) + if (string.Equals(status, "Rejected", StringComparison.Ordinal)) + query = query.Where(x => RejectedStatuses.Contains(x.u.Status)); + else if (!string.IsNullOrWhiteSpace(status)) query = query.Where(x => x.u.Status == status); if (tier.HasValue) query = query.Where(x => x.u.RequiredTier == tier.Value); @@ -54,12 +59,13 @@ namespace SeaHaven.DataServices.Implementation var total = await query.CountAsync(cancellationToken); // Approval queue read contract: the actionable queue (Pending) surfaces the - // oldest request first; the decision log (Approved) surfaces the most - // recently decided first. Every other read keeps the historical + // oldest request first; the decision logs (Approved, Rejected) surface the + // most recently decided first. Every other read keeps the historical // newest-request-first order. Id is the deterministic tiebreaker. if (string.Equals(status, "Pending", StringComparison.Ordinal)) query = query.OrderBy(x => x.u.CreatedDate).ThenBy(x => x.u.Id); - else if (string.Equals(status, "Approved", StringComparison.Ordinal)) + else if (string.Equals(status, "Approved", StringComparison.Ordinal) + || string.Equals(status, "Rejected", StringComparison.Ordinal)) query = query.OrderByDescending(x => x.u.DecidedAt).ThenByDescending(x => x.u.Id); else query = query.OrderByDescending(x => x.u.CreatedDate).ThenByDescending(x => x.u.Id);