mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
feat(locations): manage ordered site contacts
This commit is contained in:
parent
802b7a414e
commit
4872fe5ba1
15 changed files with 4905 additions and 29 deletions
581
Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs
Normal file
581
Api.SeaHavenIndustries.Tests/LocationSiteContactsTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
3933
Data.SeaHavenIndustries/Migrations/20260915002525_SH138_SiteContacts.Designer.cs
generated
Normal file
3933
Data.SeaHavenIndustries/Migrations/20260915002525_SH138_SiteContacts.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
SeaHaven.Services/Validation/SiteContactValidation.cs
Normal file
81
SeaHaven.Services/Validation/SiteContactValidation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue