Merge pull request #190 from Sea-Haven-Industries/fix/ab/wo-created-utc

fix(data): stamp audit times in UTC and keep a work order's creation time
This commit is contained in:
Alexandre Brandizzi 2026-09-25 15:39:33 +00:00 • committed by GitHub
commit e9a1d8f53f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 120 additions and 43 deletions

View file

@ -44,7 +44,7 @@ public class CalendarServiceTests
StartDate = startDate, StartDate = startDate,
EndDate = startDate, EndDate = startDate,
IsDeleted = isDeleted, IsDeleted = isDeleted,
CreatedDate = DateTime.Now CreatedDate = DateTime.UtcNow
}; };
ctx.Events.Add(ev); ctx.Events.Add(ev);
ctx.SaveChanges(); ctx.SaveChanges();
@ -64,6 +64,7 @@ public class CalendarServiceTests
saved.Title.Should().Be("Standup"); saved.Title.Should().Be("Standup");
saved.IsDeleted.Should().Be(false); saved.IsDeleted.Should().Be(false);
saved.CreatedDate.Should().NotBeNull(); saved.CreatedDate.Should().NotBeNull();
saved.CreatedDate!.Value.Kind.Should().Be(DateTimeKind.Utc);
} }
[Fact] [Fact]
@ -97,6 +98,7 @@ public class CalendarServiceTests
var updated = ctx.Events.Single(); var updated = ctx.Events.Single();
updated.Title.Should().Be("New"); updated.Title.Should().Be("New");
updated.LastModificationTime.Should().NotBeNull(); updated.LastModificationTime.Should().NotBeNull();
updated.LastModificationTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
} }
[Fact] [Fact]
@ -134,6 +136,7 @@ public class CalendarServiceTests
var row = ctx.Events.Single(); var row = ctx.Events.Single();
row.IsDeleted.Should().Be(true); row.IsDeleted.Should().Be(true);
row.DeletionTime.Should().NotBeNull(); row.DeletionTime.Should().NotBeNull();
row.DeletionTime!.Value.Kind.Should().Be(DateTimeKind.Utc);
} }
[Fact] [Fact]

View file

@ -0,0 +1,68 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// 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.
/// </summary>
public class WorkOrderDataServiceTimestampTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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);
}
}

View file

@ -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 `terraform/live/` owns the environments; GitHub Actions owns the application
versions. There is no production environment yet. 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 ## Architecture
```text ```text

View file

@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Accounts> AddAsync(Accounts account) public async Task<Accounts> AddAsync(Accounts account)
{ {
account.CreatedDate = DateTime.Now; account.CreatedDate = DateTime.UtcNow;
await _context.Accounts.AddAsync(account); await _context.Accounts.AddAsync(account);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return account; return account;
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Accounts account) public async Task UpdateAsync(Accounts account)
{ {
account.LastModificationTime = DateTime.Now; account.LastModificationTime = DateTime.UtcNow;
_context.Accounts.Update(account); _context.Accounts.Update(account);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Assets> AddAsync(Assets asset) public async Task<Assets> AddAsync(Assets asset)
{ {
asset.CreatedDate = DateTime.Now; asset.CreatedDate = DateTime.UtcNow;
await _context.Assets.AddAsync(asset); await _context.Assets.AddAsync(asset);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return asset; return asset;
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Assets asset) public async Task UpdateAsync(Assets asset)
{ {
asset.LastModificationTime = DateTime.Now; asset.LastModificationTime = DateTime.UtcNow;
_context.Assets.Update(asset); _context.Assets.Update(asset);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }

View file

@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Category> AddAsync(Category category) public async Task<Category> AddAsync(Category category)
{ {
category.CreatedDate = DateTime.Now; category.CreatedDate = DateTime.UtcNow;
await _context.Categories.AddAsync(category); await _context.Categories.AddAsync(category);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return category; return category;
@ -33,7 +33,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Category category) public async Task UpdateAsync(Category category)
{ {
category.LastModificationTime = DateTime.Now; category.LastModificationTime = DateTime.UtcNow;
_context.Categories.Update(category); _context.Categories.Update(category);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }

View file

@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Contacts> AddAsync(Contacts contact) public async Task<Contacts> AddAsync(Contacts contact)
{ {
contact.CreatedDate = DateTime.Now; contact.CreatedDate = DateTime.UtcNow;
await _context.Contacts.AddAsync(contact); await _context.Contacts.AddAsync(contact);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return contact; return contact;
@ -108,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Contacts contact) public async Task UpdateAsync(Contacts contact)
{ {
contact.LastModificationTime = DateTime.Now; contact.LastModificationTime = DateTime.UtcNow;
_context.Contacts.Update(contact); _context.Contacts.Update(contact);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
@ -130,15 +130,15 @@ namespace SeaHaven.DataServices.Implementation
public async Task<bool> EmailExistsAsync(string email, int? excludeId = null) public async Task<bool> EmailExistsAsync(string email, int? excludeId = null)
{ {
return await _context.Contacts.AnyAsync(c => return await _context.Contacts.AnyAsync(c =>
c.Email == email && c.Email == email &&
(excludeId == null || c.Id != excludeId)); (excludeId == null || c.Id != excludeId));
} }
public async Task<bool> PhoneExistsAsync(string phone, int? excludeId = null) public async Task<bool> PhoneExistsAsync(string phone, int? excludeId = null)
{ {
return await _context.Contacts.AnyAsync(c => return await _context.Contacts.AnyAsync(c =>
c.PhoneNumber == phone && c.PhoneNumber == phone &&
(excludeId == null || c.Id != excludeId)); (excludeId == null || c.Id != excludeId));
} }

View file

@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Dispatch> AddAsync(Dispatch dispatch) public async Task<Dispatch> AddAsync(Dispatch dispatch)
{ {
dispatch.CreatedDate = DateTime.Now; dispatch.CreatedDate = DateTime.UtcNow;
await _context.Dispatches.AddAsync(dispatch); await _context.Dispatches.AddAsync(dispatch);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return dispatch; return dispatch;
@ -93,7 +93,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Dispatch dispatch) public async Task UpdateAsync(Dispatch dispatch)
{ {
dispatch.LastModificationTime = DateTime.Now; dispatch.LastModificationTime = DateTime.UtcNow;
_context.Dispatches.Update(dispatch); _context.Dispatches.Update(dispatch);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Employee> AddAsync(Employee employee) public async Task<Employee> AddAsync(Employee employee)
{ {
employee.CreatedDate = DateTime.Now; employee.CreatedDate = DateTime.UtcNow;
await _context.Employees.AddAsync(employee); await _context.Employees.AddAsync(employee);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return employee; return employee;
@ -80,7 +80,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Employee employee) public async Task UpdateAsync(Employee employee)
{ {
employee.LastModificationTime = DateTime.Now; employee.LastModificationTime = DateTime.UtcNow;
_context.Employees.Update(employee); _context.Employees.Update(employee);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }

View file

@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<FollowUps> AddAsync(FollowUps followUp) public async Task<FollowUps> AddAsync(FollowUps followUp)
{ {
followUp.CreatedDate = DateTime.Now; followUp.CreatedDate = DateTime.UtcNow;
await _context.FollowUps.AddAsync(followUp); await _context.FollowUps.AddAsync(followUp);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return followUp; return followUp;
@ -96,7 +96,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(FollowUps followUp) public async Task UpdateAsync(FollowUps followUp)
{ {
followUp.LastModificationTime = DateTime.Now; followUp.LastModificationTime = DateTime.UtcNow;
_context.FollowUps.Update(followUp); _context.FollowUps.Update(followUp);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
@ -218,7 +218,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<FollowUps> AddAsync(FollowUps followUp, CancellationToken cancellationToken) public async Task<FollowUps> AddAsync(FollowUps followUp, CancellationToken cancellationToken)
{ {
followUp.CreatedDate = DateTime.Now; followUp.CreatedDate = DateTime.UtcNow;
await _context.FollowUps.AddAsync(followUp, cancellationToken); await _context.FollowUps.AddAsync(followUp, cancellationToken);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return followUp; return followUp;
@ -226,7 +226,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken) public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken)
{ {
followUp.LastModificationTime = DateTime.Now; followUp.LastModificationTime = DateTime.UtcNow;
_context.FollowUps.Update(followUp); _context.FollowUps.Update(followUp);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }
@ -238,7 +238,7 @@ namespace SeaHaven.DataServices.Implementation
return false; return false;
entity.Status = status; entity.Status = status;
entity.LastModificationTime = DateTime.Now; entity.LastModificationTime = DateTime.UtcNow;
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return true; return true;
} }

View file

@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations> AddAsync(Locations location) public async Task<Locations> AddAsync(Locations location)
{ {
location.CreatedDate = DateTime.Now; location.CreatedDate = DateTime.UtcNow;
await _context.Locations.AddAsync(location); await _context.Locations.AddAsync(location);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return location; return location;
@ -108,7 +108,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Locations location) public async Task UpdateAsync(Locations location)
{ {
location.LastModificationTime = DateTime.Now; location.LastModificationTime = DateTime.UtcNow;
_context.Locations.Update(location); _context.Locations.Update(location);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
@ -232,7 +232,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken) public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
{ {
location.CreatedDate = DateTime.Now; location.CreatedDate = DateTime.UtcNow;
await _context.Locations.AddAsync(location, cancellationToken); await _context.Locations.AddAsync(location, cancellationToken);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return location; return location;
@ -240,7 +240,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(Locations location, CancellationToken cancellationToken) public async Task UpdateAsync(Locations location, CancellationToken cancellationToken)
{ {
location.LastModificationTime = DateTime.Now; location.LastModificationTime = DateTime.UtcNow;
_context.Locations.Update(location); _context.Locations.Update(location);
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
} }

View file

@ -46,8 +46,8 @@ namespace SeaHaven.DataServices.Implementation
} }
public async Task<(IEnumerable<PMSchedules> Items, int TotalCount)> GetPagedAsync( public async Task<(IEnumerable<PMSchedules> Items, int TotalCount)> GetPagedAsync(
int page, int page,
int pageSize, int pageSize,
string? search = null) string? search = null)
{ {
var query = _context.PMSchedules.AsQueryable(); var query = _context.PMSchedules.AsQueryable();
@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<PMSchedules> AddAsync(PMSchedules schedule) public async Task<PMSchedules> AddAsync(PMSchedules schedule)
{ {
schedule.CreatedDate = DateTime.Now; schedule.CreatedDate = DateTime.UtcNow;
await _context.PMSchedules.AddAsync(schedule); await _context.PMSchedules.AddAsync(schedule);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return schedule; return schedule;
@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(PMSchedules schedule) public async Task UpdateAsync(PMSchedules schedule)
{ {
schedule.LastModificationTime = DateTime.Now; schedule.LastModificationTime = DateTime.UtcNow;
_context.PMSchedules.Update(schedule); _context.PMSchedules.Update(schedule);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }

View file

@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<WorkOrder> AddAsync(WorkOrder workOrder) public async Task<WorkOrder> AddAsync(WorkOrder workOrder)
{ {
workOrder.CreatedDate = DateTime.Now; workOrder.CreatedDate ??= DateTime.UtcNow;
await _context.workOrders.AddAsync(workOrder); await _context.workOrders.AddAsync(workOrder);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return workOrder; return workOrder;
@ -201,7 +201,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task UpdateAsync(WorkOrder workOrder) public async Task UpdateAsync(WorkOrder workOrder)
{ {
workOrder.LastModificationTime = DateTime.Now; workOrder.LastModificationTime = DateTime.UtcNow;
_context.workOrders.Update(workOrder); _context.workOrders.Update(workOrder);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }

View file

@ -31,7 +31,7 @@ namespace SeaHaven.Services.Implementation
EndDate = input.EndDate, EndDate = input.EndDate,
EndTime = input.EndTime, EndTime = input.EndTime,
AllDay = input.AllDay, AllDay = input.AllDay,
CreatedDate = DateTime.Now, CreatedDate = DateTime.UtcNow,
IsDeleted = false IsDeleted = false
}; };
@ -57,7 +57,7 @@ namespace SeaHaven.Services.Implementation
model.EndDate = input.EndDate; model.EndDate = input.EndDate;
model.EndTime = input.EndTime; model.EndTime = input.EndTime;
model.AllDay = input.AllDay; model.AllDay = input.AllDay;
model.LastModificationTime = DateTime.Now; model.LastModificationTime = DateTime.UtcNow;
await _dataService.UpdateEventAsync(model, cancellationToken); await _dataService.UpdateEventAsync(model, cancellationToken);
return true; return true;
@ -76,7 +76,7 @@ namespace SeaHaven.Services.Implementation
return false; return false;
model.IsDeleted = true; model.IsDeleted = true;
model.DeletionTime = DateTime.Now; model.DeletionTime = DateTime.UtcNow;
await _dataService.UpdateEventAsync(model, cancellationToken); await _dataService.UpdateEventAsync(model, cancellationToken);
return true; return true;

View file

@ -104,7 +104,7 @@ namespace SeaHaven.Services.Implementation
ContactType = dto.Type, ContactType = dto.Type,
AccountId = dto.AccountId, AccountId = dto.AccountId,
createdby = userId, createdby = userId,
CreatedDate = DateTime.Now CreatedDate = DateTime.UtcNow
}; };
var savedContact = await _contactDataService.AddAsync(contact); var savedContact = await _contactDataService.AddAsync(contact);
@ -151,7 +151,7 @@ namespace SeaHaven.Services.Implementation
if (dto.AccountId.HasValue) if (dto.AccountId.HasValue)
existingContact.AccountId = dto.AccountId; existingContact.AccountId = dto.AccountId;
existingContact.LastModificationTime = DateTime.Now; existingContact.LastModificationTime = DateTime.UtcNow;
await _contactDataService.UpdateAsync(existingContact); await _contactDataService.UpdateAsync(existingContact);

View file

@ -579,7 +579,7 @@ namespace SeaHaven.Services.Implementation
{ {
ThrowOnInvalidContacts(contacts); ThrowOnInvalidContacts(contacts);
var now = DateTime.Now; var now = DateTime.UtcNow;
var siteContacts = contacts var siteContacts = contacts
.Select((row, index) => new Contacts .Select((row, index) => new Contacts
{ {
@ -634,7 +634,7 @@ namespace SeaHaven.Services.Implementation
existing.PhoneNumber = phone; existing.PhoneNumber = phone;
existing.SiteContactOrder = i; existing.SiteContactOrder = i;
existing.AccountId = location.AccountId; existing.AccountId = location.AccountId;
existing.LastModificationTime = DateTime.Now; existing.LastModificationTime = DateTime.UtcNow;
} }
else else
{ {
@ -647,7 +647,7 @@ namespace SeaHaven.Services.Implementation
PhoneNumber = phone, PhoneNumber = phone,
SiteContactOrder = i, SiteContactOrder = i,
AccountId = location.AccountId, AccountId = location.AccountId,
CreatedDate = DateTime.Now, CreatedDate = DateTime.UtcNow,
createdby = actorId createdby = actorId
}); });
} }
@ -655,7 +655,7 @@ namespace SeaHaven.Services.Implementation
// Rows omitted from the request are soft deleted so historical // Rows omitted from the request are soft deleted so historical
// WorkOrderContacts keep rendering. // WorkOrderContacts keep rendering.
var now = DateTime.Now; var now = DateTime.UtcNow;
foreach (var existing in existingById.Values) foreach (var existing in existingById.Values)
{ {
if (keptIds.Contains(existing.Id) || existing.IsDeleted == true) if (keptIds.Contains(existing.Id) || existing.IsDeleted == true)

View file

@ -72,7 +72,7 @@ namespace SeaHaven.Services.Implementation
{ {
model.EmailConfirmed = true; model.EmailConfirmed = true;
model.UserName = model.Email; model.UserName = model.Email;
model.CreatedDate = DateTime.Now; model.CreatedDate = DateTime.UtcNow;
model.UniqueName = "Active"; model.UniqueName = "Active";
model.PhoneNumber = dto.Role; model.PhoneNumber = dto.Role;
@ -152,7 +152,7 @@ namespace SeaHaven.Services.Implementation
exist.Email = dto.Email; exist.Email = dto.Email;
exist.NormalizedEmail = dto.Email.ToUpperInvariant(); exist.NormalizedEmail = dto.Email.ToUpperInvariant();
exist.NormalizedUserName = dto.Email.ToUpperInvariant(); exist.NormalizedUserName = dto.Email.ToUpperInvariant();
exist.CreatedDate = DateTime.Now; exist.CreatedDate = DateTime.UtcNow;
exist.UniqueName = "Active"; exist.UniqueName = "Active";
exist.PhoneNumber = dto.Role; exist.PhoneNumber = dto.Role;
exist.AccountId = dto.AccountId; exist.AccountId = dto.AccountId;