Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	SeaHaven.Services/Implementation/WorkOrderCompletionService.cs
This commit is contained in:
Alexandre Brandizzi 2026-09-25 16:40:36 -03:00
commit fc3a29f399
94 changed files with 13204 additions and 582 deletions

View file

@ -117,11 +117,11 @@ public class AuthenticationControllerTests
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Succeeded });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None);
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Newpassword = "new", Confirmpassword = "new" }, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var response = ok.Value.Should().BeOfType<Response>().Subject;
@ -130,11 +130,11 @@ public class AuthenticationControllerTests
}
[Fact]
public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus()
public async Task ChangePassword_WrongCurrentPassword_ReturnsOldPasswordIncorrectStatus()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.CurrentPasswordIncorrect });
var controller = NewController(service, "42");
@ -143,6 +143,70 @@ public class AuthenticationControllerTests
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Old Password is incorrect");
response.Message.Should().Be("Current password is incorrect");
}
[Fact]
public async Task ChangePassword_PolicyRejection_ReturnsPasswordRequirementsMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "weak", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.PasswordRejected });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "weak", Confirmpassword = "weak" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var response = bad.Value.Should().BeOfType<Response>().Subject;
response.Status.Should().Be("Password does not meet requirements");
response.Message.Should().Be(
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.");
}
[Fact]
public async Task ChangePassword_ConfirmationMismatch_IsRejectedWithoutChangingThePassword()
{
var service = new Mock<IAuthenticationService>();
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@y" },
CancellationToken.None);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Passwords don't match");
service.Verify(
s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task ChangePassword_NonPolicyFailure_ReturnsTheGenericMessage()
{
var service = new Mock<IAuthenticationService>();
service.Setup(s => s.ChangePasswordAsync("42", "Current1!", "Next2@x", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChangePasswordResultDTO { Status = ChangePasswordStatus.Failed });
var controller = NewController(service, "42");
var result = await controller.ChangePassword(
new ChangePasswords { Currentpassword = "Current1!", Newpassword = "Next2@x", Confirmpassword = "Next2@x" },
CancellationToken.None);
var response = result.Should().BeOfType<BadRequestObjectResult>().Subject.Value.Should().BeOfType<Response>().Subject;
response.Message.Should().Be("Your password could not be changed. Try again.");
response.Message.Should().NotContain("at least 6 characters");
}
[Fact]
public void ChangePassword_RequiresAuthenticatedCaller()
{
var method = typeof(AuthenticationController).GetMethod(nameof(AuthenticationController.ChangePassword))!;
method.GetCustomAttributes(typeof(Microsoft.AspNetCore.Authorization.AuthorizeAttribute), inherit: true)
.Should().NotBeEmpty();
}
[Fact]

View file

@ -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]

View file

@ -31,7 +31,9 @@ public class LocationControllerSitesTests
dataService,
new AccountDataService(ctx),
Mock.Of<ICreateLocationValidation>(),
Mock.Of<IUpdateLocationValidation>());
Mock.Of<IUpdateLocationValidation>(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
var controller = new LocationController(service, Mock.Of<ILogger<LocationController>>())
{

View file

@ -286,4 +286,80 @@ public class LocationControllerTests
contacts[0].GetProperty("Name").GetString().Should().Be("Cara Lane");
contacts[1].GetProperty("Name").GetString().Should().Be("Alan Ford");
}
[Fact]
public async Task AddLocation_DuplicateSiteCode_Returns409WithStableCode()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).AddLocation(new Location_DTO { Name = "BK5" }, CancellationToken.None);
var conflict = result.Should().BeOfType<ConflictObjectResult>().Subject;
Prop(conflict.Value!, "Code").Should().Be("DuplicateSiteCode");
Prop(conflict.Value!, "Message").Should().Be("This site code already exists.");
}
[Fact]
public async Task EditLocation_DuplicateSiteCode_Returns409()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.UpdateLocationFromRequestAsync(4, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteCodeConflictException());
var result = await NewController(service).EditLocation(4, new EditLocation_DTO { Name = "BK5" }, CancellationToken.None);
result.Should().BeOfType<ConflictObjectResult>();
}
[Fact]
public async Task DeleteLocation_WithoutPermission_Returns403WithDeleteMessage()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.DeleteLocationByIdAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new SeaHaven.Services.Exceptions.SiteForbiddenException(
SeaHaven.Services.Exceptions.SiteForbiddenException.DeleteDeniedMessage));
var result = await NewController(service).DeleteLocation(4, CancellationToken.None);
var forbidden = result.Should().BeOfType<ObjectResult>().Subject;
forbidden.StatusCode.Should().Be(403);
forbidden.Value.Should().BeOfType<Response>().Which.Message.Should().Be("You are not allowed to delete sites.");
}
[Fact]
public async Task GetOpenWorkOrders_ReturnsCountAndIds_Or404()
{
var service = new Mock<ILocationService>();
service.Setup(s => s.GetOpenWorkOrdersAsync(4, It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
var ok = (await NewController(service).GetOpenWorkOrders(4, CancellationToken.None))
.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().BeEquivalentTo(new SiteOpenWorkOrdersDTO { Count = 2, WorkOrderIds = new[] { 11, 12 } });
(await NewController(service).GetOpenWorkOrders(5, CancellationToken.None))
.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task UpdateSiteContactInfo_MapsContactsAndNotesToTheService()
{
var service = new Mock<ILocationService>();
SiteContactInfoRequestDTO? seen = null;
service.Setup(s => s.UpdateSiteContactInfoAsync(4, It.IsAny<SiteContactInfoRequestDTO>(), It.IsAny<ClaimsPrincipal>(), It.IsAny<CancellationToken>()))
.Callback<int, SiteContactInfoRequestDTO, ClaimsPrincipal, CancellationToken>((_, request, _, _) => seen = request)
.Returns(Task.CompletedTask);
var result = await NewController(service).UpdateSiteContactInfo(4, new SiteContactInfoInput_DTO
{
Contacts = new List<SiteContactInput_DTO> { new() { Id = 7, Name = "Main", Phone = "555-0100" } },
Notes = "Gate 4"
}, CancellationToken.None);
result.Should().BeOfType<OkObjectResult>();
seen!.Notes.Should().Be("Gate 4");
seen.Contacts.Should().ContainSingle().Which.Should().BeEquivalentTo(new SiteContactRequestDTO { Id = 7, Name = "Main", Phone = "555-0100" });
}
}

View file

@ -15,6 +15,27 @@ namespace Api.SeaHavenIndustries.Tests;
public class LocationServiceTests
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
{
if (request.AccountId == null)
{
if (!ctx.Accounts.Any(a => a.Id == 7))
{
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
ctx.SaveChanges();
}
request.AccountId = 7;
}
request.Address ??= "1 Depot Rd";
request.City ??= "Dallas";
request.State ??= "TX";
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@ -28,7 +49,9 @@ public class LocationServiceTests
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
private static void SeedAccount(ApplicationDbContext ctx, int id, string name = "Customer")
{
@ -83,7 +106,7 @@ public class LocationServiceTests
SeedAccount(ctx, 9);
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Title = "Main WH",
@ -95,7 +118,7 @@ public class LocationServiceTests
ContactEmail = "wh@example.com",
Status = "Active",
AccountId = 9
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
var entity = ctx.Locations.Single();
entity.Name.Should().Be("Warehouse");
@ -211,7 +234,9 @@ public class LocationServiceTests
dataService,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
[Fact]
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
@ -237,14 +262,14 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Name = "Old",
Address = "9 New St",
City = "Plano",
Status = "Inactive"
}, OrgWideAdmin(), CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("New");
row.Name.Should().Be("Old", "the site code is immutable");
row.Address1.Should().Be("9 New St");
row.City.Should().Be("Plano");
row.Status.Should().Be("Inactive");
@ -263,7 +288,7 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Name = "Old",
City = "Plano"
}, OrgWideAdmin(), CancellationToken.None);
@ -301,12 +326,12 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(
existing.Id,
new LocationUpdateRequestDTO { Name = "Renamed", City = "Austin" },
new LocationUpdateRequestDTO { Name = "Owned", City = "Austin" },
AccountUser(4),
CancellationToken.None);
var row = ctx.Locations.Single();
row.Name.Should().Be("Renamed");
row.Name.Should().Be("Owned");
row.City.Should().Be("Austin");
row.AccountId.Should().Be(4);
}
@ -386,7 +411,7 @@ public class LocationServiceTests
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 404 }),
OrgWideAdmin(),
CancellationToken.None);
@ -402,7 +427,7 @@ public class LocationServiceTests
ctx.SaveChanges();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Warehouse", AccountId = 9 }),
OrgWideAdmin(),
CancellationToken.None);
@ -418,7 +443,7 @@ public class LocationServiceTests
SeedAccount(ctx, 99);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 99 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 99 }),
AccountUser(4),
CancellationToken.None);
@ -453,7 +478,7 @@ public class LocationServiceTests
SeedAccount(ctx, 9);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
MissingScope(),
CancellationToken.None);
@ -476,12 +501,14 @@ public class LocationServiceTests
new LocationDataService(ctx),
accounts.Object,
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
using var cts = new CancellationTokenSource();
await service.CreateLocationFromRequestAsync(
new LocationCreateRequestDTO { Name = "Site", AccountId = 9 },
WithSiteFields(ctx, new LocationCreateRequestDTO { Name = "Site", AccountId = 9 }),
OrgWideAdmin(),
cts.Token);
@ -520,20 +547,6 @@ public class LocationServiceTests
ctx.Locations.Single().AccountId.Should().Be(4);
}
[Fact]
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
{
using var ctx = NewContext();
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
removed.Should().BeTrue();
again.Should().BeFalse();
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task GetLocationListPagedAsync_NormalizesAndForwardsSortToDataService()
{

View file

@ -22,6 +22,27 @@ namespace Api.SeaHavenIndustries.Tests;
public class LocationSiteContactsTests
{
/// <summary>Fills the fields a new site must carry (client, address, one contact) unless the test set them.</summary>
private static LocationCreateRequestDTO WithSiteFields(ApplicationDbContext ctx, LocationCreateRequestDTO request)
{
if (request.AccountId == null)
{
if (!ctx.Accounts.Any(a => a.Id == 7))
{
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
ctx.SaveChanges();
}
request.AccountId = 7;
}
request.Address ??= "1 Depot Rd";
request.City ??= "Dallas";
request.State ??= "TX";
request.Contacts ??= new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } };
return request;
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
@ -35,7 +56,9 @@ public class LocationSiteContactsTests
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
private static ClaimsPrincipal OrgWideAdmin()
{
@ -51,27 +74,28 @@ public class LocationSiteContactsTests
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
{
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
await service.CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Depot",
Phone = "555-9000",
AccountId = null,
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200"}
}
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
return ctx.Locations.Include(l => l.Contacts).Single();
}
[Fact]
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_SetsAccountFromLocation()
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_KeepsSitePhoneIndependent_SetsAccountFromLocation()
{
using var ctx = NewContext();
ctx.Accounts.Add(new Accounts { Id = 7, Name = "Customer", IsDeleted = false });
await ctx.SaveChangesAsync();
await NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
await NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
AccountId = 7,
@ -80,10 +104,10 @@ public class LocationSiteContactsTests
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200" }
}
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber");
location.PhoneNumber.Should().BeNull("Site Phone is independent of the contacts");
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
contacts.Should().HaveCount(2);
@ -104,11 +128,11 @@ public class LocationSiteContactsTests
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>()
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
@ -127,11 +151,11 @@ public class LocationSiteContactsTests
using var ctx = NewContext();
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
if (expectedError == null)
{
@ -148,14 +172,14 @@ public class LocationSiteContactsTests
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = new string('x', 101), Phone = new string('5', 21) }
}
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).Which;
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100"));
@ -170,11 +194,11 @@ public class LocationSiteContactsTests
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
.ToList();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
var act = () => NewService(ctx).CreateLocationFromRequestAsync(WithSiteFields(ctx, new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
}), OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20"));
@ -220,7 +244,7 @@ public class LocationSiteContactsTests
}
[Fact]
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_LeavesSitePhoneToTheRequest()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
@ -239,7 +263,7 @@ public class LocationSiteContactsTests
}, OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().Be("555-0200", "reorder must update the mirror to the new first contact");
location.PhoneNumber.Should().BeNull("Site Phone comes from the request, not the first contact");
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
active.Should().HaveCount(3);
@ -304,7 +328,7 @@ public class LocationSiteContactsTests
after.Should().HaveCount(snapshot.Count);
after.Should().BeEquivalentTo(snapshot, o => o.Excluding(c => c.Location));
ctx.Locations.AsNoTracking().Single(l => l.Id == seeded.Id).PhoneNumber
.Should().Be("555-0100", "the rejected update must not persist any change");
.Should().Be("555-9000", "the rejected update must not persist any change");
}
[Fact]
@ -448,7 +472,9 @@ public class LocationSiteContactsTests
data.Object,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation());
new UpdateLocationValidation(),
Mock.Of<SeaHaven.DataServices.Interfaces.ITeamPermissionOverrideDataService>(),
new SeaHaven.Services.Implementation.TeamPermissionPolicy());
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
@ -489,67 +515,6 @@ public class LocationSiteContactsTests
rows.Should().BeEmpty();
}
[Fact]
public async Task DataService_DeleteByIdAsync_WithContacts_RemovesLocation_RetainsSoftDeletedDetachedContacts()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var alice = seeded.Contacts!.Single(c => c.FirstName == "Alice Cooper");
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
deleted.Should().BeTrue();
ctx.Locations.Should().BeEmpty();
var retained = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
retained.Should().HaveCount(2);
retained.Should().OnlyContain(c => c.IsDeleted == true);
retained.Should().OnlyContain(c => c.LocationId == null);
retained.Should().OnlyContain(c => c.DeletionTime != null);
retained.Should().OnlyContain(c => c.DeleterUserId == null, "the delete interface carries no actor");
retained.Single(c => c.Id == alice.Id).FirstName.Should().Be("Alice Cooper");
retained.Single(c => c.Id == alice.Id).PhoneNumber.Should().Be("555-0100");
retained.Single(c => c.Id == bob.Id).FirstName.Should().Be("Bob Dillon");
retained.Single(c => c.Id == bob.Id).PhoneNumber.Should().Be("555-0200");
}
[Fact]
public async Task DataService_DeleteByIdAsync_AlreadySoftDeletedContact_IsDetachedWithoutAuditRestamp()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var bob = seeded.Contacts!.Single(c => c.FirstName == "Bob Dillon");
bob.IsDeleted = true;
bob.DeleterUserId = "admin-1";
bob.DeletionTime = new DateTime(2026, 1, 1);
await ctx.SaveChangesAsync();
var bobStamp = bob.DeletionTime;
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(seeded.Id, CancellationToken.None);
deleted.Should().BeTrue();
ctx.Locations.Should().BeEmpty();
var retained = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
retained.LocationId.Should().BeNull("previously soft-deleted rows must also detach or the restrict FK blocks the delete");
retained.IsDeleted.Should().BeTrue();
retained.DeleterUserId.Should().Be("admin-1", "original audit stamp is preserved");
retained.DeletionTime.Should().Be(bobStamp);
}
[Fact]
public async Task DataService_DeleteByIdAsync_Missing_ReturnsFalse()
{
using var ctx = NewContext();
await SeedLocationWithContactsAsync(ctx);
var deleted = await new LocationDataService(ctx).DeleteByIdAsync(424242, CancellationToken.None);
deleted.Should().BeFalse();
ctx.Locations.Should().HaveCount(1);
ctx.Contacts.Should().HaveCount(2);
}
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
{
public void UpExposed(MigrationBuilder builder) => Up(builder);
@ -608,7 +573,7 @@ public class LocationSiteContactsTests
using var json = JsonSerializer.SerializeToDocument(ok.Value);
var root = json.RootElement;
root.GetProperty("Phone").GetString().Should().Be("555-0100", "Phone mirrors the first site contact");
root.GetProperty("Phone").GetString().Should().Be("555-9000", "Phone is the Site Phone, independent of contacts");
root.GetProperty("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name");
var contacts = root.GetProperty("Contacts");
contacts.GetArrayLength().Should().Be(2);
@ -636,7 +601,7 @@ public class LocationSiteContactsTests
var row = json.RootElement.GetProperty("Data").EnumerateArray().Single();
row.GetProperty("Contact").GetString().Should().Be("Alice Cooper");
row.GetProperty("Phone").GetString().Should().Be("555-0100");
row.GetProperty("Phone").GetString().Should().Be("555-9000");
row.GetProperty("Contacts").GetArrayLength().Should().Be(2);
}
}

View file

@ -7,11 +7,13 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
@ -546,7 +548,7 @@ public class NotificationFeedServiceTests
public async Task Controller_MapsForbiddenScopeTo403()
{
using var context = NewContext();
var controller = new NotificationsController(NewService(context))
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{
@ -565,7 +567,7 @@ public class NotificationFeedServiceTests
using var context = NewContext();
context.workOrders.Add(Open(1));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewService(context))
var controller = new NotificationsController(NewService(context), Mock.Of<ISlaBreachAcknowledgementService>())
{
ControllerContext = new ControllerContext
{

View file

@ -0,0 +1,402 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// SEV response-window alerts in the Notification Center: at risk from 50% of the window (banner set and a
/// dismissable row), breached from 100% (an acknowledge row that only acknowledging removes).
/// </summary>
public class NotificationSlaTests
{
private static readonly DateTime Now = new(2026, 9, 18, 16, 0, 0, DateTimeKind.Utc);
private sealed class FixedTimeProvider : TimeProvider
{
public override DateTimeOffset GetUtcNow() => new(Now);
}
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAccountResolver Resolver(ApplicationDbContext context)
=> new(new AccountDataService(context), new LocationDataService(context));
private static NotificationFeedService NewFeed(ApplicationDbContext context)
=> new(
new NotificationFeedDataService(context),
new VendorOperationsDataService(context, new DispatchDataService(context)),
Resolver(context),
new FixedTimeProvider());
private static SlaBreachAcknowledgementService NewAcknowledgement(ApplicationDbContext context)
=> new(
new NotificationFeedDataService(context),
new WorkOrderAuditDataService(context),
new UserDataService(context),
Resolver(context),
new FixedTimeProvider());
private static ClaimsPrincipal User(int accountId, string role, string userId = "disp-1")
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, userId),
new Claim(ClaimTypes.Role, role)
}, "test"));
private static WorkOrder Sla(
int id,
TimeSpan age,
string? severity = "1",
WorkOrderType? type = WorkOrderType.Reactive,
int accountId = 1,
string? assignTo = "disp-1",
DateTime? scheduled = null,
LifecycleStatus status = LifecycleStatus.Scheduled)
=> new()
{
Id = id,
AccountId = accountId,
AssignTo = assignTo,
InternalWONumber = $"{1000 + id}",
WorkOrderType = type,
Severity = severity,
CreatedDate = Now - age,
ScheduledDate = scheduled,
LifecycleStatus = status
};
private static TimeSpan Window(int severity) => SlaResponseWindows.Respond[severity];
private static NotificationSectionDto? SlaSection(NotificationFeedDto feed)
=> feed.Sections.SingleOrDefault(section => section.Reason == NotificationReasons.Sla);
private static IEnumerable<string> ItemIds(NotificationFeedDto feed)
=> SlaSection(feed)?.Items.Select(item => item.Id) ?? Enumerable.Empty<string>();
[Fact]
public void ResponseWindows_AreTheRespondDeadlinesOnTheSeverityBadge()
{
SlaResponseWindows.Respond.ToDictionary(pair => pair.Key, pair => pair.Value.TotalMinutes)
.Should().Equal(new Dictionary<int, double>
{
[1] = 120,
[2] = 240,
[3] = 480,
[4] = 1440,
[5] = 4320
});
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public async Task Thresholds_AtRiskFromHalfTheWindow_BreachedFromTheWholeWindow(int severity)
{
using var context = NewContext();
var second = TimeSpan.FromSeconds(1);
var window = Window(severity);
var level = severity.ToString();
context.workOrders.AddRange(
Sla(1, window / 2 - second, level),
Sla(2, window / 2, level),
Sla(3, window / 2 + second, level),
Sla(4, window - second, level),
Sla(5, window, level),
Sla(6, window + second, level));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
feed.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 2, 3, 4 });
ItemIds(feed).Should().BeEquivalentTo(
"sla-at-risk-2", "sla-at-risk-3", "sla-at-risk-4", "sla-breach-5", "sla-breach-6");
var section = SlaSection(feed)!;
section.Label.Should().Be("SLA at Risk");
section.Count.Should().Be(5);
section.Severity.Should().Be(NotificationSeverities.Critical);
section.Items.Where(item => item.Id.StartsWith("sla-breach-")).Should().OnlyContain(item =>
item.RowType == NotificationRowTypes.Acknowledge
&& item.Severity == NotificationSeverities.Critical
&& item.Title.EndsWith("missed its response deadline"));
section.Items.Where(item => item.Id.StartsWith("sla-at-risk-")).Should().OnlyContain(item =>
item.RowType == NotificationRowTypes.Dismissable
&& item.Severity == NotificationSeverities.High
&& item.Title.EndsWith("is at risk of missing its response deadline"));
}
[Fact]
public async Task AtRisk_CarriesTheServerComputedClock()
{
using var context = NewContext();
context.workOrders.Add(Sla(7, TimeSpan.FromMinutes(90), "1"));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
var atRisk = feed.SlaAtRisk.Should().ContainSingle().Subject;
atRisk.Number.Should().Be("1007");
atRisk.Severity.Should().Be(1);
atRisk.StartedAt.Should().Be(Now.AddMinutes(-90));
atRisk.DeadlineAt.Should().Be(Now.AddMinutes(30));
atRisk.DeadlineAt.Kind.Should().Be(DateTimeKind.Utc);
atRisk.PercentElapsed.Should().Be(75);
SlaSection(feed)!.Items.Single().Title.Should().Be("WO #1007 is at risk of missing its response deadline");
}
[Fact]
public async Task OnlyOpenReactiveOrEmergencyWorkOrdersWithASeverityLevelAreTracked()
{
using var context = NewContext();
var late = Window(1) * 2;
context.workOrders.AddRange(
Sla(1, late, "1", WorkOrderType.Reactive),
Sla(2, late, "2", WorkOrderType.Emergency),
Sla(3, late, "1", WorkOrderType.PM),
Sla(4, late, "1", WorkOrderType.Inspection),
Sla(5, late, "1", type: null),
Sla(6, late, severity: null),
Sla(7, late, severity: "9"),
Sla(8, late, "1", status: LifecycleStatus.Completed),
Sla(9, late, "1", status: LifecycleStatus.Canceled));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
}
[Fact]
public async Task TheClockStartsAtCreation_WhateverTheScheduledDate()
{
using var context = NewContext();
context.workOrders.AddRange(
Sla(1, Window(3) + TimeSpan.FromMinutes(1), "3", scheduled: null),
// Scheduled next week, but logged three days ago: the deadline is long gone.
Sla(2, TimeSpan.FromDays(3), "4", scheduled: Now.AddDays(7)),
// Scheduled days ago, but logged ten minutes ago: nothing is due yet.
Sla(3, TimeSpan.FromMinutes(10), "4", scheduled: Now.AddDays(-5)));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
ItemIds(feed).Should().BeEquivalentTo("sla-breach-1", "sla-breach-2");
feed.SlaAtRisk.Should().BeEmpty();
}
[Fact]
public async Task Dispatcher_OnlySeesTheirOwnWorkOrders_AndTheAccountStaysTheBoundary()
{
using var context = NewContext();
var atRisk = Window(2) * 3 / 4;
context.workOrders.AddRange(
Sla(1, atRisk, "2", assignTo: "disp-1"),
Sla(2, atRisk, "2", assignTo: "disp-2"),
Sla(3, atRisk, "2", assignTo: null),
Sla(4, atRisk, "2", assignTo: "disp-1", accountId: 2));
await context.SaveChangesAsync();
var dispatcher = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher", "disp-1"), CancellationToken.None);
var admin = await NewFeed(context).GetFeedAsync(User(1, "Admin", "admin-1"), CancellationToken.None);
var otherAccount = await NewFeed(context).GetFeedAsync(User(2, "Admin", "admin-2"), CancellationToken.None);
dispatcher.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(1);
ItemIds(dispatcher).Should().Equal("sla-at-risk-1");
admin.SlaAtRisk.Select(workOrder => workOrder.Id).Should().BeEquivalentTo(new[] { 1, 2, 3 });
otherAccount.SlaAtRisk.Select(workOrder => workOrder.Id).Should().Equal(4);
}
[Fact]
public async Task Breaches_AreCappedSeparately_SoAtRiskRowsAlwaysShow()
{
using var context = NewContext();
for (var id = 1; id <= NotificationFeedService.SectionItemLimit + 10; id++)
context.workOrders.Add(Sla(id, Window(1) + TimeSpan.FromMinutes(id), "1"));
context.workOrders.Add(Sla(500, Window(5) * 3 / 4, "5"));
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(User(1, "Dispatcher"), CancellationToken.None);
var section = SlaSection(feed)!;
section.Count.Should().Be(NotificationFeedService.SectionItemLimit + 11);
section.Items.Count(item => item.RowType == NotificationRowTypes.Acknowledge)
.Should().Be(NotificationFeedService.SectionItemLimit);
section.Items.Should().Contain(item => item.Id == "sla-at-risk-500");
// The newest breaches are kept.
section.Items.Should().Contain(item => item.Id == "sla-breach-1");
section.Items.Should().NotContain(item => item.Id == $"sla-breach-{NotificationFeedService.SectionItemLimit + 10}");
}
[Fact]
public async Task Acknowledge_RecordsWhoAndWhenInTheAuditHistory_AndRemovesTheRow()
{
using var context = NewContext();
context.Users.Add(new ApplicationUser { Id = "disp-1", UserName = "jane", FirstName = "Jane", LastName = "Doe" });
context.workOrders.AddRange(
Sla(1, Window(1) + TimeSpan.FromMinutes(5), "1"),
Sla(2, Window(1) + TimeSpan.FromMinutes(9), "1"));
await context.SaveChangesAsync();
var user = User(1, "Dispatcher", "disp-1");
var outcome = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
outcome.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
var audit = await context.WorkOrderAuditLogs.SingleAsync();
audit.WorkOrderId.Should().Be(1);
audit.UserId.Should().Be("disp-1");
audit.CreatedAt.Should().Be(Now);
audit.Action.Should().Be("SLA breach acknowledged by Jane Doe");
audit.FieldName.Should().Be(SlaBreachAcknowledgementAudit.FieldName);
audit.NewValue.Should().Be("1");
audit.EventType.Should().Be("system");
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
ItemIds(feed).Should().Equal("sla-breach-2");
SlaSection(feed)!.Count.Should().Be(1);
var again = await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
again.Should().Be(SlaBreachAcknowledgementOutcome.AlreadyAcknowledged);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(1);
}
[Fact]
public async Task Acknowledgement_CoversOnlyTheSeverityThatWasBreached()
{
using var context = NewContext();
var workOrder = Sla(1, TimeSpan.FromHours(30), "2");
context.workOrders.Add(workOrder);
await context.SaveChangesAsync();
var user = User(1, "Dispatcher", "disp-1");
await NewAcknowledgement(context).AcknowledgeAsync(user, 1, CancellationToken.None);
workOrder.Severity = "4";
await context.SaveChangesAsync();
var feed = await NewFeed(context).GetFeedAsync(user, CancellationToken.None);
ItemIds(feed).Should().Equal("sla-breach-1");
}
[Fact]
public async Task Acknowledge_IsRefusedOutsideTheCallersFeed()
{
using var context = NewContext();
var late = Window(1) * 2;
context.workOrders.AddRange(
Sla(1, late, "1", accountId: 2, assignTo: "disp-1"),
Sla(2, late, "1", assignTo: "disp-2"),
Sla(3, late, "1", status: LifecycleStatus.Completed),
Sla(4, late, "1", WorkOrderType.PM));
await context.SaveChangesAsync();
var service = NewAcknowledgement(context);
var dispatcher = User(1, "Dispatcher", "disp-1");
(await service.AcknowledgeAsync(dispatcher, 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(User(1, "Admin", "admin-1"), 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 2, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 3, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await service.AcknowledgeAsync(dispatcher, 4, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.NotFound);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
var otherAccountAdmin = User(2, "Admin", "admin-2");
(await service.AcknowledgeAsync(otherAccountAdmin, 1, CancellationToken.None))
.Should().Be(SlaBreachAcknowledgementOutcome.Acknowledged);
}
[Fact]
public async Task Acknowledge_BeforeTheDeadlineIsRefused()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 3 / 4, "1"));
await context.SaveChangesAsync();
var outcome = await NewAcknowledgement(context)
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, CancellationToken.None);
outcome.Should().Be(SlaBreachAcknowledgementOutcome.NotBreached);
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
}
[Fact]
public async Task Acknowledge_WithoutANotificationRole_FailsClosed()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
await context.SaveChangesAsync();
var act = () => NewAcknowledgement(context).AcknowledgeAsync(User(1, "Vendor"), 1, CancellationToken.None);
(await act.Should().ThrowAsync<WorkOrderBoardValidationException>()).Which.Code.Should().Be("Forbidden");
}
[Theory]
[InlineData(1, "Dispatcher", 1, StatusCodes.Status204NoContent)]
[InlineData(2, "Admin", 1, StatusCodes.Status404NotFound)]
[InlineData(1, "Dispatcher", 2, StatusCodes.Status409Conflict)]
[InlineData(1, "Vendor", 1, StatusCodes.Status403Forbidden)]
public async Task Controller_MapsAcknowledgeOutcomes(int accountId, string role, int workOrderId, int expectedStatus)
{
using var context = NewContext();
context.workOrders.AddRange(
Sla(1, Window(1) * 2, "1"),
Sla(2, Window(1) * 3 / 4, "1"));
await context.SaveChangesAsync();
var controller = new NotificationsController(NewFeed(context), NewAcknowledgement(context))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = User(accountId, role, "disp-1") }
}
};
var result = await controller.AcknowledgeSlaBreach(workOrderId, CancellationToken.None);
result.Should().BeAssignableTo<IStatusCodeActionResult>()
.Which.StatusCode.Should().Be(expectedStatus);
}
[Fact]
public async Task Acknowledge_ForwardsCancellation()
{
using var context = NewContext();
context.workOrders.Add(Sla(1, Window(1) * 2, "1"));
await context.SaveChangesAsync();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => NewAcknowledgement(context)
.AcknowledgeAsync(User(1, "Dispatcher", "disp-1"), 1, cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
(await context.WorkOrderAuditLogs.CountAsync()).Should().Be(0);
}
}

View file

@ -0,0 +1,218 @@
using Api.SeaHavenIndustries.Infrastructure;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Exercises the password rule through the same Identity registration the API
/// host uses, so these assertions describe the rule that runs in production.
/// </summary>
public sealed class PasswordPolicyTests : IAsyncDisposable
{
private const string CurrentPassword = "Current1!";
private readonly ServiceProvider _provider;
private readonly AsyncServiceScope _scope;
public PasswordPolicyTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(Guid.NewGuid().ToString()));
services.AddSeaHavenIdentity();
_provider = services.BuildServiceProvider();
_scope = _provider.CreateAsyncScope();
}
private UserManager<ApplicationUser> UserManager =>
_scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
[Theory]
[InlineData("Ab1!x", "PasswordTooShort")]
[InlineData("abc12!", "PasswordRequiresUpper")]
[InlineData("Abcde!", "PasswordRequiresDigit")]
[InlineData("Abcde1", "PasswordRequiresNonAlphanumeric")]
public async Task Policy_RejectsPasswordMissingOneRule(string password, string expectedCode)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Select(error => error.Code).Should().Equal(expectedCode);
}
[Theory]
[InlineData("Abc1!x")]
[InlineData("ABC12!")]
public async Task Policy_AcceptsSixCharacterPasswordMeetingEveryRule(string password)
{
var user = new ApplicationUser { UserName = "policy@example.com", Email = "policy@example.com" };
var errors = await ValidateAsync(user, password);
errors.Should().BeEmpty();
}
[Fact]
public void Registration_AppliesSharedPolicyOptions()
{
var options = _scope.ServiceProvider.GetRequiredService<IOptions<IdentityOptions>>().Value.Password;
options.RequiredLength.Should().Be(6);
options.RequireUppercase.Should().BeTrue();
options.RequireDigit.Should().BeTrue();
options.RequireNonAlphanumeric.Should().BeTrue();
options.RequireLowercase.Should().BeFalse();
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_IsRejectedBeforeNewPasswordIsEvaluated()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, "Wrong1!", "weak", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.CurrentPasswordIncorrect);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndWeakNewPassword_IsRejectedByPolicy()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "abcdef", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.PasswordRejected);
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
}
[Fact]
public async Task ChangePassword_CorrectCurrentPasswordAndCompliantNewPassword_ChangesPassword()
{
var user = await CreateUserAsync();
var service = NewAuthenticationService();
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Succeeded);
var reloaded = await UserManager.FindByIdAsync(user.Id);
(await UserManager.CheckPasswordAsync(reloaded!, "Next2@x")).Should().BeTrue();
}
[Theory]
[InlineData("ConcurrencyFailure")]
[InlineData("PasswordMismatch")]
[InlineData("DefaultError")]
public async Task ChangePassword_NonPolicyIdentityFailure_IsNotReportedAsAWeakPassword(string code)
{
var user = new ApplicationUser { Id = "member-1", UserName = "member@example.com" };
var userManager = new Mock<UserManager<ApplicationUser>>(
Mock.Of<IUserStore<ApplicationUser>>(), null!, null!, null!, null!, null!, null!, null!, null!);
userManager.Setup(m => m.FindByIdAsync(user.Id)).ReturnsAsync(user);
userManager.Setup(m => m.CheckPasswordAsync(user, CurrentPassword)).ReturnsAsync(true);
userManager.Setup(m => m.ChangePasswordAsync(user, CurrentPassword, "Next2@x"))
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Code = code, Description = "failed" }));
var service = new AuthenticationService(
userManager.Object,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
Mock.Of<IForgetPasswordDataService>(),
Mock.Of<IEmailSender>());
var result = await service.ChangePasswordAsync(user.Id, CurrentPassword, "Next2@x", CancellationToken.None);
result.Status.Should().Be(ChangePasswordStatus.Failed);
}
[Theory]
[InlineData("PasswordTooShort", true)]
[InlineData("PasswordRequiresUpper", true)]
[InlineData("PasswordRequiresDigit", true)]
[InlineData("PasswordRequiresNonAlphanumeric", true)]
[InlineData("ConcurrencyFailure", false)]
[InlineData("PasswordMismatch", false)]
public void IsPolicyRejection_MatchesOnlyThePasswordRuleCodes(string code, bool expected)
{
IdentityPasswordPolicy.IsPolicyRejection(IdentityResult.Failed(new IdentityError { Code = code }))
.Should().Be(expected);
}
[Fact]
public async Task ChangePassword_CancelledToken_Throws()
{
var service = NewAuthenticationService();
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
var act = () => service.ChangePasswordAsync("any", CurrentPassword, "Next2@x", cancellation.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task ResetPassword_WeakPassword_IsRejectedAndKeepsCode()
{
var user = await CreateUserAsync();
var forget = new Mock<IForgetPasswordDataService>();
forget.Setup(f => f.ExistsByEmailAndCodeAsync(user.Email!, "123456", It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
forget.Setup(f => f.GetByEmailAsync(user.Email!, It.IsAny<CancellationToken>()))
.ReturnsAsync(new ForgetPasswordCode { Email = user.Email!, UserId = user.Id, Code = "123456" });
var service = NewAuthenticationService(forget);
var reset = await service.ResetPasswordAsync(user.Email!, "123456", "abcdef", CancellationToken.None);
reset.Should().BeFalse();
(await UserManager.CheckPasswordAsync(user, CurrentPassword)).Should().BeTrue();
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
private async Task<IReadOnlyList<IdentityError>> ValidateAsync(ApplicationUser user, string password)
{
var errors = new List<IdentityError>();
foreach (var validator in UserManager.PasswordValidators)
{
var result = await validator.ValidateAsync(UserManager, user, password);
errors.AddRange(result.Errors);
}
return errors;
}
private async Task<ApplicationUser> CreateUserAsync()
{
var user = new ApplicationUser { UserName = "member@example.com", Email = "member@example.com" };
var created = await UserManager.CreateAsync(user, CurrentPassword);
created.Succeeded.Should().BeTrue();
return user;
}
private AuthenticationService NewAuthenticationService(Mock<IForgetPasswordDataService>? forget = null) =>
new(
UserManager,
Microsoft.Extensions.Options.Options.Create(new JwtOptions { Secret = new string('x', 64) }),
Mock.Of<IUserDataService>(),
(forget ?? new Mock<IForgetPasswordDataService>()).Object,
Mock.Of<IEmailSender>());
public async ValueTask DisposeAsync()
{
await _scope.DisposeAsync();
await _provider.DisposeAsync();
}
}

View file

@ -0,0 +1,503 @@
using System.Security.Claims;
using System.Text.Json;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// Site registry rules: tenant-scoped unique site codes, immutable codes,
/// permission-gated tombstone delete, open work order lookup and the
/// contact/notes write used by the work-order Site dialog.
/// </summary>
public class SiteRegistryServiceTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx, string? role = "Admin") =>
NewService(new LocationDataService(ctx), new AccountDataService(ctx), role);
private static LocationService NewService(
ILocationDataService data,
IAccountDataService accounts,
string? role = "Admin")
{
var permissions = new Mock<ITeamPermissionOverrideDataService>();
permissions
.Setup(p => p.GetUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((string userId, CancellationToken _) => role == null
? null
: new TeamPermissionUserData { UserId = userId, RoleName = role });
return new LocationService(
data,
accounts,
new CreateLocationValidation(),
new UpdateLocationValidation(),
permissions.Object,
new TeamPermissionPolicy());
}
private static ClaimsPrincipal OrgWide(string role = "Admin") => Principal(role, new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
private static ClaimsPrincipal AccountUser(int accountId, string role = "Admin") =>
Principal(role, new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()));
private static ClaimsPrincipal Principal(string role, Claim scope) =>
new(new ClaimsIdentity(new List<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, role),
scope
}, "test"));
private static void SeedAccounts(ApplicationDbContext ctx, params int[] ids)
{
foreach (var id in ids)
ctx.Accounts.Add(new Accounts { Id = id, Name = $"Client {id}", IsDeleted = false });
ctx.SaveChanges();
}
private static Locations SeedSite(ApplicationDbContext ctx, string code, int? accountId, bool deleted = false)
{
var site = new Locations { Name = code, AccountId = accountId, City = "Dallas", State = "TX", IsDeleted = deleted ? true : null };
ctx.Locations.Add(site);
ctx.SaveChanges();
return site;
}
private static WorkOrder Wo(
int id,
int? locationId,
LifecycleStatus? status = LifecycleStatus.Incomplete,
int? accountId = 1,
string? siteCode = null,
string? legacyStatus = null,
bool deleted = false) => new()
{
Id = id,
LocationId = locationId,
LifecycleStatus = status,
LegacyStatus = legacyStatus,
AccountId = accountId,
SiteCode = siteCode,
IsDeleted = deleted ? true : null
};
private static LocationCreateRequestDTO CreateRequest(string code, int? accountId) => new()
{
Name = code,
AccountId = accountId,
Address = "1 Depot Rd",
City = "Dallas",
State = "TX",
Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = "555-0100" } }
};
[Fact]
public async Task Create_DuplicateSiteCodeInSameClient_IsRejectedCaseInsensitively()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" bk5 ", 1), OrgWide(), CancellationToken.None);
await act.Should().ThrowAsync<SiteCodeConflictException>();
ctx.Locations.Should().ContainSingle();
}
[Fact]
public async Task Create_SameSiteCodeForAnotherClient_IsAllowed()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1, 2);
SeedSite(ctx, "BK5", 1);
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 2), OrgWide(), CancellationToken.None);
ctx.Locations.Where(l => l.Name == "BK5").Select(l => l.AccountId).Should().BeEquivalentTo(new int?[] { 1, 2 });
}
[Fact]
public async Task Create_SiteCodeOfADeletedSite_CanBeReused()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1, deleted: true);
await NewService(ctx).CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), CancellationToken.None);
ctx.Locations.Count(l => l.Name == "BK5" && l.IsDeleted != true).Should().Be(1);
}
[Fact]
public async Task Create_BlankSiteCode_IsAValidationError()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(CreateRequest(" ", 1), OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code is required.");
}
[Fact]
public async Task Create_StoresTrimmedCodeNotesAndSitePhoneIndependentOfContacts()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var request = CreateRequest(" DFW8 ", 1);
request.Phone = "555-9000";
request.Notes = " Gate code 4411 ";
await NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
var site = ctx.Locations.Single();
site.Name.Should().Be("DFW8");
site.PhoneNumber.Should().Be("555-9000");
site.Notes.Should().Be("Gate code 4411");
}
[Fact]
public async Task Update_ChangingAnExistingSiteCode_IsRejected()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(
site.Id, new LocationUpdateRequestDTO { Name = "BK6" }, OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage == "Site Code cannot be changed.");
ctx.Locations.AsNoTracking().Single().Name.Should().Be("BK5");
}
[Fact]
public async Task Update_KeepsCodeAndNotesWhenTheRequestOmitsThem()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
site.Notes = "Dock 3";
site.Status = "Active";
ctx.SaveChanges();
await NewService(ctx).UpdateLocationFromRequestAsync(
site.Id, new LocationUpdateRequestDTO { Name = "bk5", City = "Memphis" }, OrgWide(), CancellationToken.None);
var saved = ctx.Locations.AsNoTracking().Single();
saved.Name.Should().Be("BK5");
saved.City.Should().Be("Memphis");
saved.Notes.Should().Be("Dock 3");
saved.Status.Should().Be("Active");
}
[Fact]
public async Task Update_BlankLegacyCode_CanBeFilledOnceButMustBeUniqueInTheClient()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
SeedSite(ctx, "BK5", 1);
var legacy = SeedSite(ctx, "", 1);
var duplicate = () => NewService(ctx).UpdateLocationFromRequestAsync(
legacy.Id, new LocationUpdateRequestDTO { Name = "bk5" }, OrgWide(), CancellationToken.None);
await duplicate.Should().ThrowAsync<SiteCodeConflictException>();
await NewService(ctx).UpdateLocationFromRequestAsync(
legacy.Id, new LocationUpdateRequestDTO { Name = "MEM1" }, OrgWide(), CancellationToken.None);
ctx.Locations.AsNoTracking().Single(l => l.Id == legacy.Id).Name.Should().Be("MEM1");
}
[Theory]
[InlineData("Admin")]
[InlineData("Scheduler")]
public async Task Delete_AdminOrScheduler_TombstonesTheSiteAndLeavesWorkOrdersAsTheyWere(string role)
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var site = SeedSite(ctx, "BK5", 1);
ctx.Contacts.Add(new Contacts { LocationId = site.Id, FirstName = "Main", PhoneNumber = "555-0100" });
ctx.workOrders.Add(Wo(10, site.Id));
ctx.SaveChanges();
var deleted = await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None);
deleted.Should().BeTrue();
var tombstone = ctx.Locations.AsNoTracking().Single();
tombstone.IsDeleted.Should().BeTrue();
tombstone.DeleterUserId.Should().Be("actor-1");
ctx.workOrders.AsNoTracking().Single().LocationId.Should().Be(site.Id, "work orders keep their site reference");
ctx.Contacts.AsNoTracking().Single().IsDeleted.Should().NotBe(true, "contacts still back open work orders");
var data = new LocationDataService(ctx);
(await data.GetDetailByIdAsync(site.Id, CancellationToken.None)).Should().BeNull();
(await data.GetSiteOptionsAsync(null, CancellationToken.None)).Should().BeEmpty();
(await data.GetListPagedAsync(1, 10, null, null, CancellationToken.None)).TotalCount.Should().Be(0);
(await data.GetAccountScopeAsync(site.Id, CancellationToken.None)).Exists.Should().BeFalse("a deleted site cannot be assigned to new work orders");
(await NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role), CancellationToken.None)).Should().BeFalse();
}
[Theory]
[InlineData("Dispatcher")]
[InlineData(null)]
public async Task Delete_WithoutDeleteSitesPermission_IsForbiddenAndKeepsTheSite(string? role)
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var act = () => NewService(ctx, role).DeleteLocationByIdAsync(site.Id, OrgWide(role ?? "Dispatcher"), CancellationToken.None);
await act.Should().ThrowAsync<SiteForbiddenException>();
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
}
[Fact]
public async Task Delete_SiteOfAnotherClient_IsRejectedForAnAccountScopedCaller()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 2);
var act = () => NewService(ctx).DeleteLocationByIdAsync(site.Id, AccountUser(1), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Locations.AsNoTracking().Single().IsDeleted.Should().NotBe(true);
}
[Fact]
public async Task OpenWorkOrders_ExcludeTerminalDeletedAndOtherSitesWork()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
var other = SeedSite(ctx, "MEM1", 1);
ctx.workOrders.AddRange(
Wo(1, site.Id),
Wo(2, site.Id, LifecycleStatus.Scheduled),
Wo(3, site.Id, LifecycleStatus.Completed),
Wo(4, site.Id, LifecycleStatus.Canceled),
Wo(5, site.Id, deleted: true),
Wo(6, site.Id, status: null, legacyStatus: "Completed"),
Wo(7, site.Id, status: null, legacyStatus: "Open"),
Wo(8, null, siteCode: "bk5"),
Wo(9, null, siteCode: "BK5", accountId: 2),
Wo(10, other.Id));
ctx.SaveChanges();
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
result!.WorkOrderIds.Should().Equal(1, 2, 7, 8);
result.Count.Should().Be(4);
}
[Fact]
public async Task OpenWorkOrders_AccountScopedCaller_SeesOnlyTheirClientsWork()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
ctx.workOrders.AddRange(Wo(1, site.Id, accountId: 1), Wo(2, site.Id, accountId: 2));
ctx.SaveChanges();
var own = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(1), CancellationToken.None);
own!.WorkOrderIds.Should().Equal(1);
var foreign = () => NewService(ctx).GetOpenWorkOrdersAsync(site.Id, AccountUser(2), CancellationToken.None);
await foreign.Should().ThrowAsync<UnauthorizedAccessException>();
}
[Fact]
public async Task OpenWorkOrders_ReturnFullCountButCapIds()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", 1);
ctx.workOrders.AddRange(Enumerable.Range(1, LocationService.MaxOpenWorkOrderIds + 5).Select(id => Wo(id, site.Id)));
ctx.SaveChanges();
var result = await NewService(ctx).GetOpenWorkOrdersAsync(site.Id, OrgWide(), CancellationToken.None);
result!.Count.Should().Be(LocationService.MaxOpenWorkOrderIds + 5);
result.WorkOrderIds.Should().HaveCount(LocationService.MaxOpenWorkOrderIds);
}
[Fact]
public async Task OpenWorkOrders_MissingOrDeletedSite_ReturnsNull()
{
using var ctx = NewContext();
var deleted = SeedSite(ctx, "BK5", 1, deleted: true);
(await NewService(ctx).GetOpenWorkOrdersAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeNull();
(await NewService(ctx).GetOpenWorkOrdersAsync(999, OrgWide(), CancellationToken.None)).Should().BeNull();
}
[Fact]
public async Task UpdateSiteContactInfo_SavesContactsAndNotesToTheSiteRecord()
{
using var ctx = NewContext();
var site = SeedSite(ctx, "BK5", null);
var main = new Contacts { LocationId = site.Id, FirstName = "Old Main", PhoneNumber = "555-0100", SiteContactOrder = 0 };
ctx.Contacts.Add(main);
ctx.SaveChanges();
await NewService(ctx).UpdateSiteContactInfoAsync(site.Id, new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO>
{
new() { Id = main.Id, Name = "New Main", Phone = "555-0199" },
new() { Name = "Night Shift", Phone = "555-0200" }
},
Notes = " Call ahead "
}, OrgWide("Dispatcher"), CancellationToken.None);
var saved = ctx.Locations.AsNoTracking().Include(l => l.Contacts).Single();
saved.Notes.Should().Be("Call ahead");
saved.Contacts!.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder)
.Select(c => (c.Id == main.Id, c.FirstName, c.PhoneNumber))
.Should().Equal((true, "New Main", "555-0199"), (false, "Night Shift", "555-0200"));
}
[Fact]
public async Task UpdateSiteContactInfo_RejectsOutOfScopeDeletedAndContactlessRequests()
{
using var ctx = NewContext();
var foreign = SeedSite(ctx, "BK5", 2);
var deleted = SeedSite(ctx, "MEM1", 1, deleted: true);
var request = new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
};
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(foreign.Id, request, AccountUser(1), CancellationToken.None)))
.Should().ThrowAsync<UnauthorizedAccessException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(deleted.Id, request, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<KeyNotFoundException>();
await ((Func<Task>)(() => NewService(ctx).UpdateSiteContactInfoAsync(
foreign.Id, new SiteContactInfoRequestDTO { Notes = "x" }, OrgWide(), CancellationToken.None)))
.Should().ThrowAsync<FluentValidation.ValidationException>();
}
[Fact]
public async Task NewSiteOperations_ForwardTheCallersCancellationToken()
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var site = new Locations { Id = 5, Name = "BK5", AccountId = 1, Contacts = new List<Contacts>() };
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetByIdForUpdateAsync(5, token)).ReturnsAsync(site);
data.Setup(d => d.GetDetailByIdAsync(5, token)).ReturnsAsync(site);
data.Setup(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token))
.ReturnsAsync((1, new[] { 7 }));
var service = NewService(data.Object, Mock.Of<IAccountDataService>());
await service.GetOpenWorkOrdersAsync(5, OrgWide(), token);
await service.UpdateSiteContactInfoAsync(5, new SiteContactInfoRequestDTO
{
Contacts = new List<SiteContactRequestDTO> { new() { Name = "A", Phone = "1" } }
}, OrgWide(), token);
await service.DeleteLocationByIdAsync(5, OrgWide(), token);
data.Verify(d => d.GetOpenWorkOrderIdsAsync(5, "BK5", 1, null, LocationService.MaxOpenWorkOrderIds, token), Times.Once);
data.Verify(d => d.UpdateAsync(site, token), Times.Exactly(2));
}
[Fact]
public async Task Create_DuplicateCheck_ForwardsTheCallersCancellationToken()
{
using var cts = new CancellationTokenSource();
var token = cts.Token;
var data = new Mock<ILocationDataService>();
data.Setup(d => d.SiteCodeExistsAsync("BK5", 1, null, token)).ReturnsAsync(true);
var accounts = new Mock<IAccountDataService>();
accounts.Setup(a => a.ExistsActiveAsync(1, token)).ReturnsAsync(true);
var service = NewService(data.Object, accounts.Object);
var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", 1), OrgWide(), token);
await act.Should().ThrowAsync<SiteCodeConflictException>();
data.Verify(d => d.SiteCodeExistsAsync("BK5", 1, null, token), Times.Once);
}
[Fact]
public void CompletionSnapshot_FreezesSiteNotesWhenTheWorkOrderHasNone()
{
var workOrder = new WorkOrder
{
Id = 1,
Locations = new Locations { Id = 5, Name = "BK5", Notes = "Gate code 4411", Contacts = new List<Contacts>() }
};
WorkOrderCompletionSnapshotMapper.Capture(workOrder);
using var frozen = JsonDocument.Parse(workOrder.FrozenPoc!);
frozen.RootElement.GetProperty("notes").GetString().Should().Be("Gate code 4411");
}
public static TheoryData<string, Action<LocationCreateRequestDTO>, string> MissingSiteFields => new()
{
{ "no client", r => r.AccountId = null, "Client is required." },
{ "no street address", r => r.Address = " ", "Street Address is required." },
{ "no city", r => r.City = null, "City is required." },
{ "no state", r => r.State = "", "State is required." },
{ "no contacts list", r => r.Contacts = null, "At least one contact is required." },
{ "empty contacts list", r => r.Contacts = new List<SiteContactRequestDTO>(), "At least one contact is required." },
{ "contact without phone", r => r.Contacts = new List<SiteContactRequestDTO> { new() { Name = "Main", Phone = " " } }, "Contact phone is required." }
};
[Theory]
[MemberData(nameof(MissingSiteFields))]
public async Task Create_WithoutARequiredSiteField_IsAValidationErrorAndStoresNothing(
string _,
Action<LocationCreateRequestDTO> strip,
string expectedMessage)
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var request = CreateRequest("BK9", 1);
strip(request);
var act = () => NewService(ctx).CreateLocationFromRequestAsync(request, OrgWide(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().Contain(e => e.ErrorMessage == expectedMessage);
ctx.Locations.Should().BeEmpty();
}
[Fact]
public async Task Delete_RemovesTheSiteFromEveryLegacyRead()
{
using var ctx = NewContext();
SeedAccounts(ctx, 1);
var deleted = SeedSite(ctx, "BK5", 1);
var kept = SeedSite(ctx, "BK6", 1);
var service = NewService(ctx);
(await service.DeleteLocationByIdAsync(deleted.Id, OrgWide(), CancellationToken.None)).Should().BeTrue();
(await service.GetLocationByIdAsync(deleted.Id)).Should().BeNull();
(await service.GetLocationByIdWithDetailsAsync(deleted.Id)).Should().BeNull();
(await service.LocationExistsAsync(deleted.Id)).Should().BeFalse();
(await service.GetTotalLocationCountAsync()).Should().Be(1);
(await service.GetAllLocationsAsync()).Select(l => l.Id).Should().Equal(kept.Id);
(await service.GetLocationsByAccountIdAsync(1)).Select(l => l.Id).Should().Equal(kept.Id);
(await service.GetLocationsPagedAsync(1, 10)).Items.Select(l => l.Id).Should().Equal(kept.Id);
(await new LocationDataService(ctx).GetAddressbookPagedAsync(1, 10)).TotalCount.Should().Be(1);
(await new VendorOperationsDataService(ctx, Mock.Of<IDispatchDataService>()).LocationExistsAsync(deleted.Id, CancellationToken.None)).Should().BeFalse();
}
}

View file

@ -0,0 +1,95 @@
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries.Enums;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ActionConstraints;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using System.Security.Claims;
using System.Text.Json;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public sealed class TeamMemberPermissionsEndpointTests
{
[Fact]
public async Task Signed_in_caller_receives_their_effective_keys_as_a_permissions_array()
{
var data = new Mock<ITeamPermissionOverrideDataService>();
data.Setup(d => d.GetUserAsync("u1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new TeamPermissionUserData
{
UserId = "u1",
RoleName = "Dispatcher",
Overrides = new Dictionary<string, UserPermissionState>
{
[TeamPermissionKeys.CreateCompletionDocTemplates] = UserPermissionState.Allow
}
});
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }, "Bearer")));
var ok = Assert.IsType<OkObjectResult>(await controller.GetMyPermissions(CancellationToken.None));
var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
using var document = JsonDocument.Parse(json);
var keys = document.RootElement.GetProperty("permissions").EnumerateArray()
.Select(element => element.GetString()).ToList();
Assert.Contains(TeamPermissionKeys.CreateCompletionDocTemplates, keys);
Assert.DoesNotContain(TeamPermissionKeys.DeleteCompletionDocTemplates, keys);
}
[Fact]
public async Task Unauthenticated_caller_gets_401_without_data_access()
{
var data = new Mock<ITeamPermissionOverrideDataService>(MockBehavior.Strict);
var controller = Controller(data.Object, new ClaimsPrincipal(new ClaimsIdentity()));
var result = Assert.IsType<UnauthorizedResult>(await controller.GetMyPermissions(CancellationToken.None));
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
}
[Fact]
public void Route_is_get_me_permissions_and_requires_authentication()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMvcCore().AddApplicationPart(typeof(TeamMemberController).Assembly);
using var provider = services.BuildServiceProvider();
var descriptor = provider
.GetRequiredService<IActionDescriptorCollectionProvider>()
.ActionDescriptors.Items
.OfType<ControllerActionDescriptor>()
.Single(d => d.ControllerTypeInfo == typeof(TeamMemberController)
&& d.ActionName == nameof(TeamMemberController.GetMyPermissions));
var methods = descriptor.ActionConstraints!.OfType<HttpMethodActionConstraint>()
.SelectMany(c => c.HttpMethods);
Assert.Equal(new[] { "GET" }, methods);
Assert.Equal("api/team-members/me/permissions", descriptor.AttributeRouteInfo!.Template);
Assert.NotEmpty(typeof(TeamMemberController).GetCustomAttributes(typeof(AuthorizeAttribute), true));
Assert.Empty(descriptor.MethodInfo.GetCustomAttributes(typeof(AllowAnonymousAttribute), true));
}
private static TeamMemberController Controller(
ITeamPermissionOverrideDataService data,
ClaimsPrincipal user) =>
new(Mock.Of<ITeamMemberService>(), new TeamPermissionService(data, new TeamPermissionPolicy()))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = user }
}
};
}

View file

@ -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()
{

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

@ -0,0 +1,217 @@
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
/// <summary>
/// GET /board/search?ids= shows exactly the listed work orders inside the caller's tenant scope,
/// whatever other filters the board sends alongside.
/// </summary>
public class WorkOrderIdsFilterTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static WorkOrderAdvancedSearchService NewSearch(ApplicationDbContext ctx)
=> new(
new WorkOrderAdvancedSearchDataService(ctx),
new WorkOrderAccountResolver(new AccountDataService(ctx), new LocationDataService(ctx)));
private static ClaimsPrincipal AccountUser(int accountId)
=> new(new ClaimsIdentity(new[]
{
new Claim(SeaHavenClaimTypes.AccountId, accountId.ToString()),
new Claim(ClaimTypes.NameIdentifier, "admin-1"),
new Claim(ClaimTypes.Role, "Admin")
}, "test"));
private static WorkOrder Wo(
int id,
DateTime? scheduled,
int accountId = 1,
string? assignTo = "disp-1",
LifecycleStatus? status = LifecycleStatus.Scheduled,
bool? deleted = null,
bool template = false)
=> new()
{
Id = id,
AccountId = accountId,
InternalWONumber = $"3000000{id:0000}",
AssignTo = assignTo,
ScheduledDate = scheduled,
LifecycleStatus = status,
IsDeleted = deleted,
istemplate = template
};
private static void Seed(ApplicationDbContext ctx)
{
ctx.workOrders.AddRange(
Wo(1, new DateTime(2026, 9, 22)),
Wo(2, null), // no schedule date
Wo(3, new DateTime(2025, 1, 6), status: LifecycleStatus.Completed),
Wo(4, new DateTime(2026, 9, 22), assignTo: "disp-2"),
Wo(5, new DateTime(2026, 9, 22)), // not requested
Wo(6, new DateTime(2026, 9, 22), accountId: 2), // another tenant
Wo(7, new DateTime(2026, 9, 22), deleted: true),
Wo(8, new DateTime(2026, 9, 22), template: true));
ctx.SaveChanges();
}
[Fact]
public async Task Ids_ReturnExactlyThoseWorkOrders_IgnoringEveryOtherFilter()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "4,1,2,3",
// Filters the board may still carry; none of them may hide a listed work order.
DatePreset = WorkOrderAdvancedSearchDatePreset.ThisWeek,
Statuses = new List<LifecycleStatus> { LifecycleStatus.Scheduled },
Dispatchers = new List<string> { "disp-1" },
Search = "no match anywhere",
PageSize = 200
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(4);
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 2, 3, 4 });
}
[Fact]
public async Task Ids_NeverWidenTenantOrBaseScope()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "1,6,7,8",
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().Equal(1);
result.TotalCount.Should().Be(1);
}
[Fact]
public async Task Ids_OnlyAnotherTenantsWorkOrders_ReturnsNothing()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
Ids = "6"
}, AccountUser(1), "admin-1");
result.TotalCount.Should().Be(0);
result.Items.Should().BeEmpty();
}
[Fact]
public async Task NoIds_KeepsTheExistingFilters()
{
await using var ctx = NewContext();
Seed(ctx);
var result = await NewSearch(ctx).SearchAsync(new WorkOrderAdvancedSearchQueryDto
{
DatePreset = WorkOrderAdvancedSearchDatePreset.Custom,
DateFrom = new DateOnly(2026, 9, 21),
DateTo = new DateOnly(2026, 9, 25),
Dispatchers = new List<string> { "disp-1" },
PageSize = 200
}, AccountUser(1), "admin-1");
result.Items.Select(row => row.Id).Should().BeEquivalentTo(new[] { 1, 5 });
}
[Theory]
[InlineData("1,abc")]
[InlineData("0")]
[InlineData("-3")]
[InlineData("1,,2")]
[InlineData("1.5")]
[InlineData("99999999999")]
public async Task MalformedIds_AreABadRequest(string ids)
{
var controller = NewController();
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
var badRequest = result.Should().BeOfType<BadRequestObjectResult>().Subject;
badRequest.Value.Should().BeOfType<Response>().Which.Message.Should().Contain("ids");
}
[Fact]
public async Task MoreThanTheLimit_IsABadRequest()
{
var controller = NewController();
var ids = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount + 1));
var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto { Ids = ids });
result.Should().BeOfType<BadRequestObjectResult>()
.Which.Value.Should().BeOfType<Response>()
.Which.Message.Should().Contain("200");
}
[Fact]
public void Parse_DeduplicatesBeforeCountingAndKeepsFirstSeenOrder()
{
var withDuplicates = string.Join(",", Enumerable.Range(1, WorkOrderIdSet.MaxCount).Concat(new[] { 5, 7 }));
WorkOrderIdSet.ParseOrThrow(withDuplicates).Should().HaveCount(WorkOrderIdSet.MaxCount);
WorkOrderIdSet.ParseOrThrow(" 9, 3 ,9 ").Should().Equal(9, 3);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Parse_AbsentOrBlank_IsNoFilter(string? ids)
{
WorkOrderIdSet.ParseOrThrow(ids).Should().BeNull();
}
private static WorkOrderBoardController NewController()
{
var search = new WorkOrderAdvancedSearchService(
Mock.Of<SeaHaven.DataServices.Interfaces.IWorkOrderAdvancedSearchDataService>(),
Mock.Of<IWorkOrderAccountResolver>());
return new WorkOrderBoardController(
Mock.Of<IWorkOrderBoardService>(),
Mock.Of<IWorkOrderBoardUpdateService>(),
Mock.Of<IWorkOrderBoardCreateService>(),
Mock.Of<IWorkOrderBoardCancelService>(),
Mock.Of<IWorkOrderPocService>(),
search)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = AccountUser(1) }
}
};
}
}

View file

@ -38,8 +38,8 @@ public class WorkOrderRouteContractTests
/// Baseline public endpoint set (verb + action-relative route) that the original single
/// WorkOrderController exposed, plus the author-only board-comment edit endpoint (SH-122).
/// Every action is reachable under both api/WorkOrder and api/workorders; that base-route
/// duplication is collapsed here, so this is the distinct action-relative contract. 52 routes
/// come from 51 actions (Editworkorder binds two routes).
/// duplication is collapsed here, so this is the distinct action-relative contract. 53 routes
/// come from 52 actions (Editworkorder binds two routes).
/// </summary>
private static readonly HashSet<string> ExpectedWorkOrderEndpoints = new(StringComparer.Ordinal)
{
@ -60,6 +60,7 @@ public class WorkOrderRouteContractTests
"GET board/search",
"GET completion-templates",
"GET completion-templates/{id:int}",
"GET completion-templates/{id:int}/linked-work-orders",
"GET lookups/dispatchers",
"GET {id:int}",
"GET {id:int}/audit",

View file

@ -55,20 +55,33 @@ namespace Api.SeaHavenIndustries.Controllers
}
[Authorize]
[Route("ChangePassword")]
[HttpPost]
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
{
// [Compare] already rejects this during model validation; the check here keeps the
// unconfirmed password from ever being set if that validation is bypassed.
if (!string.Equals(usermodel.Newpassword, usermodel.Confirmpassword, StringComparison.Ordinal))
return BadRequest(new Response { Status = "Password confirmation does not match", Message = "Passwords don't match" });
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
if (succeeded)
var result = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Newpassword, cancellationToken);
return result.Status switch
{
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
}
else
return BadRequest(new Response { Status = "Old Password is incorrect" });
ChangePasswordStatus.Succeeded =>
Ok(new Response { Status = "Success ", Message = "Password successfully changed" }),
ChangePasswordStatus.PasswordRejected =>
BadRequest(new Response { Status = "Password does not meet requirements", Message = PasswordRequirementsMessage }),
ChangePasswordStatus.Failed =>
BadRequest(new Response { Status = "Password not changed", Message = "Your password could not be changed. Try again." }),
_ => BadRequest(new Response { Status = "Old Password is incorrect", Message = "Current password is incorrect" })
};
}
private const string PasswordRequirementsMessage =
"Password must be at least 6 characters and include one uppercase letter, one number, and one special character.";
[HttpPost]
[Route("UpdateProfile")]
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)

View file

@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Controllers
@ -105,6 +106,9 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = location.Email,
location.Status,
location.AccountId,
ClientName = location.AccountName,
location.AccountName,
location.Notes,
Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
};
@ -119,6 +123,10 @@ namespace Api.SeaHavenIndustries.Controllers
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
}
catch (SiteCodeConflictException)
{
return SiteCodeConflict();
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
@ -142,6 +150,10 @@ namespace Api.SeaHavenIndustries.Controllers
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), User, cancellationToken);
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
}
catch (SiteCodeConflictException)
{
return SiteCodeConflict();
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
@ -161,17 +173,73 @@ namespace Api.SeaHavenIndustries.Controllers
}
}
[HttpPatch("{id}/contact-info")]
public async Task<IActionResult> UpdateSiteContactInfo(int id, [FromBody] SiteContactInfoInput_DTO model, CancellationToken cancellationToken)
{
try
{
await _locationService.UpdateSiteContactInfoAsync(
id,
new SiteContactInfoRequestDTO { Contacts = MapSiteContacts(model.Contacts), Notes = model.Notes },
User,
cancellationToken);
return Ok(new DataResponse { Message = "Site Updated Successfully", Status = "200" });
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to edit this site." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Location not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = _logger.Sanitize(ex) });
}
}
[HttpGet("{id}/open-work-orders")]
public async Task<IActionResult> GetOpenWorkOrders(int id, CancellationToken cancellationToken)
{
try
{
var result = await _locationService.GetOpenWorkOrdersAsync(id, User, cancellationToken);
if (result == null)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
return Ok(result);
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to view this site." });
}
}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteLocation(int id, CancellationToken cancellationToken)
{
try
{
var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken);
var found = await _locationService.DeleteLocationByIdAsync(id, User, cancellationToken);
if (!found)
return NotFound(new Response { Status = "Error", Message = "Location not found" });
return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" });
}
catch (SiteForbiddenException forbidden)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = forbidden.Message });
}
catch (UnauthorizedAccessException)
{
return StatusCode(StatusCodes.Status403Forbidden, new Response { Status = "Error", Message = "You are not allowed to delete this site." });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Location not found" });
@ -188,6 +256,14 @@ namespace Api.SeaHavenIndustries.Controllers
return await DeleteLocation(id, cancellationToken);
}
private ObjectResult SiteCodeConflict() =>
Conflict(new
{
Status = "Conflict",
Message = SiteCodeConflictException.PublicMessage,
Code = SiteCodeConflictException.ErrorCode
});
private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model)
{
return new LocationCreateRequestDTO
@ -202,6 +278,7 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId(),
Notes = model.Notes,
Contacts = MapSiteContacts(model.Contacts)
};
}
@ -220,6 +297,7 @@ namespace Api.SeaHavenIndustries.Controllers
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId(),
Notes = model.Notes,
Contacts = MapSiteContacts(model.Contacts)
};
}

View file

@ -11,10 +11,50 @@ namespace Api.SeaHavenIndustries.Controllers
public class NotificationsController : ControllerBase
{
private readonly INotificationFeedService _feedService;
private readonly ISlaBreachAcknowledgementService _slaAcknowledgement;
public NotificationsController(INotificationFeedService feedService)
public NotificationsController(
INotificationFeedService feedService,
ISlaBreachAcknowledgementService slaAcknowledgement)
{
_feedService = feedService;
_slaAcknowledgement = slaAcknowledgement;
}
/// <summary>
/// Acknowledges the missed response deadline of one work order in the caller's Notification Center.
/// 204 when recorded or already recorded; 404 when the caller's feed does not cover the work order;
/// 409 when its deadline has not been missed.
/// </summary>
[HttpPost("sla/{workOrderId:int}/acknowledge")]
public async Task<IActionResult> AcknowledgeSlaBreach(int workOrderId, CancellationToken cancellationToken)
{
try
{
var outcome = await _slaAcknowledgement.AcknowledgeAsync(User, workOrderId, cancellationToken);
return outcome switch
{
SlaBreachAcknowledgementOutcome.NotFound => NotFound(new
{
code = "NotFound",
message = "Work order not found."
}),
SlaBreachAcknowledgementOutcome.NotBreached => Conflict(new
{
code = "NotBreached",
message = "This work order has not missed its response deadline."
}),
_ => NoContent()
};
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden")
{
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = ex.Code,
message = ex.Message
});
}
}
[HttpGet]

View file

@ -11,10 +11,21 @@ namespace Api.SeaHavenIndustries.Controllers;
public sealed class TeamMemberController : ControllerBase
{
private readonly ITeamMemberService _teamMemberService;
private readonly ITeamPermissionService _permissionService;
public TeamMemberController(ITeamMemberService teamMemberService)
public TeamMemberController(
ITeamMemberService teamMemberService,
ITeamPermissionService permissionService)
{
_teamMemberService = teamMemberService;
_permissionService = permissionService;
}
[HttpGet("me/permissions")]
public async Task<IActionResult> GetMyPermissions(CancellationToken cancellationToken)
{
var result = await _permissionService.GetEffectivePermissionsAsync(User, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : Unauthorized();
}
[HttpPost]

View file

@ -53,6 +53,7 @@ public sealed class TeamPermissionController : ControllerBase
return result.Status switch
{
TeamPermissionResultStatus.Success => new OkObjectResult(result.Value),
TeamPermissionResultStatus.Unauthorized => new UnauthorizedResult(),
TeamPermissionResultStatus.Forbidden => new ForbidResult(),
TeamPermissionResultStatus.NotFound => new NotFoundObjectResult(
new Response { Status = "Error", Message = "User not found." }),

View file

@ -18,83 +18,111 @@ namespace Api.SeaHavenIndustries.Controllers
public class WorkOrderCompletionController : Controller
{
private readonly IWorkOrderCompletionService _workOrderCompletionService;
private readonly ICompletionDocTemplateService _completionDocTemplateService;
private readonly IFileStoragePort _fileStorage;
public WorkOrderCompletionController(
IWorkOrderCompletionService workOrderCompletionService,
ICompletionDocTemplateService completionDocTemplateService,
IFileStoragePort fileStorage)
{
_workOrderCompletionService = workOrderCompletionService;
_completionDocTemplateService = completionDocTemplateService;
_fileStorage = fileStorage;
}
[HttpGet("completion-templates")]
public async Task<IActionResult> GetCompletionTemplates(
[FromQuery] string? search = null,
[FromQuery] string? serviceKey = null,
[FromQuery] WorkOrderType? workOrderType = null)
[FromQuery] WorkOrderType? workOrderType = null,
CancellationToken cancellationToken = default)
{
var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType);
var templates = await _completionDocTemplateService.ListAsync(search, serviceKey, workOrderType, cancellationToken);
return Ok(templates);
}
[HttpGet("completion-templates/{id:int}")]
public async Task<IActionResult> GetCompletionTemplate(int id)
public async Task<IActionResult> GetCompletionTemplate(int id, CancellationToken cancellationToken)
{
var template = await _workOrderCompletionService.GetTemplateByIdAsync(id);
var template = await _completionDocTemplateService.GetAsync(id, cancellationToken);
if (template == null)
return NotFound(new Response { Status = "Error", Message = "Template not found." });
return Ok(template);
}
[Authorize(Roles = "Admin")]
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request)
[HttpGet("completion-templates/{id:int}/linked-work-orders")]
public async Task<IActionResult> GetCompletionTemplateLinkedWorkOrders(int id, CancellationToken cancellationToken)
{
try
{
var created = await _workOrderCompletionService.CreateTemplateAsync(request);
var linked = await _completionDocTemplateService.GetLinkedWorkOrdersAsync(User, id, cancellationToken);
return Ok(linked);
}
catch (WorkOrderBoardValidationException ex)
{
return MapTemplateError(ex);
}
}
[HttpPost("completion-templates")]
public async Task<IActionResult> CreateCompletionTemplate(
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
try
{
var created = await _completionDocTemplateService.CreateAsync(User, request, cancellationToken);
return Ok(created);
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
[Authorize(Roles = "Admin")]
[HttpPut("completion-templates/{id:int}")]
public async Task<IActionResult> UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request)
public async Task<IActionResult> UpdateCompletionTemplate(
int id,
[FromBody] CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
try
{
var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request);
var updated = await _completionDocTemplateService.UpdateAsync(User, id, request, cancellationToken);
return Ok(updated);
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
}
catch (WorkOrderBoardValidationException ex)
{
return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
[Authorize(Roles = "Admin")]
[HttpDelete("completion-templates/{id:int}")]
public async Task<IActionResult> DeleteCompletionTemplate(int id)
public async Task<IActionResult> DeleteCompletionTemplate(int id, CancellationToken cancellationToken)
{
try
{
await _workOrderCompletionService.DeleteTemplateAsync(id);
await _completionDocTemplateService.DeleteAsync(User, id, cancellationToken);
return NoContent();
}
catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound")
catch (WorkOrderBoardValidationException ex)
{
return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message });
return MapTemplateError(ex);
}
}
private IActionResult MapTemplateError(WorkOrderBoardValidationException ex)
{
var body = new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message };
return ex.Code switch
{
"Forbidden" => StatusCode(StatusCodes.Status403Forbidden, body),
"NotFound" => NotFound(body),
_ => UnprocessableEntity(body)
};
}
[HttpPost("{id:int}/completion-doc")]
[RequestSizeLimit(50_000_000)]
public async Task<IActionResult> UploadCompletionDoc(

View file

@ -16,6 +16,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public string? Notes { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -11,6 +11,13 @@ namespace Api.SeaHavenIndustries.DTOs
public string? Phone { get; set; }
}
/// <summary>Contacts and notes edited from the work-order Site dialog.</summary>
public class SiteContactInfoInput_DTO
{
public List<SiteContactInput_DTO>? Contacts { get; set; }
public string? Notes { get; set; }
}
public class Location_DTO
{
public string? Title { get; set; }
@ -24,6 +31,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { get; set; }
public string? Notes { get; set; }
public List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -0,0 +1,24 @@
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity;
namespace Api.SeaHavenIndustries.Infrastructure
{
public static class IdentityRegistration
{
/// <summary>
/// Registers ASP.NET Identity for the API with the shared password policy.
/// Program.cs and the behavior tests both compose Identity through this
/// method so the rule under test is the rule that runs.
/// </summary>
public static IdentityBuilder AddSeaHavenIdentity(this IServiceCollection services)
{
return services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
IdentityPasswordPolicy.Apply(options.Password);
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
}
}
}

View file

@ -1,5 +1,6 @@
using Api.SeaHavenIndustries.Helper;
using Api.SeaHavenIndustries.HostedServices;
using Api.SeaHavenIndustries.Infrastructure;
using Api.SeaHavenIndustries.Middleware;
using Api.SeaHavenIndustries.Observability;
using Api.SeaHavenIndustries.Options;
@ -43,12 +44,7 @@ ConfigurationManager configuration = builder.Configuration;
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.User.RequireUniqueEmail = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddSeaHavenIdentity();
builder.Services.AddControllers(options =>
{

View file

@ -54,6 +54,23 @@ namespace Data.SeaHavenIndustries
builder.Entity<CompletionDocTemplate>()
.HasIndex(t => new { t.ServiceKey, t.IsActive });
builder.Entity<CompletionDocTemplate>()
.Property(t => t.ExtraSafetyNote)
.HasMaxLength(CompletionDocTemplate.ExtraSafetyNoteMaxLength);
builder.Entity<CompletionDocTemplateProcedure>(entity =>
{
entity.Property(p => p.Name)
.HasMaxLength(CompletionDocTemplateProcedure.NameMaxLength);
entity.HasOne(p => p.CompletionDocTemplate)
.WithMany(t => t.Procedures)
.HasForeignKey(p => p.CompletionDocTemplateId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => new { p.CompletionDocTemplateId, p.SortOrder });
});
builder.Entity<WorkOrder>()
.Property(w => w.RowVersion)
@ -344,6 +361,7 @@ namespace Data.SeaHavenIndustries
public DbSet<WorkOrderCategories> workOrderCategories { get; set; }
public DbSet<WorkOrderAttachments> workOrderAttachments { get; set; }
public DbSet<CompletionDocTemplate> CompletionDocTemplates { get; set; }
public DbSet<CompletionDocTemplateProcedure> CompletionDocTemplateProcedures { get; set; }
public DbSet<WorkOrderAuditLog> WorkOrderAuditLogs { get; set; }
public DbSet<WorkOrderFieldLock> WorkOrderFieldLocks { get; set; }
public DbSet<WorkOrderWeekRolledLedger> WorkOrderWeekRolledLedgers { get; set; }

View file

@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Identity;
namespace Data.SeaHavenIndustries
{
/// <summary>
/// The single password rule for every surface that sets a password: at least
/// six characters with one uppercase letter, one number, and one special
/// character. Lowercase letters are deliberately not required so the server
/// accepts exactly what the four-item checklist in the web app marks as met.
/// </summary>
public static class IdentityPasswordPolicy
{
public const int MinimumLength = 6;
public static void Apply(PasswordOptions options)
{
ArgumentNullException.ThrowIfNull(options);
options.RequiredLength = MinimumLength;
options.RequireUppercase = true;
options.RequireDigit = true;
options.RequireNonAlphanumeric = true;
options.RequireLowercase = false;
options.RequiredUniqueChars = 1;
}
private static readonly HashSet<string> PolicyErrorCodes = new(StringComparer.Ordinal)
{
nameof(IdentityErrorDescriber.PasswordTooShort),
nameof(IdentityErrorDescriber.PasswordRequiresUpper),
nameof(IdentityErrorDescriber.PasswordRequiresLower),
nameof(IdentityErrorDescriber.PasswordRequiresDigit),
nameof(IdentityErrorDescriber.PasswordRequiresNonAlphanumeric),
nameof(IdentityErrorDescriber.PasswordRequiresUniqueChars)
};
/// <summary>
/// True when Identity refused the password itself. Other failures, such as a
/// concurrency conflict, must not be reported to the user as a weak password.
/// </summary>
public static bool IsPolicyRejection(IdentityResult result)
{
ArgumentNullException.ThrowIfNull(result);
return result.Errors.Any(error => PolicyErrorCodes.Contains(error.Code));
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class CompletionDocTemplateProcedures : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates",
type: "nvarchar(2000)",
maxLength: 2000,
nullable: true);
migrationBuilder.CreateTable(
name: "CompletionDocTemplateProcedures",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompletionDocTemplateId = table.Column<int>(type: "int", nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CompletionDocTemplateProcedures", x => x.Id);
table.ForeignKey(
name: "FK_CompletionDocTemplateProcedures_CompletionDocTemplates_CompletionDocTemplateId",
column: x => x.CompletionDocTemplateId,
principalTable: "CompletionDocTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CompletionDocTemplateProcedures_CompletionDocTemplateId_SortOrder",
table: "CompletionDocTemplateProcedures",
columns: new[] { "CompletionDocTemplateId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CompletionDocTemplateProcedures");
migrationBuilder.DropColumn(
name: "ExtraSafetyNote",
table: "CompletionDocTemplates");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Data.SeaHavenIndustries.Migrations
{
/// <inheritdoc />
public partial class AddLocationNotes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Notes",
table: "Locations",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Notes",
table: "Locations");
}
}
}

View file

@ -523,6 +523,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<DateTime?>("DeletionTime")
.HasColumnType("datetime2");
b.Property<string>("ExtraSafetyNote")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
@ -560,6 +564,36 @@ namespace Data.SeaHavenIndustries.Migrations
b.ToTable("CompletionDocTemplates");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompletionDocTemplateId")
.HasColumnType("int");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CompletionDocTemplateId", "SortOrder");
b.ToTable("CompletionDocTemplateProcedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.Property<int>("Id")
@ -1582,6 +1616,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
@ -3481,6 +3518,17 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplateProcedure", b =>
{
b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate")
.WithMany("Procedures")
.HasForeignKey("CompletionDocTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CompletionDocTemplate");
});
modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b =>
{
b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact")
@ -4108,6 +4156,11 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrderCategories");
});
modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b =>
{
b.Navigation("Procedures");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b =>
{
b.Navigation("WorkOrderContacts");

View file

@ -4,10 +4,32 @@ namespace Data.SeaHavenIndustries
{
public class CompletionDocTemplate : FullAuditEntity
{
public const int ExtraSafetyNoteMaxLength = 2000;
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
// At most one template-specific note, printed after the standard
// safety bullets every generated document carries.
public string? ExtraSafetyNote { get; set; }
public virtual ICollection<CompletionDocTemplateProcedure> Procedures { get; set; } = new List<CompletionDocTemplateProcedure>();
}
// Ordered procedure step of a completion document template. SortOrder is the
// position the author gave it; documents print procedures in that order.
public class CompletionDocTemplateProcedure
{
public const int NameMaxLength = 200;
public int Id { get; set; }
public int CompletionDocTemplateId { get; set; }
public virtual CompletionDocTemplate? CompletionDocTemplate { get; set; }
public int SortOrder { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
}
}

View file

@ -27,6 +27,9 @@ namespace Data.SeaHavenIndustries
public string? ExternalSource { get; set; }
public string? ExternalLocationId { get; set; }
/// <summary>Free-text site notes, edited from the work-order Site dialog.</summary>
public string? Notes { get; set; }
// Navigation Properties
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? workOrders { get; set; }

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
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

View file

@ -38,6 +38,7 @@ namespace SeaHaven.DataServices.Helpers
w.ServiceNameSnapshot,
w.SiteCode,
LocationName = w.Locations != null ? w.Locations.Name : null,
SiteNotes = w.Locations != null ? w.Locations.Notes : null,
WoPocName = w.PocName,
WoPocPhone = w.PocPhone,
WoPocNotes = w.PocNotes,
@ -134,7 +135,7 @@ namespace SeaHaven.DataServices.Helpers
var pocPhone = primaryFrozenPoc?.Phone
?? FirstNotBlank(w.WoPocPhone, w.ContactPoc?.PhoneNumber, w.SitePoc?.PhoneNumber);
var pocNotes = frozenPoc?.Notes
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes);
?? FirstNotBlank(w.WoPocNotes, w.ContactPoc?.Notes, w.SiteNotes);
var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone)
? w.DispatchTechPhone
: (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone);

View file

@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Accounts> 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();
}

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Assets> 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();
}

View file

@ -25,7 +25,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Category> 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();
}

View file

@ -42,6 +42,7 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Where(n => n.IsDeleted != true)
.Select(n => new Locations
{
Id = n.Id,

View file

@ -1,12 +1,17 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
{
public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService
{
private static readonly LifecycleStatus[] OpenStatuses = Enum.GetValues<LifecycleStatus>()
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
.ToArray();
private readonly ApplicationDbContext _context;
public CompletionDocTemplateDataService(ApplicationDbContext context)
@ -14,33 +19,6 @@ namespace SeaHaven.DataServices.Implementation
_context = context;
}
public async Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
return await query
.OrderBy(t => t.Name)
.Select(MapRow)
.ToListAsync();
}
public async Task<CompletionDocTemplateRow?> GetByIdAsync(int id)
{
return await _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true)
.Select(MapRow)
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType)
{
if (!string.IsNullOrWhiteSpace(trade))
@ -70,33 +48,149 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync();
}
public async Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template)
public async Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.IsActive && t.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(serviceKey))
query = query.Where(t => t.ServiceKey == serviceKey);
if (workOrderType.HasValue)
query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType);
if (!string.IsNullOrWhiteSpace(search))
{
var term = search.Trim().ToLower();
query = query.Where(t => t.Name.ToLower().Contains(term));
}
return await ProjectDetail(query).ToListAsync(cancellationToken);
}
public async Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken)
{
var query = _context.CompletionDocTemplates
.AsNoTracking()
.Where(t => t.Id == id && t.IsDeleted != true);
return await ProjectDetail(query).FirstOrDefaultAsync(cancellationToken);
}
public Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates
.Include(t => t.Procedures)
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public async Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken)
{
template.CreatedDate = DateTime.UtcNow;
await _context.CompletionDocTemplates.AddAsync(template);
await _context.SaveChangesAsync();
return template;
await _context.CompletionDocTemplates.AddAsync(template, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return template.Id;
}
public async Task UpdateAsync(CompletionDocTemplate template)
public async Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken)
{
if (replacementProcedures != null)
{
// Explicit removal: the context forces Restrict on every FK before the
// procedure cascade is configured, so orphan deletion is not relied on.
_context.CompletionDocTemplateProcedures.RemoveRange(template.Procedures.ToList());
foreach (var procedure in replacementProcedures)
{
procedure.CompletionDocTemplateId = template.Id;
_context.CompletionDocTemplateProcedures.Add(procedure);
}
}
template.LastModificationTime = DateTime.UtcNow;
_context.CompletionDocTemplates.Update(template);
await _context.SaveChangesAsync();
await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> DeleteAsync(int id)
public async Task<bool> DeleteAndUnlinkServicesAsync(
int id,
string? deleterUserId,
CancellationToken cancellationToken)
{
var entity = await _context.CompletionDocTemplates.FindAsync(id);
if (entity == null)
var template = await _context.CompletionDocTemplates
.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
if (template == null)
return false;
entity.IsDeleted = true;
entity.DeletionTime = DateTime.UtcNow;
await _context.SaveChangesAsync();
var now = DateTime.UtcNow;
template.IsDeleted = true;
template.DeletionTime = now;
template.DeleterUserId = deleterUserId;
var linkedServices = await _context.Services
.Where(s => s.CompletionDocTemplateId == id)
.ToListAsync(cancellationToken);
foreach (var service in linkedServices)
{
service.CompletionDocTemplateId = null;
service.LastModificationTime = now;
}
await _context.SaveChangesAsync(cancellationToken);
return true;
}
public async Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (accountId.HasValue)
workOrders = WorkOrderBoardQueryFilters.ApplyAccountScope(workOrders, accountId.Value);
var linkedServiceIds = _context.Services
.Where(s => s.CompletionDocTemplateId == templateId)
.Select(s => (int?)s.Id);
// Shared status filter so legacy rows without a LifecycleStatus are read
// through their legacy status text (a legacy "completed" row is not open).
workOrders = WorkOrderBoardQueryFilters.ApplyStatusFilter(workOrders, OpenStatuses);
return await workOrders
.Where(w => w.ServiceId != null && linkedServiceIds.Contains(w.ServiceId))
.OrderBy(w => w.Id)
.Select(w => w.Id)
.ToListAsync(cancellationToken);
}
private IQueryable<CompletionDocTemplateDetailRow> ProjectDetail(IQueryable<CompletionDocTemplate> templates)
=> from t in templates
join u in _context.Users on t.createdby equals u.Id into creators
from creator in creators.DefaultIfEmpty()
orderby t.Name, t.Id
select new CompletionDocTemplateDetailRow(
t.Id,
t.Name,
t.ServiceKey,
t.WorkOrderType,
t.TemplateUrl,
t.IsActive,
t.ExtraSafetyNote,
t.CreatedDate,
t.LastModificationTime,
creator != null ? creator.FirstName : null,
creator != null ? creator.LastName : null,
t.Procedures
.OrderBy(p => p.SortOrder)
.ThenBy(p => p.Id)
.Select(p => new CompletionDocTemplateProcedureRow(p.Id, p.SortOrder, p.Name, p.Description))
.ToList());
private static readonly System.Linq.Expressions.Expression<Func<CompletionDocTemplate, CompletionDocTemplateRow>> MapRow =
t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive);
}

View file

@ -100,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Contacts> 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();
}
@ -130,15 +130,15 @@ namespace SeaHaven.DataServices.Implementation
public async Task<bool> 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<bool> 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));
}

View file

@ -85,7 +85,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Dispatch> 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();
}

View file

@ -72,7 +72,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Employee> 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();
}

View file

@ -88,7 +88,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<FollowUps> 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<FollowUps> 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;
}

View file

@ -1,5 +1,7 @@
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Helpers;
using SeaHaven.DataServices.Interfaces;
namespace SeaHaven.DataServices.Implementation
@ -15,25 +17,25 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations?> GetByIdAsync(int id)
{
return await _context.Locations.FindAsync(id);
return await _context.Locations.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
}
public async Task<Locations?> GetByIdWithDetailsAsync(int id)
{
return await _context.Locations
.FirstOrDefaultAsync(l => l.Id == id);
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true);
}
public async Task<IEnumerable<Locations>> GetAllAsync()
{
return await _context.Locations.ToListAsync();
return await _context.Locations.Where(l => l.IsDeleted != true).ToListAsync();
}
public async Task<IEnumerable<Locations>> GetByAccountIdAsync(int accountId)
{
return await _context.Locations
.AsNoTracking()
.Where(l => l.AccountId == accountId)
.Where(l => l.AccountId == accountId && l.IsDeleted != true)
.ToListAsync();
}
@ -43,7 +45,7 @@ namespace SeaHaven.DataServices.Implementation
{
var row = await _context.Locations
.AsNoTracking()
.Where(l => l.Id == locationId)
.Where(l => l.Id == locationId && l.IsDeleted != true)
.Select(l => new { l.AccountId })
.FirstOrDefaultAsync(cancellationToken);
@ -55,7 +57,7 @@ namespace SeaHaven.DataServices.Implementation
int pageSize,
string? search = null)
{
var query = _context.Locations.AsQueryable();
var query = _context.Locations.Where(l => l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{
@ -80,7 +82,7 @@ namespace SeaHaven.DataServices.Implementation
{
var query = _context.Locations
.AsNoTracking()
.Where(l => l.AccountId != null);
.Where(l => l.AccountId != null && l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{
@ -98,7 +100,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<Locations> AddAsync(Locations location)
{
location.CreatedDate = DateTime.Now;
location.CreatedDate = DateTime.UtcNow;
await _context.Locations.AddAsync(location);
await _context.SaveChangesAsync();
return location;
@ -106,7 +108,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();
}
@ -123,12 +125,12 @@ namespace SeaHaven.DataServices.Implementation
public async Task<bool> ExistsAsync(int id)
{
return await _context.Locations.AnyAsync(l => l.Id == id);
return await _context.Locations.AnyAsync(l => l.Id == id && l.IsDeleted != true);
}
public async Task<int> CountAsync()
{
return await _context.Locations.CountAsync();
return await _context.Locations.CountAsync(l => l.IsDeleted != true);
}
public async Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
@ -142,7 +144,8 @@ namespace SeaHaven.DataServices.Implementation
{
IQueryable<Locations> query = _context.Locations
.AsNoTracking()
.Include(l => l.Account);
.Include(l => l.Account)
.Where(l => l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{
@ -200,15 +203,16 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Include(l => l.Account)
.Include(l => l.Contacts.Where(c => c.IsDeleted != true))
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
}
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
.FirstOrDefaultAsync(l => l.Id == id && l.IsDeleted != true, cancellationToken);
}
public async Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
@ -228,7 +232,7 @@ namespace SeaHaven.DataServices.Implementation
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.SaveChangesAsync(cancellationToken);
return location;
@ -236,36 +240,72 @@ 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);
}
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
public async Task<bool> SiteCodeExistsAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken)
{
var entity = await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
if (entity == null)
return false;
var normalized = siteCode.Trim().ToUpperInvariant();
var now = DateTime.Now;
foreach (var contact in entity.Contacts ?? Enumerable.Empty<Contacts>())
{
if (contact.IsDeleted != true)
{
contact.IsDeleted = true;
contact.DeletionTime = now;
}
contact.LocationId = null;
}
_context.Locations.Remove(entity);
await _context.SaveChangesAsync(cancellationToken);
return true;
return await _context.Locations
.AsNoTracking()
.Where(l => l.IsDeleted != true
&& l.AccountId == accountId
&& l.Name != null
&& l.Name.Trim().ToUpper() == normalized)
.Where(l => excludeLocationId == null || l.Id != excludeLocationId)
.AnyAsync(cancellationToken);
}
public async Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
int locationId,
string? siteCode,
int? siteAccountId,
int? callerAccountId,
int maxIds,
CancellationToken cancellationToken)
{
var code = string.IsNullOrWhiteSpace(siteCode) ? null : siteCode.Trim().ToUpper();
var query = _context.workOrders
.AsNoTracking()
.Where(w => w.IsDeleted != true
&& (w.LocationId == locationId
|| (code != null
&& w.LocationId == null
&& w.SiteCode != null
&& w.SiteCode.Trim().ToUpper() == code
&& (w.AccountId == null || w.AccountId == siteAccountId))));
if (callerAccountId is int accountId)
query = query.Where(w => w.AccountId == accountId);
query = WorkOrderBoardQueryFilters.ApplyStatusFilter(query, OpenLifecycleStatuses);
var count = await query.CountAsync(cancellationToken);
if (count == 0)
return (0, Array.Empty<int>());
var ids = await query
.OrderBy(w => w.Id)
.Select(w => w.Id)
.Take(maxIds)
.ToListAsync(cancellationToken);
return (count, ids);
}
private static readonly IReadOnlyList<LifecycleStatus> OpenLifecycleStatuses = Enum
.GetValues<LifecycleStatus>()
.Where(status => !LifecycleStatusSets.Terminal.Contains(status))
.ToList();
public async Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(
int page,
int pageSize,
@ -273,7 +313,7 @@ namespace SeaHaven.DataServices.Implementation
{
search ??= "";
var query = _context.Locations.AsQueryable();
var query = _context.Locations.Where(l => l.IsDeleted != true);
if (!string.IsNullOrWhiteSpace(search))
{

View file

@ -14,6 +14,8 @@ namespace SeaHaven.DataServices.Implementation
// Uplift outcomes a requester is told about; "Denied" is the legacy spelling of Rejected.
private static readonly string[] DecisionStatuses = { "Approved", "Rejected", "Denied", "Revoked" };
private static readonly string[] SlaSeverities = { "1", "2", "3", "4", "5" };
private const string AssignToAuditField = "AssignTo";
private const string LegacyAssignedToAuditField = "AssignedTo";
@ -176,6 +178,89 @@ namespace SeaHaven.DataServices.Implementation
return await candidates.Take(limit).ToListAsync(cancellationToken);
}
public async Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
NotificationFeedScope scope,
NotificationSlaCutoffs atRisk,
NotificationSlaCutoffs breached,
int breachedLimit,
CancellationToken cancellationToken)
{
var eligible = SlaEligibleWorkOrders(scope);
// Bounded by the longest window: nothing created more than 72 hours ago is still at risk.
var atRiskItems = await ProjectSla(CreatedAfter(CreatedAtOrBefore(eligible, atRisk), breached))
.OrderBy(candidate => candidate.CreatedAt)
.ThenBy(candidate => candidate.Id)
.ToListAsync(cancellationToken);
// Breaches stay until acknowledged, so legacy open work orders can make this set large.
var unacknowledged = CreatedAtOrBefore(eligible, breached)
.Where(w => !_context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
&& log.NewValue == w.Severity));
var breachedTotal = await unacknowledged.CountAsync(cancellationToken);
var breachedItems = breachedTotal == 0
? new List<NotificationSlaCandidate>()
: await ProjectSla(unacknowledged)
.OrderByDescending(candidate => candidate.CreatedAt)
.ThenByDescending(candidate => candidate.Id)
.Take(breachedLimit)
.ToListAsync(cancellationToken);
return new NotificationSlaCandidates
{
AtRisk = atRiskItems,
Breached = breachedItems,
BreachedTotal = breachedTotal
};
}
public Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken)
=> ProjectSla(SlaEligibleWorkOrders(scope).Where(w => w.Id == workOrderId))
.FirstOrDefaultAsync(cancellationToken);
// Same rule as the board's severity facet: Reactive or Emergency with a stored SEV level "1"–"5".
private IQueryable<WorkOrder> SlaEligibleWorkOrders(NotificationFeedScope scope)
=> OpenWorkOrders(scope).Where(w =>
(w.WorkOrderType == WorkOrderType.Reactive || w.WorkOrderType == WorkOrderType.Emergency)
&& SlaSeverities.Contains(w.Severity ?? "")
&& w.CreatedDate != null);
private static IQueryable<WorkOrder> CreatedAtOrBefore(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
{
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
return workOrders.Where(w =>
(w.Severity == "1" && w.CreatedDate <= sev1)
|| (w.Severity == "2" && w.CreatedDate <= sev2)
|| (w.Severity == "3" && w.CreatedDate <= sev3)
|| (w.Severity == "4" && w.CreatedDate <= sev4)
|| (w.Severity == "5" && w.CreatedDate <= sev5));
}
private static IQueryable<WorkOrder> CreatedAfter(IQueryable<WorkOrder> workOrders, NotificationSlaCutoffs cutoffs)
{
var (sev1, sev2, sev3, sev4, sev5) = (cutoffs.Sev1, cutoffs.Sev2, cutoffs.Sev3, cutoffs.Sev4, cutoffs.Sev5);
return workOrders.Where(w =>
(w.Severity == "1" && w.CreatedDate > sev1)
|| (w.Severity == "2" && w.CreatedDate > sev2)
|| (w.Severity == "3" && w.CreatedDate > sev3)
|| (w.Severity == "4" && w.CreatedDate > sev4)
|| (w.Severity == "5" && w.CreatedDate > sev5));
}
private IQueryable<NotificationSlaCandidate> ProjectSla(IQueryable<WorkOrder> workOrders)
=> workOrders.Select(w => new NotificationSlaCandidate
{
Id = w.Id,
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
Severity = w.Severity!,
CreatedAt = w.CreatedDate!.Value,
BreachAcknowledged = _context.WorkOrderAuditLogs.Any(log => log.WorkOrderId == w.Id
&& log.FieldName == SlaBreachAcknowledgementAudit.FieldName
&& log.NewValue == w.Severity)
});
private IQueryable<WorkOrder> ScopedWorkOrders(int? accountId)
{
var workOrders = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());

View file

@ -46,8 +46,8 @@ namespace SeaHaven.DataServices.Implementation
}
public async Task<(IEnumerable<PMSchedules> Items, int TotalCount)> GetPagedAsync(
int page,
int pageSize,
int page,
int pageSize,
string? search = null)
{
var query = _context.PMSchedules.AsQueryable();
@ -64,7 +64,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<PMSchedules> 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();
}

View file

@ -11,6 +11,9 @@ namespace SeaHaven.DataServices.Implementation
public class UpliftDataService : IUpliftDataService
{
private static readonly ConcurrentDictionary<int, SemaphoreSlim> 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);

View file

@ -150,7 +150,7 @@ namespace SeaHaven.DataServices.Implementation
}
public Task<bool> LocationExistsAsync(int locationId, CancellationToken cancellationToken)
=> _context.Locations.AnyAsync(location => location.Id == locationId, cancellationToken);
=> _context.Locations.AnyAsync(location => location.Id == locationId && location.IsDeleted != true, cancellationToken);
public async Task<Dictionary<int, Vendor>> GetVendorsByIdsAsync(IReadOnlyCollection<int> vendorIds, CancellationToken cancellationToken)
=> await _context.Vendors.Where(vendor => vendorIds.Contains(vendor.Id))

View file

@ -19,6 +19,26 @@ namespace SeaHaven.DataServices.Implementation
var baseQuery = WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking());
if (query.AccountId.HasValue)
baseQuery = WorkOrderBoardQueryFilters.ApplyAccountScope(baseQuery, query.AccountId.Value);
// An exact id set (a link from an alert or a linked-work-orders list) shows those work
// orders whatever their date, status or owner; only the base and tenant scope still apply.
baseQuery = query.Ids is { Count: > 0 } ids
? baseQuery.Where(w => ids.Contains(w.Id))
: ApplyFilters(baseQuery, query);
var totalCount = await baseQuery.CountAsync();
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
}
private IQueryable<WorkOrder> ApplyFilters(IQueryable<WorkOrder> baseQuery, WorkOrderAdvancedSearchQuery query)
{
if (query.UnscheduledOnly)
baseQuery = WorkOrderBoardQueryFilters.ApplyUnscheduledOnlyFilter(baseQuery);
else
@ -47,15 +67,7 @@ namespace SeaHaven.DataServices.Implementation
if (normalizedSearch != null)
baseQuery = WorkOrderBoardSearchFilter.Apply(baseQuery, normalizedSearch);
var totalCount = await baseQuery.CountAsync();
var sorted = ApplySort(baseQuery, query.SortBy, query.SortDir);
var paged = sorted
.Skip(query.Page * query.PageSize)
.Take(query.PageSize);
var rows = await WorkOrderBoardProjection.ProjectRowsAsync(_context, paged, isUnscheduled: false);
return new WorkOrderAdvancedSearchResult(rows, totalCount);
return baseQuery;
}
private static IQueryable<WorkOrder> ApplySort(IQueryable<WorkOrder> query, string sortBy, string sortDir)

View file

@ -41,9 +41,6 @@ namespace SeaHaven.DataServices.Implementation
public void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version)
=> _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = version;
public Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken)
=> _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true, cancellationToken);
public Task SaveAsync(CancellationToken cancellationToken)
=> _context.SaveChangesAsync(cancellationToken);
}

View file

@ -193,7 +193,7 @@ namespace SeaHaven.DataServices.Implementation
public async Task<WorkOrder> 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();
}

View file

@ -0,0 +1,26 @@
using Data.SeaHavenIndustries.Enums;
namespace SeaHaven.DataServices.Interfaces
{
public record CompletionDocTemplateProcedureRow(
int Id,
int SortOrder,
string Name,
string Description);
// Read model for the completion document templates registry: the legacy
// columns plus authored content and audit display data.
public record CompletionDocTemplateDetailRow(
int Id,
string Name,
string ServiceKey,
WorkOrderType? WorkOrderType,
string TemplateUrl,
bool IsActive,
string? ExtraSafetyNote,
DateTime? CreatedAt,
DateTime? UpdatedAt,
string? CreatedByFirstName,
string? CreatedByLastName,
IReadOnlyList<CompletionDocTemplateProcedureRow> Procedures);
}

View file

@ -52,6 +52,31 @@ namespace SeaHaven.DataServices.Interfaces
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken);
/// <summary>
/// True when a live (not deleted) site of the same account already uses
/// <paramref name="siteCode"/>, compared trimmed and case-insensitively.
/// A null account matches only other sites without an account.
/// </summary>
Task<bool> SiteCodeExistsAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken);
/// <summary>
/// Open (not Completed or Canceled, legacy status aware) and not deleted work
/// orders that reference the site by id, or by site code when they carry no
/// location id and no other account. <paramref name="callerAccountId"/>
/// narrows to one account. Returns the full count and at most
/// <paramref name="maxIds"/> ids, ascending.
/// </summary>
Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
int locationId,
string? siteCode,
int? siteAccountId,
int? callerAccountId,
int maxIds,
CancellationToken cancellationToken);
}
}

View file

@ -48,5 +48,22 @@ namespace SeaHaven.DataServices.Interfaces
/// </summary>
Task<IReadOnlyList<NotificationUpliftDecisionCandidate>> GetUpliftDecisionsAsync(
NotificationPersonalScope scope, DateTime since, int limit, CancellationToken cancellationToken);
/// <summary>
/// Open Reactive/Emergency work orders with a SEV 1–5 level, split by creation time: at risk when
/// created at or before <paramref name="atRisk"/> but after <paramref name="breached"/>, breached when
/// created at or before <paramref name="breached"/> and not yet acknowledged for their current level.
/// Breaches are the <paramref name="breachedLimit"/> newest; the at-risk set is bounded by the longest window.
/// </summary>
Task<NotificationSlaCandidates> GetSlaCandidatesAsync(
NotificationFeedScope scope,
NotificationSlaCutoffs atRisk,
NotificationSlaCutoffs breached,
int breachedLimit,
CancellationToken cancellationToken);
/// <summary>The work order as an SLA candidate inside <paramref name="scope"/>, or null when it is not one.</summary>
Task<NotificationSlaCandidate?> GetSlaCandidateAsync(
NotificationFeedScope scope, int workOrderId, CancellationToken cancellationToken);
}
}

View file

@ -15,7 +15,6 @@ namespace SeaHaven.DataServices.Interfaces
CancellationToken cancellationToken);
void SetExpectedWorkOrderVersion(WorkOrder workOrder, byte[] version);
Task<CompletionDocTemplate?> GetTrackedTemplateAsync(int id, CancellationToken cancellationToken);
Task SaveAsync(CancellationToken cancellationToken);
}
}

View file

@ -25,11 +25,43 @@ namespace SeaHaven.DataServices.Interfaces
public interface ICompletionDocTemplateDataService
{
Task<IReadOnlyList<CompletionDocTemplateRow>> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateRow?> GetByIdAsync(int id);
Task<CompletionDocTemplateRow?> ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType);
Task<CompletionDocTemplate> CreateAsync(CompletionDocTemplate template);
Task UpdateAsync(CompletionDocTemplate template);
Task<bool> DeleteAsync(int id);
Task<IReadOnlyList<CompletionDocTemplateDetailRow>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailRow?> GetDetailAsync(int id, CancellationToken cancellationToken);
/// <summary>Tracked, not-deleted template with its procedures loaded, for editing.</summary>
Task<CompletionDocTemplate?> GetTrackedForEditAsync(int id, CancellationToken cancellationToken);
Task<int> CreateAsync(CompletionDocTemplate template, CancellationToken cancellationToken);
/// <summary>
/// Commits edits to a template from <see cref="GetTrackedForEditAsync"/>. A non-null
/// <paramref name="replacementProcedures"/> replaces the whole procedure list.
/// </summary>
Task SaveEditAsync(
CompletionDocTemplate template,
IReadOnlyList<CompletionDocTemplateProcedure>? replacementProcedures,
CancellationToken cancellationToken);
/// <summary>
/// Soft-deletes the template and clears every Service link to it in the same commit.
/// Services keep RequiresCompletionDocument. False when the template does not exist.
/// </summary>
Task<bool> DeleteAndUnlinkServicesAsync(int id, string? deleterUserId, CancellationToken cancellationToken);
/// <summary>
/// Ids of open (non-terminal) work orders whose Service links this template,
/// restricted to <paramref name="accountId"/> when set.
/// </summary>
Task<IReadOnlyList<int>> GetOpenLinkedWorkOrderIdsAsync(
int templateId,
int? accountId,
CancellationToken cancellationToken);
}
}

View file

@ -33,7 +33,8 @@ namespace SeaHaven.DataServices.Interfaces
string SortBy,
string SortDir,
int? AccountId = null,
bool IncludeDateless = false);
bool IncludeDateless = false,
IReadOnlyList<int>? Ids = null);
public record WorkOrderAdvancedSearchResult(
IReadOnlyList<WorkOrderBoardRawRow> Rows,

View file

@ -64,3 +64,38 @@ public sealed class NotificationUpliftDecisionCandidate
public string? DecisionNote { get; init; }
public DateTime DecidedAt { get; init; }
}
/// <summary>
/// Per SEV level, the latest work-order creation time that has reached a response-window threshold.
/// The business service computes them; the query only compares creation times.
/// </summary>
public sealed record NotificationSlaCutoffs(DateTime Sev1, DateTime Sev2, DateTime Sev3, DateTime Sev4, DateTime Sev5);
/// <summary>
/// The work-order audit entry that records an SLA breach acknowledgement: who (UserId), when (CreatedAt)
/// and the SEV level whose deadline was missed (NewValue). It is also what keeps the breach out of the feed.
/// </summary>
public static class SlaBreachAcknowledgementAudit
{
public const string FieldName = "SlaBreachAcknowledged";
}
/// <summary>An open Reactive/Emergency work order with a SEV 1–5 level and a creation time.</summary>
public sealed class NotificationSlaCandidate
{
public int Id { get; init; }
public string? Number { get; init; }
public string Severity { get; init; } = "";
public DateTime CreatedAt { get; init; }
/// <summary>A breach of the work order's current severity has already been acknowledged.</summary>
public bool BreachAcknowledged { get; init; }
}
public sealed class NotificationSlaCandidates
{
/// <summary>Every work order past half its window but not past the whole window.</summary>
public IReadOnlyList<NotificationSlaCandidate> AtRisk { get; init; } = Array.Empty<NotificationSlaCandidate>();
/// <summary>Unacknowledged breaches: the newest slice, plus the unbounded total.</summary>
public IReadOnlyList<NotificationSlaCandidate> Breached { get; init; } = Array.Empty<NotificationSlaCandidate>();
public int BreachedTotal { get; init; }
}

View file

@ -0,0 +1,174 @@
using Data.SeaHavenIndustries.Enums;
using FluentAssertions;
using SeaHaven.DataServices.Dto;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using System.Security.Claims;
using Xunit;
namespace SeaHaven.Services.Tests;
public sealed class TeamEffectivePermissionsTests
{
[Fact]
public async Task Scheduler_Receives_Role_Defaults()
{
var data = new FakeUsers().Add("u1", "Scheduler");
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.IsSuccess.Should().BeTrue();
result.Value!.Permissions.Should().Contain(new[]
{
TeamPermissionKeys.CreateCompletionDocTemplates,
TeamPermissionKeys.EditCompletionDocTemplates
});
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.DeleteCompletionDocTemplates);
}
[Fact]
public async Task Grant_Override_Adds_A_Key_Outside_The_Role_Defaults()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher", (TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
result.Value.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
}
[Fact]
public async Task Revoke_Override_Removes_A_Role_Default()
{
var data = new FakeUsers().Add(
"u1", "Scheduler", (TeamPermissionKeys.EditCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.EditCompletionDocTemplates);
result.Value.Permissions.Should().Contain(TeamPermissionKeys.CreateCompletionDocTemplates);
}
[Fact]
public async Task Admin_Holds_Every_Key_Even_With_A_Revoke_Override()
{
var data = new FakeUsers().Add(
"u1", "Admin", (TeamPermissionKeys.DeleteCompletionDocTemplates, UserPermissionState.Deny));
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(TeamPermissionKeys.All);
}
[Fact]
public async Task Keys_Match_What_The_Write_Path_Enforces()
{
var data = new FakeUsers().Add(
"u1", "Dispatcher",
(TeamPermissionKeys.CreateCompletionDocTemplates, UserPermissionState.Allow),
(TeamPermissionKeys.CreateSites, UserPermissionState.Deny));
var policy = new TeamPermissionPolicy();
var user = await data.GetUserAsync("u1", CancellationToken.None);
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), CancellationToken.None);
result.Value!.Permissions.Should().Equal(
TeamPermissionKeys.All.Where(key => policy.IsAllowed(user!.RoleName, key, user.Overrides)));
}
[Fact]
public async Task Reads_Only_The_Caller_Identified_By_The_Token()
{
var data = new FakeUsers()
.Add("u1", "Dispatcher")
.Add("u2", "Dispatcher", (TeamPermissionKeys.DeleteWorkOrders, UserPermissionState.Allow));
using var cancellation = new CancellationTokenSource();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("u1"), cancellation.Token);
result.Value!.Permissions.Should().NotContain(TeamPermissionKeys.DeleteWorkOrders);
data.RequestedUserIds.Should().Equal("u1");
data.LastToken.Should().Be(cancellation.Token);
}
[Fact]
public async Task Unauthenticated_Caller_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var anonymous = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, "u1") }));
var result = await Service(data).GetEffectivePermissionsAsync(anonymous, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_Without_A_User_Id_Is_Rejected_Before_Data_Access()
{
var data = new FakeUsers().Add("u1", "Admin");
var noId = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Role, "Admin") }, "test"));
var result = await Service(data).GetEffectivePermissionsAsync(noId, CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
data.RequestedUserIds.Should().BeEmpty();
}
[Fact]
public async Task Token_For_A_Removed_User_Is_Rejected()
{
var data = new FakeUsers();
var result = await Service(data).GetEffectivePermissionsAsync(Caller("gone"), CancellationToken.None);
result.Status.Should().Be(TeamPermissionResultStatus.Unauthorized);
result.Value.Should().BeNull();
}
private static TeamPermissionService Service(FakeUsers data) =>
new(data, new TeamPermissionPolicy());
private static ClaimsPrincipal Caller(string userId) =>
new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }, "test"));
private sealed class FakeUsers : ITeamPermissionOverrideDataService
{
private readonly Dictionary<string, TeamPermissionUserData> _users = new();
public List<string> RequestedUserIds { get; } = new();
public CancellationToken LastToken { get; private set; }
public FakeUsers Add(string userId, string role, params (string Key, UserPermissionState State)[] overrides)
{
_users[userId] = new TeamPermissionUserData
{
UserId = userId,
RoleName = role,
Overrides = overrides.ToDictionary(o => o.Key, o => o.State, StringComparer.OrdinalIgnoreCase)
};
return this;
}
public Task<TeamPermissionUserData?> GetUserAsync(string userId, CancellationToken cancellationToken)
{
RequestedUserIds.Add(userId);
LastToken = cancellationToken;
return Task.FromResult(_users.GetValueOrDefault(userId));
}
public Task SetOverrideAsync(string userId, string permissionKey, UserPermissionState state, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task SetOverridesAsync(string userId, IReadOnlyDictionary<string, UserPermissionState> overrides, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
public Task ClearOverridesAsync(string userId, CancellationToken cancellationToken) =>
throw new InvalidOperationException("Reading permissions must not write.");
}
}

View file

@ -11,6 +11,20 @@ namespace SeaHaven.Services.DTOs
public string Id { get; set; } = string.Empty;
}
public enum ChangePasswordStatus
{
Succeeded,
CurrentPasswordIncorrect,
PasswordRejected,
/// <summary>Identity failed for a reason other than the password policy.</summary>
Failed
}
public sealed class ChangePasswordResultDTO
{
public ChangePasswordStatus Status { get; init; }
}
public class UpdateProfileRequestDTO
{
public string? Name { get; set; }

View file

@ -15,6 +15,7 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int? AccountId { get; set; }
public string? AccountName { get; set; }
public string? Notes { get; set; }
public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; }
@ -70,6 +71,7 @@ namespace SeaHaven.Services.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
public string? Notes { get; set; }
/// <summary>SH-138: null keeps legacy behavior; empty array is a validation error.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
@ -88,6 +90,9 @@ namespace SeaHaven.Services.DTOs
public string? Status { get; set; }
public int? AccountId { get; set; }
/// <summary>Null keeps the stored notes.</summary>
public string? Notes { get; set; }
/// <summary>SH-138: null keeps legacy behavior and must not mutate contact rows.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
}
@ -99,4 +104,18 @@ namespace SeaHaven.Services.DTOs
public string? City { get; set; }
public string? State { get; set; }
}
/// <summary>Site contacts and notes edited from the work-order Site dialog.</summary>
public class SiteContactInfoRequestDTO
{
public List<SiteContactRequestDTO>? Contacts { get; set; }
public string? Notes { get; set; }
}
/// <summary>Open (not Completed or Canceled) work orders that reference a site.</summary>
public class SiteOpenWorkOrdersDTO
{
public int Count { get; set; }
public IReadOnlyList<int> WorkOrderIds { get; set; } = Array.Empty<int>();
}
}

View file

@ -126,9 +126,26 @@ namespace SeaHaven.Services.DTOs
public IReadOnlyList<NotificationItemDto> Items { get; init; } = Array.Empty<NotificationItemDto>();
}
/// <summary>
/// A work order past half of its SEV response window and not yet past the whole window, with the
/// server-computed clock. Drives the top-of-app banner and the one-time 50% toast.
/// </summary>
public sealed class NotificationSlaWorkOrderDto
{
public int Id { get; init; }
public string? Number { get; init; }
public int Severity { get; init; }
/// <summary>The work order's creation time; the response clock never starts at the scheduled date.</summary>
public DateTime StartedAt { get; init; }
public DateTime DeadlineAt { get; init; }
public int PercentElapsed { get; init; }
}
public sealed class NotificationFeedDto
{
public DateTime GeneratedAt { get; init; }
public IReadOnlyList<NotificationSectionDto> Sections { get; init; } = Array.Empty<NotificationSectionDto>();
/// <summary>Every at-risk work order in the feed's scope, oldest first; not capped like section items.</summary>
public IReadOnlyList<NotificationSlaWorkOrderDto> SlaAtRisk { get; init; } = Array.Empty<NotificationSlaWorkOrderDto>();
}
}

View file

@ -16,6 +16,11 @@ public sealed class TeamPermissionValueDTO
public bool IsGranted { get; init; }
}
public sealed class EffectivePermissionsDTO
{
public required IReadOnlyList<string> Permissions { get; init; }
}
public sealed class SetTeamPermissionOverrideDTO
{
public UserPermissionState State { get; init; }
@ -24,6 +29,7 @@ public sealed class SetTeamPermissionOverrideDTO
public enum TeamPermissionResultStatus
{
Success,
Unauthorized,
Forbidden,
NotFound,
InvalidPermissionKey

View file

@ -116,6 +116,12 @@ namespace SeaHaven.Services.DTOs
public bool HasUplift { get; set; }
public List<string>? UpliftStatuses { get; set; }
public bool MyWorkOrders { get; set; }
/// <summary>
/// Comma-separated work-order ids, at most <see cref="WorkOrderIdSet.MaxCount"/> distinct.
/// When present the result is exactly those work orders inside the caller's tenant scope,
/// and every other filter (dates, statuses, dispatchers, facets, search) is ignored.
/// </summary>
public string? Ids { get; set; }
public string? SortBy { get; set; }
public string? SortDir { get; set; }
}

View file

@ -30,13 +30,66 @@ namespace SeaHaven.Services.DTOs
public bool IsActive { get; set; }
}
// Completion document templates registry item and detail (same shape).
public class CompletionDocTemplateDetailDto : CompletionDocTemplateDto
{
public string? ExtraSafetyNote { get; set; }
public List<CompletionDocTemplateProcedureDto> Procedures { get; set; } = new();
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public string? CreatedByName { get; set; }
}
public class CompletionDocTemplateProcedureDto
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int SortOrder { get; set; }
}
// Create/update body. Legacy fields are optional: on update, null leaves the
// stored value unchanged, as does a null Procedures list. WorkOrderType is the
// exception: omitting it keeps the stored value, while an explicit
// "workOrderType": null clears it so the template applies to every work order
// type of its trade. ExtraSafetyNote is always applied, so null removes the note.
public class CompletionDocTemplateCreateDto
{
public string Name { get; set; } = "";
public string ServiceKey { get; set; } = "";
public WorkOrderType? WorkOrderType { get; set; }
public string TemplateUrl { get; set; } = "";
public bool IsActive { get; set; } = true;
private WorkOrderType? _workOrderType;
public string? Name { get; set; }
public string? ExtraSafetyNote { get; set; }
public List<CompletionDocTemplateProcedureInputDto>? Procedures { get; set; }
public string? ServiceKey { get; set; }
public WorkOrderType? WorkOrderType
{
get => _workOrderType;
set
{
_workOrderType = value;
WorkOrderTypeSpecified = true;
}
}
// True when the body carried workOrderType at all, including as null.
[System.Text.Json.Serialization.JsonIgnore]
public bool WorkOrderTypeSpecified { get; private set; }
public string? TemplateUrl { get; set; }
public bool? IsActive { get; set; }
}
public class CompletionDocTemplateProcedureInputDto
{
public string? Name { get; set; }
public string? Description { get; set; }
}
public class CompletionDocTemplateLinkedWorkOrdersDto
{
public int Count { get; set; }
public List<int> WorkOrderIds { get; set; } = new();
}
public class WorkOrderCompletionDto

View file

@ -0,0 +1,24 @@
namespace SeaHaven.Services.Exceptions;
/// <summary>Another live site of the same client already uses the requested site code.</summary>
public sealed class SiteCodeConflictException : Exception
{
public const string ErrorCode = "DuplicateSiteCode";
public const string PublicMessage = "This site code already exists.";
public SiteCodeConflictException()
: base(PublicMessage)
{
}
}
/// <summary>The caller lacks the permission required for a site mutation.</summary>
public sealed class SiteForbiddenException : Exception
{
public const string DeleteDeniedMessage = "You are not allowed to delete sites.";
public SiteForbiddenException(string message)
: base(message)
{
}
}

View file

@ -0,0 +1,27 @@
using System.Security.Claims;
using SeaHaven.Services.Exceptions;
namespace SeaHaven.Services.Helpers
{
/// <summary>Whose work orders the Notification Center covers for the signed-in user.</summary>
public static class NotificationAudience
{
// Dispatchers see their own work orders. Roles that already see every dispatcher's
// work on the dashboard see the whole account, including work nobody owns yet.
public static (string? DispatcherId, bool SeesUnassigned) Resolve(ClaimsPrincipal user)
{
if (user.IsInRole("Dispatcher"))
{
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return (dispatcherId, false);
}
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
return (null, true);
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
}
}
}

View file

@ -0,0 +1,55 @@
namespace SeaHaven.Services.Helpers
{
public enum SlaState
{
OnTrack,
/// <summary>At least half of the response window has elapsed.</summary>
AtRisk,
/// <summary>The whole response window has elapsed: the response deadline was missed.</summary>
Breached
}
/// <summary>Where one work order stands against its SEV response window.</summary>
public sealed record SlaClock(int Severity, DateTime StartedAt, DateTime DeadlineAt, int PercentElapsed, SlaState State);
/// <summary>
/// Respond deadlines from the client's SEV table, the same values the severity badge tooltip shows.
/// They apply to Reactive and Emergency work orders with a severity, and the clock starts when the
/// work order is created, never at its scheduled date. The table's "next day if after hours" note
/// is not modelled: the clock always counts the plain hours.
/// </summary>
public static class SlaResponseWindows
{
public static readonly IReadOnlyDictionary<int, TimeSpan> Respond = new Dictionary<int, TimeSpan>
{
[1] = TimeSpan.FromHours(2),
[2] = TimeSpan.FromHours(4),
[3] = TimeSpan.FromHours(8),
[4] = TimeSpan.FromHours(24),
[5] = TimeSpan.FromHours(72)
};
/// <summary>The latest creation time at which a work order of this level has used half its window.</summary>
public static DateTime AtRiskCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity] / 2;
/// <summary>The latest creation time at which a work order of this level has missed its deadline.</summary>
public static DateTime BreachedCreatedAtOrBefore(int severity, DateTime now) => now - Respond[severity];
/// <summary>Null when the severity is not a SEV 1–5 level.</summary>
public static SlaClock? Evaluate(string? severity, DateTime createdAt, DateTime now)
{
var level = WorkOrderSeverityRules.ParseLevel(severity);
if (level is not int sev)
return null;
var window = Respond[sev];
var startedAt = DateTime.SpecifyKind(createdAt, DateTimeKind.Utc);
var elapsed = now - startedAt;
var state = elapsed >= window
? SlaState.Breached
: elapsed >= window / 2 ? SlaState.AtRisk : SlaState.OnTrack;
var percent = (int)Math.Floor(Math.Max(elapsed.Ticks, 0) * 100d / window.Ticks);
return new SlaClock(sev, startedAt, startedAt + window, percent, state);
}
}
}

View file

@ -25,7 +25,7 @@ namespace SeaHaven.Services.Helpers
JoinName(contact?.POC?.FirstName, contact?.POC?.MiddleName, contact?.POC?.LastName),
WorkOrderPocSiteContact.DisplayName(sitePrimary));
var pocPhone = FirstNotBlank(workOrder.PocPhone, contact?.POC?.PhoneNumber, sitePrimary?.PhoneNumber);
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes);
var pocNotes = FirstNotBlank(workOrder.PocNotes, contact?.Notes, location?.Notes);
var contacts = new List<WorkOrderFrozenPocContact>();
AddContact(contacts, pocName, pocPhone);

View file

@ -0,0 +1,38 @@
using System.Globalization;
namespace SeaHaven.Services.Helpers
{
/// <summary>
/// The board's exact work-order id filter: comma-separated positive integers, deduplicated,
/// at most <see cref="MaxCount"/> distinct ids.
/// </summary>
public static class WorkOrderIdSet
{
public const int MaxCount = 200;
/// <summary>
/// Null when <paramref name="raw"/> is absent or blank. Throws <see cref="ArgumentException"/>
/// (a 400 on the board) for any token that is not a positive integer, or too many ids.
/// </summary>
public static IReadOnlyList<int>? ParseOrThrow(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return null;
var ids = new List<int>();
var seen = new HashSet<int>();
foreach (var token in raw.Split(','))
{
if (!int.TryParse(token.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0)
throw new ArgumentException("ids must be a comma-separated list of positive work order ids.");
if (seen.Add(id))
ids.Add(id);
}
if (ids.Count > MaxCount)
throw new ArgumentException($"ids accepts at most {MaxCount} work orders.");
return ids;
}
}
}

View file

@ -80,16 +80,30 @@ namespace SeaHaven.Services.Implementation
return null;
}
public async Task<bool> ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken)
public async Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
return false;
if (user == null || user.IsDeleted == true)
return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect);
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? "");
return result.Succeeded;
// The current password is verified before the new one is evaluated, so a
// caller without it learns nothing about the policy outcome.
if (!await _userManager.CheckPasswordAsync(user, currentPassword ?? ""))
return ChangePasswordResult(ChangePasswordStatus.CurrentPasswordIncorrect);
var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", newPassword ?? "");
if (result.Succeeded)
return ChangePasswordResult(ChangePasswordStatus.Succeeded);
return ChangePasswordResult(IdentityPasswordPolicy.IsPolicyRejection(result)
? ChangePasswordStatus.PasswordRejected
: ChangePasswordStatus.Failed);
}
private static ChangePasswordResultDTO ChangePasswordResult(ChangePasswordStatus status) =>
new() { Status = status };
public async Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken)
{
var exists = await _userDataService.UpdateProfileAsync(

View file

@ -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;

View file

@ -0,0 +1,264 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public sealed class CompletionDocTemplateService : ICompletionDocTemplateService
{
private readonly ICompletionDocTemplateDataService _templateData;
private readonly ITeamPermissionOverrideDataService _permissionUsers;
private readonly ITeamPermissionPolicy _permissionPolicy;
private readonly IWorkOrderAccountResolver _accountResolver;
public CompletionDocTemplateService(
ICompletionDocTemplateDataService templateData,
ITeamPermissionOverrideDataService permissionUsers,
ITeamPermissionPolicy permissionPolicy,
IWorkOrderAccountResolver accountResolver)
{
_templateData = templateData;
_permissionUsers = permissionUsers;
_permissionPolicy = permissionPolicy;
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<CompletionDocTemplateDetailDto>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken)
{
var rows = await _templateData.ListAsync(
search,
serviceKey,
WorkOrderCatalogType.For(workOrderType),
cancellationToken);
return rows.Select(Map).ToList();
}
public async Task<CompletionDocTemplateDetailDto?> GetAsync(int id, CancellationToken cancellationToken)
{
var row = await _templateData.GetDetailAsync(id, cancellationToken);
return row == null ? null : Map(row);
}
public async Task<CompletionDocTemplateLinkedWorkOrdersDto> GetLinkedWorkOrdersAsync(
ClaimsPrincipal user,
int id,
CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
if (await _templateData.GetDetailAsync(id, cancellationToken) == null)
throw NotFound();
var ids = await _templateData.GetOpenLinkedWorkOrderIdsAsync(id, accountId, cancellationToken);
return new CompletionDocTemplateLinkedWorkOrdersDto { Count = ids.Count, WorkOrderIds = ids.ToList() };
}
public async Task<CompletionDocTemplateDetailDto> CreateAsync(
ClaimsPrincipal user,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
var actorId = await EnsureAllowedAsync(
user,
TeamPermissionKeys.CreateCompletionDocTemplates,
"You do not have permission to create completion document templates.",
cancellationToken);
var input = Validate(request);
var entity = new CompletionDocTemplate
{
Name = input.Name,
ExtraSafetyNote = input.ExtraSafetyNote,
ServiceKey = input.ServiceKey ?? "",
WorkOrderType = request.WorkOrderType,
TemplateUrl = input.TemplateUrl ?? "",
IsActive = request.IsActive ?? true,
createdby = actorId,
Procedures = (input.Procedures ?? new List<CompletionDocTemplateProcedure>()).ToList()
};
var id = await _templateData.CreateAsync(entity, cancellationToken);
return await GetSavedAsync(id, cancellationToken);
}
public async Task<CompletionDocTemplateDetailDto> UpdateAsync(
ClaimsPrincipal user,
int id,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken)
{
await EnsureAllowedAsync(
user,
TeamPermissionKeys.EditCompletionDocTemplates,
"You do not have permission to edit completion document templates.",
cancellationToken);
var input = Validate(request);
var entity = await _templateData.GetTrackedForEditAsync(id, cancellationToken) ?? throw NotFound();
entity.Name = input.Name;
entity.ExtraSafetyNote = input.ExtraSafetyNote;
if (input.ServiceKey != null)
entity.ServiceKey = input.ServiceKey;
if (request.WorkOrderTypeSpecified)
entity.WorkOrderType = request.WorkOrderType;
if (input.TemplateUrl != null)
entity.TemplateUrl = input.TemplateUrl;
if (request.IsActive.HasValue)
entity.IsActive = request.IsActive.Value;
await _templateData.SaveEditAsync(entity, input.Procedures, cancellationToken);
return await GetSavedAsync(id, cancellationToken);
}
public async Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken)
{
var actorId = await EnsureAllowedAsync(
user,
TeamPermissionKeys.DeleteCompletionDocTemplates,
"You do not have permission to delete completion document templates.",
cancellationToken);
if (!await _templateData.DeleteAndUnlinkServicesAsync(id, actorId, cancellationToken))
throw NotFound();
}
private async Task<string> EnsureAllowedAsync(
ClaimsPrincipal user,
string permissionKey,
string deniedMessage,
CancellationToken cancellationToken)
{
var userId = user?.FindFirstValue(ClaimTypes.NameIdentifier);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionUsers.GetUserAsync(userId, cancellationToken);
if (permissionUser is null
|| !_permissionPolicy.IsAllowed(permissionUser.RoleName, permissionKey, permissionUser.Overrides))
{
throw new WorkOrderBoardValidationException("Forbidden", deniedMessage);
}
return userId!;
}
private async Task<CompletionDocTemplateDetailDto> GetSavedAsync(int id, CancellationToken cancellationToken)
{
var row = await _templateData.GetDetailAsync(id, cancellationToken) ?? throw NotFound();
return Map(row);
}
private sealed record ValidatedInput(
string Name,
string? ExtraSafetyNote,
string? ServiceKey,
string? TemplateUrl,
IReadOnlyList<CompletionDocTemplateProcedure>? Procedures);
private static ValidatedInput Validate(CompletionDocTemplateCreateDto request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Name))
throw new WorkOrderBoardValidationException("TitleRequired", "Document Title is required.");
var extraSafetyNote = string.IsNullOrWhiteSpace(request.ExtraSafetyNote)
? null
: request.ExtraSafetyNote.Trim();
if (extraSafetyNote?.Length > CompletionDocTemplate.ExtraSafetyNoteMaxLength)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"Extra safety note must be {CompletionDocTemplate.ExtraSafetyNoteMaxLength} characters or fewer.");
}
var templateUrl = request.TemplateUrl?.Trim();
if (!string.IsNullOrEmpty(templateUrl)
&& (!Uri.TryCreate(templateUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)))
{
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
}
// Blank procedure names and descriptions are allowed: authors may keep a
// placeholder step and fill it in later.
List<CompletionDocTemplateProcedure>? procedures = null;
if (request.Procedures != null)
{
procedures = new List<CompletionDocTemplateProcedure>(request.Procedures.Count);
foreach (var procedure in request.Procedures)
{
var name = procedure?.Name?.Trim() ?? "";
if (name.Length > CompletionDocTemplateProcedure.NameMaxLength)
{
throw new WorkOrderBoardValidationException(
"InvalidValue",
$"Procedure name must be {CompletionDocTemplateProcedure.NameMaxLength} characters or fewer.");
}
procedures.Add(new CompletionDocTemplateProcedure
{
SortOrder = procedures.Count,
Name = name,
Description = procedure?.Description ?? ""
});
}
}
return new ValidatedInput(
request.Name.Trim(),
extraSafetyNote,
request.ServiceKey?.Trim(),
templateUrl,
procedures);
}
private static WorkOrderBoardValidationException NotFound()
=> new("NotFound", "Template not found.");
private static CompletionDocTemplateDetailDto Map(CompletionDocTemplateDetailRow row)
{
var createdByName = string.Join(
" ",
new[] { row.CreatedByFirstName, row.CreatedByLastName }
.Where(part => !string.IsNullOrWhiteSpace(part))
.Select(part => part!.Trim()));
return new CompletionDocTemplateDetailDto
{
Id = row.Id,
Name = row.Name,
ServiceKey = row.ServiceKey,
WorkOrderType = row.WorkOrderType,
TemplateUrl = row.TemplateUrl,
IsActive = row.IsActive,
ExtraSafetyNote = row.ExtraSafetyNote,
Procedures = row.Procedures
.Select(p => new CompletionDocTemplateProcedureDto
{
Id = p.Id,
Name = p.Name,
Description = p.Description,
SortOrder = p.SortOrder
})
.ToList(),
CreatedAt = AsUtc(row.CreatedAt),
UpdatedAt = AsUtc(row.UpdatedAt),
CreatedByName = createdByName.Length == 0 ? null : createdByName
};
}
// Audit timestamps are written as DateTime.UtcNow; the store drops the kind.
private static DateTime? AsUtc(DateTime? value)
=> value.HasValue ? DateTime.SpecifyKind(value.Value, DateTimeKind.Utc) : null;
}
}

View file

@ -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);

View file

@ -3,7 +3,9 @@ using Data.SeaHavenIndustries;
using FluentValidation;
using FluentValidation.Results;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Constants;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
using SeaHaven.Services.Validation;
@ -20,17 +22,26 @@ namespace SeaHaven.Services.Implementation
private readonly IAccountDataService _accountDataService;
private readonly ICreateLocationValidation _createValidator;
private readonly IUpdateLocationValidation _updateValidator;
private readonly ITeamPermissionOverrideDataService _permissionOverrideData;
private readonly ITeamPermissionPolicy _permissionPolicy;
/// <summary>Upper bound on ids returned for the open work orders link.</summary>
public const int MaxOpenWorkOrderIds = 200;
public LocationService(
ILocationDataService locationDataService,
IAccountDataService accountDataService,
ICreateLocationValidation createValidator,
IUpdateLocationValidation updateValidator)
IUpdateLocationValidation updateValidator,
ITeamPermissionOverrideDataService permissionOverrideData,
ITeamPermissionPolicy permissionPolicy)
{
_locationDataService = locationDataService;
_accountDataService = accountDataService;
_createValidator = createValidator;
_updateValidator = updateValidator;
_permissionOverrideData = permissionOverrideData;
_permissionPolicy = permissionPolicy;
}
// Query operations
@ -262,10 +273,22 @@ namespace SeaHaven.Services.Implementation
CancellationToken cancellationToken)
{
await EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken);
ThrowOnMissingSiteFields(request);
var siteCode = request.Name?.Trim();
if (string.IsNullOrEmpty(siteCode))
{
throw new ValidationException(new[]
{
new ValidationFailure(nameof(LocationCreateRequestDTO.Name), "Site Code is required.")
});
}
await EnsureSiteCodeAvailableAsync(siteCode, request.AccountId, excludeLocationId: null, cancellationToken);
var location = new Locations
{
Name = request.Name,
Name = siteCode,
Title = request.Title,
Address1 = request.Address,
City = request.City,
@ -274,13 +297,11 @@ namespace SeaHaven.Services.Implementation
PhoneNumber = request.Phone,
Email = request.ContactEmail,
Status = request.Status,
AccountId = request.AccountId
AccountId = request.AccountId,
Notes = NormalizeNotes(request.Notes)
};
if (request.Contacts is List<SiteContactRequestDTO> contacts)
{
ApplySiteContactsForCreate(location, contacts, GetActorId(user));
}
ApplySiteContactsForCreate(location, request.Contacts!, GetActorId(user));
await _locationDataService.AddAsync(location, cancellationToken);
}
@ -297,7 +318,6 @@ namespace SeaHaven.Services.Implementation
EnsureLocationInCallerScope(user, location.AccountId);
location.Name = request.Name;
location.Title = request.Title;
location.Address1 = request.Address;
location.City = request.City;
@ -305,14 +325,19 @@ namespace SeaHaven.Services.Implementation
location.Zip = request.ZipCode;
location.PhoneNumber = request.Phone;
location.Email = request.ContactEmail;
location.Status = request.Status;
location.Status = request.Status ?? location.Status;
if (request.Notes != null)
location.Notes = NormalizeNotes(request.Notes);
var previousAccountId = location.AccountId;
if (request.AccountId is int accountId)
{
await EnsureAccountAssignableAsync(user, accountId, location.AccountId, cancellationToken);
location.AccountId = accountId;
}
await ApplySiteCodeForUpdateAsync(location, request.Name, previousAccountId, cancellationToken);
// SH-138: null contacts keeps legacy behavior and must not mutate contact rows.
if (request.Contacts is List<SiteContactRequestDTO> contacts)
{
@ -322,11 +347,147 @@ namespace SeaHaven.Services.Implementation
await _locationDataService.UpdateAsync(location, cancellationToken);
}
public Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken)
public async Task UpdateSiteContactInfoAsync(
int id,
SiteContactInfoRequestDTO request,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
return _locationDataService.DeleteByIdAsync(id, cancellationToken);
if (request.Contacts is not List<SiteContactRequestDTO> contacts)
{
throw new ValidationException(new[]
{
new ValidationFailure(nameof(SiteContactInfoRequestDTO.Contacts), "At least one contact is required.")
});
}
var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken);
if (location == null)
throw new KeyNotFoundException($"Location with ID {id} not found");
EnsureLocationInCallerScope(user, location.AccountId);
ApplySiteContactsForUpdate(location, contacts, GetActorId(user));
location.Notes = NormalizeNotes(request.Notes);
await _locationDataService.UpdateAsync(location, cancellationToken);
}
/// <summary>
/// Deletes a site for good from the caller's point of view. The row is kept as
/// a tombstone because work orders, assets and history reference it through
/// restrict foreign keys; those references are left exactly as they were.
/// </summary>
public async Task<bool> DeleteLocationByIdAsync(
int id,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
await EnsureCanDeleteSitesAsync(user, cancellationToken);
var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken);
if (location == null)
return false;
EnsureLocationInCallerScope(user, location.AccountId);
location.IsDeleted = true;
location.DeletionTime = DateTime.UtcNow;
location.DeleterUserId = GetActorId(user);
await _locationDataService.UpdateAsync(location, cancellationToken);
return true;
}
public async Task<SiteOpenWorkOrdersDTO?> GetOpenWorkOrdersAsync(
int id,
ClaimsPrincipal user,
CancellationToken cancellationToken)
{
var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken);
if (location == null)
return null;
EnsureLocationInCallerScope(user, location.AccountId);
int? callerAccountId = WorkOrderMediaAuthorization.ResolveMediaScope(user) is MediaAccountScope.Account caller
? caller.AccountId
: null;
var (count, ids) = await _locationDataService.GetOpenWorkOrderIdsAsync(
location.Id,
location.Name,
location.AccountId,
callerAccountId,
MaxOpenWorkOrderIds,
cancellationToken);
return new SiteOpenWorkOrdersDTO { Count = count, WorkOrderIds = ids };
}
private async Task EnsureCanDeleteSitesAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
{
var userId = GetActorId(user);
var permissionUser = string.IsNullOrWhiteSpace(userId)
? null
: await _permissionOverrideData.GetUserAsync(userId, cancellationToken);
if (permissionUser is null
|| !_permissionPolicy.IsAllowed(
permissionUser.RoleName,
TeamPermissionKeys.DeleteSites,
permissionUser.Overrides))
{
throw new SiteForbiddenException(SiteForbiddenException.DeleteDeniedMessage);
}
}
private async Task EnsureSiteCodeAvailableAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken)
{
if (await _locationDataService.SiteCodeExistsAsync(siteCode, accountId, excludeLocationId, cancellationToken))
throw new SiteCodeConflictException();
}
/// <summary>
/// The site code is immutable once set. A blank legacy code may be filled in
/// once; the code must stay unique within the site's account, including when
/// the site moves to another account.
/// </summary>
private async Task ApplySiteCodeForUpdateAsync(
Locations location,
string? requestedCode,
int? previousAccountId,
CancellationToken cancellationToken)
{
var stored = location.Name?.Trim();
var requested = requestedCode?.Trim();
if (!string.IsNullOrEmpty(stored)
&& !string.IsNullOrEmpty(requested)
&& !string.Equals(stored, requested, StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException(new[]
{
new ValidationFailure(nameof(LocationUpdateRequestDTO.Name), "Site Code cannot be changed.")
});
}
var fillsBlankCode = string.IsNullOrEmpty(stored) && !string.IsNullOrEmpty(requested);
var code = fillsBlankCode ? requested : stored;
if (!string.IsNullOrEmpty(code) && (fillsBlankCode || previousAccountId != location.AccountId))
await EnsureSiteCodeAvailableAsync(code, location.AccountId, location.Id, cancellationToken);
if (fillsBlankCode)
location.Name = requested;
}
private static string? NormalizeNotes(string? notes) =>
string.IsNullOrWhiteSpace(notes) ? null : notes.Trim();
private static void EnsureLocationInCallerScope(ClaimsPrincipal user, int? locationAccountId)
{
switch (WorkOrderMediaAuthorization.ResolveMediaScope(user))
@ -385,6 +546,25 @@ namespace SeaHaven.Services.Implementation
private static string? GetActorId(ClaimsPrincipal user) =>
user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
/// <summary>A new site needs a client, a full address and at least one point of contact.</summary>
private static void ThrowOnMissingSiteFields(LocationCreateRequestDTO request)
{
var failures = new List<ValidationFailure>();
if (request.AccountId == null)
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.AccountId), "Client is required."));
if (string.IsNullOrWhiteSpace(request.Address))
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Address), "Street Address is required."));
if (string.IsNullOrWhiteSpace(request.City))
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.City), "City is required."));
if (string.IsNullOrWhiteSpace(request.State))
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.State), "State is required."));
if (request.Contacts == null || request.Contacts.Count == 0)
failures.Add(new ValidationFailure(nameof(LocationCreateRequestDTO.Contacts), "At least one contact is required."));
if (failures.Count > 0)
throw new ValidationException(failures);
}
private static void ThrowOnInvalidContacts(List<SiteContactRequestDTO> contacts)
{
var failures = SiteContactsValidation.Validate(contacts);
@ -399,7 +579,7 @@ namespace SeaHaven.Services.Implementation
{
ThrowOnInvalidContacts(contacts);
var now = DateTime.Now;
var now = DateTime.UtcNow;
var siteContacts = contacts
.Select((row, index) => new Contacts
{
@ -414,9 +594,6 @@ namespace SeaHaven.Services.Implementation
})
.ToList();
location.Contacts = siteContacts;
// The first site contact mirrors Location.PhoneNumber for legacy consumers.
location.PhoneNumber = siteContacts[0].PhoneNumber;
}
private static void ApplySiteContactsForUpdate(
@ -457,7 +634,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 +647,7 @@ namespace SeaHaven.Services.Implementation
PhoneNumber = phone,
SiteContactOrder = i,
AccountId = location.AccountId,
CreatedDate = DateTime.Now,
CreatedDate = DateTime.UtcNow,
createdby = actorId
});
}
@ -478,7 +655,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)
@ -489,9 +666,6 @@ namespace SeaHaven.Services.Implementation
existing.DeletionTime = now;
existing.LastModificationTime = now;
}
// The first site contact mirrors Location.PhoneNumber for legacy consumers.
location.PhoneNumber = contacts[0].Phone!.Trim();
}
private async Task<IReadOnlyDictionary<int, IReadOnlyList<Contacts>>> GetSiteContactsByLocationIdsAsync(
@ -544,6 +718,7 @@ namespace SeaHaven.Services.Implementation
Status = location.Status,
AccountId = location.AccountId,
AccountName = location.Account?.Name,
Notes = location.Notes,
CreatedDate = location.CreatedDate,
CreatedBy = location.createdby,
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)

View file

@ -41,11 +41,13 @@ namespace SeaHaven.Services.Implementation
public async Task<NotificationFeedDto> GetFeedAsync(ClaimsPrincipal user, CancellationToken cancellationToken)
{
var accountId = _accountResolver.ResolveAccountFilter(user);
var (dispatcherId, seesUnassigned) = ResolveAudience(user);
var (dispatcherId, seesUnassigned) = NotificationAudience.Resolve(user);
var scope = new NotificationFeedScope(accountId, dispatcherId);
var now = _timeProvider.GetUtcNow().UtcDateTime;
var sections = new List<(string Reason, int Count, IReadOnlyList<NotificationItemDto> Items)>();
var (slaSection, slaAtRisk) = await SlaAsync(scope, now, cancellationToken);
sections.Add(slaSection);
if (seesUnassigned)
sections.Add(await UnassignedAsync(accountId, now, cancellationToken));
sections.Add(await NoVendorAsync(scope, now, cancellationToken));
@ -59,28 +61,80 @@ namespace SeaHaven.Services.Implementation
return new NotificationFeedDto
{
GeneratedAt = now,
Sections = NotificationFeedOrdering.Order(sections)
Sections = NotificationFeedOrdering.Order(sections),
SlaAtRisk = slaAtRisk
};
}
// Dispatchers see their own work orders. Roles that already see every dispatcher's
// work on the dashboard see the whole account, including work nobody owns yet.
private static (string? DispatcherId, bool SeesUnassigned) ResolveAudience(ClaimsPrincipal user)
// Reactive/Emergency work orders against their SEV response window. An at-risk work order is a
// normal dismissable row (and feeds the banner); a missed deadline is an acknowledge row that stays
// until someone acknowledges it. Breaches and at-risk rows are capped separately so a backlog of
// old breaches can never hide a work order that can still be saved.
private async Task<((string, int, IReadOnlyList<NotificationItemDto>) Section, IReadOnlyList<NotificationSlaWorkOrderDto> AtRisk)> SlaAsync(
NotificationFeedScope scope, DateTime now, CancellationToken cancellationToken)
{
if (user.IsInRole("Dispatcher"))
{
var dispatcherId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(dispatcherId))
throw new WorkOrderBoardValidationException("Forbidden", "Dispatcher identity is required.");
return (dispatcherId, false);
}
var candidates = await _data.GetSlaCandidatesAsync(
scope,
SlaCutoffs(level => SlaResponseWindows.AtRiskCreatedAtOrBefore(level, now)),
SlaCutoffs(level => SlaResponseWindows.BreachedCreatedAtOrBefore(level, now)),
SectionItemLimit,
cancellationToken);
if (user.IsInRole("Admin") || user.IsInRole("Manager") || user.IsInRole("Scheduler"))
return (null, true);
var atRisk = candidates.AtRisk
.Select(candidate => (Candidate: candidate, Clock: SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now)))
.Where(entry => entry.Clock is { State: SlaState.AtRisk })
.Select(entry => new NotificationSlaWorkOrderDto
{
Id = entry.Candidate.Id,
Number = entry.Candidate.Number,
Severity = entry.Clock!.Severity,
StartedAt = entry.Clock.StartedAt,
DeadlineAt = entry.Clock.DeadlineAt,
PercentElapsed = entry.Clock.PercentElapsed
})
.ToList();
throw new WorkOrderBoardValidationException("Forbidden", "Notifications require an authorized role.");
var items = candidates.Breached
.Select(candidate => SlaItem(candidate, SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now)))
.OfType<NotificationItemDto>()
.Concat(atRisk
.OrderByDescending(workOrder => workOrder.PercentElapsed)
.Take(SectionItemLimit)
.Select(workOrder => new NotificationItemDto
{
Id = $"sla-at-risk-{workOrder.Id}",
Reason = NotificationReasons.Sla,
Severity = NotificationSeverities.High,
Title = $"{WorkOrderLabel(workOrder.Id, workOrder.Number)} is at risk of missing its response deadline",
Count = 1,
TriggeredAt = workOrder.StartedAt + SlaResponseWindows.Respond[workOrder.Severity] / 2,
Target = WorkOrderTarget(workOrder.Id, NotificationWorkOrderTabs.Info),
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = workOrder.Id, Number = workOrder.Number } }
}))
.ToList();
return ((NotificationReasons.Sla, candidates.BreachedTotal + atRisk.Count, items), atRisk);
}
private static NotificationItemDto? SlaItem(NotificationSlaCandidate candidate, SlaClock? clock)
=> clock is not { State: SlaState.Breached }
? null
: new NotificationItemDto
{
Id = $"sla-breach-{candidate.Id}",
Reason = NotificationReasons.Sla,
Severity = NotificationSeverities.Critical,
RowType = NotificationRowTypes.Acknowledge,
Title = $"{WorkOrderLabel(candidate.Id, candidate.Number)} missed its response deadline",
Count = 1,
TriggeredAt = clock.DeadlineAt,
Target = WorkOrderTarget(candidate.Id, NotificationWorkOrderTabs.Info),
WorkOrders = new[] { new NotificationWorkOrderRefDto { Id = candidate.Id, Number = candidate.Number } }
};
private static NotificationSlaCutoffs SlaCutoffs(Func<int, DateTime> cutoff)
=> new(cutoff(1), cutoff(2), cutoff(3), cutoff(4), cutoff(5));
// Items addressed to the signed-in user whatever their role: assignments to them, comments on
// work they take part in, mentions of them, and decisions on uplifts they requested. The
// account scope still applies; the dispatcher scope does not, because a mention or an uplift

View file

@ -0,0 +1,76 @@
using System.Security.Claims;
using Data.SeaHavenIndustries;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces;
namespace SeaHaven.Services.Implementation
{
public class SlaBreachAcknowledgementService : ISlaBreachAcknowledgementService
{
private readonly INotificationFeedDataService _feedData;
private readonly IWorkOrderAuditDataService _auditData;
private readonly IUserDataService _userData;
private readonly IWorkOrderAccountResolver _accountResolver;
private readonly TimeProvider _timeProvider;
public SlaBreachAcknowledgementService(
INotificationFeedDataService feedData,
IWorkOrderAuditDataService auditData,
IUserDataService userData,
IWorkOrderAccountResolver accountResolver,
TimeProvider timeProvider)
{
_feedData = feedData;
_auditData = auditData;
_userData = userData;
_accountResolver = accountResolver;
_timeProvider = timeProvider;
}
public async Task<SlaBreachAcknowledgementOutcome> AcknowledgeAsync(
ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken)
{
// The same audience as the feed: a row the caller cannot see cannot be acknowledged.
var accountId = _accountResolver.ResolveAccountFilter(user);
var (dispatcherId, _) = NotificationAudience.Resolve(user);
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
throw new WorkOrderBoardValidationException("Forbidden", "Acknowledging requires a signed-in user.");
var candidate = await _feedData.GetSlaCandidateAsync(
new NotificationFeedScope(accountId, dispatcherId), workOrderId, cancellationToken);
if (candidate == null)
return SlaBreachAcknowledgementOutcome.NotFound;
var now = _timeProvider.GetUtcNow().UtcDateTime;
var clock = SlaResponseWindows.Evaluate(candidate.Severity, candidate.CreatedAt, now);
if (clock is not { State: SlaState.Breached })
return SlaBreachAcknowledgementOutcome.NotBreached;
if (candidate.BreachAcknowledged)
return SlaBreachAcknowledgementOutcome.AlreadyAcknowledged;
var names = await _userData.GetDisplayNamesByIdsAsync(new[] { userId });
var name = names.TryGetValue(userId, out var displayName) && !string.IsNullOrWhiteSpace(displayName)
? displayName
: user.FindFirstValue(ClaimTypes.Name) ?? userId;
_auditData.EnqueueAuditLog(new WorkOrderAuditLog
{
WorkOrderId = workOrderId,
UserId = userId,
FieldName = SlaBreachAcknowledgementAudit.FieldName,
OldValue = clock.DeadlineAt.ToString("o"),
NewValue = candidate.Severity,
Action = $"SLA breach acknowledged by {name}",
EventType = "system",
ActorType = "internal",
CreatedAt = now
});
await _auditData.PersistAsync(cancellationToken);
return SlaBreachAcknowledgementOutcome.Acknowledged;
}
}
}

View file

@ -35,6 +35,28 @@ public sealed class TeamPermissionService : ITeamPermissionService
: TeamPermissionResult<TeamPermissionProfileDTO>.Success(BuildProfile(user));
}
public async Task<TeamPermissionResult<EffectivePermissionsDTO>> GetEffectivePermissionsAsync(
ClaimsPrincipal caller,
CancellationToken cancellationToken)
{
var userId = caller?.Identity?.IsAuthenticated == true
? caller.FindFirstValue(ClaimTypes.NameIdentifier)
: null;
if (string.IsNullOrWhiteSpace(userId))
return TeamPermissionResult<EffectivePermissionsDTO>.Failure(TeamPermissionResultStatus.Unauthorized);
var user = await _dataService.GetUserAsync(userId, cancellationToken);
if (user is null)
return TeamPermissionResult<EffectivePermissionsDTO>.Failure(TeamPermissionResultStatus.Unauthorized);
return TeamPermissionResult<EffectivePermissionsDTO>.Success(new EffectivePermissionsDTO
{
Permissions = TeamPermissionKeys.All
.Where(key => _policy.IsAllowed(user.RoleName, key, user.Overrides))
.ToList()
});
}
public async Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
string userId,
string permissionKey,

View file

@ -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;

View file

@ -42,6 +42,7 @@ namespace SeaHaven.Services.Implementation
query.DateTo);
var sortBy = WorkOrderBoardSortFields.NormalizeOrThrow(query.SortBy);
var ids = WorkOrderIdSet.ParseOrThrow(query.Ids);
var dataQuery = new WorkOrderAdvancedSearchQuery(
WorkOrderBoardSearchFilter.NormalizeSearch(query.Search),
@ -77,7 +78,8 @@ namespace SeaHaven.Services.Implementation
query.IncludeDateless,
query.DatePreset,
query.DateFrom,
query.DateTo));
query.DateTo),
ids);
var result = await _searchDataService.SearchAsync(dataQuery);
var utcNow = DateTime.UtcNow;

View file

@ -31,18 +31,6 @@ namespace SeaHaven.Services.Implementation
_accountResolver = accountResolver;
}
public async Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType)
{
var rows = await _templateData.GetActiveAsync(serviceKey, WorkOrderCatalogType.For(workOrderType));
return rows.Select(WorkOrderDetailService.MapTemplate).ToList();
}
public async Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id)
{
var row = await _templateData.GetByIdAsync(id);
return row == null ? null : WorkOrderDetailService.MapTemplate(row);
}
public async Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
@ -127,49 +115,6 @@ namespace SeaHaven.Services.Implementation
};
}
public async Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request)
{
ValidateTemplateRequest(request);
var entity = new CompletionDocTemplate
{
Name = request.Name.Trim(),
ServiceKey = request.ServiceKey.Trim(),
WorkOrderType = request.WorkOrderType,
TemplateUrl = request.TemplateUrl.Trim(),
IsActive = request.IsActive
};
var created = await _templateData.CreateAsync(entity);
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive));
}
public async Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request)
{
ValidateTemplateRequest(request);
var entity = await _completionData.GetTrackedTemplateAsync(id, CancellationToken.None);
if (entity == null)
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
entity.Name = request.Name.Trim();
entity.ServiceKey = request.ServiceKey.Trim();
entity.WorkOrderType = request.WorkOrderType;
entity.TemplateUrl = request.TemplateUrl.Trim();
entity.IsActive = request.IsActive;
await _templateData.UpdateAsync(entity);
return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow(
entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive));
}
public async Task DeleteTemplateAsync(int id)
{
if (!await _templateData.DeleteAsync(id))
throw new WorkOrderBoardValidationException("NotFound", "Template not found.");
}
private async Task<WorkOrder> GetMutableWorkOrderForAuthAsync(
int workOrderId,
ClaimsPrincipal user,
@ -187,21 +132,6 @@ namespace SeaHaven.Services.Implementation
return workOrder;
}
private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey))
throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required.");
if (string.IsNullOrWhiteSpace(request.TemplateUrl))
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required.");
if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL.");
}
}
private static byte[]? ParseRowVersion(string? base64)
{
if (string.IsNullOrWhiteSpace(base64))

View file

@ -5,7 +5,7 @@ namespace SeaHaven.Services.Interfaces
public interface IAuthenticationService
{
Task<LoginResultDTO?> LoginAsync(string? username, string? password, CancellationToken cancellationToken);
Task<bool> ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken);
Task<ChangePasswordResultDTO> ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken);
Task<UserProfileDTO?> UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken);
Task<bool> ForgetPasswordAsync(string email, CancellationToken cancellationToken);
Task<bool> VerifyCodeAsync(string code, CancellationToken cancellationToken);

View file

@ -0,0 +1,40 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
/// <summary>
/// Completion document templates registry: authoring (title, extra safety note,
/// ordered procedures), search, and safe deletion. Reads are open to every
/// authenticated caller; writes require the matching team permission.
/// </summary>
public interface ICompletionDocTemplateService
{
Task<IReadOnlyList<CompletionDocTemplateDetailDto>> ListAsync(
string? search,
string? serviceKey,
WorkOrderType? workOrderType,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailDto?> GetAsync(int id, CancellationToken cancellationToken);
Task<CompletionDocTemplateLinkedWorkOrdersDto> GetLinkedWorkOrdersAsync(
ClaimsPrincipal user,
int id,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailDto> CreateAsync(
ClaimsPrincipal user,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken);
Task<CompletionDocTemplateDetailDto> UpdateAsync(
ClaimsPrincipal user,
int id,
CompletionDocTemplateCreateDto request,
CancellationToken cancellationToken);
Task DeleteAsync(ClaimsPrincipal user, int id, CancellationToken cancellationToken);
}
}

View file

@ -23,6 +23,8 @@ namespace SeaHaven.Services.Interfaces
Task<LocationDTO?> GetLocationDetailAsync(int id, CancellationToken cancellationToken);
Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task<bool> DeleteLocationByIdAsync(int id, CancellationToken cancellationToken);
Task UpdateSiteContactInfoAsync(int id, SiteContactInfoRequestDTO request, ClaimsPrincipal user, CancellationToken cancellationToken);
Task<bool> DeleteLocationByIdAsync(int id, ClaimsPrincipal user, CancellationToken cancellationToken);
Task<SiteOpenWorkOrdersDTO?> GetOpenWorkOrdersAsync(int id, ClaimsPrincipal user, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,25 @@
using System.Security.Claims;
namespace SeaHaven.Services.Interfaces
{
public enum SlaBreachAcknowledgementOutcome
{
Acknowledged,
/// <summary>This breach was already acknowledged; nothing new is recorded.</summary>
AlreadyAcknowledged,
/// <summary>Not an SLA work order the caller's Notification Center covers (another account or dispatcher, closed, no SEV level).</summary>
NotFound,
/// <summary>The work order has not missed its response deadline.</summary>
NotBreached
}
/// <summary>
/// Acknowledging a missed SEV response deadline from the Notification Center: records who and when in
/// the work order's audit history, which is also what removes the breach row from the feed.
/// </summary>
public interface ISlaBreachAcknowledgementService
{
Task<SlaBreachAcknowledgementOutcome> AcknowledgeAsync(
ClaimsPrincipal user, int workOrderId, CancellationToken cancellationToken);
}
}

View file

@ -11,6 +11,14 @@ public interface ITeamPermissionService
ClaimsPrincipal caller,
CancellationToken cancellationToken);
/// <summary>
/// Keys the caller holds after role defaults and their own overrides. The
/// user is read from the caller's identity, never from request input.
/// </summary>
Task<TeamPermissionResult<EffectivePermissionsDTO>> GetEffectivePermissionsAsync(
ClaimsPrincipal caller,
CancellationToken cancellationToken);
Task<TeamPermissionResult<TeamPermissionProfileDTO>> SetOverrideAsync(
string userId,
string permissionKey,

View file

@ -1,13 +1,10 @@
using System.Security.Claims;
using Data.SeaHavenIndustries.Enums;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IWorkOrderCompletionService
{
Task<IReadOnlyList<CompletionDocTemplateDto>> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType);
Task<CompletionDocTemplateDto?> GetTemplateByIdAsync(int id);
Task EnsureCanUploadCompletionDocAsync(
int workOrderId,
ClaimsPrincipal user,
@ -20,8 +17,5 @@ namespace SeaHaven.Services.Interfaces
ClaimsPrincipal user,
string? actorId,
CancellationToken cancellationToken = default);
Task<CompletionDocTemplateDto> CreateTemplateAsync(CompletionDocTemplateCreateDto request);
Task<CompletionDocTemplateDto> UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request);
Task DeleteTemplateAsync(int id);
}
}

View file

@ -0,0 +1,720 @@
using System.Reflection;
using System.Security.Claims;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums;
using Data.SeaHavenIndustries.Migrations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Exceptions;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using Xunit;
namespace SeaHavenIndustries.Tests;
// Completion document templates registry: authoring with ordered procedures,
// search, permission-gated writes, tenant-scoped linked work orders, and a
// delete that unlinks Services without dropping their document requirement.
public class CompletionDocTemplateServiceTests
{
private const string AdminId = "admin-1";
private const string SchedulerId = "scheduler-1";
private const string DispatcherId = "dispatcher-1";
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
var context = new ApplicationDbContext(options);
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
SeedUser(context, SchedulerId, "Scheduler", "Sam", "Scheduler");
SeedUser(context, DispatcherId, "Dispatcher", "Dee", "Dispatcher");
context.SaveChanges();
return context;
}
private static void SeedUser(ApplicationDbContext context, string userId, string role, string first, string last)
{
context.Users.Add(new ApplicationUser
{
Id = userId,
UserName = userId,
NormalizedUserName = userId.ToUpperInvariant(),
FirstName = first,
LastName = last
});
var roleId = "role-" + role;
if (context.Roles.Local.All(r => r.Id != roleId))
context.Roles.Add(new IdentityRole { Id = roleId, Name = role, NormalizedName = role.ToUpperInvariant() });
context.UserRoles.Add(new IdentityUserRole<string> { UserId = userId, RoleId = roleId });
}
private static CompletionDocTemplateService NewService(ApplicationDbContext context) =>
new(
new CompletionDocTemplateDataService(context),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
WorkOrderAccountTestHelpers.Resolver(context));
private static ClaimsPrincipal Caller(string userId, string role, int? accountId = null)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, userId),
new(ClaimTypes.Role, role)
};
claims.Add(accountId.HasValue
? new Claim(SeaHavenClaimTypes.AccountId, accountId.Value.ToString())
: new Claim(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll));
return new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType: "test"));
}
private static ClaimsPrincipal Admin(int? accountId = null) => Caller(AdminId, "Admin", accountId);
private static ClaimsPrincipal Scheduler() => Caller(SchedulerId, "Scheduler");
private static ClaimsPrincipal Dispatcher() => Caller(DispatcherId, "Dispatcher");
private static CompletionDocTemplateCreateDto Body(
string? name = "Backflow Test Report",
string? extraSafetyNote = null,
params (string? Name, string? Description)[] procedures) => new()
{
Name = name,
ExtraSafetyNote = extraSafetyNote,
Procedures = procedures
.Select(p => new CompletionDocTemplateProcedureInputDto { Name = p.Name, Description = p.Description })
.ToList()
};
[Fact]
public async Task Create_PersistsTitleNoteAndProceduresInSubmittedOrder()
{
using var context = NewContext();
var service = NewService(context);
var created = await service.CreateAsync(
Admin(),
Body(" Backflow Test Report ", " Wear gloves ",
("Backflow Procedure", "line 1\nline 2"),
("Shutoff", "Close valve"),
("Cleanup", "")),
CancellationToken.None);
Assert.Equal("Backflow Test Report", created.Name);
Assert.Equal("Wear gloves", created.ExtraSafetyNote);
Assert.Equal(new[] { "Backflow Procedure", "Shutoff", "Cleanup" }, created.Procedures.Select(p => p.Name));
Assert.Equal(new[] { 0, 1, 2 }, created.Procedures.Select(p => p.SortOrder));
Assert.Equal("line 1\nline 2", created.Procedures[0].Description);
Assert.All(created.Procedures, p => Assert.True(p.Id > 0));
Assert.Equal("Jane Doe", created.CreatedByName);
Assert.NotNull(created.CreatedAt);
Assert.Equal(DateTimeKind.Utc, created.CreatedAt!.Value.Kind);
Assert.Null(created.UpdatedAt);
Assert.True(created.IsActive);
Assert.Equal("", created.ServiceKey);
Assert.Equal("", created.TemplateUrl);
var fetched = await service.GetAsync(created.Id, CancellationToken.None);
Assert.Equal(created.Procedures.Select(p => p.Id), fetched!.Procedures.Select(p => p.Id));
}
[Fact]
public async Task Create_WithZeroProceduresAndNoNote_Succeeds()
{
using var context = NewContext();
var created = await NewService(context).CreateAsync(
Admin(), new CompletionDocTemplateCreateDto { Name = "Empty" }, CancellationToken.None);
Assert.Empty(created.Procedures);
Assert.Null(created.ExtraSafetyNote);
}
[Fact]
public async Task Create_AllowsBlankProcedureNameAndDescription()
{
using var context = NewContext();
var created = await NewService(context).CreateAsync(
Admin(), Body("Placeholder", null, (null, null), (" ", "")), CancellationToken.None);
Assert.Equal(2, created.Procedures.Count);
Assert.All(created.Procedures, p => Assert.Equal("", p.Name));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task CreateAndUpdate_WithoutTitle_ThrowTitleRequired(string? name)
{
using var context = NewContext();
var service = NewService(context);
var existing = await service.CreateAsync(Admin(), Body(), CancellationToken.None);
var onCreate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Admin(), Body(name), CancellationToken.None));
var onUpdate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Admin(), existing.Id, Body(name), CancellationToken.None));
Assert.Equal("TitleRequired", onCreate.Code);
Assert.Equal("Document Title is required.", onCreate.Message);
Assert.Equal("TitleRequired", onUpdate.Code);
Assert.Single(context.CompletionDocTemplates);
}
[Fact]
public async Task Create_RejectsOverlongExtraSafetyNoteAndProcedureName()
{
using var context = NewContext();
var service = NewService(context);
var note = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Admin(), Body("T", new string('n', 2001)), CancellationToken.None));
var procedure = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Admin(), Body("T", null, (new string('p', 201), "d")), CancellationToken.None));
Assert.Equal("InvalidValue", note.Code);
Assert.Equal("InvalidValue", procedure.Code);
Assert.Empty(context.CompletionDocTemplates);
}
[Fact]
public async Task Update_ReplacesWholeProcedureListAndStampsUpdatedAt()
{
using var context = NewContext();
var service = NewService(context);
var created = await service.CreateAsync(
Admin(), Body("T", "Note", ("A", "a"), ("B", "b"), ("C", "c")), CancellationToken.None);
var updated = await service.UpdateAsync(
Admin(), created.Id, Body("T2", "Note", ("C", "c2"), ("A", "a2")), CancellationToken.None);
Assert.Equal("T2", updated.Name);
Assert.Equal(new[] { "C", "A" }, updated.Procedures.Select(p => p.Name));
Assert.Equal(new[] { 0, 1 }, updated.Procedures.Select(p => p.SortOrder));
Assert.Equal(2, context.CompletionDocTemplateProcedures.Count());
Assert.NotNull(updated.UpdatedAt);
Assert.Equal("Jane Doe", updated.CreatedByName);
}
[Fact]
public async Task Update_NullNoteClearsIt_EmptyProceduresClearThem_NullProceduresKeepThem()
{
using var context = NewContext();
var service = NewService(context);
var created = await service.CreateAsync(Admin(), Body("T", "Note", ("A", "a")), CancellationToken.None);
var kept = await service.UpdateAsync(
Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = null }, CancellationToken.None);
Assert.Null(kept.ExtraSafetyNote);
Assert.Equal(new[] { "A" }, kept.Procedures.Select(p => p.Name));
var cleared = await service.UpdateAsync(
Admin(), created.Id, new CompletionDocTemplateCreateDto { Name = "T", Procedures = new() }, CancellationToken.None);
Assert.Empty(cleared.Procedures);
}
[Fact]
public async Task Update_OmittedLegacyFieldsKeepStoredValues()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Id = 7,
Name = "HVAC PM Completion",
ServiceKey = "HVAC PM",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "https://example.com/hvac.pdf",
IsActive = true
});
await context.SaveChangesAsync();
var updated = await NewService(context).UpdateAsync(
Admin(), 7, Body("HVAC PM Completion v2", "Mask"), CancellationToken.None);
Assert.Equal("HVAC PM", updated.ServiceKey);
Assert.Equal(WorkOrderType.PM, updated.WorkOrderType);
Assert.Equal("https://example.com/hvac.pdf", updated.TemplateUrl);
Assert.True(updated.IsActive);
Assert.Null(updated.CreatedByName);
}
// Bodies are bound by System.Text.Json with the web defaults, as MVC does.
private static CompletionDocTemplateCreateDto JsonBody(string json) =>
System.Text.Json.JsonSerializer.Deserialize<CompletionDocTemplateCreateDto>(
json, new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web))!;
[Fact]
public async Task Update_WorkOrderTypeExplicitNullClears_OmittedKeeps_ValueSets()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate
{
Id = 8,
Name = "HVAC PM Completion",
ServiceKey = "HVAC",
WorkOrderType = WorkOrderType.PM,
TemplateUrl = "",
IsActive = true
});
await context.SaveChangesAsync();
var service = NewService(context);
var omitted = JsonBody("""{"name":"HVAC Completion"}""");
Assert.False(omitted.WorkOrderTypeSpecified);
var kept = await service.UpdateAsync(Admin(), 8, omitted, CancellationToken.None);
Assert.Equal(WorkOrderType.PM, kept.WorkOrderType);
var cleared = await service.UpdateAsync(
Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":null}"""), CancellationToken.None);
Assert.Null(cleared.WorkOrderType);
Assert.Null((await context.CompletionDocTemplates.AsNoTracking().SingleAsync(t => t.Id == 8)).WorkOrderType);
var set = await service.UpdateAsync(
Admin(), 8, JsonBody("""{"name":"HVAC Completion","workOrderType":6}"""), CancellationToken.None);
Assert.Equal(WorkOrderType.Reactive, set.WorkOrderType);
}
[Fact]
public async Task Update_MissingOrDeletedTemplate_ThrowsNotFound()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 3, Name = "Gone", IsDeleted = true });
await context.SaveChangesAsync();
var service = NewService(context);
var missing = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Admin(), 999, Body(), CancellationToken.None));
var deleted = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Admin(), 3, Body(), CancellationToken.None));
Assert.Equal("NotFound", missing.Code);
Assert.Equal("NotFound", deleted.Code);
}
[Fact]
public async Task List_SearchIsPartialCaseInsensitiveAndOrderedByName()
{
using var context = NewContext();
var service = NewService(context);
foreach (var name in new[] { "Roof Inspection", "Backflow Test Report", "Annual BACKFLOW check", "HVAC PM" })
await service.CreateAsync(Admin(), Body(name), CancellationToken.None);
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Name = "Backflow deleted", IsDeleted = true });
await context.SaveChangesAsync();
var matches = await service.ListAsync("backFLOW", null, null, CancellationToken.None);
var all = await service.ListAsync(null, null, null, CancellationToken.None);
var none = await service.ListAsync("zzz", null, null, CancellationToken.None);
Assert.Equal(new[] { "Annual BACKFLOW check", "Backflow Test Report" }, matches.Select(t => t.Name));
Assert.Equal(
new[] { "Annual BACKFLOW check", "Backflow Test Report", "HVAC PM", "Roof Inspection" },
all.Select(t => t.Name));
Assert.Empty(none);
}
[Fact]
public async Task List_KeepsLegacyServiceKeyAndWorkOrderTypeFilters()
{
using var context = NewContext();
context.CompletionDocTemplates.AddRange(
new CompletionDocTemplate { Name = "HVAC PM", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.PM },
new CompletionDocTemplate { Name = "HVAC Any", ServiceKey = "HVAC" },
new CompletionDocTemplate { Name = "HVAC Reactive", ServiceKey = "HVAC", WorkOrderType = WorkOrderType.Reactive },
new CompletionDocTemplate { Name = "Plumbing", ServiceKey = "Plumbing" });
await context.SaveChangesAsync();
var rows = await NewService(context).ListAsync(null, "HVAC", WorkOrderType.PM, CancellationToken.None);
Assert.Equal(new[] { "HVAC Any", "HVAC PM" }, rows.Select(t => t.Name));
}
[Fact]
public async Task Writes_FollowTeamPermissions()
{
using var context = NewContext();
var service = NewService(context);
var byScheduler = await service.CreateAsync(Scheduler(), Body("Scheduler template"), CancellationToken.None);
await service.UpdateAsync(Scheduler(), byScheduler.Id, Body("Scheduler edit"), CancellationToken.None);
Assert.Equal("Sam Scheduler", byScheduler.CreatedByName);
var schedulerDelete = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteAsync(Scheduler(), byScheduler.Id, CancellationToken.None));
var dispatcherCreate = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Dispatcher(), Body("Nope"), CancellationToken.None));
var dispatcherEdit = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.UpdateAsync(Dispatcher(), byScheduler.Id, Body("Nope"), CancellationToken.None));
var anonymous = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(new ClaimsPrincipal(new ClaimsIdentity()), Body("Nope"), CancellationToken.None));
Assert.All(new[] { schedulerDelete, dispatcherCreate, dispatcherEdit, anonymous },
ex => Assert.Equal("Forbidden", ex.Code));
Assert.Equal("Scheduler edit", context.CompletionDocTemplates.Single().Name);
Assert.NotEqual(true, context.CompletionDocTemplates.Single().IsDeleted);
}
[Fact]
public async Task Writes_HonorPerPersonOverrides()
{
using var context = NewContext();
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = SchedulerId,
PermissionKey = "deleteCompletionDocTemplates",
State = UserPermissionState.Allow
});
context.UserPermissionOverrides.Add(new UserPermissionOverride
{
UserId = SchedulerId,
PermissionKey = "createCompletionDocTemplates",
State = UserPermissionState.Deny
});
await context.SaveChangesAsync();
var service = NewService(context);
var template = await service.CreateAsync(Admin(), Body(), CancellationToken.None);
var denied = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.CreateAsync(Scheduler(), Body("Denied"), CancellationToken.None));
await service.DeleteAsync(Scheduler(), template.Id, CancellationToken.None);
Assert.Equal("Forbidden", denied.Code);
Assert.True(context.CompletionDocTemplates.Single().IsDeleted);
}
[Fact]
public async Task LinkedWorkOrders_CountsOnlyOpenWorkOrdersOfLinkedServicesInCallerTenant()
{
using var context = NewContext();
var template = new CompletionDocTemplate { Id = 10, Name = "Backflow" };
var other = new CompletionDocTemplate { Id = 11, Name = "Other" };
context.CompletionDocTemplates.AddRange(template, other);
context.Services.AddRange(
new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
new Service { Id = 2, Name = "Backflow 2", NormalizedName = "BACKFLOW 2", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
new Service { Id = 3, Name = "Roof", NormalizedName = "ROOF", RequiresCompletionDocument = true, CompletionDocTemplateId = 11 });
context.workOrders.AddRange(
new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 102, ServiceId = 2, AccountId = 1, LifecycleStatus = null },
new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed },
new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled },
new WorkOrder { Id = 105, ServiceId = 3, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 106, ServiceId = null, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 107, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, IsDeleted = true },
new WorkOrder { Id = 108, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled, istemplate = true },
new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.InProgress });
context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1));
await context.SaveChangesAsync();
var service = NewService(context);
var tenantOne = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 1), 10, CancellationToken.None);
var tenantTwo = await service.GetLinkedWorkOrdersAsync(Admin(accountId: 2), 10, CancellationToken.None);
var orgWide = await service.GetLinkedWorkOrdersAsync(Admin(), 10, CancellationToken.None);
Assert.Equal(3, tenantOne.Count);
Assert.Equal(new[] { 101, 102, 111 }, tenantOne.WorkOrderIds);
Assert.Equal(new[] { 205 }, tenantTwo.WorkOrderIds);
Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide.WorkOrderIds);
Assert.Equal(4, orgWide.Count);
}
// Legacy rows carry no LifecycleStatus; their legacy status text decides whether they are open.
private static WorkOrder[] LegacyStatusWorkOrders(int serviceId, int accountId) => new[]
{
new WorkOrder { Id = 109, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "Completed" },
new WorkOrder { Id = 110, ServiceId = serviceId, AccountId = accountId, Status = " Cancelled " },
new WorkOrder { Id = 111, ServiceId = serviceId, AccountId = accountId, LegacyStatus = "On Hold" }
};
[Fact]
public async Task LinkedWorkOrders_UnknownTemplateOrMissingScope_Throws()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
await context.SaveChangesAsync();
var service = NewService(context);
var noScope = new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.NameIdentifier, DispatcherId), new Claim(ClaimTypes.Role, "Dispatcher") },
authenticationType: "test"));
var missing = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetLinkedWorkOrdersAsync(Admin(), 999, CancellationToken.None));
var forbidden = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.GetLinkedWorkOrdersAsync(noScope, 10, CancellationToken.None));
Assert.Equal("NotFound", missing.Code);
Assert.Equal("Forbidden", forbidden.Code);
}
[Fact]
public async Task Delete_ClearsServiceLinksKeepsDocumentRequirementAndHidesTemplate()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
context.Services.AddRange(
new Service { Id = 1, Name = "Backflow", NormalizedName = "BACKFLOW", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 },
new Service { Id = 2, Name = "Unrelated", NormalizedName = "UNRELATED", RequiresCompletionDocument = true, CompletionDocTemplateId = null });
await context.SaveChangesAsync();
var service = NewService(context);
await service.DeleteAsync(Admin(), 10, CancellationToken.None);
var linked = context.Services.Single(s => s.Id == 1);
Assert.Null(linked.CompletionDocTemplateId);
Assert.True(linked.RequiresCompletionDocument);
Assert.True(context.Services.Single(s => s.Id == 2).RequiresCompletionDocument);
var template = context.CompletionDocTemplates.Single();
Assert.True(template.IsDeleted);
Assert.Equal(AdminId, template.DeleterUserId);
Assert.Null(await service.GetAsync(10, CancellationToken.None));
Assert.Empty(await service.ListAsync(null, null, null, CancellationToken.None));
var again = await Assert.ThrowsAsync<WorkOrderBoardValidationException>(() =>
service.DeleteAsync(Admin(), 10, CancellationToken.None));
Assert.Equal("NotFound", again.Code);
}
[Fact]
public async Task Reads_ForwardCancellation()
{
using var context = NewContext();
var service = NewService(context);
await service.CreateAsync(Admin(), Body(), CancellationToken.None);
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.ListAsync(null, null, null, cts.Token));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
service.CreateAsync(Admin(), Body("Cancelled"), cts.Token));
Assert.Single(context.CompletionDocTemplates);
}
// Relational evidence (SQLite enforces foreign keys; the in-memory provider does not).
[Fact]
public async Task Relational_DeleteOfLinkedTemplateSucceedsAndUnlinksService()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using var context = await NewSqliteContextAsync(connection);
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
var template = new CompletionDocTemplate { Name = "Backflow" };
context.CompletionDocTemplates.Add(template);
await context.SaveChangesAsync();
context.Services.Add(new Service
{
Name = "Backflow",
NormalizedName = "BACKFLOW",
RequiresCompletionDocument = true,
CompletionDocTemplateId = template.Id
});
await context.SaveChangesAsync();
var service = NewService(context);
await service.DeleteAsync(Admin(), template.Id, CancellationToken.None);
context.ChangeTracker.Clear();
var stored = await context.Services.SingleAsync();
Assert.Null(stored.CompletionDocTemplateId);
Assert.True(stored.RequiresCompletionDocument);
Assert.True((await context.CompletionDocTemplates.SingleAsync()).IsDeleted);
}
[Fact]
public async Task Relational_LinkedWorkOrdersExcludeTerminalAndLegacyTerminalRows()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using var context = await NewSqliteContextAsync(connection);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 1);
await WorkOrderAccountTestHelpers.EnsureAccountAsync(context, 2, "Other Corp");
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
context.Services.Add(new Service
{
Id = 1,
Name = "Backflow",
NormalizedName = "BACKFLOW",
RequiresCompletionDocument = true,
CompletionDocTemplateId = 10
});
context.workOrders.AddRange(
new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled },
new WorkOrder { Id = 102, ServiceId = 1, AccountId = 1 },
new WorkOrder { Id = 103, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Completed },
new WorkOrder { Id = 104, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Canceled },
new WorkOrder { Id = 205, ServiceId = 1, AccountId = 2, LifecycleStatus = LifecycleStatus.OnSite });
context.workOrders.AddRange(LegacyStatusWorkOrders(serviceId: 1, accountId: 1));
await context.SaveChangesAsync();
var data = new CompletionDocTemplateDataService(context);
var tenantOne = await data.GetOpenLinkedWorkOrderIdsAsync(10, 1, CancellationToken.None);
var orgWide = await data.GetOpenLinkedWorkOrderIdsAsync(10, null, CancellationToken.None);
Assert.Equal(new[] { 101, 102, 111 }, tenantOne);
Assert.Equal(new[] { 101, 102, 111, 205 }, orgWide);
}
[Fact]
public async Task Relational_UpdateReplacesProceduresInOrder()
{
await using var connection = new SqliteConnection("DataSource=:memory:");
await connection.OpenAsync();
await using var context = await NewSqliteContextAsync(connection);
SeedUser(context, AdminId, "Admin", "Jane", "Doe");
await context.SaveChangesAsync();
var service = NewService(context);
var created = await service.CreateAsync(
Admin(), Body("T", null, ("A", "a"), ("B", "b")), CancellationToken.None);
context.ChangeTracker.Clear();
var updated = await service.UpdateAsync(
Admin(), created.Id, Body("T", null, ("B", "b"), ("C", "c"), ("A", "a")), CancellationToken.None);
Assert.Equal(new[] { "B", "C", "A" }, updated.Procedures.Select(p => p.Name));
Assert.Equal("Jane Doe", updated.CreatedByName);
context.ChangeTracker.Clear();
Assert.Equal(3, await context.CompletionDocTemplateProcedures.CountAsync());
}
private static async Task<ApplicationDbContext> NewSqliteContextAsync(SqliteConnection connection)
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(connection)
.Options;
var context = new SqliteTemplateTestDbContext(options);
await context.Database.EnsureCreatedAsync();
return context;
}
private sealed class SqliteTemplateTestDbContext : ApplicationDbContext
{
public SqliteTemplateTestDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// SQL Server filtered index syntax is invalid on SQLite.
foreach (var index in builder.Model.GetEntityTypes().SelectMany(e => e.GetIndexes()))
{
if (index.GetFilter() != null)
index.SetFilter(null);
}
// SQLite has no rowversion type; treat as plain nullable blobs.
foreach (var entityType in new[] { typeof(WorkOrder), typeof(Dispatch) })
{
var property = builder.Entity(entityType).Property("RowVersion").Metadata;
property.ValueGenerated = Microsoft.EntityFrameworkCore.Metadata.ValueGenerated.Never;
property.IsConcurrencyToken = false;
}
}
}
// HTTP contract of the template endpoints.
private static WorkOrderCompletionController NewController(ApplicationDbContext context, ClaimsPrincipal user) =>
new(null!, NewService(context), null!)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = user } }
};
[Fact]
public async Task Controller_MapsTitleRequiredTo422AndForbiddenTo403()
{
using var context = NewContext();
var blank = await NewController(context, Admin())
.CreateCompletionTemplate(Body(" "), CancellationToken.None);
var denied = await NewController(context, Dispatcher())
.CreateCompletionTemplate(Body("Allowed title"), CancellationToken.None);
var missing = await NewController(context, Admin())
.DeleteCompletionTemplate(999, CancellationToken.None);
var unprocessable = Assert.IsType<UnprocessableEntityObjectResult>(blank);
var error = Assert.IsType<WorkOrderBoardValidationErrorDto>(unprocessable.Value);
Assert.Equal("TitleRequired", error.Code);
Assert.Equal("Document Title is required.", error.Message);
var forbidden = Assert.IsType<ObjectResult>(denied);
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
Assert.IsType<NotFoundObjectResult>(missing);
}
[Fact]
public async Task Controller_LinkedWorkOrdersAndDelete_ReturnContractShapes()
{
using var context = NewContext();
context.CompletionDocTemplates.Add(new CompletionDocTemplate { Id = 10, Name = "Backflow" });
context.Services.Add(new Service { Id = 1, Name = "B", NormalizedName = "B", RequiresCompletionDocument = true, CompletionDocTemplateId = 10 });
context.workOrders.Add(new WorkOrder { Id = 101, ServiceId = 1, AccountId = 1, LifecycleStatus = LifecycleStatus.Scheduled });
await context.SaveChangesAsync();
var controller = NewController(context, Admin());
var linked = await controller.GetCompletionTemplateLinkedWorkOrders(10, CancellationToken.None);
var deleted = await controller.DeleteCompletionTemplate(10, CancellationToken.None);
var body = Assert.IsType<CompletionDocTemplateLinkedWorkOrdersDto>(Assert.IsType<OkObjectResult>(linked).Value);
Assert.Equal(1, body.Count);
Assert.Equal(new[] { 101 }, body.WorkOrderIds);
Assert.IsType<NoContentResult>(deleted);
}
// Migration shape (G6 evidence): additive only.
[Fact]
public void Migration_Up_AddsBoundedNoteAndCascadingProcedureTableOnly()
{
var operations = BuildOperations("Up");
var column = Assert.Single(operations.OfType<AddColumnOperation>());
Assert.Equal("CompletionDocTemplates", column.Table);
Assert.Equal("ExtraSafetyNote", column.Name);
Assert.True(column.IsNullable);
Assert.Equal(2000, column.MaxLength);
var table = Assert.Single(operations.OfType<CreateTableOperation>());
Assert.Equal("CompletionDocTemplateProcedures", table.Name);
Assert.Contains(table.Columns, c => c.Name == "Name" && c.MaxLength == 200 && !c.IsNullable);
var fk = Assert.Single(table.ForeignKeys);
Assert.Equal("CompletionDocTemplates", fk.PrincipalTable);
Assert.Equal(ReferentialAction.Cascade, fk.OnDelete);
var index = Assert.Single(operations.OfType<CreateIndexOperation>());
Assert.Equal(new[] { "CompletionDocTemplateId", "SortOrder" }, index.Columns);
Assert.Equal(3, operations.Count);
}
[Fact]
public void Migration_Down_DropsOnlyWhatUpAdded()
{
var operations = BuildOperations("Down");
Assert.Equal("CompletionDocTemplateProcedures", Assert.Single(operations.OfType<DropTableOperation>()).Name);
var dropped = Assert.Single(operations.OfType<DropColumnOperation>());
Assert.Equal(("CompletionDocTemplates", "ExtraSafetyNote"), (dropped.Table, dropped.Name));
Assert.Equal(2, operations.Count);
}
private static List<MigrationOperation> BuildOperations(string direction)
{
var builder = new MigrationBuilder("Microsoft.EntityFrameworkCore.SqlServer");
var operation = typeof(Migration).GetMethod(direction, BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException($"Migration.{direction} not found.");
operation.Invoke(new CompletionDocTemplateProcedures(), [builder]);
return builder.Operations;
}
}

View file

@ -5,7 +5,6 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using SeaHaven.DataServices.Implementation;
namespace SeaHavenIndustries.Tests;
@ -84,150 +83,6 @@ public class SH138SiteContactsSqlServerTests
}
}
[Fact]
public async Task SH138_LocationDelete_DetachesSoftDeletedContacts_WithRestrictFk_WhenLocalDbAvailable()
{
var masterConnectionString = await ResolveMasterConnectionStringAsync();
if (masterConnectionString == null)
return;
var dbName = $"SH138LocationDelete_{Guid.NewGuid():N}";
var connectionString = WithDatabase(masterConnectionString, dbName);
try
{
await CreateDatabaseAsync(masterConnectionString, dbName);
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using (var createTables = connection.CreateCommand())
{
createTables.CommandText =
"""
CREATE TABLE Locations (
Id int NOT NULL IDENTITY PRIMARY KEY,
AccountId int NULL,
Title nvarchar(max) NULL,
Name nvarchar(max) NULL,
Latitude nvarchar(max) NULL,
Longitude nvarchar(max) NULL,
Address1 nvarchar(max) NULL,
Address2 nvarchar(max) NULL,
City nvarchar(max) NULL,
State nvarchar(max) NULL,
Zip nvarchar(max) NULL,
PhoneNumber nvarchar(max) NULL,
Email nvarchar(max) NULL,
Status nvarchar(max) NULL,
ExternalSource nvarchar(max) NULL,
ExternalLocationId nvarchar(max) NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL
);
CREATE TABLE Contacts (
Id int NOT NULL IDENTITY PRIMARY KEY,
AccountId int NULL,
LocationId int NULL,
Title nvarchar(max) NULL,
Owner nvarchar(max) NULL,
FirstName nvarchar(max) NULL,
MiddleName nvarchar(max) NULL,
LastName nvarchar(max) NULL,
ContactType nvarchar(max) NULL,
PhoneNumber nvarchar(max) NULL,
Email nvarchar(max) NULL,
Address1 nvarchar(max) NULL,
Address2 nvarchar(max) NULL,
City nvarchar(max) NULL,
State nvarchar(max) NULL,
Zip nvarchar(max) NULL,
FacebookUrl nvarchar(max) NULL,
LinkedInUrl nvarchar(max) NULL,
TwitterUrl nvarchar(max) NULL,
IsDeleted bit NULL,
createdby nvarchar(max) NULL,
DeleterUserId nvarchar(max) NULL,
DeletionTime datetime2 NULL,
CreatedDate datetime2 NULL,
LastModificationTime datetime2 NULL,
LastModifierUserId int NULL,
SiteContactOrder int NULL,
CONSTRAINT FK_Contacts_Locations_LocationId FOREIGN KEY (LocationId) REFERENCES Locations (Id) ON DELETE NO ACTION
);
SET IDENTITY_INSERT Locations ON;
INSERT INTO Locations (Id, Name) VALUES (11, 'Depot');
SET IDENTITY_INSERT Locations OFF;
INSERT INTO Contacts (LocationId, FirstName, PhoneNumber, SiteContactOrder) VALUES (11, 'Alice Cooper', '555-0100', 0);
INSERT INTO Contacts (LocationId, FirstName, PhoneNumber, IsDeleted, DeleterUserId, DeletionTime) VALUES (11, 'Bob Dillon', '555-0200', 1, 'actor-1', '2026-01-01T00:00:00');
""";
await createTables.ExecuteNonQueryAsync();
}
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(connection)
.Options;
await using var context = new ApplicationDbContext(options);
var service = new LocationDataService(context);
var deleted = await service.DeleteByIdAsync(11, CancellationToken.None);
Assert.True(deleted);
await using (var locationCmd = connection.CreateCommand())
{
locationCmd.CommandText = "SELECT COUNT(*) FROM Locations;";
var locationCount = await locationCmd.ExecuteScalarAsync();
Assert.Equal(0, Convert.ToInt32(locationCount));
}
await using (var activeCmd = connection.CreateCommand())
{
activeCmd.CommandText =
"""
SELECT FirstName, PhoneNumber, LocationId, IsDeleted, DeleterUserId, DeletionTime
FROM Contacts
WHERE FirstName = N'Alice Cooper';
""";
await using var reader = await activeCmd.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.Equal("Alice Cooper", reader.GetString(0));
Assert.Equal("555-0100", reader.GetString(1));
Assert.True(reader.IsDBNull(2));
Assert.True(reader.GetBoolean(3));
Assert.True(reader.IsDBNull(4), "delete carries no audit actor");
Assert.False(reader.IsDBNull(5));
Assert.False(await reader.ReadAsync());
}
await using (var previouslyDeletedCmd = connection.CreateCommand())
{
previouslyDeletedCmd.CommandText =
"""
SELECT LocationId, IsDeleted, DeleterUserId, DeletionTime
FROM Contacts
WHERE FirstName = N'Bob Dillon';
""";
await using var reader = await previouslyDeletedCmd.ExecuteReaderAsync();
Assert.True(await reader.ReadAsync());
Assert.True(reader.IsDBNull(0), "already soft-deleted rows detach so the restrict FK cannot block the delete");
Assert.True(reader.GetBoolean(1));
Assert.Equal("actor-1", reader.GetString(2));
Assert.Equal(new DateTime(2026, 1, 1), reader.GetDateTime(3));
Assert.False(await reader.ReadAsync());
}
}
finally
{
await DropDatabaseAsync(masterConnectionString, dbName);
}
}
private static async Task ApplyMigrationUpAsync(ApplicationDbContext context, Migration migration)
{
var builder = new MigrationBuilder(context.Database.ProviderName!);

View file

@ -849,7 +849,19 @@ public class WorkOrderAccountScopeTests
=> _inner.AddAsync(location, cancellationToken);
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)
=> _inner.UpdateAsync(location, cancellationToken);
public Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
=> _inner.DeleteByIdAsync(id, cancellationToken);
public Task<bool> SiteCodeExistsAsync(
string siteCode,
int? accountId,
int? excludeLocationId,
CancellationToken cancellationToken)
=> _inner.SiteCodeExistsAsync(siteCode, accountId, excludeLocationId, cancellationToken);
public Task<(int Count, IReadOnlyList<int> Ids)> GetOpenWorkOrderIdsAsync(
int locationId,
string? siteCode,
int? siteAccountId,
int? callerAccountId,
int maxIds,
CancellationToken cancellationToken)
=> _inner.GetOpenWorkOrderIdsAsync(locationId, siteCode, siteAccountId, callerAccountId, maxIds, cancellationToken);
}
}

View file

@ -383,16 +383,13 @@ public class WorkOrderOverdueTypeTests
});
await context.SaveChangesAsync();
var service = new WorkOrderCompletionService(
new WorkOrderCompletionDataService(context),
var service = new CompletionDocTemplateService(
new CompletionDocTemplateDataService(context),
new WorkOrderDetailDataService(context),
new WorkOrderAuditService(
new WorkOrderAuditDataService(context),
new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))),
new TeamPermissionOverrideDataService(context),
new TeamPermissionPolicy(),
WorkOrderAccountTestHelpers.Resolver(context));
var templates = await service.GetTemplatesAsync(null, WorkOrderType.Overdue);
var templates = await service.ListAsync(null, null, WorkOrderType.Overdue, CancellationToken.None);
Assert.Equal(new[] { "PM Completion" }, templates.Select(t => t.Name).ToArray());
}

View file

@ -31,7 +31,8 @@ builder.Services.AddAuthentication(options =>
.AddIdentityCookies();
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options => {
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(connectionString);
}, ServiceLifetime.Transient);
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
@ -39,11 +40,7 @@ builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
IdentityPasswordPolicy.Apply(options.Password);
}).AddRoles<IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>().AddSignInManager()
.AddDefaultTokenProviders();