From 6bfb56f3491e27ca9d72342dcc3e440d21f55d84 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:04:40 -0300 Subject: [PATCH 1/5] fix(data): stamp audit times in UTC and keep a caller-set work order creation time WorkOrderDataService.AddAsync overwrote the UTC CreatedDate set by WorkOrderService with local server time, offsetting the SLA response clock on any host not running in UTC. Data services now stamp CreatedDate, LastModificationTime and DeletionTime with DateTime.UtcNow, and a work order keeps the creation time its caller set. --- .../WorkOrderDataServiceTimestampTests.cs | 68 +++++++++++++++++++ .../Implementation/AccountDataService.cs | 4 +- .../Implementation/AssetDataService.cs | 4 +- .../Implementation/CategoryDataService.cs | 4 +- .../Implementation/ContactDataService.cs | 4 +- .../Implementation/DispatchDataService.cs | 4 +- .../Implementation/EmployeeDataService.cs | 4 +- .../Implementation/FollowUpDataService.cs | 10 +-- .../Implementation/LocationDataService.cs | 10 +-- .../Implementation/PMScheduleDataService.cs | 4 +- .../Implementation/WorkOrderDataService.cs | 4 +- 11 files changed, 94 insertions(+), 26 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs new file mode 100644 index 0000000..512ad0e --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceTimestampTests.cs @@ -0,0 +1,68 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +/// +/// The SLA clock and every "created"/"modified" display read these stamps as UTC, so the data layer +/// must never write local server time into them. +/// +public class WorkOrderDataServiceTimestampTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static WorkOrder NewWorkOrder() => + new() { InternalWONumber = "WO-1", WorkerOrderTitle = "Leak", LocationId = 100 }; + + [Fact] + public async Task AddAsync_KeepsTheCreationTimeTheCallerSet() + { + await using var context = NewContext(); + var createdAt = new DateTime(2026, 9, 25, 14, 0, 0, DateTimeKind.Utc); + var workOrder = NewWorkOrder(); + workOrder.CreatedDate = createdAt; + + var saved = await new WorkOrderDataService(context).AddAsync(workOrder); + + saved.CreatedDate.Should().Be(createdAt); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); + (await context.workOrders.SingleAsync()).CreatedDate.Should().Be(createdAt); + } + + [Fact] + public async Task AddAsync_StampsUtcWhenTheCallerSetNoCreationTime() + { + await using var context = NewContext(); + var before = DateTime.UtcNow; + + var saved = await new WorkOrderDataService(context).AddAsync(NewWorkOrder()); + + saved.CreatedDate.Should().NotBeNull(); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); + saved.CreatedDate.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } + + [Fact] + public async Task UpdateAsync_StampsTheModificationTimeInUtc() + { + await using var context = NewContext(); + var service = new WorkOrderDataService(context); + var saved = await service.AddAsync(NewWorkOrder()); + var before = DateTime.UtcNow; + + await service.UpdateAsync(saved); + + saved.LastModificationTime.Should().NotBeNull(); + saved.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc); + saved.LastModificationTime.Value.Should().BeOnOrAfter(before).And.BeOnOrBefore(DateTime.UtcNow); + } +} diff --git a/SeaHaven.DataServices/Implementation/AccountDataService.cs b/SeaHaven.DataServices/Implementation/AccountDataService.cs index 5b00af2..ec2f874 100644 --- a/SeaHaven.DataServices/Implementation/AccountDataService.cs +++ b/SeaHaven.DataServices/Implementation/AccountDataService.cs @@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Accounts account) { - account.CreatedDate = DateTime.Now; + account.CreatedDate = DateTime.UtcNow; await _context.Accounts.AddAsync(account); await _context.SaveChangesAsync(); return account; @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Accounts account) { - account.LastModificationTime = DateTime.Now; + account.LastModificationTime = DateTime.UtcNow; _context.Accounts.Update(account); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/AssetDataService.cs b/SeaHaven.DataServices/Implementation/AssetDataService.cs index 93a3f1e..1d60a7d 100644 --- a/SeaHaven.DataServices/Implementation/AssetDataService.cs +++ b/SeaHaven.DataServices/Implementation/AssetDataService.cs @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Assets asset) { - asset.CreatedDate = DateTime.Now; + asset.CreatedDate = DateTime.UtcNow; await _context.Assets.AddAsync(asset); await _context.SaveChangesAsync(); return asset; @@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Assets asset) { - asset.LastModificationTime = DateTime.Now; + asset.LastModificationTime = DateTime.UtcNow; _context.Assets.Update(asset); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/CategoryDataService.cs b/SeaHaven.DataServices/Implementation/CategoryDataService.cs index c5af096..994acc3 100644 --- a/SeaHaven.DataServices/Implementation/CategoryDataService.cs +++ b/SeaHaven.DataServices/Implementation/CategoryDataService.cs @@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Category category) { - category.CreatedDate = DateTime.Now; + category.CreatedDate = DateTime.UtcNow; await _context.Categories.AddAsync(category); await _context.SaveChangesAsync(); return category; @@ -33,7 +33,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Category category) { - category.LastModificationTime = DateTime.Now; + category.LastModificationTime = DateTime.UtcNow; _context.Categories.Update(category); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/ContactDataService.cs b/SeaHaven.DataServices/Implementation/ContactDataService.cs index 8196d2b..e4abf4f 100644 --- a/SeaHaven.DataServices/Implementation/ContactDataService.cs +++ b/SeaHaven.DataServices/Implementation/ContactDataService.cs @@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Contacts contact) { - contact.CreatedDate = DateTime.Now; + contact.CreatedDate = DateTime.UtcNow; await _context.Contacts.AddAsync(contact); await _context.SaveChangesAsync(); return contact; @@ -108,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Contacts contact) { - contact.LastModificationTime = DateTime.Now; + contact.LastModificationTime = DateTime.UtcNow; _context.Contacts.Update(contact); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 3274713..398051d 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Dispatch dispatch) { - dispatch.CreatedDate = DateTime.Now; + dispatch.CreatedDate = DateTime.UtcNow; await _context.Dispatches.AddAsync(dispatch); await _context.SaveChangesAsync(); return dispatch; @@ -93,7 +93,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Dispatch dispatch) { - dispatch.LastModificationTime = DateTime.Now; + dispatch.LastModificationTime = DateTime.UtcNow; _context.Dispatches.Update(dispatch); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/EmployeeDataService.cs b/SeaHaven.DataServices/Implementation/EmployeeDataService.cs index 4606d11..7b0b2c7 100644 --- a/SeaHaven.DataServices/Implementation/EmployeeDataService.cs +++ b/SeaHaven.DataServices/Implementation/EmployeeDataService.cs @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Employee employee) { - employee.CreatedDate = DateTime.Now; + employee.CreatedDate = DateTime.UtcNow; await _context.Employees.AddAsync(employee); await _context.SaveChangesAsync(); return employee; @@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Employee employee) { - employee.LastModificationTime = DateTime.Now; + employee.LastModificationTime = DateTime.UtcNow; _context.Employees.Update(employee); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs index 4eebe82..72ef131 100644 --- a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs +++ b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs @@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(FollowUps followUp) { - followUp.CreatedDate = DateTime.Now; + followUp.CreatedDate = DateTime.UtcNow; await _context.FollowUps.AddAsync(followUp); await _context.SaveChangesAsync(); return followUp; @@ -96,7 +96,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(FollowUps followUp) { - followUp.LastModificationTime = DateTime.Now; + followUp.LastModificationTime = DateTime.UtcNow; _context.FollowUps.Update(followUp); await _context.SaveChangesAsync(); } @@ -218,7 +218,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(FollowUps followUp, CancellationToken cancellationToken) { - followUp.CreatedDate = DateTime.Now; + followUp.CreatedDate = DateTime.UtcNow; await _context.FollowUps.AddAsync(followUp, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return followUp; @@ -226,7 +226,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken) { - followUp.LastModificationTime = DateTime.Now; + followUp.LastModificationTime = DateTime.UtcNow; _context.FollowUps.Update(followUp); await _context.SaveChangesAsync(cancellationToken); } @@ -238,7 +238,7 @@ namespace SeaHaven.DataServices.Implementation return false; entity.Status = status; - entity.LastModificationTime = DateTime.Now; + entity.LastModificationTime = DateTime.UtcNow; await _context.SaveChangesAsync(cancellationToken); return true; } diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 11cb89e..b331a01 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -98,7 +98,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Locations location) { - location.CreatedDate = DateTime.Now; + location.CreatedDate = DateTime.UtcNow; await _context.Locations.AddAsync(location); await _context.SaveChangesAsync(); return location; @@ -106,7 +106,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Locations location) { - location.LastModificationTime = DateTime.Now; + location.LastModificationTime = DateTime.UtcNow; _context.Locations.Update(location); await _context.SaveChangesAsync(); } @@ -228,7 +228,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(Locations location, CancellationToken cancellationToken) { - location.CreatedDate = DateTime.Now; + location.CreatedDate = DateTime.UtcNow; await _context.Locations.AddAsync(location, cancellationToken); await _context.SaveChangesAsync(cancellationToken); return location; @@ -236,7 +236,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(Locations location, CancellationToken cancellationToken) { - location.LastModificationTime = DateTime.Now; + location.LastModificationTime = DateTime.UtcNow; _context.Locations.Update(location); await _context.SaveChangesAsync(cancellationToken); } @@ -249,7 +249,7 @@ namespace SeaHaven.DataServices.Implementation if (entity == null) return false; - var now = DateTime.Now; + var now = DateTime.UtcNow; foreach (var contact in entity.Contacts ?? Enumerable.Empty()) { if (contact.IsDeleted != true) diff --git a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs index a2418ea..6bc925a 100644 --- a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs +++ b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs @@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(PMSchedules schedule) { - schedule.CreatedDate = DateTime.Now; + schedule.CreatedDate = DateTime.UtcNow; await _context.PMSchedules.AddAsync(schedule); await _context.SaveChangesAsync(); return schedule; @@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(PMSchedules schedule) { - schedule.LastModificationTime = DateTime.Now; + schedule.LastModificationTime = DateTime.UtcNow; _context.PMSchedules.Update(schedule); await _context.SaveChangesAsync(); } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index d32b77b..b34db11 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation public async Task AddAsync(WorkOrder workOrder) { - workOrder.CreatedDate = DateTime.Now; + workOrder.CreatedDate ??= DateTime.UtcNow; await _context.workOrders.AddAsync(workOrder); await _context.SaveChangesAsync(); return workOrder; @@ -201,7 +201,7 @@ namespace SeaHaven.DataServices.Implementation public async Task UpdateAsync(WorkOrder workOrder) { - workOrder.LastModificationTime = DateTime.Now; + workOrder.LastModificationTime = DateTime.UtcNow; _context.workOrders.Update(workOrder); await _context.SaveChangesAsync(); } From 27c21ec32e33d8bc89656c9dcf96ede7c9fc41b8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:06:36 -0300 Subject: [PATCH 2/5] fix(services): stamp user, contact, calendar and location audit times in UTC User creation, contact, calendar event and site-contact create/modify/delete stamps used local server time. Validation rules comparing user-entered dates and the JWT expiry are unchanged. --- Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs | 5 ++++- SeaHaven.Services/Implementation/CalendarService.cs | 6 +++--- SeaHaven.Services/Implementation/ContactService.cs | 4 ++-- SeaHaven.Services/Implementation/LocationService.cs | 8 ++++---- SeaHaven.Services/Implementation/UserService.cs | 4 ++-- 5 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs index bceb520..fbab136 100644 --- a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs @@ -44,7 +44,7 @@ public class CalendarServiceTests StartDate = startDate, EndDate = startDate, IsDeleted = isDeleted, - CreatedDate = DateTime.Now + CreatedDate = DateTime.UtcNow }; ctx.Events.Add(ev); ctx.SaveChanges(); @@ -64,6 +64,7 @@ public class CalendarServiceTests saved.Title.Should().Be("Standup"); saved.IsDeleted.Should().Be(false); saved.CreatedDate.Should().NotBeNull(); + saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] @@ -97,6 +98,7 @@ public class CalendarServiceTests var updated = ctx.Events.Single(); updated.Title.Should().Be("New"); updated.LastModificationTime.Should().NotBeNull(); + updated.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] @@ -134,6 +136,7 @@ public class CalendarServiceTests var row = ctx.Events.Single(); row.IsDeleted.Should().Be(true); row.DeletionTime.Should().NotBeNull(); + row.DeletionTime!.Value.Kind.Should().Be(DateTimeKind.Utc); } [Fact] diff --git a/SeaHaven.Services/Implementation/CalendarService.cs b/SeaHaven.Services/Implementation/CalendarService.cs index cece13d..596c083 100644 --- a/SeaHaven.Services/Implementation/CalendarService.cs +++ b/SeaHaven.Services/Implementation/CalendarService.cs @@ -31,7 +31,7 @@ namespace SeaHaven.Services.Implementation EndDate = input.EndDate, EndTime = input.EndTime, AllDay = input.AllDay, - CreatedDate = DateTime.Now, + CreatedDate = DateTime.UtcNow, IsDeleted = false }; @@ -57,7 +57,7 @@ namespace SeaHaven.Services.Implementation model.EndDate = input.EndDate; model.EndTime = input.EndTime; model.AllDay = input.AllDay; - model.LastModificationTime = DateTime.Now; + model.LastModificationTime = DateTime.UtcNow; await _dataService.UpdateEventAsync(model, cancellationToken); return true; @@ -76,7 +76,7 @@ namespace SeaHaven.Services.Implementation return false; model.IsDeleted = true; - model.DeletionTime = DateTime.Now; + model.DeletionTime = DateTime.UtcNow; await _dataService.UpdateEventAsync(model, cancellationToken); return true; diff --git a/SeaHaven.Services/Implementation/ContactService.cs b/SeaHaven.Services/Implementation/ContactService.cs index cfeab5b..bf359d2 100644 --- a/SeaHaven.Services/Implementation/ContactService.cs +++ b/SeaHaven.Services/Implementation/ContactService.cs @@ -104,7 +104,7 @@ namespace SeaHaven.Services.Implementation ContactType = dto.Type, AccountId = dto.AccountId, createdby = userId, - CreatedDate = DateTime.Now + CreatedDate = DateTime.UtcNow }; var savedContact = await _contactDataService.AddAsync(contact); @@ -151,7 +151,7 @@ namespace SeaHaven.Services.Implementation if (dto.AccountId.HasValue) existingContact.AccountId = dto.AccountId; - existingContact.LastModificationTime = DateTime.Now; + existingContact.LastModificationTime = DateTime.UtcNow; await _contactDataService.UpdateAsync(existingContact); diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index 64b275a..3e89cea 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -399,7 +399,7 @@ namespace SeaHaven.Services.Implementation { ThrowOnInvalidContacts(contacts); - var now = DateTime.Now; + var now = DateTime.UtcNow; var siteContacts = contacts .Select((row, index) => new Contacts { @@ -457,7 +457,7 @@ namespace SeaHaven.Services.Implementation existing.PhoneNumber = phone; existing.SiteContactOrder = i; existing.AccountId = location.AccountId; - existing.LastModificationTime = DateTime.Now; + existing.LastModificationTime = DateTime.UtcNow; } else { @@ -470,7 +470,7 @@ namespace SeaHaven.Services.Implementation PhoneNumber = phone, SiteContactOrder = i, AccountId = location.AccountId, - CreatedDate = DateTime.Now, + CreatedDate = DateTime.UtcNow, createdby = actorId }); } @@ -478,7 +478,7 @@ namespace SeaHaven.Services.Implementation // Rows omitted from the request are soft deleted so historical // WorkOrderContacts keep rendering. - var now = DateTime.Now; + var now = DateTime.UtcNow; foreach (var existing in existingById.Values) { if (keptIds.Contains(existing.Id) || existing.IsDeleted == true) diff --git a/SeaHaven.Services/Implementation/UserService.cs b/SeaHaven.Services/Implementation/UserService.cs index 99105a3..f448a1f 100644 --- a/SeaHaven.Services/Implementation/UserService.cs +++ b/SeaHaven.Services/Implementation/UserService.cs @@ -72,7 +72,7 @@ namespace SeaHaven.Services.Implementation { model.EmailConfirmed = true; model.UserName = model.Email; - model.CreatedDate = DateTime.Now; + model.CreatedDate = DateTime.UtcNow; model.UniqueName = "Active"; model.PhoneNumber = dto.Role; @@ -152,7 +152,7 @@ namespace SeaHaven.Services.Implementation exist.Email = dto.Email; exist.NormalizedEmail = dto.Email.ToUpperInvariant(); exist.NormalizedUserName = dto.Email.ToUpperInvariant(); - exist.CreatedDate = DateTime.Now; + exist.CreatedDate = DateTime.UtcNow; exist.UniqueName = "Active"; exist.PhoneNumber = dto.Role; exist.AccountId = dto.AccountId; From 227691269d1810ed864b064790695386c8429d73 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:07:46 -0300 Subject: [PATCH 3/5] style(data): trim trailing whitespace in touched data services --- .../Implementation/ContactDataService.cs | 8 ++++---- .../Implementation/PMScheduleDataService.cs | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/SeaHaven.DataServices/Implementation/ContactDataService.cs b/SeaHaven.DataServices/Implementation/ContactDataService.cs index e4abf4f..acd7e88 100644 --- a/SeaHaven.DataServices/Implementation/ContactDataService.cs +++ b/SeaHaven.DataServices/Implementation/ContactDataService.cs @@ -130,15 +130,15 @@ namespace SeaHaven.DataServices.Implementation public async Task EmailExistsAsync(string email, int? excludeId = null) { - return await _context.Contacts.AnyAsync(c => - c.Email == email && + return await _context.Contacts.AnyAsync(c => + c.Email == email && (excludeId == null || c.Id != excludeId)); } public async Task PhoneExistsAsync(string phone, int? excludeId = null) { - return await _context.Contacts.AnyAsync(c => - c.PhoneNumber == phone && + return await _context.Contacts.AnyAsync(c => + c.PhoneNumber == phone && (excludeId == null || c.Id != excludeId)); } diff --git a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs index 6bc925a..1ce854f 100644 --- a/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs +++ b/SeaHaven.DataServices/Implementation/PMScheduleDataService.cs @@ -46,8 +46,8 @@ namespace SeaHaven.DataServices.Implementation } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( - int page, - int pageSize, + int page, + int pageSize, string? search = null) { var query = _context.PMSchedules.AsQueryable(); From 1c8da7f344560d3c98a1158508737c5085691e3e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:20:45 -0300 Subject: [PATCH 4/5] docs(readme): record that stored audit times are UTC and need no backfill Review asked whether rows written by the old DateTime.Now stamps need a backfill. They do not: the API has only run on Linux Elastic Beanstalk hosts at their UTC default, and nothing in Terraform, .ebextensions or .platform sets a time zone, so DateTime.Now already equalled UTC there. Record that next to the hosting table so the decision is findable. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 30598d7..0f1afd4 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,12 @@ Both run in `us-east-1` on the .NET 8 Amazon Linux 2023 platform. Terraform in `terraform/live/` owns the environments; GitHub Actions owns the application versions. There is no production environment yet. +Stored created, modified and deletion times are UTC: the API stamps them with +`DateTime.UtcNow`, whatever the host's time zone. The API has only ever run on +Linux Elastic Beanstalk hosts left at their UTC default (nothing in Terraform, +`.ebextensions` or `.platform` sets a time zone), so rows written before the +switch from `DateTime.Now` are already UTC and need no backfill. + ## Architecture ```text From 53538994182e18cf28ddb5df47fc8d09294b4134 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:18:06 -0300 Subject: [PATCH 5/5] feat(uplifts): order the rejected queue by decision time and include legacy denied rows --- .../UpliftQueueReadTests.cs | 87 +++++++++++++++++++ .../Implementation/UpliftDataService.cs | 14 ++- 2 files changed, 97 insertions(+), 4 deletions(-) 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);