refactor(backend): enforce architecture and optimize dispatch

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,7 +34,9 @@ public class UpliftControllerTests
var service = new Mock<IUpliftService>();
var dto = new UpliftListResultDTO
{
Total = 1, Page = 1, PageSize = 25,
Total = 1,
Page = 1,
PageSize = 25,
Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } }
};
service.Setup(x => x.ListAsync(It.IsAny<ClaimsPrincipal>(), "Pending", null, 1, 25, It.IsAny<CancellationToken>()))
@ -107,7 +109,8 @@ public class UpliftControllerTests
var ok = result.Should().BeOfType<OkObjectResult>().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]

View file

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

View file

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

View file

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

View file

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

View file

@ -1,564 +1,106 @@
# Backend Architecture Guide
# Backend Architecture and Quality Rules
## 🏗️ Clean Architecture Overview
This repository uses a feature-oriented service boundary over Entity Framework Core:
The backend follows **Clean Architecture** with strict layer separation:
```
┌─────────────────────────────────────────────────────────────┐
│ API LAYER (Controllers) │
│ - HTTP Request/Response │
│ - Authentication & Authorization │
│ - Exception Handling │
│ - Input Validation (basic) │
└─────────────────┬───────────────────────────────────────────┘
│ Calls
▼
┌─────────────────────────────────────────────────────────────┐
│ SERVICE LAYER (Business Logic) │
│ - Business Rules │
│ - FluentValidation (detailed validation) │
│ - Data Transformation │
│ - Orchestration │
└─────────────────┬───────────────────────────────────────────┘
│ Calls
▼
┌─────────────────────────────────────────────────────────────┐
│ DATA SERVICE LAYER (Persistence) │
│ - Database Access │
│ - Entity Operations (CRUD) │
│ - Query Building │
│ - Transaction Management │
└─────────────────┬───────────────────────────────────────────┘
│ Uses
▼
┌─────────────────────────────────────────────────────────────┐
│ DATA LAYER (EF Core) │
│ - DbContext │
│ - Entity Models │
│ - Database Schema │
└─────────────────────────────────────────────────────────────┘
```text
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
```
---
## 📁 Project Structure
```
backend/shoc-backend/
│
├── Api.SeaHavenIndustries/ # 🌐 API Layer
│ ├── Controllers/ # HTTP endpoints
│ ├── DTOs/ # Data Transfer Objects (API contracts)
│ ├── Program.cs # App startup & DI registration
│ └── appsettings.json # Configuration
│
├── SeaHaven.Services/ # 💼 Service Layer (Business Logic)
│ ├── Interfaces/ # Service contracts (IContactService, etc.)
│ ├── Implementation/ # Service implementations
│ ├── DTOs/ # Internal DTOs for service layer
│ └── Validators/ # FluentValidation validators
│
├── SeaHaven.DataServices/ # 💾 Data Access Layer
│ ├── Interfaces/ # DataService contracts (IContactDataService, etc.)
│ ├── Implementation/ # DataService implementations
│ └── DTOs/ # Data layer DTOs
│
└── Data.SeaHavenIndustries/ # 🗄️ Data Layer (EF Core)
├── Models/ # Entity models (Contact, Employee, etc.)
├── ApplicationDbContext.cs # EF Core DbContext
└── Migrations/ # Database migrations
```
---
## 🔄 Data Flow Example (Creating a Contact)
### 1️⃣ **API Layer** (`ContactController.cs`)
```csharp
[HttpPost("Create")]
public async Task<IActionResult> AddContacts(Contact_DTO dto)
{
try
{
// Get authenticated user ID
var userId = _userManager.GetUserId(User);
if (userId == null)
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
// Convert API DTO → Service DTO
var serviceDto = dto.ToServiceCreateDTO();
// Call Service Layer
await _contactService.CreateContactAsync(serviceDto, userId);
return Ok(new Response { Status = "Success", Message = "Contact created" });
}
catch (ValidationException vex)
{
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
return BadRequest(new Response { Status = "Validation Error", Message = errors });
}
catch (KeyNotFoundException)
{
return NotFound(new Response { Status = "Error", Message = "Resource not found" });
}
catch (Exception ex)
{
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
}
}
```
**Responsibilities:**
- ✅ Authenticate user
- ✅ Extract user ID from claims
- ✅ Map API DTO to Service DTO
- ✅ Catch exceptions and return proper HTTP status codes
- ❌ NO business logic
- ❌ NO database access
---
### 2️⃣ **Service Layer** (`ContactService.cs`)
```csharp
public class ContactService : IContactService
{
private readonly IContactDataService _dataService;
private readonly IValidator<CreateContactDTO> _createValidator;
public ContactService(
IContactDataService dataService,
IValidator<CreateContactDTO> createValidator)
{
_dataService = dataService;
_createValidator = createValidator;
}
public async Task<ContactDTO> CreateContactAsync(CreateContactDTO dto, string userId)
{
// STEP 1: Validate using FluentValidation
var validationResult = await _createValidator.ValidateAsync(dto);
if (!validationResult.IsValid)
{
throw new ValidationException(validationResult.Errors);
}
// STEP 2: Business logic (if any)
// Example: Check for duplicates, apply business rules, etc.
// STEP 3: Map Service DTO → Data DTO
var dataDto = new CreateContactDataDTO
{
FirstName = dto.FirstName,
LastName = dto.LastName,
Email = dto.Email,
Phone = dto.Phone
};
// STEP 4: Call Data Service Layer
var createdContact = await _dataService.CreateAsync(dataDto, userId);
// STEP 5: Map Data DTO → Service DTO and return
return new ContactDTO
{
Id = createdContact.Id,
FirstName = createdContact.FirstName,
LastName = createdContact.LastName,
Email = createdContact.Email,
Phone = createdContact.Phone
};
}
}
```
**Responsibilities:**
- ✅ Validate input using FluentValidation
- ✅ Apply business rules
- ✅ Orchestrate operations
- ✅ Transform data between layers
- ❌ NO HTTP concerns
- ❌ NO direct database access
---
### 3️⃣ **Data Service Layer** (`ContactDataService.cs`)
```csharp
public class ContactDataService : IContactDataService
{
private readonly ApplicationDbContext _db;
public ContactDataService(ApplicationDbContext db)
{
_db = db;
}
public async Task<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**.
The interfaces are architectural seams, not a requirement to wrap every class or every
EF method. Add an interface when it separates HTTP, business, persistence, or external
I/O responsibilities and enables behavior-focused testing.
## Layer responsibilities
### API controllers
- Parse HTTP input, authorize the caller, invoke a business-service interface, and map
the result to the established HTTP contract.
- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete
data-service implementations.
- Do not contain persistence queries or business workflows.
- Do not expose exception messages to clients. Map known failures explicitly and let the
centralized exception boundary handle unexpected failures.
### Business services
- Own validation, business decisions, orchestration, and transaction intent.
- Depend on interfaces for persistence and external I/O.
- Use `IOptions<T>` for typed configuration. Do not inject `IConfiguration`.
- Keep feature responsibilities cohesive. Split a service when it has independently
changing business reasons, not because it crossed an arbitrary line count.
- Preserve existing API and board-backed behavior unless the accepted requirement
explicitly changes it.
### Data services
- Own EF Core query shape, persistence, batching, and transaction implementation.
- Accept cancellation tokens on new I/O methods and pass them to EF Core.
- Use `AsNoTracking()` for read-only queries.
- Project only required columns for list/read models; include full graphs only when the
caller needs them.
- Page unbounded collections at the database.
- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes
while preserving the workflow's failure and transaction semantics.
- Do not introduce a generic repository over EF Core. Feature data services should expose
intent-revealing operations.
## Performance review
For each changed hot path, document and test:
- input size variables;
- CPU and memory complexity;
- database round trips;
- whether filtering, ordering, and paging execute in SQL;
- whether tracking is necessary;
- whether repeated work can be hoisted out of loops;
- the partial-failure and transaction behavior.
Prefer dictionary or hash-set reconstruction when database results must follow caller
order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory;
repeated `First`/`Single` scans over the result are `O(n²)`.
Do not optimize by weakening correctness. In particular, batching all writes into one
final save can change partial-success behavior, and adding a transaction can change
locking and retry semantics. Those are business decisions, not mechanical cleanup.
## Configuration
Configuration is bound once during composition in
`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive
typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and
`VendorPortalOptions`.
Defaults must preserve current behavior. Startup validation should be introduced only
when every deployed environment is known to provide the required value.
## Enforcement
`ArchitectureTests` enforces mechanical dependency rules:
- controllers cannot inject EF contexts or concrete service/data-service classes;
- business services cannot inject EF contexts or raw `IConfiguration`.
The pull-request quality workflow runs those tests and verifies formatting for changed
C# files. The normal CI workflow builds the solution and runs the full test suite.
Architecture tests are appropriate for dependency direction. Behavior belongs in tests
through public interfaces. Do not add tests merely to prove a linter or analyzer itself
works; test the product behavior the rule protects.
## Review checklist
- Controller depends only on service abstractions.
- Business logic is in a cohesive feature service.
- EF operations are behind a feature data-service abstraction.
- No raw `IConfiguration` in a business service.
- No unbounded load followed by in-memory filtering.
- No query/save loop where a set-based operation preserves semantics.
- Read-only EF queries use no tracking.
- New async I/O accepts and propagates cancellation where the public contract permits.
- Errors do not leak internal exception details.
- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing
records, and relevant failure boundaries.
- Changed behavior has been checked against the authoritative ticket board. Missing board
access blocks readiness and merge claims.

View file

@ -29,6 +29,28 @@ namespace SeaHaven.DataServices.Implementation
.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()
{
return await _context.Vendors.ToListAsync();

View file

@ -283,6 +283,15 @@ namespace SeaHaven.DataServices.Implementation
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)
{
var contacts = await _context.WorkOrderContacts
@ -369,6 +378,31 @@ namespace SeaHaven.DataServices.Implementation
.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)
{
return await _context.workOrders

View file

@ -6,6 +6,10 @@ namespace SeaHaven.DataServices.Interfaces
{
Task<Vendor?> GetByIdAsync(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<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);

View file

@ -14,7 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
Task<IEnumerable<WorkOrder>> GetByAssigneeAsync(string assigneeId);
Task<IEnumerable<WorkOrder>> GetByStatusAsync(string status);
Task<IEnumerable<WorkOrder>> GetByPriorityAsync(string priority);
// Paged results
Task<(IEnumerable<WorkOrder> Items, int TotalCount)> GetPagedAsync(
int page,
@ -33,12 +33,12 @@ namespace SeaHaven.DataServices.Interfaces
int? locationId = null,
string? sortBy = "lastUpdated",
string? sortDir = "desc");
// Command operations
Task<WorkOrder> AddAsync(WorkOrder workOrder);
Task UpdateAsync(WorkOrder workOrder);
Task DeleteAsync(int id);
// Related entities - Read
Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId);
Task<IEnumerable<WorkOrderCategories>> GetWorkOrderCategoriesAsync(int workOrderId);
@ -51,6 +51,7 @@ namespace SeaHaven.DataServices.Interfaces
Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable<int> categoryIds);
Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment);
Task AddAuditLogAsync(WorkOrderAuditLog auditLog);
Task AddAuditLogsAsync(IReadOnlyCollection<WorkOrderAuditLog> auditLogs);
// Related entities - Delete
Task DeleteWorkOrderContactsAsync(int workOrderId);
@ -71,6 +72,12 @@ namespace SeaHaven.DataServices.Interfaces
Task<int> CountByLocationAsync(int locationId);
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
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id);
Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync();

View file

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

View file

@ -1,5 +1,7 @@
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using SeaHaven.Services.Configuration;
using System.Reflection;
namespace SeaHaven.Services.DependencyInjection
@ -8,8 +10,13 @@ namespace SeaHaven.Services.DependencyInjection
{
private static readonly List<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 allClasses = assembly.GetTypes()

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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