mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
refactor(backend): enforce architecture and optimize dispatch
This commit is contained in:
parent
848e973d62
commit
f6e71edd00
24 changed files with 792 additions and 660 deletions
51
.github/workflows/architecture-quality.yml
vendored
Normal file
51
.github/workflows/architecture-quality.yml
vendored
Normal 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[@]}"
|
||||||
76
Api.SeaHavenIndustries.Tests/ArchitectureTests.cs
Normal file
76
Api.SeaHavenIndustries.Tests/ArchitectureTests.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
199
Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs
Normal file
199
Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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**.
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
31
SeaHaven.Services/Configuration/ServiceOptions.cs
Normal file
31
SeaHaven.Services/Configuration/ServiceOptions.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue