diff --git a/.github/workflows/architecture-quality.yml b/.github/workflows/architecture-quality.yml new file mode 100644 index 0000000..ada851d --- /dev/null +++ b/.github/workflows/architecture-quality.yml @@ -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[@]}" diff --git a/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs new file mode 100644 index 0000000..b636e67 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs @@ -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 ApiControllers => + ApiAssembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && typeof(ControllerBase).IsAssignableFrom(t)); + + private static IEnumerable 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 ParametersOf(IEnumerable 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"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs index f233055..71b592b 100644 --- a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -1,9 +1,10 @@ using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -24,17 +25,15 @@ public class AuthenticationServiceTests var (manager, s, h) = IdentityTestHelpers.CreateUserManager(); store = s; 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() - .AddInMemoryCollection(new Dictionary - { - ["JWT:Secret"] = new string('x', 64), - ["JWT:ValidIssuer"] = "issuer", - ["JWT:ValidAudience"] = "audience" - }) - .Build(); + private static JwtOptions JwtOptions => new() + { + Secret = new string('x', 64), + ValidIssuer = "issuer", + ValidAudience = "audience" + }; [Fact] public async Task Login_UnknownUser_ReturnsNull() diff --git a/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs new file mode 100644 index 0000000..8992315 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs @@ -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? woData = null, + Mock? vendorData = null, + Mock? templateData = null, + Mock? dispatchData = null) + { + var dd = dispatchData ?? new Mock(); + dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1"); + dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1"); + dd.Setup(d => d.AddAsync(It.IsAny())) + .ReturnsAsync((Dispatch d) => d); + + var emailPort = new Mock(); + emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var tokenPort = new Mock(); + tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny())).ReturnsAsync("token"); + + return new WorkOrderDispatchService( + (woData ?? new Mock()).Object, + dd.Object, + (vendorData ?? new Mock()).Object, + Mock.Of(), + (templateData ?? new Mock()).Object, + Mock.Of(), + emailPort.Object, + tokenPort.Object, + Options.Create(new FrontendOptions())); + } + + private static Mock WorkOrderDataReturning(params WorkOrder[] workOrders) => + WorkOrderDataReturning(new Mock(), workOrders); + + private static Mock WorkOrderDataReturning(Mock mock, params WorkOrder[] workOrders) + { + mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(workOrders.ToList()); + return mock; + } + + private static Mock VendorDataReturning(params Vendor[] vendors) + { + var mock = new Mock(); + mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) + .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(); + 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())) + .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(); + vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { 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>(ids => ids.SequenceEqual(new[] { 2, 999 })), + It.IsAny()), Times.Once); + vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount() + { + var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); + var templateData = new Mock(); + templateData.Setup(d => d.GetByIdWithItemsAsync(7)) + .ReturnsAsync(new TaskListTemplate + { + Items = new List + { + 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>(), It.IsAny()), Times.Once); + vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), 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>(logs => logs.Count == 2)), Times.Exactly(2)); + woData.Verify(d => d.AddAuditLogAsync(It.IsAny()), 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(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs index c786e8f..1c84083 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs @@ -1,8 +1,9 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -209,7 +210,7 @@ public class WorkOrderDispatchServiceBehaviorTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Options.Create(new FrontendOptions())); } } diff --git a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs index 660307b..ad81b94 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs @@ -18,9 +18,10 @@ public class ServiceRegistrationTests { private static ServiceProvider BuildProvider() { + var configuration = new ConfigurationBuilder().Build(); var services = new ServiceCollection(); services.AddLogging(); - services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddSingleton(configuration); services.AddSingleton(_ => new DummyZipCodeDistance()); services.AddSingleton(_ => new DummyEmailSender()); @@ -31,7 +32,7 @@ public class ServiceRegistrationTests .AddEntityFrameworkStores(); services.AddDataServices(); - services.AddBusinessServices(); + services.AddBusinessServices(configuration); return services.BuildServiceProvider(); } diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs index c6ab45a..7c2f89f 100644 --- a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -34,7 +34,9 @@ public class UpliftControllerTests var service = new Mock(); var dto = new UpliftListResultDTO { - Total = 1, Page = 1, PageSize = 25, + Total = 1, + Page = 1, + PageSize = 25, Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } } }; service.Setup(x => x.ListAsync(It.IsAny(), "Pending", null, 1, 25, It.IsAny())) @@ -107,7 +109,8 @@ public class UpliftControllerTests var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; - ((UpliftApproveResultDTO)envelope.Data).Should().Be(approved); + var approveData = envelope.Data as UpliftApproveResultDTO; + approveData.Should().Be(approved); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs index 91a2f49..d2900c1 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -24,7 +24,11 @@ public class VendorPortalControllerTests 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] @@ -64,7 +68,8 @@ public class VendorPortalControllerTests var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; envelope.Status.Should().Be("Success"); - ((VendorPortalSession)envelope.Data).Should().Be(Session); + var sessionData = envelope.Data as VendorPortalSession; + sessionData.Should().Be(Session); } [Fact] @@ -146,7 +151,8 @@ public class VendorPortalControllerTests var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; - ((AcceptDispatchResultDTO)envelope.Data).Should().Be(acceptResult); + var acceptData = envelope.Data as AcceptDispatchResultDTO; + acceptData.Should().Be(acceptResult); } [Fact] @@ -210,7 +216,10 @@ public class VendorPortalControllerTests 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(); @@ -244,12 +253,14 @@ public class VendorPortalControllerTests 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().Subject; var envelope = ok.Value.Should().BeOfType().Subject; - ((UpliftRequestResultDTO)envelope.Data).Should().Be(upliftResult); + var upliftData = envelope.Data as UpliftRequestResultDTO; + upliftData.Should().Be(upliftResult); } [Fact] @@ -282,7 +293,8 @@ public class VendorPortalControllerTests var ok = result.Should().BeOfType().Subject; var envelope = ok.Value.Should().BeOfType().Subject; - ((CancelUpliftResultDTO)envelope.Data).Should().Be(cancelResult); + var cancelData = envelope.Data as CancelUpliftResultDTO; + cancelData.Should().Be(cancelResult); } [Fact] diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index ccc909b..3f4f6bc 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -2,9 +2,10 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using FluentValidation; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Moq; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -20,7 +21,7 @@ public class VendorServiceTests data.Object, Mock.Of(), Mock.Of(), - new ConfigurationBuilder().Build(), + Options.Create(new FrontendOptions()), new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs new file mode 100644 index 0000000..a32815f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs @@ -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() + .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 }); + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 211429c..c711931 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -52,7 +52,7 @@ builder.Services.AddSingleton( serviceProvider => serviceProvider.GetRequiredService()); builder.Services.AddDataServices(); -builder.Services.AddBusinessServices(); +builder.Services.AddBusinessServices(configuration); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -74,7 +74,9 @@ builder.Services.AddAuthentication(options => ValidateAudience = true, ValidAudience = configuration["JWT:ValidAudience"], 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(); diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md index ee1d809..ca27a73 100644 --- a/BACKEND_ARCHITECTURE.md +++ b/BACKEND_ARCHITECTURE.md @@ -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: - -``` -ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” -│ 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 │ -ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +```text +HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext ``` ---- - -## šŸ“ Project Structure - -``` -backend/shoc-backend/ -│ -ā”œā”€ā”€ Api.SeaHavenIndustries/ # 🌐 API Layer -│ ā”œā”€ā”€ Controllers/ # HTTP endpoints -│ ā”œā”€ā”€ DTOs/ # Data Transfer Objects (API contracts) -│ ā”œā”€ā”€ Program.cs # App startup & DI registration -│ └── appsettings.json # Configuration -│ -ā”œā”€ā”€ SeaHaven.Services/ # šŸ’¼ Service Layer (Business Logic) -│ ā”œā”€ā”€ Interfaces/ # Service contracts (IContactService, etc.) -│ ā”œā”€ā”€ Implementation/ # Service implementations -│ ā”œā”€ā”€ DTOs/ # Internal DTOs for service layer -│ └── Validators/ # FluentValidation validators -│ -ā”œā”€ā”€ SeaHaven.DataServices/ # šŸ’¾ Data Access Layer -│ ā”œā”€ā”€ Interfaces/ # DataService contracts (IContactDataService, etc.) -│ ā”œā”€ā”€ Implementation/ # DataService implementations -│ └── DTOs/ # Data layer DTOs -│ -└── Data.SeaHavenIndustries/ # šŸ—„ļø Data Layer (EF Core) - ā”œā”€ā”€ Models/ # Entity models (Contact, Employee, etc.) - ā”œā”€ā”€ ApplicationDbContext.cs # EF Core DbContext - └── Migrations/ # Database migrations -``` - ---- - -## šŸ”„ Data Flow Example (Creating a Contact) - -### 1ļøāƒ£ **API Layer** (`ContactController.cs`) -```csharp -[HttpPost("Create")] -public async Task AddContacts(Contact_DTO dto) -{ - try - { - // Get authenticated user ID - var userId = _userManager.GetUserId(User); - if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - - // Convert API DTO → Service DTO - var serviceDto = dto.ToServiceCreateDTO(); - - // Call Service Layer - await _contactService.CreateContactAsync(serviceDto, userId); - - return Ok(new Response { Status = "Success", Message = "Contact created" }); - } - catch (ValidationException vex) - { - var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); - return BadRequest(new Response { Status = "Validation Error", Message = errors }); - } - catch (KeyNotFoundException) - { - return NotFound(new Response { Status = "Error", Message = "Resource not found" }); - } - catch (Exception ex) - { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); - } -} -``` - -**Responsibilities:** -- āœ… Authenticate user -- āœ… Extract user ID from claims -- āœ… Map API DTO to Service DTO -- āœ… Catch exceptions and return proper HTTP status codes -- āŒ NO business logic -- āŒ NO database access - ---- - -### 2ļøāƒ£ **Service Layer** (`ContactService.cs`) - -```csharp -public class ContactService : IContactService -{ - private readonly IContactDataService _dataService; - private readonly IValidator _createValidator; - - public ContactService( - IContactDataService dataService, - IValidator createValidator) - { - _dataService = dataService; - _createValidator = createValidator; - } - - public async Task CreateContactAsync(CreateContactDTO dto, string userId) - { - // 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 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 -{ - 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 CreateContactAsync(CreateContactDTO dto, string userId); - Task UpdateContactAsync(int id, UpdateContactDTO dto, string userId); - Task DeleteContactAsync(int id, string userId); - Task GetContactByIdAsync(int id); - Task> GetContactsPagedAsync(int page, int pageSize, string? search); -} -``` - -### Data Service Interface (`IContactDataService.cs`) -```csharp -public interface IContactDataService -{ - Task CreateAsync(CreateContactDataDTO dto, string userId); - Task UpdateAsync(int id, UpdateContactDataDTO dto, string userId); - Task DeleteAsync(int id, string userId); - Task GetByIdAsync(int id); - Task> 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 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**. - +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 +I/O responsibilities and enables behavior-focused testing. + +## Layer responsibilities + +### API controllers + +- Parse HTTP input, authorize the caller, invoke a business-service interface, and map + the result to the established HTTP contract. +- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete + data-service implementations. +- Do not contain persistence queries or business workflows. +- Do not expose exception messages to clients. Map known failures explicitly and let the + centralized exception boundary handle unexpected failures. + +### Business services + +- Own validation, business decisions, orchestration, and transaction intent. +- Depend on interfaces for persistence and external I/O. +- Use `IOptions` for typed configuration. Do not inject `IConfiguration`. +- Keep feature responsibilities cohesive. Split a service when it has independently + changing business reasons, not because it crossed an arbitrary line count. +- Preserve existing API and board-backed behavior unless the accepted requirement + explicitly changes it. + +### Data services + +- 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 + caller needs them. +- Page unbounded collections at the database. +- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes + while preserving the workflow's failure and transaction semantics. +- Do not introduce a generic repository over EF Core. Feature data services should expose + intent-revealing operations. + +## Performance review + +For each changed hot path, document and test: + +- input size variables; +- CPU and memory complexity; +- database round trips; +- whether filtering, ordering, and paging execute in SQL; +- whether tracking is necessary; +- whether repeated work can be hoisted out of loops; +- the partial-failure and transaction behavior. + +Prefer dictionary or hash-set reconstruction when database results must follow caller +order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory; +repeated `First`/`Single` scans over the result are `O(n²)`. + +Do not optimize by weakening correctness. In particular, batching all writes into one +final save can change partial-success behavior, and adding a transaction can change +locking and retry semantics. Those are business decisions, not mechanical cleanup. + +## Configuration + +Configuration is bound once during composition in +`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive +typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and +`VendorPortalOptions`. + +Defaults must preserve current behavior. Startup validation should be introduced only +when every deployed environment is known to provide the required value. + +## Enforcement + +`ArchitectureTests` enforces mechanical dependency rules: + +- controllers cannot inject EF contexts or concrete service/data-service classes; +- business services cannot inject EF contexts or raw `IConfiguration`. + +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 +through public interfaces. Do not add tests merely to prove a linter or analyzer itself +works; test the product behavior the rule protects. + +## Review checklist + +- Controller depends only on service abstractions. +- Business logic is in a cohesive feature service. +- EF operations are behind a feature data-service abstraction. +- No raw `IConfiguration` in a business service. +- No unbounded load followed by in-memory filtering. +- No query/save loop where a set-based operation preserves semantics. +- Read-only EF queries use no tracking. +- New async I/O accepts and propagates cancellation where the public contract permits. +- Errors do not leak internal exception details. +- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing + records, and relevant failure boundaries. +- Changed behavior has been checked against the authoritative ticket board. Missing board + access blocks readiness and merge claims. diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 2ae49cf..d6767dc 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -29,6 +29,28 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(); } + public async Task> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken) + { + var orderedIds = ids.Distinct().ToList(); + if (orderedIds.Count == 0) + return Array.Empty(); + + 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(orderedIds.Count); + foreach (var id in orderedIds) + { + if (byId.TryGetValue(id, out var vendor)) + ordered.Add(vendor); + } + + return ordered; + } + public async Task> GetAllAsync() { return await _context.Vendors.ToListAsync(); diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index fe51046..a382d80 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -283,6 +283,15 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + public async Task AddAuditLogsAsync(IReadOnlyCollection auditLogs) + { + if (auditLogs.Count == 0) + return; + + await _context.WorkOrderAuditLogs.AddRangeAsync(auditLogs); + await _context.SaveChangesAsync(); + } + public async Task DeleteWorkOrderContactsAsync(int workOrderId) { var contacts = await _context.WorkOrderContacts @@ -369,6 +378,31 @@ namespace SeaHaven.DataServices.Implementation .ToListAsync(); } + public async Task> GetByRequestedIdsWithDetailsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken) + { + var orderedIds = ids.Distinct().ToList(); + if (orderedIds.Count == 0) + return Array.Empty(); + + 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(orderedIds.Count); + foreach (var id in orderedIds) + { + if (byId.TryGetValue(id, out var workOrder)) + ordered.Add(workOrder); + } + + return ordered; + } + public async Task GetWorkOrderDetailAsync(int id) { return await _context.workOrders diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 6bd12f8..3324b6e 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -6,6 +6,10 @@ namespace SeaHaven.DataServices.Interfaces { Task GetByIdAsync(int id); Task 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> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken); Task> GetAllAsync(); Task> GetActiveVendorsAsync(CancellationToken cancellationToken); Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index e298516..3edbd12 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -14,7 +14,7 @@ namespace SeaHaven.DataServices.Interfaces Task> GetByAssigneeAsync(string assigneeId); Task> GetByStatusAsync(string status); Task> GetByPriorityAsync(string priority); - + // Paged results Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, @@ -33,12 +33,12 @@ namespace SeaHaven.DataServices.Interfaces int? locationId = null, string? sortBy = "lastUpdated", string? sortDir = "desc"); - + // Command operations Task AddAsync(WorkOrder workOrder); Task UpdateAsync(WorkOrder workOrder); Task DeleteAsync(int id); - + // Related entities - Read Task> GetWorkOrderContactsAsync(int workOrderId); Task> GetWorkOrderCategoriesAsync(int workOrderId); @@ -51,6 +51,7 @@ namespace SeaHaven.DataServices.Interfaces Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable categoryIds); Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment); Task AddAuditLogAsync(WorkOrderAuditLog auditLog); + Task AddAuditLogsAsync(IReadOnlyCollection auditLogs); // Related entities - Delete Task DeleteWorkOrderContactsAsync(int workOrderId); @@ -71,6 +72,12 @@ namespace SeaHaven.DataServices.Interfaces Task CountByLocationAsync(int locationId); Task> GetAllWithDetailsAsync(); + // Batched lookup: server-filters the distinct requested ids, excludes templates, + // eagerly loads the location detail, deduplicates, and returns results in input order. + Task> GetByRequestedIdsWithDetailsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken); + // Complex read models Task GetWorkOrderDetailAsync(int id); Task> GetNonTemplateWorkOrdersWithLocationsAsync(); diff --git a/SeaHaven.Services/Configuration/ServiceOptions.cs b/SeaHaven.Services/Configuration/ServiceOptions.cs new file mode 100644 index 0000000..dad10e0 --- /dev/null +++ b/SeaHaven.Services/Configuration/ServiceOptions.cs @@ -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(); + public string[] Tier2Roles { get; set; } = Array.Empty(); +} + +public sealed class VendorPortalOptions +{ + public const string SectionName = "VendorPortal"; + + public int? TokenLifetimeDays { get; set; } +} diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index 12673b4..d5fddc2 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -1,5 +1,7 @@ using FluentValidation; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using SeaHaven.Services.Configuration; using System.Reflection; namespace SeaHaven.Services.DependencyInjection @@ -8,8 +10,13 @@ namespace SeaHaven.Services.DependencyInjection { private static readonly List ExcludedTypes = new(); - public static IServiceCollection AddBusinessServices(this IServiceCollection services) + public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) { + services.Configure(configuration); + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure(configuration.GetSection(ApprovalsOptions.SectionName)); + services.Configure(configuration.GetSection(VendorPortalOptions.SectionName)); + var assembly = Assembly.GetExecutingAssembly(); var allClasses = assembly.GetTypes() diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 5f4a59e..74c6de8 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -1,8 +1,9 @@ using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Identity; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.IdentityModel.Tokens.Jwt; @@ -15,19 +16,19 @@ namespace SeaHaven.Services.Implementation public class AuthenticationService : IAuthenticationService { private readonly UserManager _userManager; - private readonly IConfiguration _configuration; + private readonly JwtOptions _jwtOptions; private readonly IUserDataService _userDataService; private readonly IForgetPasswordDataService _forgetPasswordDataService; private readonly IEmailSender _emailSender; public AuthenticationService( UserManager userManager, - IConfiguration configuration, + IOptions jwtOptions, IUserDataService userDataService, IForgetPasswordDataService forgetPasswordDataService, IEmailSender emailSender) { _userManager = userManager; - _configuration = configuration; + _jwtOptions = jwtOptions.Value; _userDataService = userDataService; _forgetPasswordDataService = forgetPasswordDataService; _emailSender = emailSender; @@ -140,10 +141,10 @@ namespace SeaHaven.Services.Implementation private JwtSecurityToken GetToken(List authClaims) { - var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"] ?? "")); + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret)); var token = new JwtSecurityToken( - issuer: _configuration["JWT:ValidIssuer"], - audience: _configuration["JWT:ValidAudience"], + issuer: _jwtOptions.ValidIssuer, + audience: _jwtOptions.ValidAudience, expires: DateTime.Now.AddDays(10), claims: authClaims, signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs index fa97467..051dd69 100644 --- a/SeaHaven.Services/Implementation/UpliftService.cs +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; @@ -11,13 +12,13 @@ namespace SeaHaven.Services.Implementation { private readonly IUpliftDataService _upliftData; 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) { _upliftData = upliftData; _dispatchData = dispatchData; - _config = config; + _approvalsOptions = approvalsOptions.Value; } public async Task 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) { - var key = $"Approvals:Tier{requiredTier}Roles"; - var roles = _config.GetSection(key).Get() ?? Array.Empty(); + var roles = RolesForTier(requiredTier); foreach (var r in roles) { if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true; } return false; } + + private string[] RolesForTier(int requiredTier) => requiredTier switch + { + 1 => _approvalsOptions.Tier1Roles, + 2 => _approvalsOptions.Tier2Roles, + _ => Array.Empty() + }; } } diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs index 66123d7..470eb98 100644 --- a/SeaHaven.Services/Implementation/VendorPortalService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -14,7 +15,8 @@ namespace SeaHaven.Services.Implementation private readonly ICommentDataService _commentData; private readonly IUserDataService _userData; private readonly IEmailSender _emailSender; - private readonly IConfiguration _config; + private readonly FrontendOptions _frontendOptions; + private readonly ApprovalsOptions _approvalsOptions; public VendorPortalService( IVendorPortalTokenService tokens, @@ -23,7 +25,8 @@ namespace SeaHaven.Services.Implementation ICommentDataService commentData, IUserDataService userData, IEmailSender emailSender, - IConfiguration config) + IOptions frontendOptions, + IOptions approvalsOptions) { _tokens = tokens; _dispatchData = dispatchData; @@ -31,7 +34,8 @@ namespace SeaHaven.Services.Implementation _commentData = commentData; _userData = userData; _emailSender = emailSender; - _config = config; + _frontendOptions = frontendOptions.Value; + _approvalsOptions = approvalsOptions.Value; } public async Task 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"); } - var tier1Max = _config.GetValue("Approvals:UpliftTier1MaxUsd") ?? 2500m; + var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; var delta = requestedNTE - current; var requiredTier = delta > tier1Max ? 2 : 1; @@ -500,7 +504,7 @@ namespace SeaHaven.Services.Implementation var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); if (string.IsNullOrWhiteSpace(dispatcherEmail)) return; - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; var workOrderLink = dispatch.WorkOrderId.HasValue ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" : frontendBase; diff --git a/SeaHaven.Services/Implementation/VendorPortalTokenService.cs b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs index 22a3af5..377b333 100644 --- a/SeaHaven.Services/Implementation/VendorPortalTokenService.cs +++ b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Cryptography; @@ -12,10 +13,10 @@ namespace SeaHaven.Services.Implementation private readonly IVendorDataService _vendorDataService; private readonly int _lifetimeDays; - public VendorPortalTokenService(IVendorDataService vendorDataService, IConfiguration config) + public VendorPortalTokenService(IVendorDataService vendorDataService, IOptions options) { _vendorDataService = vendorDataService; - _lifetimeDays = config.GetValue("VendorPortal:TokenLifetimeDays") ?? 365; + _lifetimeDays = options.Value.TokenLifetimeDays ?? 365; } public async Task GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken) diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 871dd17..117b0da 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -2,8 +2,9 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using FluentValidation.Results; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; @@ -19,7 +20,7 @@ namespace SeaHaven.Services.Implementation private readonly IVendorDataService _vendorDataService; private readonly IVendorPortalTokenService _vendorTokenService; private readonly IZipCodeDistance _zipDistance; - private readonly IConfiguration _config; + private readonly FrontendOptions _frontendOptions; private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; @@ -28,7 +29,7 @@ namespace SeaHaven.Services.Implementation IVendorDataService vendorDataService, IVendorPortalTokenService vendorTokenService, IZipCodeDistance zipDistance, - IConfiguration config, + IOptions frontendOptions, ICreateVendorValidation createValidator, IUpdateVendorValidation updateValidator, IWorkOrderVendorUpdateValidation workOrderUpdateValidator) @@ -36,7 +37,7 @@ namespace SeaHaven.Services.Implementation _vendorDataService = vendorDataService; _vendorTokenService = vendorTokenService; _zipDistance = zipDistance; - _config = config; + _frontendOptions = frontendOptions.Value; _createValidator = createValidator; _updateValidator = updateValidator; _workOrderUpdateValidator = workOrderUpdateValidator; @@ -650,7 +651,7 @@ namespace SeaHaven.Services.Implementation private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token) { - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; return new VendorPortalTokenDTO { Token = token.Token, diff --git a/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs index 9226c84..1b906a6 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs @@ -1,8 +1,9 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -18,7 +19,7 @@ namespace SeaHaven.Services.Implementation private readonly ICommentDataService _commentDataService; private readonly IDispatchEmailPort _emailPort; private readonly IVendorTokenPort _vendorTokenPort; - private readonly IConfiguration _configuration; + private readonly FrontendOptions _frontendOptions; public WorkOrderDispatchService( IWorkOrderDataService workOrderDataService, @@ -29,7 +30,7 @@ namespace SeaHaven.Services.Implementation ICommentDataService commentDataService, IDispatchEmailPort emailPort, IVendorTokenPort vendorTokenPort, - IConfiguration configuration) + IOptions frontendOptions) { _workOrderDataService = workOrderDataService; _dispatchDataService = dispatchDataService; @@ -39,18 +40,19 @@ namespace SeaHaven.Services.Implementation _commentDataService = commentDataService; _emailPort = emailPort; _vendorTokenPort = vendorTokenPort; - _configuration = configuration; + _frontendOptions = frontendOptions.Value; } public async Task DispatchToVendorAsync(DispatchToVendorInput input) { - var allWorkOrders = await _workOrderDataService.GetAllWithDetailsAsync(); - var workOrders = allWorkOrders.Where(w => input.WorkOrderIds.Contains(w.Id)).ToList(); + var workOrders = await _workOrderDataService.GetByRequestedIdsWithDetailsAsync( + input.WorkOrderIds, + CancellationToken.None); if (workOrders.Count == 0) throw new InvalidOperationException("No work orders found"); - var primaryWO = workOrders.First(); + var primaryWO = workOrders[0]; var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); 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 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(); - 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}"; nextPO++; var dispatchNumber = $"DSP-{nextDSP:D5}"; @@ -78,7 +82,7 @@ namespace SeaHaven.Services.Implementation var dispatch = new Dispatch { WorkOrderId = primaryWO.Id, - VendorId = vendorId, + VendorId = vendor.Id, DispatchNumber = dispatchNumber, PONumber = poNumber, NTEAmount = input.NTEAmount, @@ -90,22 +94,7 @@ namespace SeaHaven.Services.Implementation DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() }; - var checklistItems = new List(); - 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 })); - } + var checklistItems = BuildChecklistItems(templateItemTexts, input.CustomChecklistItems); dispatch.ChecklistItems = checklistItems; 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 senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); - var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendorId); - var frontendBase = _configuration.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; + var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendor.Id); + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; var portalUrl = $"{frontendBase}/v/{vendorToken}/dashboard"; var html = $@" @@ -152,19 +141,17 @@ namespace SeaHaven.Services.Implementation 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, - FieldName = "Dispatch", - OldValue = "", - NewValue = $"{vendor.CompanyName} ({dispatchNumber})", - Action = AuditAction.Dispatch.ToString(), - CreatedAt = DateTime.UtcNow - }); - } + WorkOrderId = wo.Id, + UserId = input.UserId, + FieldName = "Dispatch", + OldValue = "", + NewValue = $"{vendor.CompanyName} ({dispatchNumber})", + Action = AuditAction.Dispatch.ToString(), + CreatedAt = DateTime.UtcNow + }).ToList(); + await _workOrderDataService.AddAuditLogsAsync(auditLogs); dispatches.Add(new DispatchResultItem { @@ -180,6 +167,41 @@ namespace SeaHaven.Services.Implementation return new DispatchToVendorResult { Message = "Dispatched", Dispatches = dispatches }; } + private async Task?> 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 BuildChecklistItems(List? templateItemTexts, List? customChecklistItems) + { + var checklistItems = new List(); + + 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> GetDispatchesAsync(int workOrderId) { var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId);