refactor(backend): enforce architecture and optimize dispatch

This commit is contained in:
Alexandre Brandizzi 2026-07-24 12:07:48 -03:00
parent 848e973d62
commit f6e71edd00
24 changed files with 792 additions and 660 deletions

View file

@ -0,0 +1,51 @@
name: Architecture and changed-file quality
on:
pull_request:
branches: [main, dev]
permissions:
contents: read
jobs:
architecture:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Restore
run: dotnet restore SeaHavenIndustries.sln
- name: Verify architecture boundaries
run: >-
dotnet test
Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj
--no-restore
--filter FullyQualifiedName~ArchitectureTests
- name: Verify formatting of changed C# files
shell: bash
run: |
mapfile -t files < <(
git diff --name-only --diff-filter=ACMR \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" \
-- '*.cs'
)
if (( ${#files[@]} == 0 )); then
exit 0
fi
dotnet format SeaHavenIndustries.sln \
--no-restore \
--verify-no-changes \
--include "${files[@]}"

View file

@ -0,0 +1,76 @@
using System.Reflection;
using Api.SeaHavenIndustries.Controllers;
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using SeaHaven.DataServices.Implementation;
using SeaHaven.Services.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class ArchitectureTests
{
private static readonly Assembly ApiAssembly = typeof(VendorPortalController).Assembly;
private static readonly Assembly ServicesAssembly = typeof(VendorService).Assembly;
private static readonly Assembly DataServicesAssembly = typeof(VendorDataService).Assembly;
private const string ServiceImplementationNamespace = "SeaHaven.Services.Implementation";
private const string DataServiceImplementationNamespace = "SeaHaven.DataServices.Implementation";
private static IEnumerable<Type> ApiControllers =>
ApiAssembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && typeof(ControllerBase).IsAssignableFrom(t));
private static IEnumerable<Type> BusinessServiceImplementations =>
ServicesAssembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType)
.Where(t => t.GetInterfaces().Any(i => i.Name == "I" + t.Name));
private static bool IsConcrete(Type type) => type.IsClass && !type.IsAbstract && !type.IsInterface;
private static bool IsDbContext(Type type) => typeof(DbContext).IsAssignableFrom(type);
private static bool IsConcreteServiceImplementation(Type type) =>
type.Namespace == ServiceImplementationNamespace && IsConcrete(type);
private static bool IsConcreteDataService(Type type) =>
type.Namespace == DataServiceImplementationNamespace && IsConcrete(type);
private static IEnumerable<ParameterInfo> ParametersOf(IEnumerable<Type> types) =>
types.SelectMany(t => t.GetConstructors().SelectMany(c => c.GetParameters()));
[Fact]
public void Controllers_DoNotInject_DbContext_ConcreteDataServices_Or_ConcreteServiceImplementations()
{
ApiControllers.Should().NotBeEmpty("the API must expose controllers for this invariant to be meaningful");
var violations = ParametersOf(ApiControllers)
.Where(p => IsDbContext(p.ParameterType)
|| IsConcreteDataService(p.ParameterType)
|| IsConcreteServiceImplementation(p.ParameterType))
.Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})")
.ToList();
violations.Should().BeEmpty(
"controllers must depend only on interfaces/abstractions, never on DbContext, concrete data services, or concrete service implementations");
}
[Fact]
public void BusinessServices_DoNotInject_DbContext_Or_IConfiguration()
{
BusinessServiceImplementations.Should().NotBeEmpty(
"business services must be discovered for this invariant to be meaningful");
var violations = ParametersOf(BusinessServiceImplementations)
.Where(p => IsDbContext(p.ParameterType)
|| typeof(IConfiguration).IsAssignableFrom(p.ParameterType))
.Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})")
.ToList();
violations.Should().BeEmpty(
"business services must depend on data-service interfaces and typed options, never on DbContext or IConfiguration");
}
}

View file

@ -1,9 +1,10 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using FluentAssertions; using FluentAssertions;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using Moq; using Moq;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -24,17 +25,15 @@ public class AuthenticationServiceTests
var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
store = s; store = s;
hasher = h; hasher = h;
return new AuthenticationService(manager, Config(), userData.Object, forget.Object, email.Object); return new AuthenticationService(manager, Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
} }
private static IConfiguration Config() => new ConfigurationBuilder() private static JwtOptions JwtOptions => new()
.AddInMemoryCollection(new Dictionary<string, string?> {
{ Secret = new string('x', 64),
["JWT:Secret"] = new string('x', 64), ValidIssuer = "issuer",
["JWT:ValidIssuer"] = "issuer", ValidAudience = "audience"
["JWT:ValidAudience"] = "audience" };
})
.Build();
[Fact] [Fact]
public async Task Login_UnknownUser_ReturnsNull() public async Task Login_UnknownUser_ReturnsNull()

View file

@ -0,0 +1,199 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.Extensions.Options;
using Moq;
using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class DispatchBatchingBehaviorTests
{
private static WorkOrderDispatchService BuildService(
Mock<IWorkOrderDataService>? woData = null,
Mock<IVendorDataService>? vendorData = null,
Mock<ITaskListTemplateDataService>? templateData = null,
Mock<IDispatchDataService>? dispatchData = null)
{
var dd = dispatchData ?? new Mock<IDispatchDataService>();
dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1");
dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1");
dd.Setup(d => d.AddAsync(It.IsAny<Dispatch>()))
.ReturnsAsync((Dispatch d) => d);
var emailPort = new Mock<IDispatchEmailPort>();
emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(true);
var tokenPort = new Mock<IVendorTokenPort>();
tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny<int>())).ReturnsAsync("token");
return new WorkOrderDispatchService(
(woData ?? new Mock<IWorkOrderDataService>()).Object,
dd.Object,
(vendorData ?? new Mock<IVendorDataService>()).Object,
Mock.Of<IUserDataService>(),
(templateData ?? new Mock<ITaskListTemplateDataService>()).Object,
Mock.Of<ICommentDataService>(),
emailPort.Object,
tokenPort.Object,
Options.Create(new FrontendOptions()));
}
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) =>
WorkOrderDataReturning(new Mock<IWorkOrderDataService>(), workOrders);
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(Mock<IWorkOrderDataService> mock, params WorkOrder[] workOrders)
{
mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync(
It.IsAny<IReadOnlyCollection<int>>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(workOrders.ToList());
return mock;
}
private static Mock<IVendorDataService> VendorDataReturning(params Vendor[] vendors)
{
var mock = new Mock<IVendorDataService>();
mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(vendors.ToList());
return mock;
}
[Fact]
public async Task Dispatch_PrimaryWorkOrderFollowsReturnedOrder()
{
var woData = WorkOrderDataReturning(
new WorkOrder { Id = 30, InternalWONumber = "WO-30" },
new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var dispatchData = new Mock<IDispatchDataService>();
var service = BuildService(woData, VendorDataReturning(new Vendor { Id = 1, CompanyName = "V", Email = "v@x.io" }), dispatchData: dispatchData);
Dispatch? saved = null;
dispatchData.Setup(d => d.AddAsync(It.IsAny<Dispatch>()))
.ReturnsAsync((Dispatch d) => { saved = d; return d; });
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 30, 10 },
VendorIds = new() { 1 }
});
saved.Should().NotBeNull();
saved!.WorkOrderId.Should().Be(30);
}
[Fact]
public async Task Dispatch_DeduplicatesVendorsAndSkipsMissing()
{
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var vendorData = new Mock<IVendorDataService>();
vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<Vendor> { new() { Id = 2, CompanyName = "Only Co", Email = "v@x.io" } });
var service = BuildService(woData, vendorData);
var result = await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10 },
VendorIds = new() { 2, 2, 999 }
});
result.Dispatches.Should().HaveCount(1);
result.Dispatches.Single().VendorName.Should().Be("Only Co");
vendorData.Verify(d => d.GetByIdsOrderedAsync(
It.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 2, 999 })),
It.IsAny<CancellationToken>()), Times.Once);
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount()
{
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var templateData = new Mock<ITaskListTemplateDataService>();
templateData.Setup(d => d.GetByIdWithItemsAsync(7))
.ReturnsAsync(new TaskListTemplate
{
Items = new List<TaskListTemplateItem>
{
new() { ItemText = "A", SortOrder = 0 },
new() { ItemText = "B", SortOrder = 1 }
}
});
var vendorData = VendorDataReturning(
new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" },
new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" });
var service = BuildService(woData, vendorData, templateData);
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10 },
VendorIds = new() { 1, 2 },
TaskListTemplateId = 7
});
templateData.Verify(d => d.GetByIdWithItemsAsync(7), Times.Once);
}
[Fact]
public async Task Dispatch_PerformsSingleBatchedVendorLookup()
{
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" });
var service = BuildService(woData, vendorData);
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10 },
VendorIds = new() { 1, 2, 3 }
});
vendorData.Verify(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), Times.Never);
}
[Fact]
public async Task Dispatch_BatchesAuditLogsOncePerVendorAndNeverSingles()
{
var woData = WorkOrderDataReturning(
new WorkOrder { Id = 10, InternalWONumber = "WO-10" },
new WorkOrder { Id = 11, InternalWONumber = "WO-11" });
var vendorData = VendorDataReturning(
new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" },
new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" });
var service = BuildService(woData, vendorData);
await service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 10, 11 },
VendorIds = new() { 1, 2 }
});
woData.Verify(d => d.AddAuditLogsAsync(It.Is<IReadOnlyCollection<WorkOrderAuditLog>>(logs => logs.Count == 2)), Times.Exactly(2));
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), Times.Never);
}
[Fact]
public async Task Dispatch_NoWorkOrders_Throws()
{
var woData = WorkOrderDataReturning();
var service = BuildService(woData, VendorDataReturning());
var act = () => service.DispatchToVendorAsync(new DispatchToVendorInput
{
WorkOrderIds = new() { 404 },
VendorIds = new() { 1 }
});
await act.Should().ThrowAsync<InvalidOperationException>();
}
}

View file

@ -1,8 +1,9 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using Moq; using Moq;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -209,7 +210,7 @@ public class WorkOrderDispatchServiceBehaviorTests
Mock.Of<ICommentDataService>(), Mock.Of<ICommentDataService>(),
Mock.Of<IDispatchEmailPort>(), Mock.Of<IDispatchEmailPort>(),
Mock.Of<IVendorTokenPort>(), Mock.Of<IVendorTokenPort>(),
Mock.Of<IConfiguration>()); Options.Create(new FrontendOptions()));
} }
} }

View file

@ -18,9 +18,10 @@ public class ServiceRegistrationTests
{ {
private static ServiceProvider BuildProvider() private static ServiceProvider BuildProvider()
{ {
var configuration = new ConfigurationBuilder().Build();
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddLogging(); services.AddLogging();
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().Build()); services.AddSingleton<IConfiguration>(configuration);
services.AddSingleton<IZipCodeDistance>(_ => new DummyZipCodeDistance()); services.AddSingleton<IZipCodeDistance>(_ => new DummyZipCodeDistance());
services.AddSingleton<IEmailSender>(_ => new DummyEmailSender()); services.AddSingleton<IEmailSender>(_ => new DummyEmailSender());
@ -31,7 +32,7 @@ public class ServiceRegistrationTests
.AddEntityFrameworkStores<ApplicationDbContext>(); .AddEntityFrameworkStores<ApplicationDbContext>();
services.AddDataServices(); services.AddDataServices();
services.AddBusinessServices(); services.AddBusinessServices(configuration);
return services.BuildServiceProvider(); return services.BuildServiceProvider();
} }

View file

@ -34,7 +34,9 @@ public class UpliftControllerTests
var service = new Mock<IUpliftService>(); var service = new Mock<IUpliftService>();
var dto = new UpliftListResultDTO var dto = new UpliftListResultDTO
{ {
Total = 1, Page = 1, PageSize = 25, Total = 1,
Page = 1,
PageSize = 25,
Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } } Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } }
}; };
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Pending", null, 1, 25, It.IsAny<CancellationToken>())) service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Pending", null, 1, 25, It.IsAny<CancellationToken>()))
@ -107,7 +109,8 @@ public class UpliftControllerTests
var ok = result.Should().BeOfType<OkObjectResult>().Subject; var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject; var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((UpliftApproveResultDTO)envelope.Data).Should().Be(approved); var approveData = envelope.Data as UpliftApproveResultDTO;
approveData.Should().Be(approved);
} }
[Fact] [Fact]

View file

@ -24,7 +24,11 @@ public class VendorPortalControllerTests
private static readonly VendorPortalSession Session = new() private static readonly VendorPortalSession Session = new()
{ {
Id = 1, CompanyName = "Test Co", ContactName = "Sam", Email = "sam@test.co", Phone = "555-0100" Id = 1,
CompanyName = "Test Co",
ContactName = "Sam",
Email = "sam@test.co",
Phone = "555-0100"
}; };
[Fact] [Fact]
@ -64,7 +68,8 @@ public class VendorPortalControllerTests
var ok = result.Should().BeOfType<OkObjectResult>().Subject; var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject; var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success"); envelope.Status.Should().Be("Success");
((VendorPortalSession)envelope.Data).Should().Be(Session); var sessionData = envelope.Data as VendorPortalSession;
sessionData.Should().Be(Session);
} }
[Fact] [Fact]
@ -146,7 +151,8 @@ public class VendorPortalControllerTests
var ok = result.Should().BeOfType<OkObjectResult>().Subject; var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject; var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((AcceptDispatchResultDTO)envelope.Data).Should().Be(acceptResult); var acceptData = envelope.Data as AcceptDispatchResultDTO;
acceptData.Should().Be(acceptResult);
} }
[Fact] [Fact]
@ -210,7 +216,10 @@ public class VendorPortalControllerTests
var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest
{ {
SignoffType = "vendor", Name = "Name", Signature = "sig", SignatureMethod = "drawn" SignoffType = "vendor",
Name = "Name",
Signature = "sig",
SignatureMethod = "drawn"
}); });
result.Should().BeOfType<BadRequestObjectResult>(); result.Should().BeOfType<BadRequestObjectResult>();
@ -244,12 +253,14 @@ public class VendorPortalControllerTests
var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody
{ {
RequestedNTE = 500m, Reason = "Need more parts" RequestedNTE = 500m,
Reason = "Need more parts"
}); });
var ok = result.Should().BeOfType<OkObjectResult>().Subject; var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject; var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((UpliftRequestResultDTO)envelope.Data).Should().Be(upliftResult); var upliftData = envelope.Data as UpliftRequestResultDTO;
upliftData.Should().Be(upliftResult);
} }
[Fact] [Fact]
@ -282,7 +293,8 @@ public class VendorPortalControllerTests
var ok = result.Should().BeOfType<OkObjectResult>().Subject; var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject; var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((CancelUpliftResultDTO)envelope.Data).Should().Be(cancelResult); var cancelData = envelope.Data as CancelUpliftResultDTO;
cancelData.Should().Be(cancelResult);
} }
[Fact] [Fact]

View file

@ -2,9 +2,10 @@ using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using FluentAssertions; using FluentAssertions;
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using Moq; using Moq;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Implementation; using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -20,7 +21,7 @@ public class VendorServiceTests
data.Object, data.Object,
Mock.Of<IVendorPortalTokenService>(), Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(), Mock.Of<IZipCodeDistance>(),
new ConfigurationBuilder().Build(), Options.Create(new FrontendOptions()),
new CreateVendorValidation(), new CreateVendorValidation(),
new UpdateVendorValidation(), new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation()); new WorkOrderVendorUpdateValidation());

View file

@ -0,0 +1,104 @@
using Data.SeaHavenIndustries;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SeaHaven.DataServices.Implementation;
using Xunit;
namespace Api.SeaHavenIndustries.Tests;
public class WorkOrderDataServiceBatchingTests
{
private static ApplicationDbContext NewContext()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
private static async Task SeedAsync(ApplicationDbContext context)
{
context.Locations.Add(new Locations { Id = 100, Name = "Site A", Address1 = "1 Main St" });
context.workOrders.AddRange(
new WorkOrder { Id = 1, InternalWONumber = "WO-1", WorkerOrderTitle = "First", LocationId = 100 },
new WorkOrder { Id = 2, InternalWONumber = "WO-2", WorkerOrderTitle = "Second", LocationId = 100 },
new WorkOrder { Id = 3, InternalWONumber = "WO-3", WorkerOrderTitle = "Third", LocationId = 100 },
new WorkOrder { Id = 4, InternalWONumber = "WO-T", WorkerOrderTitle = "Template", LocationId = 100, istemplate = true });
await context.SaveChangesAsync();
}
[Fact]
public async Task GetByRequestedIdsWithDetailsAsync_ReturnsResultsInInputOrder()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
var result = await service.GetByRequestedIdsWithDetailsAsync(
new[] { 3, 1, 2 },
CancellationToken.None);
result.Select(w => w.Id).Should().Equal(new[] { 3, 1, 2 });
}
[Fact]
public async Task GetByRequestedIdsWithDetailsAsync_DeduplicatesIdsAndSkipsMissing()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
var result = await service.GetByRequestedIdsWithDetailsAsync(
new[] { 2, 2, 999, 1 },
CancellationToken.None);
result.Select(w => w.Id).Should().Equal(new[] { 2, 1 });
}
[Fact]
public async Task GetByRequestedIdsWithDetailsAsync_ExcludesTemplatesAndEagerLoadsLocation()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
var result = await service.GetByRequestedIdsWithDetailsAsync(
new[] { 1, 4 },
CancellationToken.None);
result.Select(w => w.Id).Should().Equal(new[] { 1 });
result.Single().Locations.Should().NotBeNull();
result.Single().Locations!.Name.Should().Be("Site A");
}
[Fact]
public async Task AddAuditLogsAsync_PersistsAllRowsInSingleSave()
{
await using var context = NewContext();
await SeedAsync(context);
var service = new WorkOrderDataService(context);
await service.AddAuditLogsAsync(new[]
{
new WorkOrderAuditLog { WorkOrderId = 1, FieldName = "Dispatch", Action = "Dispatch" },
new WorkOrderAuditLog { WorkOrderId = 2, FieldName = "Dispatch", Action = "Dispatch" }
});
context.WorkOrderAuditLogs.Should().HaveCount(2);
}
[Fact]
public async Task GetByIdsOrderedAsync_PreservesInputOrderDeduplicatesAndSkipsMissing()
{
await using var context = NewContext();
context.Vendors.AddRange(
new Vendor { Id = 7, CompanyName = "V7", Email = "v7@x.io" },
new Vendor { Id = 8, CompanyName = "V8", Email = "v8@x.io" });
await context.SaveChangesAsync();
var service = new VendorDataService(context);
var result = await service.GetByIdsOrderedAsync(new[] { 8, 8, 7, 999 }, CancellationToken.None);
result.Select(v => v.Id).Should().Equal(new[] { 8, 7 });
}
}

View file

@ -52,7 +52,7 @@ builder.Services.AddSingleton<SeaHaven.Services.Interfaces.IZipCodeDistance>(
serviceProvider => serviceProvider.GetRequiredService<Api.SeaHavenIndustries.Helper.ZipCodeDistance>()); serviceProvider => serviceProvider.GetRequiredService<Api.SeaHavenIndustries.Helper.ZipCodeDistance>());
builder.Services.AddDataServices(); builder.Services.AddDataServices();
builder.Services.AddBusinessServices(); builder.Services.AddBusinessServices(configuration);
builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api.SeaHavenIndustries.Infrastructure.DynamoSyncExternalSource>(); builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api.SeaHavenIndustries.Infrastructure.DynamoSyncExternalSource>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>(); builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>(); builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
@ -74,7 +74,9 @@ builder.Services.AddAuthentication(options =>
ValidateAudience = true, ValidateAudience = true,
ValidAudience = configuration["JWT:ValidAudience"], ValidAudience = configuration["JWT:ValidAudience"],
ValidIssuer = configuration["JWT:ValidIssuer"], ValidIssuer = configuration["JWT:ValidIssuer"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Secret"])) IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
configuration["JWT:Secret"]
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
}; };
}); });
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();

View file

