feat(locations): manage ordered site contacts

This commit is contained in:
Alexandre Brandizzi 2026-09-14 21:44:45 -03:00
parent 802b7a414e
commit 4872fe5ba1
15 changed files with 4905 additions and 29 deletions

View file

@ -0,0 +1,581 @@
using System.Security.Claims;
using System.Text.Json;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations.Operations;
using Microsoft.Extensions.Logging;
using Moq;
using SeaHaven.DataServices.Implementation;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Validation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class LocationSiteContactsTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static LocationService NewService(ApplicationDbContext ctx) =>
new(
new LocationDataService(ctx),
new AccountDataService(ctx),
new CreateLocationValidation(),
new UpdateLocationValidation());
private static ClaimsPrincipal OrgWideAdmin()
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "admin-1"),
new(ClaimTypes.Role, "Admin"),
new(SeaHavenClaimTypes.OrgScope, SeaHavenClaimTypes.OrgScopeAll)
};
return new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
}
private static async Task<Locations> SeedLocationWithContactsAsync(ApplicationDbContext ctx)
{
var service = NewService(ctx);
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Depot",
AccountId = null,
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200"}
}
}, OrgWideAdmin(), CancellationToken.None);
return ctx.Locations.Include(l => l.Contacts).Single();
}
[Fact]
public async Task Create_WithContacts_PersistsTrimmedOrderedRows_MirrorsFirstPhone_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
{
Name = "Warehouse",
AccountId = 7,
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = " Alice Cooper ", Phone = " 555-0100 " },
new() { Name = "Bob Dillon", Phone = "555-0200" }
}
}, OrgWideAdmin(), CancellationToken.None);
var location = ctx.Locations.Include(l => l.Contacts).Single();
location.PhoneNumber.Should().Be("555-0100", "first contact mirrors Location.PhoneNumber");
var contacts = location.Contacts.OrderBy(c => c.SiteContactOrder).ToList();
contacts.Should().HaveCount(2);
contacts[0].FirstName.Should().Be("Alice Cooper");
contacts[0].MiddleName.Should().BeNull();
contacts[0].LastName.Should().BeNull();
contacts[0].PhoneNumber.Should().Be("555-0100");
contacts[0].SiteContactOrder.Should().Be(0);
contacts[0].AccountId.Should().Be(7, "AccountId must come from the location");
contacts[0].LocationId.Should().Be(location.Id);
contacts[0].createdby.Should().Be("admin-1");
contacts[0].CreatedDate.Should().NotBeNull();
contacts[1].SiteContactOrder.Should().Be(1);
}
[Fact]
public async Task Create_EmptyContactsArray_IsExplicitValidationError_AndWritesNothing()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>()
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
ctx.Locations.Should().BeEmpty();
ctx.Contacts.Should().BeEmpty();
}
[Theory]
[InlineData(null, "555-0100", "Contact name is required.")]
[InlineData(" ", "555-0100", "Contact name is required.")]
[InlineData("Alice", null, "Contact phone is required.")]
[InlineData("Alice", " ", "Contact phone is required.")]
[InlineData("Alice", "555-0100", null)]
public async Task Create_ValidatesEachRow(string? name, string? phone, string? expectedError)
{
using var ctx = NewContext();
var contacts = new List<SiteContactRequestDTO> { new() { Name = name, Phone = phone } };
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
if (expectedError == null)
{
await act.Should().NotThrowAsync();
return;
}
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().Contain(e => e.ErrorMessage == expectedError);
}
[Fact]
public async Task Create_RejectsOversizedNameAndPhone()
{
using var ctx = NewContext();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = new string('x', 101), Phone = new string('5', 21) }
}
}, OrgWideAdmin(), CancellationToken.None);
var thrown = (await act.Should().ThrowAsync<FluentValidation.ValidationException>()).Which;
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 100"));
thrown.Errors.Should().Contain(e => e.ErrorMessage.Contains("cannot exceed 20"));
}
[Fact]
public async Task Create_RejectsMoreThanTwentyContacts()
{
using var ctx = NewContext();
var contacts = Enumerable.Range(1, 21)
.Select(i => new SiteContactRequestDTO { Name = $"C{i}", Phone = "555-0100" })
.ToList();
var act = () => NewService(ctx).CreateLocationFromRequestAsync(new LocationCreateRequestDTO
{
Name = "Warehouse",
Contacts = contacts
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("cannot exceed 20"));
}
[Fact]
public async Task Update_ContactsNull_KeepsLegacyBehavior_AndDoesNotMutateContactRows()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var before = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Phone = "555-9999"
}, OrgWideAdmin(), CancellationToken.None);
var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
after.Should().HaveCount(before.Count);
after[0].FirstName.Should().Be(before[0].FirstName);
after[0].PhoneNumber.Should().Be(before[0].PhoneNumber);
after[0].SiteContactOrder.Should().Be(before[0].SiteContactOrder);
after[0].IsDeleted.Should().Be(before[0].IsDeleted);
ctx.Locations.Single().PhoneNumber.Should().Be("555-9999", "legacy updates keep request.Phone");
}
[Fact]
public async Task Update_EmptyContactsArray_IsExplicitValidationError()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>()
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName == "Contacts");
ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true);
}
[Fact]
public async Task Update_ReordersAndSoftDeletes_DensifiesOrder_AndUpdatesMirror()
{
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");
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>
{
new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" },
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0300" },
new() { Name = "Cara Lane", Phone = "555-0400" }
}
}, 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");
var active = location.Contacts.Where(c => c.IsDeleted != true).OrderBy(c => c.SiteContactOrder).ToList();
active.Should().HaveCount(3);
active.Select(c => c.FirstName).Should().ContainInOrder("Bob Dillon", "Alice Cooper", "Cara Lane");
active.Select(c => c.SiteContactOrder).Should().ContainInOrder(new int?[] { 0, 1, 2 }, "order is densified from zero");
active[1].PhoneNumber.Should().Be("555-0300");
active[0].Id.Should().Be(bob.Id);
active[1].Id.Should().Be(alice.Id, "existing ids are preserved");
active[2].AccountId.Should().Be(location.AccountId);
}
[Fact]
public async Task Update_SoftDeletesRemovedContacts_WithAuditFields_KeepingRowsReadable()
{
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");
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } }
}, OrgWideAdmin(), CancellationToken.None);
var removed = ctx.Contacts.AsNoTracking().Single(c => c.Id == bob.Id);
removed.IsDeleted.Should().BeTrue();
removed.DeleterUserId.Should().Be("admin-1");
removed.DeletionTime.Should().NotBeNull();
removed.LastModificationTime.Should().NotBeNull();
removed.FirstName.Should().Be("Bob Dillon", "soft deleted rows keep data so historical WorkOrderContacts still render");
removed.PhoneNumber.Should().Be("555-0200");
var kept = ctx.Contacts.AsNoTracking().Single(c => c.Id == alice.Id);
kept.IsDeleted.Should().NotBeTrue();
}
[Fact]
public async Task Update_ForeignLocationContactId_RejectedWithoutMutation()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
ctx.Locations.Add(new Locations { Id = seeded.Id + 1, Name = "Other" });
ctx.Contacts.Add(new Contacts { Id = 901, LocationId = seeded.Id + 1, FirstName = "Foreign", PhoneNumber = "555-0900" });
await ctx.SaveChangesAsync();
var snapshot = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>
{
new() { Name = "Alice Cooper", Phone = "555-0100" },
new() { Id = 901, Name = "Foreign", Phone = "555-0900" }
}
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
var after = ctx.Contacts.AsNoTracking().OrderBy(c => c.Id).ToList();
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");
}
[Fact]
public async Task Update_DeletedContactId_Rejected()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var bob = seeded.Contacts.Single(c => c.FirstName == "Bob Dillon");
bob.IsDeleted = true;
await ctx.SaveChangesAsync();
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = bob.Id, Name = "Bob Dillon", Phone = "555-0200" } }
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
}
[Fact]
public async Task Update_AccountLevelContactId_Rejected()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
ctx.Contacts.Add(new Contacts { Id = 902, AccountId = 55, LocationId = null, FirstName = "AccountOnly", PhoneNumber = "555-0900" });
await ctx.SaveChangesAsync();
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = 902, Name = "AccountOnly", Phone = "555-0900" } }
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.PropertyName.Contains("Id"));
}
[Fact]
public async Task Update_DuplicateContactIds_Rejected()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var alice = seeded.Contacts.Single(c => c.FirstName == "Alice Cooper");
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO>
{
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" },
new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" }
}
}, OrgWideAdmin(), CancellationToken.None);
(await act.Should().ThrowAsync<FluentValidation.ValidationException>())
.Which.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("more than once"));
}
[Fact]
public async Task Update_AccountScopedCaller_CannotAttachContactsToForeignLocation()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
seeded.AccountId = 99;
await ctx.SaveChangesAsync();
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, "actor-1"),
new(ClaimTypes.Role, "Dispatcher"),
new(SeaHavenClaimTypes.AccountId, "4")
};
var caller = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Name = "Nope", Phone = "555-0000" } }
}, caller, CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedAccessException>();
ctx.Contacts.Should().OnlyContain(c => c.IsDeleted != true);
}
[Fact]
public async Task Detail_ExcludesDeletedContacts_AndOrdersBySiteContactOrderThenId()
{
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");
await NewService(ctx).UpdateLocationFromRequestAsync(seeded.Id, new LocationUpdateRequestDTO
{
Name = "Depot",
Contacts = new List<SiteContactRequestDTO> { new() { Id = alice.Id, Name = "Alice Cooper", Phone = "555-0100" } }
}, OrgWideAdmin(), CancellationToken.None);
var detail = await NewService(ctx).GetLocationDetailAsync(seeded.Id, CancellationToken.None);
detail!.Contacts.Should().ContainSingle().Which.Id.Should().Be(alice.Id);
}
[Fact]
public async Task Detail_OrdersContactsByOrderThenId_WhenOrdersAreMissing()
{
using var ctx = NewContext();
var loc = new Locations { Id = 5, Name = "Legacy", AccountId = 7 };
ctx.Locations.Add(loc);
ctx.Contacts.AddRange(
new Contacts { Id = 31, LocationId = 5, AccountId = 7, FirstName = "No Order B", PhoneNumber = "555-2" },
new Contacts { Id = 30, LocationId = 5, AccountId = 7, FirstName = "No Order A", PhoneNumber = "555-1" });
await ctx.SaveChangesAsync();
var detail = await NewService(ctx).GetLocationDetailAsync(5, CancellationToken.None);
detail!.Contacts!.Select(c => c.Name).Should().ContainInOrder("No Order A", "No Order B");
}
[Fact]
public async Task List_LoadsPageContactsInSingleBatchedRead()
{
var data = new Mock<ILocationDataService>();
data.Setup(d => d.GetListPagedAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string?>(), It.IsAny<IReadOnlyCollection<string>?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((new List<Locations>
{
new() { Id = 1, Name = "One" },
new() { Id = 2, Name = "Two" }
}, 2));
data.Setup(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Contacts>
{
new() { Id = 10, LocationId = 1, SiteContactOrder = 1, FirstName = "Second", PhoneNumber = "555-2" },
new() { Id = 11, LocationId = 1, SiteContactOrder = 0, FirstName = "First", PhoneNumber = "555-1" },
new() { Id = 12, LocationId = 2, SiteContactOrder = 0, FirstName = "Solo", PhoneNumber = "555-3" }
});
var service = new LocationService(
data.Object,
Mock.Of<IAccountDataService>(),
new CreateLocationValidation(),
new UpdateLocationValidation());
var page = await service.GetLocationListPagedAsync(1, 10, null, cancellationToken: CancellationToken.None);
page.Items.Should().HaveCount(2);
page.Items.Single(i => i.Id == 1).Contacts!.Select(c => c.Name)
.Should().ContainInOrder(new[] { "First", "Second" }, "contacts are ordered per location");
page.Items.Single(i => i.Id == 2).Contacts!.Select(c => c.Name).Should().ContainSingle("Solo");
data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
data.Verify(d => d.GetSiteContactsByLocationIdsAsync(It.Is<IReadOnlyCollection<int>>(ids => ids.Contains(1) && ids.Contains(2)), It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task DataService_GetSiteContactsByLocationIdsAsync_ExcludesDeleted_AndOrders()
{
using var ctx = NewContext();
ctx.Contacts.AddRange(
new Contacts { Id = 1, LocationId = 5, SiteContactOrder = 1, FirstName = "B" },
new Contacts { Id = 2, LocationId = 5, SiteContactOrder = 0, FirstName = "A" },
new Contacts { Id = 3, LocationId = 5, SiteContactOrder = 0, FirstName = "Tie", IsDeleted = true },
new Contacts { Id = 4, LocationId = 6, SiteContactOrder = 0, FirstName = "Other" });
await ctx.SaveChangesAsync();
var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(new[] { 5 }, CancellationToken.None);
rows.Select(r => r.FirstName).Should().ContainInOrder("A", "B");
rows.Should().OnlyContain(r => r.IsDeleted != true);
}
[Fact]
public async Task DataService_GetSiteContactsByLocationIdsAsync_EmptyInput_ReturnsWithoutQuerying()
{
using var ctx = NewContext();
ctx.Contacts.Add(new Contacts { Id = 1, LocationId = 5, FirstName = "A" });
await ctx.SaveChangesAsync();
var rows = await new LocationDataService(ctx).GetSiteContactsByLocationIdsAsync(Array.Empty<int>(), CancellationToken.None);
rows.Should().BeEmpty();
}
private sealed class ExposedSH138Migration : Data.SeaHavenIndustries.Migrations.SH138_SiteContacts
{
public void UpExposed(MigrationBuilder builder) => Up(builder);
public void DownExposed(MigrationBuilder builder) => Down(builder);
}
[Fact]
public void Migration_SH138_IsAdditiveOnly()
{
var migration = new ExposedSH138Migration();
var up = new MigrationBuilder("SqlServer");
migration.UpExposed(up);
up.Operations.Should().ContainSingle("the migration must only add the SiteContactOrder column");
var column = up.Operations.Single().Should().BeOfType<AddColumnOperation>().Subject;
column.Table.Should().Be("Contacts");
column.Name.Should().Be("SiteContactOrder");
column.IsNullable.Should().BeTrue("existing rows and legacy contacts have no order");
column.ClrType.Should().Be(typeof(int));
var down = new MigrationBuilder("SqlServer");
migration.DownExposed(down);
down.Operations.Should().ContainSingle("reverting drops only the added column");
down.Operations.Single().Should().BeOfType<DropColumnOperation>();
}
[Fact]
public void Migration_SH138_HasNoReorderIndexOrFkChange()
{
var migration = new ExposedSH138Migration();
var up = new MigrationBuilder("SqlServer");
migration.UpExposed(up);
up.Operations.Should().NotContain(o => o is CreateIndexOperation, "no unique reorder index");
up.Operations.Should().NotContain(o => o is AddForeignKeyOperation || o is DropForeignKeyOperation, "the existing restrict relationship is unchanged");
up.Operations.Should().NotContain(o => o is DropColumnOperation || o is DropTableOperation || o is SqlOperation, "no destructive or backfill operations");
}
[Fact]
public async Task Controller_Detail_ProjectsContactsAndCompatibilityFields()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var controller = new LocationController(
NewService(ctx),
Mock.Of<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.GetLocationById(seeded.Id, CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
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("Contact").GetString().Should().Be("Alice Cooper", "Contact is the first contact display name");
var contacts = root.GetProperty("Contacts");
contacts.GetArrayLength().Should().Be(2);
contacts[0].GetProperty("Name").GetString().Should().Be("Alice Cooper");
contacts[0].GetProperty("Phone").GetString().Should().Be("555-0100");
contacts[1].GetProperty("Name").GetString().Should().Be("Bob Dillon");
}
[Fact]
public async Task Controller_List_ProjectsFirstContactAsLegacyContactField()
{
using var ctx = NewContext();
var seeded = await SeedLocationWithContactsAsync(ctx);
var controller = new LocationController(
NewService(ctx),
Mock.Of<ILogger<LocationController>>())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.GetLocationList(cancellationToken: CancellationToken.None);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
using var json = JsonSerializer.SerializeToDocument(ok.Value);
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("Contacts").GetArrayLength().Should().Be(2);
}
}

View file

@ -54,10 +54,11 @@ namespace Api.SeaHavenIndustries.Controllers
State = l.State,
ZipCode = l.Zip,
Phone = l.PhoneNumber,
Contact = (string?)null,
Contact = l.Contacts?.FirstOrDefault()?.Name,
ContactEmail = l.Email,
Status = l.Status,
AccountId = l.AccountId
AccountId = l.AccountId,
Contacts = l.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
});
var viewModel = new Pagination_DTO
@ -98,10 +99,11 @@ namespace Api.SeaHavenIndustries.Controllers
location.State,
ZipCode = location.Zip,
Phone = location.PhoneNumber,
Contact = (string?)null,
Contact = location.Contacts?.FirstOrDefault()?.Name,
ContactEmail = location.Email,
location.Status,
location.AccountId
location.AccountId,
Contacts = location.Contacts?.Select(c => new { c.Id, c.Name, c.Phone }).ToList()
};
return Ok(result);
@ -197,7 +199,8 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = model.Phone,
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId()
AccountId = model.GetAccountId(),
Contacts = MapSiteContacts(model.Contacts)
};
}
@ -214,8 +217,19 @@ namespace Api.SeaHavenIndustries.Controllers
Phone = model.Phone,
ContactEmail = model.ContactEmail,
Status = model.Status,
AccountId = model.GetAccountId()
AccountId = model.GetAccountId(),
Contacts = MapSiteContacts(model.Contacts)
};
}
private static List<SiteContactRequestDTO>? MapSiteContacts(List<SiteContactInput_DTO>? contacts)
{
if (contacts == null)
return null;
return contacts
.Select(c => new SiteContactRequestDTO { Id = c.Id, Name = c.Name, Phone = c.Phone })
.ToList();
}
}
}

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 List<SiteContactInput_DTO>? Contacts { get; set; }
public int? GetAccountId() => LocationAccountIdMapping.ParseOptional(AccountId);

View file

@ -3,6 +3,14 @@ using SeaHaven.Services.DTOs;
namespace Api.SeaHavenIndustries.DTOs
{
// SH-138: additive site-contact write row (id optional on writes)
public class SiteContactInput_DTO
{
public int? Id { get; set; }
public string? Name { get; set; }
public string? Phone { get; set; }
}
public class Location_DTO
{
public string? Title { get; set; }
@ -16,6 +24,7 @@ namespace Api.SeaHavenIndustries.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public string? AccountId { 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 SH138_SiteContacts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "SiteContactOrder",
table: "Contacts",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SiteContactOrder",
table: "Contacts");
}
}
}

View file

@ -651,6 +651,9 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SiteContactOrder")
.HasColumnType("int");
b.Property<string>("State")
.HasColumnType("nvarchar(max)");
@ -2400,6 +2403,10 @@ namespace Data.SeaHavenIndustries.Migrations
b.Property<int?>("AccountId")
.HasColumnType("int");
b.Property<string>("AdditionalContacts")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("AfterPhotoAttachment")
.HasColumnType("nvarchar(max)");
@ -2548,10 +2555,6 @@ namespace Data.SeaHavenIndustries.Migrations
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("AdditionalContacts")
.HasMaxLength(4000)
.HasColumnType("nvarchar(4000)");
b.Property<string>("PocPhone")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
@ -3224,16 +3227,6 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
@ -3294,7 +3287,7 @@ namespace Data.SeaHavenIndustries.Migrations
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("Data.SeaHavenIndustries.Locations", "Location")
.WithMany()
.WithMany("Contacts")
.HasForeignKey("LocationId")
.OnDelete(DeleteBehavior.Restrict);
@ -3474,6 +3467,16 @@ namespace Data.SeaHavenIndustries.Migrations
b.Navigation("WorkOrder");
});
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.HasOne("Data.SeaHavenIndustries.Accounts", "Account")
.WithMany()
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Account");
});
modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b =>
{
b.HasOne("Data.SeaHavenIndustries.Assets", "Asset")
@ -3869,7 +3872,7 @@ namespace Data.SeaHavenIndustries.Migrations
modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b =>
{
b.Navigation("Account");
b.Navigation("Contacts");
b.Navigation("Templates");

View file

@ -30,6 +30,9 @@ namespace Data.SeaHavenIndustries
public int? LocationId { get; set; }
public int? AccountId { get; set; }
// SH-138: dense zero-based display order for site-managed contacts
public int? SiteContactOrder { get; set; }
// Navigation Properties
[ForeignKey(nameof(LocationId))]
public Locations? Location { get; set; }

View file

@ -30,5 +30,8 @@ namespace Data.SeaHavenIndustries
// Navigation Properties
public ICollection<Template>? Templates { get; set; }
public ICollection<WorkOrder>? workOrders { get; set; }
// SH-138: site contacts (soft-deleted rows stay for historical WorkOrderContacts)
public ICollection<Contacts>? Contacts { get; set; }
}
}

View file

@ -166,12 +166,30 @@ namespace SeaHaven.DataServices.Implementation
{
return await _context.Locations
.AsNoTracking()
.Include(l => l.Contacts.Where(c => c.IsDeleted != true))
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
{
return await _context.Locations.FindAsync(new object[] { id }, cancellationToken);
return await _context.Locations
.Include(l => l.Contacts)
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
}
public async Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
IReadOnlyCollection<int> locationIds,
CancellationToken cancellationToken)
{
if (locationIds.Count == 0)
return Array.Empty<Contacts>();
return await _context.Contacts
.AsNoTracking()
.Where(c => c.LocationId != null && locationIds.Contains(c.LocationId.Value) && c.IsDeleted != true)
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.ToListAsync(cancellationToken);
}
public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)

View file

@ -37,6 +37,15 @@ namespace SeaHaven.DataServices.Interfaces
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, IReadOnlyCollection<string>? states, CancellationToken cancellationToken);
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
/// <summary>
/// SH-138: single bounded read of active site contacts for a page of locations,
/// ordered by SiteContactOrder then Id. Deleted contacts are excluded.
/// </summary>
Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
IReadOnlyCollection<int> locationIds,
CancellationToken cancellationToken);
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken);

View file

@ -16,6 +16,25 @@ namespace SeaHaven.Services.DTOs
public int? AccountId { get; set; }
public DateTime? CreatedDate { get; set; }
public string? CreatedBy { get; set; }
/// <summary>SH-138: ordered site contacts; null when the site has none.</summary>
public IReadOnlyList<SiteContactResponseDTO>? Contacts { get; set; }
}
/// <summary>SH-138: additive site-contact write row. Id is optional on writes.</summary>
public class SiteContactRequestDTO
{
public int? Id { get; set; }
public string? Name { get; set; }
public string? Phone { get; set; }
}
/// <summary>SH-138: site-contact read row, ordered by SiteContactOrder then Id.</summary>
public class SiteContactResponseDTO
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Phone { get; set; }
}
public class CreateLocationDTO
@ -50,6 +69,9 @@ namespace SeaHaven.Services.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
/// <summary>SH-138: null keeps legacy behavior; empty array is a validation error.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
}
public class LocationUpdateRequestDTO
@ -64,6 +86,9 @@ namespace SeaHaven.Services.DTOs
public string? ContactEmail { get; set; }
public string? Status { get; set; }
public int? AccountId { get; set; }
/// <summary>SH-138: null keeps legacy behavior and must not mutate contact rows.</summary>
public List<SiteContactRequestDTO>? Contacts { get; set; }
}
public class SiteOptionDTO

View file

@ -49,13 +49,13 @@ namespace SeaHaven.Services.Implementation
public async Task<IEnumerable<LocationDTO>> GetAllLocationsAsync()
{
var locations = await _locationDataService.GetAllAsync();
return locations.Select(MapToDTO);
return locations.Select(l => MapToDTO(l));
}
public async Task<IEnumerable<LocationDTO>> GetLocationsByAccountIdAsync(int accountId)
{
var locations = await _locationDataService.GetByAccountIdAsync(accountId);
return locations.Select(MapToDTO);
return locations.Select(l => MapToDTO(l));
}
public async Task<PagedResult<LocationDTO>> GetLocationsPagedAsync(int page, int pageSize, string? search = null)
@ -63,7 +63,7 @@ namespace SeaHaven.Services.Implementation
var (items, totalCount) = await _locationDataService.GetPagedAsync(page, pageSize, search);
return new PagedResult<LocationDTO>
{
Items = items.Select(MapToDTO).ToList(),
Items = items.Select(l => MapToDTO(l)).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
@ -171,9 +171,13 @@ namespace SeaHaven.Services.Implementation
var stateFilter = NormalizeStateFilter(states);
var storageStateFilter = stateFilter == null ? null : UsStateCodes.ExpandStorageValues(stateFilter);
var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, storageStateFilter, cancellationToken);
// SH-138: one bounded contact read for the whole page, never per row.
var contactsByLocation = await GetSiteContactsByLocationIdsAsync(items, cancellationToken);
return new PagedResult<LocationDTO>
{
Items = items.Select(MapToDTO).ToList(),
Items = items.Select(l => MapToDTO(l, contactsByLocation)).ToList(),
TotalCount = totalCount,
Page = page,
PageSize = pageSize
@ -233,6 +237,11 @@ namespace SeaHaven.Services.Implementation
AccountId = request.AccountId
};
if (request.Contacts is List<SiteContactRequestDTO> contacts)
{
ApplySiteContactsForCreate(location, contacts, GetActorId(user));
}
await _locationDataService.AddAsync(location, cancellationToken);
}
@ -264,6 +273,12 @@ namespace SeaHaven.Services.Implementation
location.AccountId = accountId;
}
// SH-138: null contacts keeps legacy behavior and must not mutate contact rows.
if (request.Contacts is List<SiteContactRequestDTO> contacts)
{
ApplySiteContactsForUpdate(location, contacts, GetActorId(user));
}
await _locationDataService.UpdateAsync(location, cancellationToken);
}
@ -326,7 +341,153 @@ namespace SeaHaven.Services.Implementation
}
// Private helper methods
private LocationDTO MapToDTO(Locations location)
private static string? GetActorId(ClaimsPrincipal user) =>
user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
private static void ThrowOnInvalidContacts(List<SiteContactRequestDTO> contacts)
{
var failures = SiteContactsValidation.Validate(contacts);
if (failures.Count > 0)
throw new ValidationException(failures);
}
private static void ApplySiteContactsForCreate(
Locations location,
List<SiteContactRequestDTO> contacts,
string? actorId)
{
ThrowOnInvalidContacts(contacts);
var now = DateTime.Now;
var siteContacts = contacts
.Select((row, index) => new Contacts
{
FirstName = row.Name!.Trim(),
MiddleName = null,
LastName = null,
PhoneNumber = row.Phone!.Trim(),
SiteContactOrder = index,
AccountId = location.AccountId,
CreatedDate = now,
createdby = actorId
})
.ToList();
location.Contacts = siteContacts;
// The first site contact mirrors Location.PhoneNumber for legacy consumers.
location.PhoneNumber = siteContacts[0].PhoneNumber;
}
private static void ApplySiteContactsForUpdate(
Locations location,
List<SiteContactRequestDTO> contacts,
string? actorId)
{
ThrowOnInvalidContacts(contacts);
var existingById = (location.Contacts ?? Enumerable.Empty<Contacts>())
.ToDictionary(c => c.Id);
var keptIds = new HashSet<int>();
for (var i = 0; i < contacts.Count; i++)
{
var row = contacts[i];
var name = row.Name!.Trim();
var phone = row.Phone!.Trim();
if (row.Id is int id)
{
if (!existingById.TryGetValue(id, out var existing)
|| existing.LocationId != location.Id
|| existing.IsDeleted == true)
{
throw new ValidationException(new[]
{
new ValidationFailure(
$"{nameof(LocationUpdateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Id)}",
$"Contact {id} does not exist, is deleted, or does not belong to this location.")
});
}
keptIds.Add(id);
existing.FirstName = name;
existing.MiddleName = null;
existing.LastName = null;
existing.PhoneNumber = phone;
existing.SiteContactOrder = i;
existing.AccountId = location.AccountId;
existing.LastModificationTime = DateTime.Now;
}
else
{
location.Contacts ??= new List<Contacts>();
location.Contacts.Add(new Contacts
{
FirstName = name,
MiddleName = null,
LastName = null,
PhoneNumber = phone,
SiteContactOrder = i,
AccountId = location.AccountId,
CreatedDate = DateTime.Now,
createdby = actorId
});
}
}
// Rows omitted from the request are soft deleted so historical
// WorkOrderContacts keep rendering.
var now = DateTime.Now;
foreach (var existing in existingById.Values)
{
if (keptIds.Contains(existing.Id) || existing.IsDeleted == true)
continue;
existing.IsDeleted = true;
existing.DeleterUserId = actorId;
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(
List<Locations> items,
CancellationToken cancellationToken)
{
if (items.Count == 0)
return new Dictionary<int, IReadOnlyList<Contacts>>();
var rows = await _locationDataService.GetSiteContactsByLocationIdsAsync(
items.Select(l => l.Id).ToList(),
cancellationToken);
return rows
.GroupBy(c => c.LocationId!.Value)
.ToDictionary(g => g.Key, g => (IReadOnlyList<Contacts>)g.ToList());
}
private static IReadOnlyList<SiteContactResponseDTO> MapSiteContacts(IEnumerable<Contacts>? contacts)
{
if (contacts == null)
return new List<SiteContactResponseDTO>();
return contacts
.OrderBy(c => c.SiteContactOrder)
.ThenBy(c => c.Id)
.Select(c => new SiteContactResponseDTO
{
Id = c.Id,
Name = c.FirstName,
Phone = c.PhoneNumber
})
.ToList();
}
private LocationDTO MapToDTO(Locations location, IReadOnlyDictionary<int, IReadOnlyList<Contacts>>? contactsByLocation = null)
{
return new LocationDTO
{
@ -343,7 +504,10 @@ namespace SeaHaven.Services.Implementation
Status = location.Status,
AccountId = location.AccountId,
CreatedDate = location.CreatedDate,
CreatedBy = location.createdby
CreatedBy = location.createdby,
Contacts = contactsByLocation != null && contactsByLocation.TryGetValue(location.Id, out var contacts)
? MapSiteContacts(contacts)
: MapSiteContacts(location.Contacts?.Where(c => c.IsDeleted != true))
};
}
}

View file

@ -0,0 +1,81 @@
using FluentValidation.Results;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Validation
{
/// <summary>
/// SH-138 site-contact request validation shared by location create and update.
/// Null contacts means legacy behavior (no validation, no mutation);
/// a present array must contain 1..20 complete rows.
/// </summary>
public static class SiteContactsValidation
{
public const int MaxContacts = 20;
public const int MaxNameLength = 100;
public const int MaxPhoneLength = 20;
public static List<ValidationFailure> Validate(IReadOnlyList<SiteContactRequestDTO> contacts)
{
var failures = new List<ValidationFailure>();
if (contacts.Count == 0)
{
failures.Add(new ValidationFailure(
nameof(LocationCreateRequestDTO.Contacts),
"Contacts must contain at least one contact when provided."));
return failures;
}
if (contacts.Count > MaxContacts)
{
failures.Add(new ValidationFailure(
nameof(LocationCreateRequestDTO.Contacts),
$"Contacts cannot exceed {MaxContacts} entries."));
return failures;
}
var seenIds = new HashSet<int>();
for (var i = 0; i < contacts.Count; i++)
{
var row = contacts[i];
var name = row.Name?.Trim();
var phone = row.Phone?.Trim();
if (string.IsNullOrWhiteSpace(name))
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Name)}",
"Contact name is required."));
}
else if (name.Length > MaxNameLength)
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Name)}",
$"Contact name cannot exceed {MaxNameLength} characters."));
}
if (string.IsNullOrWhiteSpace(phone))
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Phone)}",
"Contact phone is required."));
}
else if (phone.Length > MaxPhoneLength)
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Phone)}",
$"Contact phone cannot exceed {MaxPhoneLength} characters."));
}
if (row.Id is int id && !seenIds.Add(id))
{
failures.Add(new ValidationFailure(
$"{nameof(LocationCreateRequestDTO.Contacts)}[{i}].{nameof(SiteContactRequestDTO.Id)}",
$"Contact id {id} appears more than once."));
}
}
return failures;
}
}
}

View file

@ -841,6 +841,10 @@ public class WorkOrderAccountScopeTests
=> _inner.GetDetailByIdAsync(id, cancellationToken);
public Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
=> _inner.GetByIdForUpdateAsync(id, cancellationToken);
public Task<IReadOnlyList<Contacts>> GetSiteContactsByLocationIdsAsync(
IReadOnlyCollection<int> locationIds,
CancellationToken cancellationToken)
=> _inner.GetSiteContactsByLocationIdsAsync(locationIds, cancellationToken);
public Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
=> _inner.AddAsync(location, cancellationToken);
public Task UpdateAsync(Locations location, CancellationToken cancellationToken)