Sites API: site code uniqueness, soft delete with role check, open work orders, site notes

- Reject duplicate site codes per client (case-insensitive); site code is immutable once set
- Delete tombstones the site and requires the DeleteSites permission (Admin, Scheduler)
- GET /api/locations/{id}/open-work-orders returns the open count and ids
- PATCH /api/locations/{id}/contact-info saves contacts and notes from the work-order Site dialog
- Add nullable Locations.Notes, used as the site-level POC notes fallback
This commit is contained in:
Alexandre Brandizzi 2026-09-25 11:19:29 -03:00
parent 06eae2fb02
commit c3865e56ac
21 changed files with 5207 additions and 137 deletions

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

@ -28,7 +28,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")
{
@ -211,7 +213,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 +241,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 +267,7 @@ public class LocationServiceTests
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
{
Name = "New",
Name = "Old",
City = "Plano"
}, OrgWideAdmin(), CancellationToken.None);
@ -301,12 +305,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);
}
@ -476,7 +480,9 @@ 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();
@ -520,20 +526,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

@ -35,7 +35,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()
{
@ -54,6 +56,7 @@ public class LocationSiteContactsTests
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Depot",
Phone = "555-9000",
AccountId = null,
Contacts = new List<SiteContactRequestDTO>
{
@ -65,7 +68,7 @@ public class LocationSiteContactsTests
}
[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 });
@ -83,7 +86,7 @@ public class LocationSiteContactsTests
}, 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);
@ -220,7 +223,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 +242,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 +307,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 +451,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 +494,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 +552,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 +580,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

@ -0,0 +1,449 @@
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", null, null, token)).ReturnsAsync(true);
var service = NewService(data.Object, Mock.Of<IAccountDataService>());
var act = () => service.CreateLocationFromRequestAsync(CreateRequest("BK5", null), OrgWide(), token);
await act.Should().ThrowAsync<SiteCodeConflictException>();
data.Verify(d => d.SiteCodeExistsAsync("BK5", null, 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");
}
}

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

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

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

@ -1582,6 +1582,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)");

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

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

@ -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,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
@ -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);
@ -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))
{
@ -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(
@ -241,31 +245,67 @@ namespace SeaHaven.DataServices.Implementation
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,

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

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

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

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

@ -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
@ -263,9 +274,20 @@ namespace SeaHaven.Services.Implementation
{
await EnsureAccountAssignableAsync(user, request.AccountId, existingLocationAccountId: null, cancellationToken);
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,7 +296,8 @@ 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)
@ -297,7 +320,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 +327,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 +349,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))
@ -414,9 +577,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(
@ -489,9 +649,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 +701,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

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