@ -1,564 +1,106 @@
# Backend Architecture Guide # Backend Architecture and Quality Rules
## 🏗️ Clean Architecture Overview This repository uses a feature-oriented service boundary over Entity Framework Core:
The backend follows **Clean Architecture** with strict layer separation: ```text
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
```
┌─────────────────────────────────────────────────────────────┐
│ API LAYER (Controllers) │
│ - HTTP Request/Response │
│ - Authentication & Authorization │
│ - Exception Handling │
│ - Input Validation (basic) │
└─────────────────┬───────────────────────────────────────────┘
│ Calls
▼
┌─────────────────────────────────────────────────────────────┐
│ SERVICE LAYER (Business Logic) │
│ - Business Rules │
│ - FluentValidation (detailed validation) │
│ - Data Transformation │
│ - Orchestration │
└─────────────────┬───────────────────────────────────────────┘
│ Calls
▼
┌─────────────────────────────────────────────────────────────┐
│ DATA SERVICE LAYER (Persistence) │
│ - Database Access │
│ - Entity Operations (CRUD) │
│ - Query Building │
│ - Transaction Management │
└─────────────────┬───────────────────────────────────────────┘
│ Uses
▼
┌─────────────────────────────────────────────────────────────┐
│ DATA LAYER (EF Core) │
│ - DbContext │
│ - Entity Models │
│ - Database Schema │
└─────────────────────────────────────────────────────────────┘
``` ```
--- The interfaces are architectural seams, not a requirement to wrap every class or every
EF method. Add an interface when it separates HTTP, business, persistence, or external
## 📁 Project Structure I/O responsibilities and enables behavior-focused testing.
``` ## Layer responsibilities
backend/shoc-backend/
│ ### API controllers
├── Api.SeaHavenIndustries/ # 🌐 API Layer
│ ├── Controllers/ # HTTP endpoints - Parse HTTP input, authorize the caller, invoke a business-service interface, and map
│ ├── DTOs/ # Data Transfer Objects (API contracts) the result to the established HTTP contract.
│ ├── Program.cs # App startup & DI registration - Do not inject `ApplicationDbContext`, concrete service implementations, or concrete
│ └── appsettings.json # Configuration data-service implementations.
│ - Do not contain persistence queries or business workflows.
├── SeaHaven.Services/ # 💼 Service Layer (Business Logic) - Do not expose exception messages to clients. Map known failures explicitly and let the
│ ├── Interfaces/ # Service contracts (IContactService, etc.) centralized exception boundary handle unexpected failures.
│ ├── Implementation/ # Service implementations
│ ├── DTOs/ # Internal DTOs for service layer ### Business services
│ └── Validators/ # FluentValidation validators
│ - Own validation, business decisions, orchestration, and transaction intent.
├── SeaHaven.DataServices/ # 💾 Data Access Layer - Depend on interfaces for persistence and external I/O.
│ ├── Interfaces/ # DataService contracts (IContactDataService, etc.) - Use `IOptions<T>` for typed configuration. Do not inject `IConfiguration`.
│ ├── Implementation/ # DataService implementations - Keep feature responsibilities cohesive. Split a service when it has independently
│ └── DTOs/ # Data layer DTOs changing business reasons, not because it crossed an arbitrary line count.
│ - Preserve existing API and board-backed behavior unless the accepted requirement
└── Data.SeaHavenIndustries/ # 🗄️ Data Layer (EF Core) explicitly changes it.
├── Models/ # Entity models (Contact, Employee, etc.)
├── ApplicationDbContext.cs # EF Core DbContext ### Data services
└── Migrations/ # Database migrations
``` - Own EF Core query shape, persistence, batching, and transaction implementation.
- Accept cancellation tokens on new I/O methods and pass them to EF Core.
--- - Use `AsNoTracking()` for read-only queries.
- Project only required columns for list/read models; include full graphs only when the
## 🔄 Data Flow Example (Creating a Contact) caller needs them.
- Page unbounded collections at the database.
### 1️⃣ **API Layer** (`ContactController.cs`) - Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes
```csharp while preserving the workflow's failure and transaction semantics.
[HttpPost("Create")] - Do not introduce a generic repository over EF Core. Feature data services should expose
public async Task<IActionResult> AddContacts(Contact_DTO dto) intent-revealing operations.
{
try ## Performance review
{
// Get authenticated user ID For each changed hot path, document and test:
var userId = _userManager.GetUserId(User);
if (userId == null) - input size variables;
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - CPU and memory complexity;
- database round trips;
// Convert API DTO → Service DTO - whether filtering, ordering, and paging execute in SQL;
var serviceDto = dto.ToServiceCreateDTO(); - whether tracking is necessary;
- whether repeated work can be hoisted out of loops;
// Call Service Layer - the partial-failure and transaction behavior.
await _contactService.CreateContactAsync(serviceDto, userId);
Prefer dictionary or hash-set reconstruction when database results must follow caller
return Ok(new Response { Status = "Success", Message = "Contact created" }); order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory;
} repeated `First`/`Single` scans over the result are `O(n²)`.
catch (ValidationException vex)
{ Do not optimize by weakening correctness. In particular, batching all writes into one
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); final save can change partial-success behavior, and adding a transaction can change
return BadRequest(new Response { Status = "Validation Error", Message = errors }); locking and retry semantics. Those are business decisions, not mechanical cleanup.
}
catch (KeyNotFoundException) ## Configuration
{
return NotFound(new Response { Status = "Error", Message = "Resource not found" }); Configuration is bound once during composition in
} `SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive
catch (Exception ex) typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and
{ `VendorPortalOptions`.
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
} Defaults must preserve current behavior. Startup validation should be introduced only
} when every deployed environment is known to provide the required value.
```
## Enforcement
**Responsibilities:**
- ✅ Authenticate user `ArchitectureTests` enforces mechanical dependency rules:
- ✅ Extract user ID from claims
- ✅ Map API DTO to Service DTO - controllers cannot inject EF contexts or concrete service/data-service classes;
- ✅ Catch exceptions and return proper HTTP status codes - business services cannot inject EF contexts or raw `IConfiguration`.
- ❌ NO business logic
- ❌ NO database access The pull-request quality workflow runs those tests and verifies formatting for changed
C# files. The normal CI workflow builds the solution and runs the full test suite.
---
Architecture tests are appropriate for dependency direction. Behavior belongs in tests
### 2️⃣ **Service Layer** (`ContactService.cs`) through public interfaces. Do not add tests merely to prove a linter or analyzer itself
works; test the product behavior the rule protects.
```csharp
public class ContactService : IContactService ## Review checklist
{
private readonly IContactDataService _dataService; - Controller depends only on service abstractions.
private readonly IValidator<CreateContactDTO> _createValidator; - Business logic is in a cohesive feature service.
- EF operations are behind a feature data-service abstraction.
public ContactService( - No raw `IConfiguration` in a business service.
IContactDataService dataService, - No unbounded load followed by in-memory filtering.
IValidator<CreateContactDTO> createValidator) - No query/save loop where a set-based operation preserves semantics.
{ - Read-only EF queries use no tracking.
_dataService = dataService; - New async I/O accepts and propagates cancellation where the public contract permits.
_createValidator = createValidator; - Errors do not leak internal exception details.
} - Tests cover behavior, authorization, data contracts, ordering, duplicates, missing
records, and relevant failure boundaries.
public async Task<ContactDTO> CreateContactAsync(CreateContactDTO dto, string userId) - Changed behavior has been checked against the authoritative ticket board. Missing board
{ access blocks readiness and merge claims.
// STEP 1: Validate using FluentValidation
var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
// STEP 2: Business logic (if any)
// Example: Check for duplicates, apply business rules, etc.
// STEP 3: Map Service DTO → Data DTO
var dataDto = new CreateContactDataDTO
{
FirstName = dto.FirstName,
LastName = dto.LastName,
Email = dto.Email,
Phone = dto.Phone
};
// STEP 4: Call Data Service Layer
var createdContact = await _dataService.CreateAsync(dataDto, userId);
// STEP 5: Map Data DTO → Service DTO and return
return new ContactDTO
{
Id = createdContact.Id,
FirstName = createdContact.FirstName,
LastName = createdContact.LastName,
Email = createdContact.Email,
Phone = createdContact.Phone
};
}
}
```
**Responsibilities:**
- ✅ Validate input using FluentValidation
- ✅ Apply business rules
- ✅ Orchestrate operations
- ✅ Transform data between layers
- ❌ NO HTTP concerns
- ❌ NO direct database access
---
### 3️⃣ **Data Service Layer** (`ContactDataService.cs`)
```csharp
public class ContactDataService : IContactDataService
{
private readonly ApplicationDbContext _db;
public ContactDataService(ApplicationDbContext db)
{
_db = db;
}
public async Task<ContactDataDTO> CreateAsync(CreateContactDataDTO dto, string userId)
{
// STEP 1: Create entity from DTO
var contact = new Contact
{
FirstName = dto.FirstName,
LastName = dto.LastName,
Email = dto.Email,
Phone = dto.Phone,
CreatedBy = userId,
CreatedDate = DateTime.UtcNow,
ModifiedBy = userId,
ModifiedDate = DateTime.UtcNow
};
// STEP 2: Add to database
_db.Contacts.Add(contact);
await _db.SaveChangesAsync();
// STEP 3: Map entity → Data DTO and return
return new ContactDataDTO
{
Id = contact.Id,
FirstName = contact.FirstName,
LastName = contact.LastName,
Email = contact.Email,
Phone = contact.Phone
};
}
}
```
**Responsibilities:**
- ✅ Direct database operations (CRUD)
- ✅ Entity mapping
- ✅ Set audit fields (CreatedBy, CreatedDate, etc.)
- ✅ Transaction management
- ❌ NO validation
- ❌ NO business logic
---
## 🔌 Dependency Injection (DI)
### Automatic Assembly Scanning
Services are **automatically registered** by scanning assemblies using naming convention.
**In `Program.cs`:**
```csharp
builder.Services.AddDataServices(); // Auto-register all Data Services
builder.Services.AddBusinessServices(); // Auto-register all Business Services + Validators
```
### How It Works
**Module Files:**
- `SeaHaven.DataServices/DependencyInjection/DataServicesModule.cs`
- `SeaHaven.Services/DependencyInjection/ServicesModule.cs`
**Process:**
1. Scans assembly for all classes
2. Finds interface matching pattern `I{ClassName}`
3. Registers as Scoped service
4. Also auto-registers FluentValidation validators
### Naming Convention Required
| Interface | Implementation | Registered? |
|-----------|---------------|-------------|
| `IContactService` | `ContactService` | ✅ YES |
| `IContactDataService` | `ContactDataService` | ✅ YES |
| `ISomething` | `SomethingDifferent` | ❌ NO |
**Rule**: Interface must be `I` + Class name exactly
### Usage in Code
Once registered, inject via constructor:
```csharp
public class ContactController : Controller
{
private readonly IContactService _contactService;
public ContactController(IContactService contactService)
{
_contactService = contactService;
}
}
```
### Benefits
✅ No manual registration - just follow naming convention
✅ Auto-discovery - new services work automatically
✅ FluentValidation auto-registered
---
## ✅ FluentValidation
### Validator Example (`CreateContactDTOValidator.cs`)
```csharp
public class CreateContactDTOValidator : AbstractValidator<CreateContactDTO>
{
public CreateContactDTOValidator()
{
RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("First name is required")
.MaximumLength(50).WithMessage("First name cannot exceed 50 characters");
RuleFor(x => x.LastName)
.NotEmpty().WithMessage("Last name is required")
.MaximumLength(50).WithMessage("Last name cannot exceed 50 characters");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format")
.MaximumLength(100).WithMessage("Email cannot exceed 100 characters");
RuleFor(x => x.Phone)
.Matches(@"^\+?[1-9]\d{1,14}$").When(x => !string.IsNullOrEmpty(x.Phone))
.WithMessage("Invalid phone number format");
}
}
```
### Where Validation Happens
```
❌ Controller → NO detailed validation (only catches ValidationException)
✅ Service → YES - FluentValidation runs here
❌ DataService → NO validation
```
---
## 🎯 Interface Examples
### Service Interface (`IContactService.cs`)
```csharp
public interface IContactService
{
Task<ContactDTO> CreateContactAsync(CreateContactDTO dto, string userId);
Task<ContactDTO> UpdateContactAsync(int id, UpdateContactDTO dto, string userId);
Task DeleteContactAsync(int id, string userId);
Task<ContactDTO> GetContactByIdAsync(int id);
Task<PagedResult<ContactDTO>> GetContactsPagedAsync(int page, int pageSize, string? search);
}
```
### Data Service Interface (`IContactDataService.cs`)
```csharp
public interface IContactDataService
{
Task<ContactDataDTO> CreateAsync(CreateContactDataDTO dto, string userId);
Task<ContactDataDTO> UpdateAsync(int id, UpdateContactDataDTO dto, string userId);
Task DeleteAsync(int id, string userId);
Task<ContactDataDTO?> GetByIdAsync(int id);
Task<PagedResult<ContactDataDTO>> GetPagedAsync(int page, int pageSize, string? search);
}
```
---
## 📦 DTO Types
### 1. **API DTOs** (`Api.SeaHavenIndustries/DTOs/`)
- Used for HTTP requests/responses
- Map to frontend JSON structure
- Example: `Contact_DTO`, `EditContact_DTO`
### 2. **Service DTOs** (`SeaHaven.Services/DTOs/`)
- Used between Controller ↔ Service
- Business-focused structure
- Example: `CreateContactDTO`, `UpdateContactDTO`, `ContactDTO`
### 3. **Data DTOs** (`SeaHaven.DataServices/DTOs/`)
- Used between Service ↔ DataService
- Database-focused structure
- Example: `CreateContactDataDTO`, `ContactDataDTO`
### DTO Mapping Flow
```
API DTO → Service DTO → Data DTO → Entity
(HTTP) (Business) (Data) (Database)
```
---
## 🚨 Exception Handling Pattern
### Controller Exception Handling (STANDARD PATTERN)
```csharp
[HttpPost("Create")]
public async Task<IActionResult> Create([FromBody] SomeDTO dto)
{
try
{
// Get user ID
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
// Call service
await _service.CreateAsync(dto, userId);
return Ok(new Response { Status = "Success", Message = "Created successfully" });
}
catch (ValidationException vex) // ⚠️ CATCH THIS FIRST
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (KeyNotFoundException) // ⚠️ THEN THIS
{
return NotFound(new Response { Status = "Error", Message = "Not found" });
}
catch (Exception ex) // ⚠️ GENERIC LAST
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
```
### HTTP Status Code Mapping
- **200 OK** → Success
- **400 Bad Request** → Validation errors
- **401 Unauthorized** → Not authenticated
- **404 Not Found** → Resource not found
- **500 Internal Server Error** → Unexpected errors
---
## 🔐 Authentication & User ID
### Getting User ID (Two Methods)
**Method 1: Using Claims (Preferred)**
```csharp
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
```
**Method 2: Using UserManager (for Identity)**
```csharp
var userId = _userManager.GetUserId(User);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
```
### Audit Fields
Every entity should track:
- `CreatedBy` (string - User ID GUID)
- `CreatedDate` (DateTime)
- `ModifiedBy` (string - User ID GUID)
- `ModifiedDate` (DateTime)
---
## 📋 Naming Conventions
### Controllers
- Named after entity + "Controller"
- Example: `ContactController`, `EmployeeController`
### Services
- Interface: `I[Entity]Service`
- Implementation: `[Entity]Service`
- Example: `IContactService`, `ContactService`
### Data Services
- Interface: `I[Entity]DataService`
- Implementation: `[Entity]DataService`
- Example: `IContactDataService`, `ContactDataService`
### DTOs
- API Layer: `[Entity]_DTO`, `Edit[Entity]_DTO`
- Service Layer: `Create[Entity]DTO`, `Update[Entity]DTO`, `[Entity]DTO`
- Data Layer: `Create[Entity]DataDTO`, `[Entity]DataDTO`
---
## ✅ Best Practices Checklist
### Controllers
- [ ] Use dependency injection for services
- [ ] Get user ID from `User.FindFirstValue(ClaimTypes.NameIdentifier)`
- [ ] Return 401 if userId is null
- [ ] Catch `ValidationException` BEFORE generic `Exception`
- [ ] Return appropriate HTTP status codes
- [ ] NO business logic in controllers
- [ ] NO direct database access (`_db`)
### Services
- [ ] Validate using FluentValidation
- [ ] Throw `ValidationException` for validation errors
- [ ] Throw `KeyNotFoundException` for not found
- [ ] Pass userId to DataService
- [ ] Transform DTOs between layers
- [ ] Implement business rules here
### Data Services
- [ ] Direct database access only
- [ ] Set audit fields (CreatedBy, CreatedDate, ModifiedBy, ModifiedDate)
- [ ] Use async/await for all database operations
- [ ] NO validation logic
- [ ] NO business logic
---
## 🎓 Quick Reference
| Layer | Purpose | Can Access | Cannot Access |
|-------|---------|-----------|---------------|
| **Controller** | HTTP handling, auth | Service layer | Database directly |
| **Service** | Business logic, validation | DataService layer | Database directly |
| **DataService** | Database operations | DbContext, Entities | HTTP context |
| **Data** | Entity definitions | Nothing (POCOs) | Application logic |
---
## 📚 Example: Full CRUD Implementation
See these files for complete examples:
- **Contact**: `ContactController.cs`, `ContactService.cs`, `ContactDataService.cs`
- **Employee**: `EmployeeController.cs`, `EmployeeService.cs`, `EmployeeDataService.cs`
- **Asset**: `AssetController.cs`, `AssetService.cs`, `AssetDataService.cs`
All follow the same Clean Architecture pattern!
---
## 🔐 Configuration & Secrets
No secrets are stored in source. The committed `appsettings*.json` files hold only
`${PLACEHOLDER}` tokens; real values must be supplied at runtime through one of the
standard .NET configuration sources (later sources win):
1. `appsettings.json` / `appsettings.{Environment}.json` — committed, placeholders only.
2. **User Secrets** (local dev): `dotnet user-secrets set "Section:Key" "value"`.
3. **Environment variables** — use `__` (double underscore) as the section separator.
In AWS Elastic Beanstalk these are the environment properties.
See `.env.example` in the repo root for the full list. Required values:
| Setting | Env-var form | Used by | Notes |
|---|---|---|---|
| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` | Both projects | SQL Server connection string |
| `SendGrid:ApiKey` | `SendGrid__ApiKey` | Both projects | Transactional email |
| `JWT:Secret` | `JWT__Secret` | `Api.SeaHavenIndustries` | JWT signing key |
| `GoogleMaps:ApiKey` | `GoogleMaps__ApiKey` | `SeaHavenIndustries` (Blazor) | Maps/Places; read in `App.razor` + `Home.razor` |
### AWS credentials
`UploadFileHp` (S3 uploads) uses the **AWS SDK default credential chain** — it no longer
hardcodes access keys. Credentials resolve from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
env vars, the shared profile/SSO, or (preferred in production) the EC2/ECS instance role.
The region comes from `AddDefaultAWSOptions` in `Program.cs`.
> ⚠️ The secrets previously committed to this repo (DB password, SendGrid key, JWT secret,
> Google Maps keys, AWS access key) should be considered compromised and **rotated**.

View file

@ -29,6 +29,28 @@ namespace SeaHaven.DataServices.Implementation
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
public async Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken)
{
var orderedIds = ids.Distinct().ToList();
if (orderedIds.Count == 0)
return Array.Empty<Vendor>();
var loaded = await _context.Vendors
.AsNoTracking()
.Where(v => orderedIds.Contains(v.Id))
.ToListAsync(cancellationToken);
var byId = loaded.ToDictionary(v => v.Id);
var ordered = new List<Vendor>(orderedIds.Count);
foreach (var id in orderedIds)
{
if (byId.TryGetValue(id, out var vendor))
ordered.Add(vendor);
}
return ordered;
}
public async Task<IEnumerable<Vendor>> GetAllAsync() public async Task<IEnumerable<Vendor>> GetAllAsync()
{ {
return await _context.Vendors.ToListAsync(); return await _context.Vendors.ToListAsync();

View file

@ -283,6 +283,15 @@ namespace SeaHaven.DataServices.Implementation
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
public async Task AddAuditLogsAsync(IReadOnlyCollection<WorkOrderAuditLog> auditLogs)
{
if (auditLogs.Count == 0)
return;
await _context.WorkOrderAuditLogs.AddRangeAsync(auditLogs);
await _context.SaveChangesAsync();
}
public async Task DeleteWorkOrderContactsAsync(int workOrderId) public async Task DeleteWorkOrderContactsAsync(int workOrderId)
{ {
var contacts = await _context.WorkOrderContacts var contacts = await _context.WorkOrderContacts
@ -369,6 +378,31 @@ namespace SeaHaven.DataServices.Implementation
.ToListAsync(); .ToListAsync();
} }
public async Task<IReadOnlyList<WorkOrder>> GetByRequestedIdsWithDetailsAsync(
IReadOnlyCollection<int> ids,
CancellationToken cancellationToken)
{
var orderedIds = ids.Distinct().ToList();
if (orderedIds.Count == 0)
return Array.Empty<WorkOrder>();
var loaded = await _context.workOrders
.AsNoTracking()
.Where(w => orderedIds.Contains(w.Id) && w.istemplate != true)
.Include(w => w.Locations)
.ToListAsync(cancellationToken);
var byId = loaded.ToDictionary(w => w.Id);
var ordered = new List<WorkOrder>(orderedIds.Count);
foreach (var id in orderedIds)
{
if (byId.TryGetValue(id, out var workOrder))
ordered.Add(workOrder);
}
return ordered;
}
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id) public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id)
{ {
return await _context.workOrders return await _context.workOrders

View file

@ -6,6 +6,10 @@ namespace SeaHaven.DataServices.Interfaces
{ {
Task<Vendor?> GetByIdAsync(int id); Task<Vendor?> GetByIdAsync(int id);
Task<Vendor?> GetByIdWithDetailsAsync(int id); Task<Vendor?> GetByIdWithDetailsAsync(int id);
// Batched lookup: one query for the distinct requested ids, preserving input order
// and skipping any ids that are not present (no rows returned for missing ids).
Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken);
Task<IEnumerable<Vendor>> GetAllAsync(); Task<IEnumerable<Vendor>> GetAllAsync();
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken); Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);

View file

@ -14,7 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<IEnumerable<WorkOrder>> GetByAssigneeAsync(string assigneeId); Task<IEnumerable<WorkOrder>> GetByAssigneeAsync(string assigneeId);
Task<IEnumerable<WorkOrder>> GetByStatusAsync(string status); Task<IEnumerable<WorkOrder>> GetByStatusAsync(string status);
Task<IEnumerable<WorkOrder>> GetByPriorityAsync(string priority); Task<IEnumerable<WorkOrder>> GetByPriorityAsync(string priority);
// Paged results // Paged results
Task<(IEnumerable<WorkOrder> Items, int TotalCount)> GetPagedAsync( Task<(IEnumerable<WorkOrder> Items, int TotalCount)> GetPagedAsync(
int page, int page,
@ -33,12 +33,12 @@ namespace SeaHaven.DataServices.Interfaces
int? locationId = null, int? locationId = null,
string? sortBy = "lastUpdated", string? sortBy = "lastUpdated",
string? sortDir = "desc"); string? sortDir = "desc");
// Command operations // Command operations
Task<WorkOrder> AddAsync(WorkOrder workOrder); Task<WorkOrder> AddAsync(WorkOrder workOrder);
Task UpdateAsync(WorkOrder workOrder); Task UpdateAsync(WorkOrder workOrder);
Task DeleteAsync(int id); Task DeleteAsync(int id);
// Related entities - Read // Related entities - Read
Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId); Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId);
Task<IEnumerable<WorkOrderCategories>> GetWorkOrderCategoriesAsync(int workOrderId); Task<IEnumerable<WorkOrderCategories>> GetWorkOrderCategoriesAsync(int workOrderId);
@ -51,6 +51,7 @@ namespace SeaHaven.DataServices.Interfaces
Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable<int> categoryIds); Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable<int> categoryIds);
Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment); Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment);
Task AddAuditLogAsync(WorkOrderAuditLog auditLog); Task AddAuditLogAsync(WorkOrderAuditLog auditLog);
Task AddAuditLogsAsync(IReadOnlyCollection<WorkOrderAuditLog> auditLogs);
// Related entities - Delete // Related entities - Delete
Task DeleteWorkOrderContactsAsync(int workOrderId); Task DeleteWorkOrderContactsAsync(int workOrderId);
@ -71,6 +72,12 @@ namespace SeaHaven.DataServices.Interfaces
Task<int> CountByLocationAsync(int locationId); Task<int> CountByLocationAsync(int locationId);
Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync(); Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync();
// Batched lookup: server-filters the distinct requested ids, excludes templates,
// eagerly loads the location detail, deduplicates, and returns results in input order.
Task<IReadOnlyList<WorkOrder>> GetByRequestedIdsWithDetailsAsync(
IReadOnlyCollection<int> ids,
CancellationToken cancellationToken);
// Complex read models // Complex read models
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id); Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id);
Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync(); Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync();

View file

@ -0,0 +1,31 @@
namespace SeaHaven.Services.Configuration;
public sealed class FrontendOptions
{
public string FrontendBaseUrl { get; set; } = string.Empty;
}
public sealed class JwtOptions
{
public const string SectionName = "JWT";
public string Secret { get; set; } = string.Empty;
public string? ValidIssuer { get; set; }
public string? ValidAudience { get; set; }
}
public sealed class ApprovalsOptions
{
public const string SectionName = "Approvals";
public decimal? UpliftTier1MaxUsd { get; set; }
public string[] Tier1Roles { get; set; } = Array.Empty<string>();
public string[] Tier2Roles { get; set; } = Array.Empty<string>();
}
public sealed class VendorPortalOptions
{
public const string SectionName = "VendorPortal";
public int? TokenLifetimeDays { get; set; }
}

View file

@ -1,5 +1,7 @@
using FluentValidation; using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using SeaHaven.Services.Configuration;
using System.Reflection; using System.Reflection;
namespace SeaHaven.Services.DependencyInjection namespace SeaHaven.Services.DependencyInjection
@ -8,8 +10,13 @@ namespace SeaHaven.Services.DependencyInjection
{ {
private static readonly List<Type> ExcludedTypes = new(); private static readonly List<Type> ExcludedTypes = new();
public static IServiceCollection AddBusinessServices(this IServiceCollection services) public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.Configure<FrontendOptions>(configuration);
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<ApprovalsOptions>(configuration.GetSection(ApprovalsOptions.SectionName));
services.Configure<VendorPortalOptions>(configuration.GetSection(VendorPortalOptions.SectionName));
var assembly = Assembly.GetExecutingAssembly(); var assembly = Assembly.GetExecutingAssembly();
var allClasses = assembly.GetTypes() var allClasses = assembly.GetTypes()

View file

@ -1,8 +1,9 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
@ -15,19 +16,19 @@ namespace SeaHaven.Services.Implementation
public class AuthenticationService : IAuthenticationService public class AuthenticationService : IAuthenticationService
{ {
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IConfiguration _configuration; private readonly JwtOptions _jwtOptions;
private readonly IUserDataService _userDataService; private readonly IUserDataService _userDataService;
private readonly IForgetPasswordDataService _forgetPasswordDataService; private readonly IForgetPasswordDataService _forgetPasswordDataService;
private readonly IEmailSender _emailSender; private readonly IEmailSender _emailSender;
public AuthenticationService( public AuthenticationService(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IConfiguration configuration, IOptions<JwtOptions> jwtOptions,
IUserDataService userDataService, IUserDataService userDataService,
IForgetPasswordDataService forgetPasswordDataService, IForgetPasswordDataService forgetPasswordDataService,
IEmailSender emailSender) IEmailSender emailSender)
{ {
_userManager = userManager; _userManager = userManager;
_configuration = configuration; _jwtOptions = jwtOptions.Value;
_userDataService = userDataService; _userDataService = userDataService;
_forgetPasswordDataService = forgetPasswordDataService; _forgetPasswordDataService = forgetPasswordDataService;
_emailSender = emailSender; _emailSender = emailSender;
@ -140,10 +141,10 @@ namespace SeaHaven.Services.Implementation
private JwtSecurityToken GetToken(List<Claim> authClaims) private JwtSecurityToken GetToken(List<Claim> authClaims)
{ {
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"] ?? "")); var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret));
var token = new JwtSecurityToken( var token = new JwtSecurityToken(
issuer: _configuration["JWT:ValidIssuer"], issuer: _jwtOptions.ValidIssuer,
audience: _configuration["JWT:ValidAudience"], audience: _jwtOptions.ValidAudience,
expires: DateTime.Now.AddDays(10), expires: DateTime.Now.AddDays(10),
claims: authClaims, claims: authClaims,
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Claims; using System.Security.Claims;
@ -11,13 +12,13 @@ namespace SeaHaven.Services.Implementation
{ {
private readonly IUpliftDataService _upliftData; private readonly IUpliftDataService _upliftData;
private readonly IDispatchDataService _dispatchData; private readonly IDispatchDataService _dispatchData;
private readonly IConfiguration _config; private readonly ApprovalsOptions _approvalsOptions;
public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IConfiguration config) public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IOptions<ApprovalsOptions> approvalsOptions)
{ {
_upliftData = upliftData; _upliftData = upliftData;
_dispatchData = dispatchData; _dispatchData = dispatchData;
_config = config; _approvalsOptions = approvalsOptions.Value;
} }
public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) public async Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken)
@ -159,13 +160,19 @@ namespace SeaHaven.Services.Implementation
private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) private bool UserCanApprove(ClaimsPrincipal user, int requiredTier)
{ {
var key = $"Approvals:Tier{requiredTier}Roles"; var roles = RolesForTier(requiredTier);
var roles = _config.GetSection(key).Get<string[]>() ?? Array.Empty<string>();
foreach (var r in roles) foreach (var r in roles)
{ {
if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true; if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true;
} }
return false; return false;
} }
private string[] RolesForTier(int requiredTier) => requiredTier switch
{
1 => _approvalsOptions.Tier1Roles,
2 => _approvalsOptions.Tier2Roles,
_ => Array.Empty<string>()
};
} }
} }

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -14,7 +15,8 @@ namespace SeaHaven.Services.Implementation
private readonly ICommentDataService _commentData; private readonly ICommentDataService _commentData;
private readonly IUserDataService _userData; private readonly IUserDataService _userData;
private readonly IEmailSender _emailSender; private readonly IEmailSender _emailSender;
private readonly IConfiguration _config; private readonly FrontendOptions _frontendOptions;
private readonly ApprovalsOptions _approvalsOptions;
public VendorPortalService( public VendorPortalService(
IVendorPortalTokenService tokens, IVendorPortalTokenService tokens,
@ -23,7 +25,8 @@ namespace SeaHaven.Services.Implementation
ICommentDataService commentData, ICommentDataService commentData,
IUserDataService userData, IUserDataService userData,
IEmailSender emailSender, IEmailSender emailSender,
IConfiguration config) IOptions<FrontendOptions> frontendOptions,
IOptions<ApprovalsOptions> approvalsOptions)
{ {
_tokens = tokens; _tokens = tokens;
_dispatchData = dispatchData; _dispatchData = dispatchData;
@ -31,7 +34,8 @@ namespace SeaHaven.Services.Implementation
_commentData = commentData; _commentData = commentData;
_userData = userData; _userData = userData;
_emailSender = emailSender; _emailSender = emailSender;
_config = config; _frontendOptions = frontendOptions.Value;
_approvalsOptions = approvalsOptions.Value;
} }
public async Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken) public async Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken)
@ -416,7 +420,7 @@ namespace SeaHaven.Services.Implementation
throw new InvalidOperationException("A pending uplift request already exists for this dispatch"); throw new InvalidOperationException("A pending uplift request already exists for this dispatch");
} }
var tier1Max = _config.GetValue<decimal?>("Approvals:UpliftTier1MaxUsd") ?? 2500m; var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m;
var delta = requestedNTE - current; var delta = requestedNTE - current;
var requiredTier = delta > tier1Max ? 2 : 1; var requiredTier = delta > tier1Max ? 2 : 1;
@ -500,7 +504,7 @@ namespace SeaHaven.Services.Implementation
var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken);
if (string.IsNullOrWhiteSpace(dispatcherEmail)) return; if (string.IsNullOrWhiteSpace(dispatcherEmail)) return;
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? ""; var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
var workOrderLink = dispatch.WorkOrderId.HasValue var workOrderLink = dispatch.WorkOrderId.HasValue
? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}"
: frontendBase; : frontendBase;

View file

@ -1,6 +1,7 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
using System.Security.Cryptography; using System.Security.Cryptography;
@ -12,10 +13,10 @@ namespace SeaHaven.Services.Implementation
private readonly IVendorDataService _vendorDataService; private readonly IVendorDataService _vendorDataService;
private readonly int _lifetimeDays; private readonly int _lifetimeDays;
public VendorPortalTokenService(IVendorDataService vendorDataService, IConfiguration config) public VendorPortalTokenService(IVendorDataService vendorDataService, IOptions<VendorPortalOptions> options)
{ {
_vendorDataService = vendorDataService; _vendorDataService = vendorDataService;
_lifetimeDays = config.GetValue<int?>("VendorPortal:TokenLifetimeDays") ?? 365; _lifetimeDays = options.Value.TokenLifetimeDays ?? 365;
} }
public async Task<VendorPortalTokenStateDTO> GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken) public async Task<VendorPortalTokenStateDTO> GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken)

View file

@ -2,8 +2,9 @@ using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using FluentValidation; using FluentValidation;
using FluentValidation.Results; using FluentValidation.Results;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Helpers; using SeaHaven.Services.Helpers;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -19,7 +20,7 @@ namespace SeaHaven.Services.Implementation
private readonly IVendorDataService _vendorDataService; private readonly IVendorDataService _vendorDataService;
private readonly IVendorPortalTokenService _vendorTokenService; private readonly IVendorPortalTokenService _vendorTokenService;
private readonly IZipCodeDistance _zipDistance; private readonly IZipCodeDistance _zipDistance;
private readonly IConfiguration _config; private readonly FrontendOptions _frontendOptions;
private readonly ICreateVendorValidation _createValidator; private readonly ICreateVendorValidation _createValidator;
private readonly IUpdateVendorValidation _updateValidator; private readonly IUpdateVendorValidation _updateValidator;
private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator;
@ -28,7 +29,7 @@ namespace SeaHaven.Services.Implementation
IVendorDataService vendorDataService, IVendorDataService vendorDataService,
IVendorPortalTokenService vendorTokenService, IVendorPortalTokenService vendorTokenService,
IZipCodeDistance zipDistance, IZipCodeDistance zipDistance,
IConfiguration config, IOptions<FrontendOptions> frontendOptions,
ICreateVendorValidation createValidator, ICreateVendorValidation createValidator,
IUpdateVendorValidation updateValidator, IUpdateVendorValidation updateValidator,
IWorkOrderVendorUpdateValidation workOrderUpdateValidator) IWorkOrderVendorUpdateValidation workOrderUpdateValidator)
@ -36,7 +37,7 @@ namespace SeaHaven.Services.Implementation
_vendorDataService = vendorDataService; _vendorDataService = vendorDataService;
_vendorTokenService = vendorTokenService; _vendorTokenService = vendorTokenService;
_zipDistance = zipDistance; _zipDistance = zipDistance;
_config = config; _frontendOptions = frontendOptions.Value;
_createValidator = createValidator; _createValidator = createValidator;
_updateValidator = updateValidator; _updateValidator = updateValidator;
_workOrderUpdateValidator = workOrderUpdateValidator; _workOrderUpdateValidator = workOrderUpdateValidator;
@ -650,7 +651,7 @@ namespace SeaHaven.Services.Implementation
private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token) private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token)
{ {
var frontendBase = _config.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? ""; var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
return new VendorPortalTokenDTO return new VendorPortalTokenDTO
{ {
Token = token.Token, Token = token.Token,

View file

@ -1,8 +1,9 @@
using Data.SeaHavenIndustries; using Data.SeaHavenIndustries;
using Data.SeaHavenIndustries.Enums; using Data.SeaHavenIndustries.Enums;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Options;
using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Interfaces;
using SeaHaven.DataServices.Models; using SeaHaven.DataServices.Models;
using SeaHaven.Services.Configuration;
using SeaHaven.Services.DTOs; using SeaHaven.Services.DTOs;
using SeaHaven.Services.Interfaces; using SeaHaven.Services.Interfaces;
@ -18,7 +19,7 @@ namespace SeaHaven.Services.Implementation
private readonly ICommentDataService _commentDataService; private readonly ICommentDataService _commentDataService;
private readonly IDispatchEmailPort _emailPort; private readonly IDispatchEmailPort _emailPort;
private readonly IVendorTokenPort _vendorTokenPort; private readonly IVendorTokenPort _vendorTokenPort;
private readonly IConfiguration _configuration; private readonly FrontendOptions _frontendOptions;
public WorkOrderDispatchService( public WorkOrderDispatchService(
IWorkOrderDataService workOrderDataService, IWorkOrderDataService workOrderDataService,
@ -29,7 +30,7 @@ namespace SeaHaven.Services.Implementation
ICommentDataService commentDataService, ICommentDataService commentDataService,
IDispatchEmailPort emailPort, IDispatchEmailPort emailPort,
IVendorTokenPort vendorTokenPort, IVendorTokenPort vendorTokenPort,
IConfiguration configuration) IOptions<FrontendOptions> frontendOptions)
{ {
_workOrderDataService = workOrderDataService; _workOrderDataService = workOrderDataService;
_dispatchDataService = dispatchDataService; _dispatchDataService = dispatchDataService;
@ -39,18 +40,19 @@ namespace SeaHaven.Services.Implementation
_commentDataService = commentDataService; _commentDataService = commentDataService;
_emailPort = emailPort; _emailPort = emailPort;
_vendorTokenPort = vendorTokenPort; _vendorTokenPort = vendorTokenPort;
_configuration = configuration; _frontendOptions = frontendOptions.Value;
} }
public async Task<DispatchToVendorResult> DispatchToVendorAsync(DispatchToVendorInput input) public async Task<DispatchToVendorResult> DispatchToVendorAsync(DispatchToVendorInput input)
{ {
var allWorkOrders = await _workOrderDataService.GetAllWithDetailsAsync(); var workOrders = await _workOrderDataService.GetByRequestedIdsWithDetailsAsync(
var workOrders = allWorkOrders.Where(w => input.WorkOrderIds.Contains(w.Id)).ToList(); input.WorkOrderIds,
CancellationToken.None);
if (workOrders.Count == 0) if (workOrders.Count == 0)
throw new InvalidOperationException("No work orders found"); throw new InvalidOperationException("No work orders found");
var primaryWO = workOrders.First(); var primaryWO = workOrders[0];
var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); var lastPO = await _dispatchDataService.GenerateNextPONumberAsync();
int nextPO = int.TryParse(lastPO, out var po) ? po : 1; int nextPO = int.TryParse(lastPO, out var po) ? po : 1;
@ -61,13 +63,15 @@ namespace SeaHaven.Services.Implementation
var currentUser = input.UserId != null ? await _userDataService.GetByIdAsync(input.UserId) : null; var currentUser = input.UserId != null ? await _userDataService.GetByIdAsync(input.UserId) : null;
var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : "";
var templateItemTexts = await LoadTemplateItemTextsAsync(input.TaskListTemplateId);
var distinctVendorIds = input.VendorIds.Distinct().ToList();
var vendors = await _vendorDataService.GetByIdsOrderedAsync(distinctVendorIds, CancellationToken.None);
var dispatches = new List<DispatchResultItem>(); var dispatches = new List<DispatchResultItem>();
foreach (var vendorId in input.VendorIds) foreach (var vendor in vendors)
{ {
var vendor = await _vendorDataService.GetByIdAsync(vendorId);
if (vendor == null) continue;
var poNumber = $"VPO-{nextPO:D5}"; var poNumber = $"VPO-{nextPO:D5}";
nextPO++; nextPO++;
var dispatchNumber = $"DSP-{nextDSP:D5}"; var dispatchNumber = $"DSP-{nextDSP:D5}";
@ -78,7 +82,7 @@ namespace SeaHaven.Services.Implementation
var dispatch = new Dispatch var dispatch = new Dispatch
{ {
WorkOrderId = primaryWO.Id, WorkOrderId = primaryWO.Id,
VendorId = vendorId, VendorId = vendor.Id,
DispatchNumber = dispatchNumber, DispatchNumber = dispatchNumber,
PONumber = poNumber, PONumber = poNumber,
NTEAmount = input.NTEAmount, NTEAmount = input.NTEAmount,
@ -90,22 +94,7 @@ namespace SeaHaven.Services.Implementation
DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList()
}; };
var checklistItems = new List<DispatchChecklistItem>(); var checklistItems = BuildChecklistItems(templateItemTexts, input.CustomChecklistItems);
if (input.TaskListTemplateId.HasValue)
{
var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(input.TaskListTemplateId.Value);
if (template?.Items != null)
{
checklistItems.AddRange(template.Items.OrderBy(i => i.SortOrder).Select((i, idx) =>
new DispatchChecklistItem { ItemText = i.ItemText, SortOrder = idx }));
}
}
if (input.CustomChecklistItems != null)
{
var startIdx = checklistItems.Count;
checklistItems.AddRange(input.CustomChecklistItems.Select((text, idx) =>
new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx }));
}
dispatch.ChecklistItems = checklistItems; dispatch.ChecklistItems = checklistItems;
var woListHtml = string.Join("", workOrders.Select(wo => var woListHtml = string.Join("", workOrders.Select(wo =>
@ -120,8 +109,8 @@ namespace SeaHaven.Services.Implementation
var descriptionHtml = System.Net.WebUtility.HtmlEncode(input.Description ?? primaryWO.Description ?? ""); var descriptionHtml = System.Net.WebUtility.HtmlEncode(input.Description ?? primaryWO.Description ?? "");
var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName);
var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendorId); var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendor.Id);
var frontendBase = _configuration.GetValue<string>("FrontendBaseUrl")?.TrimEnd('/') ?? ""; var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? "";
var portalUrl = $"{frontendBase}/v/{vendorToken}/dashboard"; var portalUrl = $"{frontendBase}/v/{vendorToken}/dashboard";
var html = $@" var html = $@"
@ -152,19 +141,17 @@ namespace SeaHaven.Services.Implementation
var savedDispatch = await _dispatchDataService.AddAsync(dispatch); var savedDispatch = await _dispatchDataService.AddAsync(dispatch);
foreach (var wo in workOrders) var auditLogs = workOrders.Select(wo => new WorkOrderAuditLog
{ {
await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog WorkOrderId = wo.Id,
{ UserId = input.UserId,
WorkOrderId = wo.Id, FieldName = "Dispatch",
UserId = input.UserId, OldValue = "",
FieldName = "Dispatch", NewValue = $"{vendor.CompanyName} ({dispatchNumber})",
OldValue = "", Action = AuditAction.Dispatch.ToString(),
NewValue = $"{vendor.CompanyName} ({dispatchNumber})", CreatedAt = DateTime.UtcNow
Action = AuditAction.Dispatch.ToString(), }).ToList();
CreatedAt = DateTime.UtcNow await _workOrderDataService.AddAuditLogsAsync(auditLogs);
});
}
dispatches.Add(new DispatchResultItem dispatches.Add(new DispatchResultItem
{ {
@ -180,6 +167,41 @@ namespace SeaHaven.Services.Implementation
return new DispatchToVendorResult { Message = "Dispatched", Dispatches = dispatches }; return new DispatchToVendorResult { Message = "Dispatched", Dispatches = dispatches };
} }
private async Task<List<string?>?> LoadTemplateItemTextsAsync(int? taskListTemplateId)
{
if (!taskListTemplateId.HasValue)
return null;
var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(taskListTemplateId.Value);
if (template?.Items == null)
return null;
return template.Items
.OrderBy(i => i.SortOrder)
.Select(i => i.ItemText)
.ToList();
}
private static List<DispatchChecklistItem> BuildChecklistItems(List<string?>? templateItemTexts, List<string>? customChecklistItems)
{
var checklistItems = new List<DispatchChecklistItem>();
if (templateItemTexts != null)
{
checklistItems.AddRange(templateItemTexts.Select((text, idx) =>
new DispatchChecklistItem { ItemText = text, SortOrder = idx }));
}
if (customChecklistItems != null)
{
var startIdx = checklistItems.Count;
checklistItems.AddRange(customChecklistItems.Select((text, idx) =>
new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx }));
}
return checklistItems;
}
public async Task<IEnumerable<DispatchReadModel>> GetDispatchesAsync(int workOrderId) public async Task<IEnumerable<DispatchReadModel>> GetDispatchesAsync(int workOrderId)
{ {
var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId); var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId);