From 9cd1c7e7fc612116ac53c9b1056c4ca5da4733fe Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 11:12:00 -0300 Subject: [PATCH] refactor(vendors): enforce service and data boundaries --- .../ServiceRegistrationTests.cs | 37 + .../UpliftControllerTests.cs | 174 +++++ .../VendorControllerTests.cs | 217 ++++-- .../VendorPortalControllerTests.cs | 302 ++++++++ .../VendorServiceTests.cs | 11 +- .../Controllers/UpliftController.cs | 240 ++----- .../Controllers/VendorController.cs | 160 +---- .../Controllers/VendorPortalController.cs | 660 +++--------------- .../Controllers/WorkOrderController.cs | 6 +- Api.SeaHavenIndustries/Helper/SendMessage.cs | 5 +- .../Helper/VendorPortalTokenService.cs | 92 --- .../Helper/ZipCodeDistance.cs | 4 +- Api.SeaHavenIndustries/Program.cs | 3 +- .../Models/VendorPortalReadModels.cs | 101 +++ .../Implementation/CommentDataService.cs | 27 + .../Implementation/DispatchDataService.cs | 145 ++++ .../Implementation/UpliftDataService.cs | 138 ++++ .../Implementation/UserDataService.cs | 10 + .../Implementation/VendorDataService.cs | 62 +- .../Interfaces/ICommentDataService.cs | 2 + .../Interfaces/IDispatchDataService.cs | 17 + .../Interfaces/IUpliftDataService.cs | 17 + .../Interfaces/IUserDataService.cs | 1 + .../Interfaces/IVendorDataService.cs | 9 +- SeaHaven.Services/DTOs/UpliftDTOs.cs | 66 ++ SeaHaven.Services/DTOs/VendorPortalDTOs.cs | 65 ++ .../DTOs/VendorPortalServiceDTOs.cs | 164 +++++ .../Implementation/UpliftService.cs | 171 +++++ .../Implementation/VendorPortalService.cs | 544 +++++++++++++++ .../VendorPortalTokenService.cs | 92 +++ .../Implementation/VendorService.cs | 141 ++++ .../Interfaces/IUpliftService.cs | 14 + .../Interfaces/IVendorPortalService.cs | 19 + .../Interfaces/IVendorPortalTokenService.cs | 12 + .../Interfaces/IVendorService.cs | 5 + .../Interfaces/IZipCodeDistance.cs | 7 + 36 files changed, 2698 insertions(+), 1042 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs delete mode 100644 Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs create mode 100644 SeaHaven.DataServices/Implementation/UpliftDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IUpliftDataService.cs create mode 100644 SeaHaven.Services/DTOs/UpliftDTOs.cs create mode 100644 SeaHaven.Services/DTOs/VendorPortalDTOs.cs create mode 100644 SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs create mode 100644 SeaHaven.Services/Implementation/UpliftService.cs create mode 100644 SeaHaven.Services/Implementation/VendorPortalService.cs create mode 100644 SeaHaven.Services/Implementation/VendorPortalTokenService.cs create mode 100644 SeaHaven.Services/Interfaces/IUpliftService.cs create mode 100644 SeaHaven.Services/Interfaces/IVendorPortalService.cs create mode 100644 SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs create mode 100644 SeaHaven.Services/Interfaces/IZipCodeDistance.cs diff --git a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs index bdbd896..660307b 100644 --- a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs +++ b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SeaHaven.DataServices.DependencyInjection; using SeaHaven.DataServices.Implementation; @@ -19,6 +20,9 @@ public class ServiceRegistrationTests { var services = new ServiceCollection(); services.AddLogging(); + services.AddSingleton(new ConfigurationBuilder().Build()); + services.AddSingleton(_ => new DummyZipCodeDistance()); + services.AddSingleton(_ => new DummyEmailSender()); services.AddDbContext(options => options.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())); @@ -76,4 +80,37 @@ public class ServiceRegistrationTests provider.GetRequiredService().Should().BeOfType(); provider.GetRequiredService().Should().BeOfType(); } + + [Fact] + public void ConventionDi_RegistersVendorAndUpliftServices() + { + using var provider = BuildProvider(); + + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + } + + [Fact] + public void ConventionDi_RegistersVendorAndUpliftDataServices() + { + using var provider = BuildProvider(); + + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + } + + private sealed class DummyZipCodeDistance : IZipCodeDistance + { + public double? GetDistanceMiles(string? zip1, string? zip2) => null; + } + + private sealed class DummyEmailSender : IEmailSender + { + public Task SendEmailAsync(string emailTo, string subject, string body) => + Task.FromResult(true); + } } diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs new file mode 100644 index 0000000..c6ab45a --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -0,0 +1,174 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class UpliftControllerTests +{ + private static UpliftController NewController(Mock service, params string[] roles) + { + var controller = new UpliftController(service.Object); + var claims = new List { new(ClaimTypes.NameIdentifier, "42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")) + } + }; + return controller; + } + + [Fact] + public async Task List_ReturnsPagedResult() + { + var service = new Mock(); + var dto = new UpliftListResultDTO + { + Total = 1, Page = 1, PageSize = 25, + Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } } + }; + service.Setup(x => x.ListAsync(It.IsAny(), "Pending", null, 1, 25, It.IsAny())) + .ReturnsAsync(dto); + + var controller = NewController(service); + + var result = await controller.List(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + } + + [Fact] + public async Task Approve_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 99, null, It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("not found")); + + var controller = NewController(service); + + var result = await controller.Approve(99, null); + + var nf = result.Should().BeOfType().Subject; + nf.Value.Should().BeOfType(); + } + + [Fact] + public async Task Approve_ForbiddenTier_Returns403() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("requires Tier 2")); + + var controller = NewController(service); + + var result = await controller.Approve(5, null); + + var status = result.Should().BeOfType().Subject; + status.StatusCode.Should().Be(403); + } + + [Fact] + public async Task Approve_AlreadyDecided_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("already decided")); + + var controller = NewController(service); + + var result = await controller.Approve(5, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Approve_Success_ReturnsApprovedResult() + { + var service = new Mock(); + var approved = new UpliftApproveResultDTO { Id = 5, Status = "Approved", NTEAmount = 750m }; + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, "ok", It.IsAny())) + .ReturnsAsync(approved); + + var controller = NewController(service); + + var result = await controller.Approve(5, new UpliftController.DecisionRequest { Note = "ok" }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + ((UpliftApproveResultDTO)envelope.Data).Should().Be(approved); + } + + [Fact] + public async Task Deny_WithoutNote_Returns400() + { + var service = new Mock(); + service.Setup(x => x.DenyAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("A note is required when denying")); + + var controller = NewController(service); + + var result = await controller.Deny(5, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Deny_Success_ReturnsDeniedResult() + { + var service = new Mock(); + var denied = new UpliftDenyResultDTO { Id = 5, Status = "Denied" }; + service.Setup(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny())) + .ReturnsAsync(denied); + + var controller = NewController(service); + + var result = await controller.Deny(5, new UpliftController.DecisionRequest { Note = "too high" }); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny()), Times.Once); + } + + [Fact] + public void CanApprove_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.CanApprove(It.IsAny(), 2)) + .Returns(true); + + var controller = NewController(service, "Manager"); + + var result = controller.CanApprove(2); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.CanApprove(It.Is(p => p.IsInRole("Manager")), 2), Times.Once); + } + + [Fact] + public async Task ListForDispatch_ReturnsItems() + { + var service = new Mock(); + var items = new[] { new UpliftForDispatchDTO { Id = 1, DispatchId = 10 } }; + service.Setup(x => x.ListForDispatchAsync(It.IsAny(), 10, It.IsAny())) + .ReturnsAsync(items); + + var controller = NewController(service); + + var result = await controller.ListForDispatch(10); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index 2440734..2f73a12 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -4,12 +4,10 @@ using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Moq; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Text.Json; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -18,12 +16,7 @@ public class VendorControllerTests { private static VendorController NewController(Mock service) { - var controller = new VendorController( - service.Object, - db: null!, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = new VendorController(service.Object); controller.ControllerContext = new ControllerContext { @@ -108,62 +101,178 @@ public class VendorControllerTests "42"), Times.Once); } - [Theory] - [InlineData(true, "Active Co", "Inactive Co")] - [InlineData(false, "Inactive Co", "Active Co")] - public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors( - bool isActive, - string expected, - string excluded) + [Fact] + public async Task GetFacets_DelegatesToServiceAndReturnsOk() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using var context = new ApplicationDbContext(options); - context.Vendors.AddRange( - new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, - new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); - await context.SaveChangesAsync(); + var service = new Mock(); + var facets = new VendorFacetsDTO + { + Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } }, + Trades = new[] { "Plumbing" }, + Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } }, + JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } } + }; + service.Setup(x => x.GetFacetsAsync(true, It.IsAny())) + .ReturnsAsync(facets); - var controller = new VendorController( - Mock.Of(), - context, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = NewController(service); - var result = await controller.GetFacets(isActive); - var payload = JsonSerializer.Serialize( - result.Should().BeOfType().Subject.Value); + var result = await controller.GetFacets(true); - payload.Should().Contain(expected); - payload.Should().NotContain(excluded); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(facets); + service.Verify(x => x.GetFacetsAsync(true, It.IsAny()), Times.Once); } [Fact] - public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup() + public async Task GetDropdown_DelegatesToServiceAndReturnsOk() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using var context = new ApplicationDbContext(options); - context.Vendors.AddRange( - new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, - new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); - await context.SaveChangesAsync(); + var service = new Mock(); + var items = new[] + { + new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 } + }; + service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny())) + .ReturnsAsync(items); - var controller = new VendorController( - Mock.Of(), - context, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = NewController(service); - var result = await controller.GetFacets(); - var payload = JsonSerializer.Serialize( - result.Should().BeOfType().Subject.Value); + var result = await controller.GetDropdown("Plumbing", "60601"); - payload.Should().Contain("Active Co"); - payload.Should().Contain("Inactive Co"); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(items); + } + + [Fact] + public async Task GetPortalToken_ReturnsTokenDtoFromService() + { + var service = new Mock(); + var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" }; + service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(token); + + var controller = NewController(service); + + var result = await controller.GetPortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(token); + } + + [Fact] + public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist() + { + var service = new Mock(); + service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny())) + .ReturnsAsync((VendorPortalTokenDTO?)null); + + var controller = NewController(service); + + var result = await controller.GetPortalToken(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RotatePortalToken_ReturnsRotatedToken() + { + var service = new Mock(); + var token = new VendorPortalTokenDTO { Token = "new-token" }; + service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(token); + + var controller = NewController(service); + + var result = await controller.RotatePortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(token); + } + + [Fact] + public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess() + { + var service = new Mock(); + service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.RevokePortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().NotBeNull(); + } + + [Fact] + public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist() + { + var service = new Mock(); + service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.RevokePortalToken(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_MapsDeactivationBlocked_ToConflict() + { + var service = new Mock(); + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List())); + + var controller = NewController(service); + + var result = await controller.Delete(5); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound() + { + var service = new Mock(); + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException()); + + var controller = NewController(service); + + var result = await controller.Delete(5); + + var nf = result.Should().BeOfType().Subject; + nf.Value.Should().BeOfType(); + } + + [Fact] + public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull() + { + var service = new Mock(); + service.Setup(x => x.GetVendorByIdAsync(99)) + .ReturnsAsync((VendorDTO?)null); + + var controller = NewController(service); + + var result = await controller.GetVendorById(99, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Update_UnauthenticatedUser_ReturnsUnauthorized() + { + var service = new Mock(); + var controller = new VendorController(service.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() } + }; + + var result = await controller.Update(5, new EditVendor_DTO { Id = 5, CompanyName = "X" }); + + result.Should().BeOfType(); } } diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs new file mode 100644 index 0000000..91a2f49 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -0,0 +1,302 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorPortalControllerTests +{ + private static VendorPortalController NewController(Mock service, string? token = "valid-token") + { + var controller = new VendorPortalController(service.Object); + var context = new DefaultHttpContext(); + if (token != null) + context.Request.Headers["X-Vendor-Token"] = token; + controller.ControllerContext = new ControllerContext { HttpContext = context }; + return controller; + } + + private static readonly VendorPortalSession Session = new() + { + Id = 1, CompanyName = "Test Co", ContactName = "Sam", Email = "sam@test.co", Phone = "555-0100" + }; + + [Fact] + public async Task Session_MissingToken_ReturnsUnauthorized() + { + var service = new Mock(); + var controller = NewController(service, token: null); + + var result = await controller.Session(); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Session_InvalidToken_ReturnsUnauthorized() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny())) + .ReturnsAsync((VendorPortalSession?)null); + var controller = NewController(service, token: "bad"); + + var result = await controller.Session(); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Session_ValidToken_ReturnsSessionData() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny())) + .ReturnsAsync(Session); + var controller = NewController(service); + + var result = await controller.Session(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + ((VendorPortalSession)envelope.Data).Should().Be(Session); + } + + [Fact] + public async Task ListDispatches_DelegatesToServiceWithStatus() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } }; + service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny())) + .ReturnsAsync(dispatches); + var controller = NewController(service); + + var result = await controller.ListDispatches("Sent"); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDispatch_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny())) + .ReturnsAsync((VendorDispatchDetailDTO?)null); + var controller = NewController(service); + + var result = await controller.GetDispatch(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Accept_KeyNotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny())) + .ThrowsAsync(new KeyNotFoundException()); + var controller = NewController(service); + + var result = await controller.Accept(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Accept_InvalidOperation_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot accept")); + var controller = NewController(service); + + var result = await controller.Accept(10); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().Be("Cannot accept"); + } + + [Fact] + public async Task Accept_Success_ReturnsAcknowledgedResult() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" }; + service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) + .ReturnsAsync(acceptResult); + var controller = NewController(service); + + var result = await controller.Accept(10); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + ((AcceptDispatchResultDTO)envelope.Data).Should().Be(acceptResult); + } + + [Fact] + public async Task ChangeStatus_ForbiddenTransition_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Transition not allowed")); + var controller = NewController(service); + + var result = await controller.ChangeStatus(10, new VendorPortalController.ChangeStatusRequest { Status = "Completed" }); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().Contain("Transition"); + } + + [Fact] + public async Task RequestCancel_Success_ReturnsSuccessWithMessage() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var controller = NewController(service); + + var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + envelope.Message.Should().Be("Cancel request sent to dispatcher"); + service.Verify(x => x.RequestCancelAsync(Session, 10, "Parts delay", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateChecklistItem_LockedDispatch_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Dispatch is locked")); + var controller = NewController(service); + + var result = await controller.UpdateChecklistItem(10, 5, new VendorPortalController.ChecklistUpdateRequest { IsCompleted = true }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task AddSignoff_DuplicateSignoff_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("A vendor signoff already exists")); + var controller = NewController(service); + + var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest + { + SignoffType = "vendor", Name = "Name", Signature = "sig", SignatureMethod = "drawn" + }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RequestUplift_NTEBelowCurrent_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE")); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RequestUplift_Success_ReturnsResult() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 }; + service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny())) + .ReturnsAsync(upliftResult); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody + { + RequestedNTE = 500m, Reason = "Need more parts" + }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + ((UpliftRequestResultDTO)envelope.Data).Should().Be(upliftResult); + } + + [Fact] + public async Task CancelUpliftRequest_NonPending_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request")); + var controller = NewController(service); + + var result = await controller.CancelUpliftRequest(10, 77); + + result.Should().BeOfType(); + } + + [Fact] + public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" }; + service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) + .ReturnsAsync(cancelResult); + var controller = NewController(service); + + var result = await controller.CancelUpliftRequest(10, 77); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + ((CancelUpliftResultDTO)envelope.Data).Should().Be(cancelResult); + } + + [Fact] + public async Task AddComment_EmptyText_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Comment cannot be empty")); + var controller = NewController(service); + + var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" }); + + result.Should().BeOfType(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 315bd18..ccc909b 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -2,10 +2,12 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using FluentValidation; +using Microsoft.Extensions.Configuration; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; using Xunit; @@ -14,7 +16,14 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorServiceTests { private static VendorService NewService(Mock data) => - new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); + new( + data.Object, + Mock.Of(), + Mock.Of(), + new ConfigurationBuilder().Build(), + new CreateVendorValidation(), + new UpdateVendorValidation(), + new WorkOrderVendorUpdateValidation()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index 4b90ff2..73d7d5d 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -1,8 +1,8 @@ using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.Security.Claims; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -12,225 +12,75 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/uplifts")] public class UpliftController : Controller { - private readonly ApplicationDbContext _db; - private readonly IConfiguration _config; + private readonly IUpliftService _upliftService; - public UpliftController(ApplicationDbContext db, IConfiguration config) + public UpliftController(IUpliftService upliftService) { - _db = db; - _config = config; + _upliftService = upliftService; } [HttpGet] - public async Task List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25) + public async Task List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default) { - var query = from u in _db.DispatchUpliftRequests - join d in _db.Dispatches on u.DispatchId equals d.Id - join v in _db.Vendors on d.VendorId equals v.Id into vendors - from v in vendors.DefaultIfEmpty() - where (u.IsDeleted == null || u.IsDeleted == false) - select new { u, d, v }; - - if (!string.IsNullOrWhiteSpace(status)) - query = query.Where(x => x.u.Status == status); - if (tier.HasValue) - query = query.Where(x => x.u.RequiredTier == tier.Value); - - var total = await query.CountAsync(); - - var raw = await query - .OrderByDescending(x => x.u.CreatedDate) - .Skip((Math.Max(page, 1) - 1) * pageSize) - .Take(pageSize) - .Select(x => new - { - x.u.Id, - DispatchId = x.d.Id, - x.d.DispatchNumber, - x.d.PONumber, - WorkOrderId = x.d.WorkOrderId, - VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, - x.u.CurrentNTE, - x.u.RequestedNTE, - x.u.VendorReason, - x.u.RequiredTier, - x.u.Status, - RequestedAt = x.u.CreatedDate, - x.u.DecidedAt, - x.u.DecisionNote - }) - .ToListAsync(); - - var items = raw.Select(r => new - { - r.Id, - r.DispatchId, - r.DispatchNumber, - r.PONumber, - r.WorkOrderId, - r.VendorCompanyName, - r.CurrentNTE, - r.RequestedNTE, - Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), - r.VendorReason, - r.RequiredTier, - r.Status, - r.RequestedAt, - r.DecidedAt, - r.DecisionNote, - CanDecide = UserCanApprove(r.RequiredTier) - }).ToList(); - - return Ok(new DataResponse - { - Status = "Success", - Data = new { total, page, pageSize, items } - }); + var result = await _upliftService.ListAsync(User, status, tier, page, pageSize, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } [HttpGet("dispatch/{dispatchId:int}")] - public async Task ListForDispatch(int dispatchId) + public async Task ListForDispatch(int dispatchId, CancellationToken cancellationToken = default) { - var rows = await (from u in _db.DispatchUpliftRequests - where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) - join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs - from dec in decs.DefaultIfEmpty() - orderby u.CreatedDate descending - select new - { - u.Id, - u.DispatchId, - u.CurrentNTE, - u.RequestedNTE, - Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m), - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - RequestedAt = u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null - }).ToListAsync(); - - var items = rows.Select(r => new - { - r.Id, - r.DispatchId, - r.CurrentNTE, - r.RequestedNTE, - r.Delta, - r.VendorReason, - r.Status, - r.RequiredTier, - r.RequestedByVendorName, - r.RequestedAt, - r.DecidedAt, - r.DecisionNote, - DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), - CanDecide = UserCanApprove(r.RequiredTier) - }).ToList(); - + var items = await _upliftService.ListForDispatchAsync(User, dispatchId, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = items }); } [HttpPost("{id:int}/approve")] - public async Task Approve(int id, [FromBody] DecisionRequest? body) + public async Task Approve(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) { - var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot approve a '{req.Status}' request" }); - if (!UserCanApprove(req.RequiredTier)) - return StatusCode(403, new Response { Status = "Error", Message = $"Approval requires a Tier {req.RequiredTier} role" }); - - var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") - return BadRequest(new Response { Status = "Error", Message = $"Cannot approve uplift on a '{dispatch.Status}' dispatch" }); - - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; - var oldNTE = dispatch.NTEAmount ?? 0m; - - dispatch.NTEAmount = req.RequestedNTE; - dispatch.LastModificationTime = now; - - req.Status = "Approved"; - req.DecidedAt = now; - req.DecidedByUserId = userId; - req.DecisionNote = string.IsNullOrWhiteSpace(body?.Note) ? null : body!.Note!.Trim(); - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + try { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", - OldValue = $"${oldNTE:F2}", - NewValue = $"${req.RequestedNTE:F2}", - Action = "uplift_approved", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, dispatch.NTEAmount } }); + var result = await _upliftService.ApproveAsync(User, id, body?.Note, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("{id:int}/deny")] - public async Task Deny(int id, [FromBody] DecisionRequest body) + public async Task Deny(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) { - if (body == null || string.IsNullOrWhiteSpace(body.Note)) - return BadRequest(new Response { Status = "Error", Message = "A note is required when denying" }); - - var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot deny a '{req.Status}' request" }); - if (!UserCanApprove(req.RequiredTier)) - return StatusCode(403, new Response { Status = "Error", Message = $"Decision requires a Tier {req.RequiredTier} role" }); - - var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId); - - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; - - req.Status = "Denied"; - req.DecidedAt = now; - req.DecidedByUserId = userId; - req.DecisionNote = body.Note!.Trim(); - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + try { - WorkOrderId = dispatch?.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Denied", - Action = "uplift_denied", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); + var result = await _upliftService.DenyAsync(User, id, body?.Note, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { - return Ok(new DataResponse { Status = "Success", Data = new { canApprove = UserCanApprove(tier) } }); - } - - private bool UserCanApprove(int requiredTier) - { - var key = $"Approvals:Tier{requiredTier}Roles"; - var roles = _config.GetSection(key).Get() ?? Array.Empty(); - foreach (var r in roles) - { - if (!string.IsNullOrWhiteSpace(r) && User.IsInRole(r)) return true; - } - return false; + return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } }); } public class DecisionRequest diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 00763f0..9eafd77 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -3,7 +3,6 @@ using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; using System.Security.Claims; @@ -17,18 +16,10 @@ namespace Api.SeaHavenIndustries.Controllers public class VendorController : Controller { private readonly IVendorService _vendorService; - private readonly ApplicationDbContext _db; // Keep for complex queries like nearby vendors - private readonly Helper.ZipCodeDistance _zipDistance; - private readonly Helper.VendorPortalTokenService _vendorTokens; - private readonly IConfiguration _config; - public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config) + public VendorController(IVendorService vendorService) { _vendorService = vendorService; - _db = db; - _zipDistance = zipDistance; - _vendorTokens = vendorTokens; - _config = config; } [HttpGet("GetVendorList")] @@ -318,158 +309,41 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("Dropdown")] - public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null) + public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null, CancellationToken cancellationToken = default) { - var query = _db.Vendors.Where(v => v.IsActive); - - var vendors = await query.OrderBy(v => v.CompanyName) - .Select(v => new - { - v.Id, - v.CompanyName, - v.ContactName, - v.PreferredContact, - v.TradeSpecialties, - v.Address, - v.City, - v.State, - v.Zip - }) - .ToListAsync(); - - var result = vendors.Select(v => - { - var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip); - var addr = new[] { v.Address, v.City, v.State, v.Zip } - .Where(s => !string.IsNullOrWhiteSpace(s)); - return new - { - v.Id, - v.CompanyName, - v.ContactName, - v.PreferredContact, - v.TradeSpecialties, - address = string.Join(", ", addr), - distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null - }; - }).ToList(); - - if (!string.IsNullOrWhiteSpace(trade)) - { - var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList(); - var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList(); - result = tradeMatched.Concat(rest).ToList(); - } - - if (!string.IsNullOrWhiteSpace(siteZip)) - { - result = result.OrderBy(v => v.distanceMiles ?? 99999).ToList(); - } - + var result = await _vendorService.GetDropdownAsync(trade, siteZip, cancellationToken); return Ok(result); } [HttpGet("facets")] - public async Task GetFacets([FromQuery] bool? isActive = null) + public async Task GetFacets([FromQuery] bool? isActive = null, CancellationToken cancellationToken = default) { - var query = _db.Vendors - .AsNoTracking() - .Where(v => v.IsDeleted == null || v.IsDeleted == false); - - if (isActive.HasValue) - query = query.Where(v => v.IsActive == isActive.Value); - - var vendors = await query - .OrderByDescending(v => v.Id) - .ToListAsync(); - - var companies = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) - .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(v => v.CompanyName) - .Select(v => new - { - Name = v.CompanyName, - v.CompanyId, - v.CompanyPhone, - v.Email, - v.PreferredContact, - v.Address, - v.City, - v.State, - Zip = v.Zip, - v.GoogleMapsUrl - }); - - var trades = vendors - .SelectMany(v => (v.TradeSpecialties ?? "") - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(value => value); - - var locations = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) - .Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) - .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) - .OrderBy(location => location.Label); - - return Ok(new - { - Companies = companies, - Trades = trades, - Locations = locations, - JobBuckets = new[] - { - new { Id = "under-50", Label = "Under 50" }, - new { Id = "50-99", Label = "50–99" }, - new { Id = "100-149", Label = "100–149" }, - new { Id = "150-plus", Label = "150+" } - } - }); + var result = await _vendorService.GetFacetsAsync(isActive, cancellationToken); + return Ok(result); } [HttpGet("{id:int}/portal-token")] - public async Task GetPortalToken(int id) + public async Task GetPortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id); - return Ok(BuildPortalTokenResponse(token)); + var result = await _vendorService.GetPortalTokenAsync(id, cancellationToken); + if (result == null) return NotFound(); + return Ok(result); } [HttpPost("{id:int}/portal-token/rotate")] - public async Task RotatePortalToken(int id) + public async Task RotatePortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - var token = await _vendorTokens.RotateAsync(id); - return Ok(BuildPortalTokenResponse(token)); + var result = await _vendorService.RotatePortalTokenAsync(id, cancellationToken); + if (result == null) return NotFound(); + return Ok(result); } [HttpPost("{id:int}/portal-token/revoke")] - public async Task RevokePortalToken(int id) + public async Task RevokePortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - await _vendorTokens.RevokeAllAsync(id); + var success = await _vendorService.RevokePortalTokenAsync(id, cancellationToken); + if (!success) return NotFound(); return Ok(new { revoked = true }); } - - private object BuildPortalTokenResponse(VendorAccessToken token) - { - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - return new - { - token.Token, - token.IssuedAt, - token.ExpiresAt, - token.LastUsedAt, - PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard" - }; - } } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 8841861..dd2d82d 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -1,8 +1,7 @@ -using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -13,650 +12,221 @@ namespace Api.SeaHavenIndustries.Controllers { private const string TokenHeader = "X-Vendor-Token"; - private static readonly string[] VendorViewableStatuses = - new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" }; + private readonly IVendorPortalService _portalService; - private readonly ApplicationDbContext _db; - private readonly VendorPortalTokenService _tokens; - private readonly IConfiguration _config; - private readonly SendMessage _sendMessage; - - public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage) + public VendorPortalController(IVendorPortalService portalService) { - _db = db; - _tokens = tokens; - _config = config; - _sendMessage = sendMessage; + _portalService = portalService; } [HttpGet("session")] - public async Task Session() + public async Task Session(CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); return Ok(new DataResponse { Status = "Success", - Data = new - { - vendor.Id, - vendor.CompanyName, - vendor.ContactName, - vendor.Email, - vendor.Phone - } + Data = session }); } [HttpGet("dispatches")] - public async Task ListDispatches([FromQuery] string? status = null) + public async Task ListDispatches([FromQuery] string? status = null, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - - var query = _db.Dispatches - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .Where(d => d.VendorId == vendor.Id && (d.IsDeleted == null || d.IsDeleted == false)); - - if (!string.IsNullOrWhiteSpace(status)) - { - query = query.Where(d => d.Status == status); - } - - var dispatches = await query - .OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate) - .Select(d => new - { - d.Id, - d.DispatchNumber, - d.PONumber, - d.Status, - d.NTEAmount, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.AcknowledgedAt, - WorkOrderTitle = d.WorkOrder!.WorkerOrderTitle, - InternalWONumber = d.WorkOrder!.InternalWONumber, - LocationName = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.Name : null, - LocationCity = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.City : null, - LocationState = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.State : null - }) - .ToListAsync(); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var dispatches = await _portalService.ListDispatchesAsync(session, status, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = dispatches }); } [HttpGet("dispatches/{id:int}")] - public async Task GetDispatch(int id) + public async Task GetDispatch(int id, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + var detail = await _portalService.GetDispatchDetailAsync(session, id, cancellationToken); + if (detail == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - var checklist = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)) - .OrderBy(c => c.SortOrder) - .Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt }) - .ToListAsync(); - - var signoffs = await _db.DispatchSignoffs - .Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false)) - .Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt }) - .ToListAsync(); - - var rawUplifts = await (from u in _db.DispatchUpliftRequests - where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false) - join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs - from dec in decs.DefaultIfEmpty() - orderby u.CreatedDate descending - select new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null - }) - .ToListAsync(); - - var upliftRequests = rawUplifts.Select(u => new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - RequestedAt = u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() - }).ToList(); - - var rawComments = await (from c in _db.Comments - where c.DispatchId == id - && c.CommentType != "internal" - && (c.IsDeleted == null || c.IsDeleted == false) - join u in _db.Users on c.UserId equals u.Id into users - from u in users.DefaultIfEmpty() - orderby c.CreatedDate - select new - { - c.Id, - c.Commenttext, - c.Commenter, - c.CommentType, - c.CreatedDate, - c.UserId, - UserFirstName = u != null ? u.FirstName : null, - UserLastName = u != null ? u.LastName : null - }) - .ToListAsync(); - - var comments = rawComments.Select(c => - { - var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); - var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); - - string resolvedType; - string resolvedCommenter; - if (hasShocUser) - { - resolvedType = "dispatcher"; - resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName - : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! - : "Dispatcher"; - } - else if (c.CommentType == "customer") - { - resolvedType = "customer"; - resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; - } - else - { - resolvedType = "vendor"; - resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : vendor.CompanyName ?? "Vendor"; - } - - return new - { - c.Id, - c.Commenttext, - Commenter = resolvedCommenter, - CommentType = resolvedType, - c.CreatedDate - }; - }).ToList(); - - return Ok(new DataResponse - { - Status = "Success", - Data = new - { - dispatch.Id, - dispatch.DispatchNumber, - dispatch.PONumber, - dispatch.Status, - dispatch.NTEAmount, - dispatch.Description, - dispatch.ScheduledDate, - dispatch.CompletedDate, - dispatch.DispatchedAt, - dispatch.AcknowledgedAt, - WorkOrder = dispatch.WorkOrder == null ? null : new - { - dispatch.WorkOrder.Id, - dispatch.WorkOrder.InternalWONumber, - dispatch.WorkOrder.WorkerOrderTitle, - dispatch.WorkOrder.Description, - dispatch.WorkOrder.Priority, - dispatch.WorkOrder.DueDate, - dispatch.WorkOrder.Trade, - dispatch.WorkOrder.SubTrade, - dispatch.WorkOrder.Problem - }, - Location = dispatch.WorkOrder?.Locations == null ? null : new - { - dispatch.WorkOrder.Locations.Name, - dispatch.WorkOrder.Locations.Address1, - dispatch.WorkOrder.Locations.City, - dispatch.WorkOrder.Locations.State, - dispatch.WorkOrder.Locations.Zip - }, - Checklist = checklist, - Signoffs = signoffs, - Comments = comments, - UpliftRequests = upliftRequests - } - }); + return Ok(new DataResponse { Status = "Success", Data = detail }); } [HttpPost("dispatches/{id:int}/accept")] - public async Task Accept(int id) + public async Task Accept(int id, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status != "Sent") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" }); + var result = await _portalService.AcceptDispatchAsync(session, id, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - dispatch.Status = "Acknowledged"; - dispatch.AcknowledgedAt = now; - dispatch.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + catch (KeyNotFoundException) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = "Sent", - NewValue = "Acknowledged", - Action = "vendor_accept", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/status")] - public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body) + public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - var from = dispatch.Status; - var to = body.Status; - if (!IsAllowedVendorTransition(from, to)) + try { - return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" }); + var result = await _portalService.ChangeStatusAsync(session, id, body?.Status, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - dispatch.Status = to; - dispatch.LastModificationTime = now; - if (to == "Completed") dispatch.CompletedDate = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + catch (KeyNotFoundException) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = from, - NewValue = to, - Action = "vendor_status_change", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/request-cancel")] - public async Task RequestCancel(int id, [FromBody] RequestCancelRequest body) + public async Task RequestCancel(int id, [FromBody] RequestCancelRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" }); + await _portalService.RequestCancelAsync(session, id, body?.Reason, cancellationToken); + return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" }); } - - var now = DateTime.UtcNow; - var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim(); - - _db.Comments.Add(new Comments + catch (KeyNotFoundException) { - DispatchId = id, - WorkerOrderId = dispatch.WorkOrderId, - Commenter = vendor.CompanyName, - CommentType = "vendor", - RecordType = "cancel_request", - Commenttext = $"Vendor requested cancellation: {reason}", - CreatedDate = now - }); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber}", - OldValue = dispatch.Status, - NewValue = "Cancel Requested", - Action = "vendor_request_cancel", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/checklist/{itemId:int}")] - public async Task UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body) + public async Task UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" }); + var result = await _portalService.UpdateChecklistItemAsync(session, id, itemId, body.IsCompleted, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var item = await _db.DispatchChecklistItems - .FirstOrDefaultAsync(c => c.Id == itemId && c.DispatchId == id); - if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); - - var now = DateTime.UtcNow; - item.IsCompleted = body.IsCompleted; - item.CompletedBy = body.IsCompleted ? vendor.CompanyName : null; - item.CompletedAt = body.IsCompleted ? now : null; - item.LastModificationTime = now; - - await _db.SaveChangesAsync(); - return Ok(new DataResponse + catch (KeyNotFoundException) { - Status = "Success", - Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt } - }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/signoff")] - public async Task AddSignoff(int id, [FromBody] SignoffRequest body) + public async Task AddSignoff(int id, [FromBody] SignoffRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") + try { - return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" }); + var result = await _portalService.AddSignoffAsync(session, id, body?.SignoffType, body?.Name, body?.Signature, body?.SignatureMethod, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature)) + catch (KeyNotFoundException) { - return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - - var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant(); - if (signoffType != "vendor" && signoffType != "customer") + catch (InvalidOperationException ex) { - return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } - - var existing = await _db.DispatchSignoffs - .FirstOrDefaultAsync(s => s.DispatchId == id && s.SignoffType == signoffType); - if (existing != null) - { - return BadRequest(new Response { Status = "Error", Message = $"A {signoffType} signoff already exists for this dispatch" }); - } - - var now = DateTime.UtcNow; - var signoff = new DispatchSignoff - { - DispatchId = id, - SignoffType = signoffType, - Name = body!.Name, - Signature = body.Signature, - SignatureMethod = body.SignatureMethod ?? "drawn", - SignedAt = now, - CreatedDate = now - }; - _db.DispatchSignoffs.Add(signoff); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", - OldValue = null, - NewValue = $"{(signoffType == "customer" ? "Customer" : "Vendor")}: {body.Name}", - Action = signoffType == "customer" ? "customer_signoff" : "vendor_signoff", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { signoff.Id, signoff.SignedAt } }); } [HttpPost("dispatches/{id:int}/comments")] - public async Task AddComment(int id, [FromBody] CommentRequest body) + public async Task AddComment(int id, [FromBody] CommentRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (string.IsNullOrWhiteSpace(body?.CommentText)) + try { - return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" }); + var result = await _portalService.AddCommentAsync(session, id, body?.CommentText, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - var comment = new Comments + catch (KeyNotFoundException) { - DispatchId = id, - WorkerOrderId = dispatch.WorkOrderId, - Commenter = vendor.CompanyName, - CommentType = "vendor", - RecordType = "comment", - Commenttext = body!.CommentText, - CreatedDate = now - }; - _db.Comments.Add(comment); - await _db.SaveChangesAsync(); - - return Ok(new DataResponse + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) { - Status = "Success", - Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate } - }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/uplift-request")] - public async Task RequestUplift(int id, [FromBody] UpliftRequestBody body) + public async Task RequestUplift(int id, [FromBody] UpliftRequestBody body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" }); + var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - if (body == null || body.RequestedNTE <= 0) + catch (KeyNotFoundException) { - return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - - var current = dispatch.NTEAmount ?? 0m; - if (body.RequestedNTE <= current) + catch (InvalidOperationException ex) { - return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } - - var pendingExists = await _db.DispatchUpliftRequests - .AnyAsync(u => u.DispatchId == id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false)); - if (pendingExists) - { - return BadRequest(new Response { Status = "Error", Message = "A pending uplift request already exists for this dispatch" }); - } - - var tier1Max = _config.GetValue("Approvals:UpliftTier1MaxUsd") ?? 2500m; - var delta = body.RequestedNTE - current; - var requiredTier = delta > tier1Max ? 2 : 1; - - var now = DateTime.UtcNow; - var req = new DispatchUpliftRequest - { - DispatchId = id, - CurrentNTE = current, - RequestedNTE = body.RequestedNTE, - VendorReason = string.IsNullOrWhiteSpace(body.Reason) ? null : body.Reason!.Trim(), - Status = "Pending", - RequiredTier = requiredTier, - RequestedByVendorName = vendor.CompanyName, - CreatedDate = now - }; - _db.DispatchUpliftRequests.Add(req); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", - OldValue = $"${current:F2}", - NewValue = $"${body.RequestedNTE:F2}", - Action = "uplift_requested", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - - await NotifyDispatcherOfUpliftAsync(dispatch, req, vendor); - - return Ok(new DataResponse - { - Status = "Success", - Data = new { req.Id, req.Status, req.RequiredTier, req.CurrentNTE, req.RequestedNTE } - }); } [HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/cancel")] - public async Task CancelUpliftRequest(int id, int requestId) + public async Task CancelUpliftRequest(int id, int requestId, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - var req = await _db.DispatchUpliftRequests - .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - - if (req.Status != "Pending") - { - return BadRequest(new Response { Status = "Error", Message = $"Cannot cancel a '{req.Status}' uplift request" }); - } - - var now = DateTime.UtcNow; - req.Status = "Cancelled"; - req.DecidedAt = now; - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Cancelled", - Action = "uplift_cancelled", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); - } - - private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor) - { try { - var dispatchAudit = await _db.WorkOrderAuditLogs - .Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId)) - .OrderByDescending(a => a.CreatedAt) - .FirstOrDefaultAsync(); - if (dispatchAudit == null || string.IsNullOrWhiteSpace(dispatchAudit.UserId)) return; - - var dispatcher = await _db.Users.FindAsync(dispatchAudit.UserId); - if (dispatcher == null || string.IsNullOrWhiteSpace(dispatcher.Email)) return; - - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - var workOrderLink = dispatch.WorkOrderId.HasValue - ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" - : frontendBase; - - var vendorName = System.Net.WebUtility.HtmlEncode(vendor.CompanyName ?? "Vendor"); - var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); - var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); - var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; - - var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendor.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; - var body = $@" -

Vendor Uplift Request

-

Dispatch: {dispatchNum}

-

Vendor: {vendorName}

- - - - - -
Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Required Approval{tierText}
-

Vendor Reason

-

{reason}

- "; - - await _sendMessage.SendEMail(dispatcher.Email, subject, body); + var result = await _portalService.CancelUpliftRequestAsync(session, id, requestId, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - catch + catch (KeyNotFoundException) { + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } - private async Task ResolveVendorAsync() + private async Task ResolveSessionAsync(CancellationToken cancellationToken) { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; var token = values.ToString(); - return await _tokens.ResolveVendorAsync(token); - } - - private Task LoadVendorDispatchAsync(int id, int vendorId) - { - return _db.Dispatches - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .FirstOrDefaultAsync(d => d.Id == id - && d.VendorId == vendorId - && (d.IsDeleted == null || d.IsDeleted == false)); - } - - private static bool IsAllowedVendorTransition(string? from, string? to) - { - if (from == "Acknowledged" && to == "In Progress") return true; - if (from == "In Progress" && to == "Completed") return true; - return false; + return await _portalService.ResolveSessionAsync(token, cancellationToken); } public class ChangeStatusRequest diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9c3b181..705f22b 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -40,7 +40,7 @@ namespace Api.SeaHavenIndustries.Controllers private readonly IWebHostEnvironment _webHostEnvironment; IHttpContextAccessor _httpContext; private readonly Helper.SendMessage _sendMessage; - private readonly Helper.VendorPortalTokenService _vendorTokens; + private readonly IVendorPortalTokenService _vendorTokens; private readonly IConfiguration _config; public WorkOrderController( @@ -57,7 +57,7 @@ namespace Api.SeaHavenIndustries.Controllers IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext, Helper.SendMessage sendMessage, - Helper.VendorPortalTokenService vendorTokens, + IVendorPortalTokenService vendorTokens, IConfiguration config) { _userManager = userManager; @@ -917,7 +917,7 @@ namespace Api.SeaHavenIndustries.Controllers var descriptionHtml = System.Net.WebUtility.HtmlEncode(model.Description ?? primaryWO.Description ?? ""); var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); - var vendorToken = await _vendorTokens.GetOrCreateActiveTokenAsync(vendorId); + var vendorToken = await _vendorTokens.GetOrCreateActiveTokenAsync(vendorId, HttpContext.RequestAborted); var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; var portalUrl = $"{frontendBase}/v/{vendorToken.Token}/dashboard"; diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index fc5bf0d..08db640 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -1,11 +1,12 @@  +using SeaHaven.Services.Interfaces; using SendGrid; using SendGrid.Helpers.Mail; namespace Api.SeaHavenIndustries.Helper { - public class SendMessage + public class SendMessage : IEmailSender { private IConfiguration _configuration; //string keysapi = ""; @@ -58,6 +59,8 @@ namespace Api.SeaHavenIndustries.Helper return false; } } + public async Task SendEmailAsync(string emailTo, string subject, string htmlBody) + => await SendEMail(emailTo, subject, htmlBody); public async Task SendEMailAttachment(string emailTo, string subject, byte[] pdfBytes) { try diff --git a/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs b/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs deleted file mode 100644 index 1b9ddee..0000000 --- a/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Security.Cryptography; -using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; - -namespace Api.SeaHavenIndustries.Helper -{ - public class VendorPortalTokenService - { - private readonly ApplicationDbContext _db; - private readonly int _lifetimeDays; - - public VendorPortalTokenService(ApplicationDbContext db, IConfiguration config) - { - _db = db; - _lifetimeDays = config.GetValue("VendorPortal:TokenLifetimeDays") ?? 365; - } - - public async Task GetOrCreateActiveTokenAsync(int vendorId) - { - var now = DateTime.UtcNow; - var existing = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId - && t.RevokedAt == null - && t.ExpiresAt > now - && (t.IsDeleted == null || t.IsDeleted == false)) - .OrderByDescending(t => t.IssuedAt) - .FirstOrDefaultAsync(); - - if (existing != null) return existing; - - var token = new VendorAccessToken - { - VendorId = vendorId, - Token = GenerateToken(), - IssuedAt = now, - ExpiresAt = now.AddDays(_lifetimeDays), - CreatedDate = now - }; - _db.VendorAccessTokens.Add(token); - await _db.SaveChangesAsync(); - return token; - } - - public async Task ResolveVendorAsync(string? token) - { - if (string.IsNullOrWhiteSpace(token)) return null; - var now = DateTime.UtcNow; - var record = await _db.VendorAccessTokens - .Include(t => t.Vendor) - .FirstOrDefaultAsync(t => t.Token == token - && t.RevokedAt == null - && t.ExpiresAt > now - && (t.IsDeleted == null || t.IsDeleted == false)); - - if (record?.Vendor == null) return null; - - record.LastUsedAt = now; - await _db.SaveChangesAsync(); - return record.Vendor; - } - - public async Task RotateAsync(int vendorId) - { - var now = DateTime.UtcNow; - var active = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId && t.RevokedAt == null) - .ToListAsync(); - foreach (var t in active) t.RevokedAt = now; - await _db.SaveChangesAsync(); - return await GetOrCreateActiveTokenAsync(vendorId); - } - - public async Task RevokeAllAsync(int vendorId) - { - var now = DateTime.UtcNow; - var active = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId && t.RevokedAt == null) - .ToListAsync(); - foreach (var t in active) t.RevokedAt = now; - await _db.SaveChangesAsync(); - } - - private static string GenerateToken() - { - var bytes = RandomNumberGenerator.GetBytes(32); - return Convert.ToBase64String(bytes) - .Replace("+", "-") - .Replace("/", "_") - .TrimEnd('='); - } - } -} diff --git a/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs b/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs index e8d21c3..b7b7953 100644 --- a/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs +++ b/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs @@ -1,6 +1,8 @@ +using SeaHaven.Services.Interfaces; + namespace Api.SeaHavenIndustries.Helper { - public class ZipCodeDistance + public class ZipCodeDistance : IZipCodeDistance { private readonly Dictionary _coords = new(); private bool _loaded; diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 1e6de6a..c3644e6 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -40,7 +40,6 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); builder.Services.AddSingleton(sp => { var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance(); @@ -49,6 +48,8 @@ builder.Services.AddSingleton(sp if (File.Exists(csvPath)) zipService.Load(csvPath); return zipService; }); +builder.Services.AddSingleton( + serviceProvider => serviceProvider.GetRequiredService()); builder.Services.AddDataServices(); builder.Services.AddBusinessServices(); diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs new file mode 100644 index 0000000..c2097f0 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -0,0 +1,101 @@ +namespace Data.SeaHavenIndustries +{ + public class VendorDispatchSummaryData + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? WorkOrderTitle { get; set; } + public string? InternalWONumber { get; set; } + public string? LocationName { get; set; } + public string? LocationCity { get; set; } + public string? LocationState { get; set; } + } + + public class PortalChecklistItemData + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class PortalSignoffData + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class PortalUpliftData + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByFirstName { get; set; } + public string? DecidedByLastName { get; set; } + } + + public class PortalCommentData + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public DateTime? CreatedDate { get; set; } + public string? UserId { get; set; } + public string? UserFirstName { get; set; } + public string? UserLastName { get; set; } + } + + public class UpliftListItemData + { + public int Id { get; set; } + public int DispatchId { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public int? WorkOrderId { get; set; } + public string? VendorCompanyName { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public int RequiredTier { get; set; } + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + } + + public class UpliftForDispatchData + { + public int Id { get; set; } + public int DispatchId { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByFirstName { get; set; } + public string? DecidedByLastName { get; set; } + } +} diff --git a/SeaHaven.DataServices/Implementation/CommentDataService.cs b/SeaHaven.DataServices/Implementation/CommentDataService.cs index 72db1a1..705beaf 100644 --- a/SeaHaven.DataServices/Implementation/CommentDataService.cs +++ b/SeaHaven.DataServices/Implementation/CommentDataService.cs @@ -71,5 +71,32 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Comments.AnyAsync(c => c.Id == id); } + + public async Task StageAsync(Comments comment, CancellationToken cancellationToken) + { + await _context.Comments.AddAsync(comment, cancellationToken); + } + + public async Task> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from c in _context.Comments + where c.DispatchId == dispatchId + && c.CommentType != "internal" + && (c.IsDeleted == null || c.IsDeleted == false) + join u in _context.Users on c.UserId equals u.Id into users + from u in users.DefaultIfEmpty() + orderby c.CreatedDate + select new PortalCommentData + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + CommentType = c.CommentType, + CreatedDate = c.CreatedDate, + UserId = c.UserId, + UserFirstName = u != null ? u.FirstName : null, + UserLastName = u != null ? u.LastName : null + }).ToListAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index f4d0c31..e5d60b6 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -558,5 +558,150 @@ namespace SeaHaven.DataServices.Implementation AuditLog = auditLog }; } + + public async Task StageSignoffAsync( + DispatchSignoff signoff, CancellationToken cancellationToken) + { + await _context.DispatchSignoffs.AddAsync(signoff, cancellationToken); + } + + public async Task> GetVendorDispatchSummariesAsync( + int vendorId, string? status, CancellationToken cancellationToken) + { + var query = _context.Dispatches + .AsNoTracking() + .Where(dispatch => dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false)); + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(dispatch => dispatch.Status == status); + + return await query + .OrderByDescending(dispatch => dispatch.DispatchedAt ?? dispatch.CreatedDate) + .Select(dispatch => new VendorDispatchSummaryData + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + Status = dispatch.Status, + NTEAmount = dispatch.NTEAmount, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + AcknowledgedAt = dispatch.AcknowledgedAt, + WorkOrderTitle = dispatch.WorkOrder != null + ? dispatch.WorkOrder.WorkerOrderTitle + : null, + InternalWONumber = dispatch.WorkOrder != null + ? dispatch.WorkOrder.InternalWONumber + : null, + LocationName = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.Name + : null, + LocationCity = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.City + : null, + LocationState = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.State + : null + }) + .ToListAsync(cancellationToken); + } + + public async Task GetVendorDispatchDetailAsync( + int id, int vendorId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .Include(dispatch => dispatch.WorkOrder) + .ThenInclude(workOrder => workOrder!.Locations) + .AsNoTracking() + .FirstOrDefaultAsync(dispatch => dispatch.Id == id + && dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false), + cancellationToken); + } + + public async Task GetVendorDispatchForMutationAsync( + int id, int vendorId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .Include(dispatch => dispatch.WorkOrder) + .ThenInclude(workOrder => workOrder!.Locations) + .FirstOrDefaultAsync(dispatch => dispatch.Id == id + && dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false), + cancellationToken); + } + + public async Task> GetPortalChecklistAsync( + int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchChecklistItems + .AsNoTracking() + .Where(item => item.DispatchId == dispatchId + && (item.IsDeleted == null || item.IsDeleted == false)) + .OrderBy(item => item.SortOrder) + .Select(item => new PortalChecklistItemData + { + Id = item.Id, + ItemText = item.ItemText, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetPortalSignoffsAsync( + int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchSignoffs + .AsNoTracking() + .Where(signoff => signoff.DispatchId == dispatchId + && (signoff.IsDeleted == null || signoff.IsDeleted == false)) + .Select(signoff => new PortalSignoffData + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + SignatureMethod = signoff.SignatureMethod, + SignedAt = signoff.SignedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task GetChecklistItemForDispatchAsync( + int itemId, int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchChecklistItems + .FirstOrDefaultAsync( + item => item.Id == itemId && item.DispatchId == dispatchId, + cancellationToken); + } + + public async Task StageAuditLogAsync( + WorkOrderAuditLog log, CancellationToken cancellationToken) + { + await _context.WorkOrderAuditLogs.AddAsync(log, cancellationToken); + } + + public async Task GetDispatchDispatcherUserIdAsync( + int workOrderId, CancellationToken cancellationToken) + { + return await _context.WorkOrderAuditLogs + .AsNoTracking() + .Where(audit => audit.WorkOrderId == workOrderId + && audit.Action == "dispatch" + && audit.UserId != null + && audit.UserId != "") + .OrderByDescending(audit => audit.CreatedAt) + .Select(audit => audit.UserId) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs new file mode 100644 index 0000000..f7000c2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -0,0 +1,138 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class UpliftDataService : IUpliftDataService + { + private readonly ApplicationDbContext _context; + + public UpliftDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( + string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) + { + var query = from u in _context.DispatchUpliftRequests + join d in _context.Dispatches on u.DispatchId equals d.Id + join v in _context.Vendors on d.VendorId equals v.Id into vendors + from v in vendors.DefaultIfEmpty() + where (u.IsDeleted == null || u.IsDeleted == false) + select new { u, d, v }; + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(x => x.u.Status == status); + if (tier.HasValue) + query = query.Where(x => x.u.RequiredTier == tier.Value); + + var total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(x => x.u.CreatedDate) + .Skip((Math.Max(page, 1) - 1) * pageSize) + .Take(pageSize) + .Select(x => new UpliftListItemData + { + Id = x.u.Id, + DispatchId = x.d.Id, + DispatchNumber = x.d.DispatchNumber, + PONumber = x.d.PONumber, + WorkOrderId = x.d.WorkOrderId, + VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, + CurrentNTE = x.u.CurrentNTE, + RequestedNTE = x.u.RequestedNTE, + VendorReason = x.u.VendorReason, + RequiredTier = x.u.RequiredTier, + Status = x.u.Status, + CreatedDate = x.u.CreatedDate, + DecidedAt = x.u.DecidedAt, + DecisionNote = x.u.DecisionNote + }) + .ToListAsync(cancellationToken); + + return (total, items); + } + + public async Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from u in _context.DispatchUpliftRequests + where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) + join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs + from dec in decs.DefaultIfEmpty() + orderby u.CreatedDate descending + select new UpliftForDispatchData + { + Id = u.Id, + DispatchId = u.DispatchId, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + CreatedDate = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByFirstName = dec != null ? dec.FirstName : null, + DecidedByLastName = dec != null ? dec.LastName : null + }).ToListAsync(cancellationToken); + } + + public async Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from u in _context.DispatchUpliftRequests + where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) + join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs + from dec in decs.DefaultIfEmpty() + orderby u.CreatedDate descending + select new PortalUpliftData + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + CreatedDate = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByFirstName = dec != null ? dec.FirstName : null, + DecidedByLastName = dec != null ? dec.LastName : null + }).ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == dispatchId, cancellationToken); + } + + public async Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .AnyAsync(u => u.DispatchId == dispatchId + && u.Status == "Pending" + && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); + } + + public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) + { + await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index fbf0dd0..1e1c4bb 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -141,5 +141,15 @@ namespace SeaHaven.DataServices.Implementation await transaction.CommitAsync(cancellationToken); } + + public async Task GetEmailByIdAsync( + string userId, CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Where(user => user.Id == userId) + .Select(user => user.Email) + .FirstOrDefaultAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 541c89f..2ae49cf 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -34,6 +34,29 @@ namespace SeaHaven.DataServices.Implementation return await _context.Vendors.ToListAsync(); } + public async Task> GetActiveVendorsAsync(CancellationToken cancellationToken) + { + return await _context.Vendors + .AsNoTracking() + .Where(v => v.IsActive) + .OrderBy(v => v.CompanyName) + .ToListAsync(cancellationToken); + } + + public async Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken) + { + var query = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + return await query + .OrderByDescending(v => v.Id) + .ToListAsync(cancellationToken); + } + public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -284,16 +307,47 @@ namespace SeaHaven.DataServices.Implementation return await _context.Vendors.AnyAsync(v => v.Id == id); } - public async Task GetActiveTokenAsync(int vendorId) + public async Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken) { + var now = DateTime.UtcNow; return await _context.VendorAccessTokens - .FirstOrDefaultAsync(t => t.VendorId == vendorId); + .AsNoTracking() + .Where(t => t.VendorId == vendorId + && t.RevokedAt == null + && t.ExpiresAt > now + && (t.IsDeleted == null || t.IsDeleted == false)) + .OrderByDescending(t => t.IssuedAt) + .FirstOrDefaultAsync(cancellationToken); } - public async Task GetTokenByStringAsync(string token) + public async Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + return await _context.VendorAccessTokens + .Include(t => t.Vendor) + .FirstOrDefaultAsync(t => t.Token == token + && t.RevokedAt == null + && t.ExpiresAt > now + && (t.IsDeleted == null || t.IsDeleted == false), cancellationToken); + } + + public async Task AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken) + { + await _context.VendorAccessTokens.AddAsync(token, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return token; + } + + public async Task> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken) { return await _context.VendorAccessTokens - .FirstOrDefaultAsync(t => t.Token == token); + .Where(t => t.VendorId == vendorId && t.RevokedAt == null) + .ToListAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); } public async Task CountAsync() diff --git a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs index e6bd08c..a4e87f7 100644 --- a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs @@ -12,5 +12,7 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(Comments comment); Task DeleteAsync(int id); Task ExistsAsync(int id); + Task StageAsync(Comments comment, CancellationToken cancellationToken); + Task> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs index 050de10..5336898 100644 --- a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs @@ -26,11 +26,28 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateChecklistItemAsync(DispatchChecklistItem item); Task> GetSignoffsAsync(int dispatchId); Task AddSignoffAsync(DispatchSignoff signoff); + Task StageSignoffAsync(DispatchSignoff signoff, CancellationToken cancellationToken); Task HasSignoffTypeAsync(int dispatchId, string signoffType); Task> GetDispatchWorkOrdersAsync(int dispatchId); Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder); Task GetListAsync(DispatchListQuery query, CancellationToken cancellationToken); Task GetDetailAsync(int id, CancellationToken cancellationToken); + Task> GetVendorDispatchSummariesAsync( + int vendorId, string? status, CancellationToken cancellationToken); + Task GetVendorDispatchDetailAsync( + int id, int vendorId, CancellationToken cancellationToken); + Task GetVendorDispatchForMutationAsync( + int id, int vendorId, CancellationToken cancellationToken); + Task> GetPortalChecklistAsync( + int dispatchId, CancellationToken cancellationToken); + Task> GetPortalSignoffsAsync( + int dispatchId, CancellationToken cancellationToken); + Task GetChecklistItemForDispatchAsync( + int itemId, int dispatchId, CancellationToken cancellationToken); + Task StageAuditLogAsync(WorkOrderAuditLog log, CancellationToken cancellationToken); + Task GetDispatchDispatcherUserIdAsync( + int workOrderId, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs new file mode 100644 index 0000000..ee7ead2 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -0,0 +1,17 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IUpliftDataService + { + Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( + string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); + Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); + Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); + Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 8597002..8ca1655 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -17,5 +17,6 @@ namespace SeaHaven.DataServices.Interfaces Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken); Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); + Task GetEmailByIdAsync(string userId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index b0acd6d..6bd12f8 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); Task> GetAllAsync(); + Task> GetActiveVendorsAsync(CancellationToken cancellationToken); + Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -20,8 +22,11 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); Task ExistsAsync(int id); - Task GetActiveTokenAsync(int vendorId); - Task GetTokenByStringAsync(string token); + Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken); + Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken); + Task AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken); + Task> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); Task CountAsync(); Task GetCompanyByIdAsync(int id); diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs new file mode 100644 index 0000000..0dcc483 --- /dev/null +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -0,0 +1,66 @@ +namespace SeaHaven.Services.DTOs +{ + public class UpliftListItemDTO + { + public int Id { get; set; } + public int DispatchId { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public int? WorkOrderId { get; set; } + public string? VendorCompanyName { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public int RequiredTier { get; set; } + public string? Status { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public bool CanDecide { get; set; } + } + + public class UpliftListResultDTO + { + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public IEnumerable Items { get; set; } = Enumerable.Empty(); + } + + public class UpliftForDispatchDTO + { + public int Id { get; set; } + public int DispatchId { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByName { get; set; } + public bool CanDecide { get; set; } + } + + public class UpliftApproveResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + } + + public class UpliftDenyResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } + + public class UpliftForbiddenException : Exception + { + public UpliftForbiddenException(string message) : base(message) { } + } +} diff --git a/SeaHaven.Services/DTOs/VendorPortalDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs new file mode 100644 index 0000000..8af1cfa --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs @@ -0,0 +1,65 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class VendorPortalTokenStateDTO + { + public required string Token { get; init; } + public DateTime IssuedAt { get; init; } + public DateTime ExpiresAt { get; init; } + public DateTime? LastUsedAt { get; init; } + } + + public class VendorPortalTokenDTO + { + public string Token { get; set; } = string.Empty; + public DateTime IssuedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? LastUsedAt { get; set; } + public string PortalUrl { get; set; } = string.Empty; + } + + public class VendorDropdownItemDTO + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? TradeSpecialties { get; set; } + public string? Address { get; set; } + public double? DistanceMiles { get; set; } + } + + public class VendorFacetCompanyDTO + { + public string? Name { get; set; } + public int? CompanyId { get; set; } + public string? CompanyPhone { get; set; } + public string? Email { get; set; } + public string? PreferredContact { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? GoogleMapsUrl { get; set; } + } + + public class VendorFacetLocationDTO + { + public string? City { get; set; } + public string? State { get; set; } + public string? Label { get; set; } + } + + public class VendorJobBucketDTO + { + public string Id { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + } + + public class VendorFacetsDTO + { + public IEnumerable Companies { get; set; } = Enumerable.Empty(); + public IEnumerable Trades { get; set; } = Enumerable.Empty(); + public IEnumerable Locations { get; set; } = Enumerable.Empty(); + public IEnumerable JobBuckets { get; set; } = Enumerable.Empty(); + } +} diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs new file mode 100644 index 0000000..27cc8e7 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -0,0 +1,164 @@ +namespace SeaHaven.Services.DTOs +{ + public class VendorPortalSession + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + } + + public class VendorDispatchSummaryDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? WorkOrderTitle { get; set; } + public string? InternalWONumber { get; set; } + public string? LocationName { get; set; } + public string? LocationCity { get; set; } + public string? LocationState { get; set; } + } + + public class PortalChecklistItemDTO + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class PortalSignoffDTO + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class PortalUpliftDTO + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByName { get; set; } + } + + public class PortalCommentDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public DateTime? CreatedDate { get; set; } + } + + public class PortalWorkOrderDTO + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Priority { get; set; } + public DateTime? DueDate { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public string? Problem { get; set; } + } + + public class PortalLocationDTO + { + public string? Name { get; set; } + public string? Address1 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + } + + public class VendorDispatchDetailDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public PortalWorkOrderDTO? WorkOrder { get; set; } + public PortalLocationDTO? Location { get; set; } + public IEnumerable Checklist { get; set; } = Enumerable.Empty(); + public IEnumerable Signoffs { get; set; } = Enumerable.Empty(); + public IEnumerable Comments { get; set; } = Enumerable.Empty(); + public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); + } + + public class AcceptDispatchResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public DateTime? AcknowledgedAt { get; set; } + } + + public class ChangeStatusResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public DateTime? CompletedDate { get; set; } + } + + public class ChecklistItemResultDTO + { + public int Id { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class SignoffResultDTO + { + public int Id { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class CommentResultDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public DateTime? CreatedDate { get; set; } + } + + public class UpliftRequestResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + } + + public class CancelUpliftResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs new file mode 100644 index 0000000..fa97467 --- /dev/null +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -0,0 +1,171 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Configuration; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace SeaHaven.Services.Implementation +{ + public class UpliftService : IUpliftService + { + private readonly IUpliftDataService _upliftData; + private readonly IDispatchDataService _dispatchData; + private readonly IConfiguration _config; + + public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IConfiguration config) + { + _upliftData = upliftData; + _dispatchData = dispatchData; + _config = config; + } + + public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) + { + var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken); + + var mapped = items.Select(r => new UpliftListItemDTO + { + Id = r.Id, + DispatchId = r.DispatchId, + DispatchNumber = r.DispatchNumber, + PONumber = r.PONumber, + WorkOrderId = r.WorkOrderId, + VendorCompanyName = r.VendorCompanyName, + CurrentNTE = r.CurrentNTE, + RequestedNTE = r.RequestedNTE, + Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + VendorReason = r.VendorReason, + RequiredTier = r.RequiredTier, + Status = r.Status, + RequestedAt = r.CreatedDate, + DecidedAt = r.DecidedAt, + DecisionNote = r.DecisionNote, + CanDecide = UserCanApprove(user, r.RequiredTier) + }).ToList(); + + return new UpliftListResultDTO + { + Total = total, + Page = page, + PageSize = pageSize, + Items = mapped + }; + } + + public async Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken) + { + var rows = await _upliftData.GetForDispatchAsync(dispatchId, cancellationToken); + + return rows.Select(r => new UpliftForDispatchDTO + { + Id = r.Id, + DispatchId = r.DispatchId, + CurrentNTE = r.CurrentNTE, + RequestedNTE = r.RequestedNTE, + Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + VendorReason = r.VendorReason, + Status = r.Status, + RequiredTier = r.RequiredTier, + RequestedByVendorName = r.RequestedByVendorName, + RequestedAt = r.CreatedDate, + DecidedAt = r.DecidedAt, + DecisionNote = r.DecisionNote, + DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), + CanDecide = UserCanApprove(user, r.RequiredTier) + }).ToList(); + } + + public async Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + { + var req = await _upliftData.GetByIdAsync(id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + if (req.Status != "Pending") throw new InvalidOperationException($"Cannot approve a '{req.Status}' request"); + if (!UserCanApprove(user, req.RequiredTier)) + throw new UpliftForbiddenException($"Approval requires a Tier {req.RequiredTier} role"); + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch"); + + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var now = DateTime.UtcNow; + var oldNTE = dispatch.NTEAmount ?? 0m; + + dispatch.NTEAmount = req.RequestedNTE; + dispatch.LastModificationTime = now; + + req.Status = "Approved"; + req.DecidedAt = now; + req.DecidedByUserId = userId; + req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim(); + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", + OldValue = $"${oldNTE:F2}", + NewValue = $"${req.RequestedNTE:F2}", + Action = "uplift_approved", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new UpliftApproveResultDTO { Id = req.Id, Status = req.Status, NTEAmount = dispatch.NTEAmount }; + } + + public async Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(note)) + throw new InvalidOperationException("A note is required when denying"); + + var req = await _upliftData.GetByIdAsync(id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + if (req.Status != "Pending") throw new InvalidOperationException($"Cannot deny a '{req.Status}' request"); + if (!UserCanApprove(user, req.RequiredTier)) + throw new UpliftForbiddenException($"Decision requires a Tier {req.RequiredTier} role"); + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var now = DateTime.UtcNow; + + req.Status = "Denied"; + req.DecidedAt = now; + req.DecidedByUserId = userId; + req.DecisionNote = note!.Trim(); + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch?.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", + OldValue = "Pending", + NewValue = "Denied", + Action = "uplift_denied", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new UpliftDenyResultDTO { Id = req.Id, Status = req.Status }; + } + + public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier); + + private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) + { + var key = $"Approvals:Tier{requiredTier}Roles"; + var roles = _config.GetSection(key).Get() ?? Array.Empty(); + foreach (var r in roles) + { + if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true; + } + return false; + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs new file mode 100644 index 0000000..66123d7 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -0,0 +1,544 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Configuration; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class VendorPortalService : IVendorPortalService + { + private readonly IVendorPortalTokenService _tokens; + private readonly IDispatchDataService _dispatchData; + private readonly IUpliftDataService _upliftData; + private readonly ICommentDataService _commentData; + private readonly IUserDataService _userData; + private readonly IEmailSender _emailSender; + private readonly IConfiguration _config; + + public VendorPortalService( + IVendorPortalTokenService tokens, + IDispatchDataService dispatchData, + IUpliftDataService upliftData, + ICommentDataService commentData, + IUserDataService userData, + IEmailSender emailSender, + IConfiguration config) + { + _tokens = tokens; + _dispatchData = dispatchData; + _upliftData = upliftData; + _commentData = commentData; + _userData = userData; + _emailSender = emailSender; + _config = config; + } + + public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) + { + return await _tokens.ResolveSessionAsync(token, cancellationToken); + } + + public async Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken) + { + var summaries = await _dispatchData.GetVendorDispatchSummariesAsync(session.Id, status, cancellationToken); + + return summaries.Select(d => new VendorDispatchSummaryDTO + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + Status = d.Status, + NTEAmount = d.NTEAmount, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + DispatchedAt = d.DispatchedAt, + AcknowledgedAt = d.AcknowledgedAt, + WorkOrderTitle = d.WorkOrderTitle, + InternalWONumber = d.InternalWONumber, + LocationName = d.LocationName, + LocationCity = d.LocationCity, + LocationState = d.LocationState + }); + } + + public async Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchDetailAsync(id, session.Id, cancellationToken); + if (dispatch == null) return null; + + var checklist = await _dispatchData.GetPortalChecklistAsync(id, cancellationToken); + var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); + var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); + var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken); + + var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + RequestedAt = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() + }).ToList(); + + var comments = rawComments.Select(c => + { + var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); + var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + + string resolvedType; + string resolvedCommenter; + if (hasShocUser) + { + resolvedType = "dispatcher"; + resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName + : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! + : "Dispatcher"; + } + else if (c.CommentType == "customer") + { + resolvedType = "customer"; + resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; + } + else + { + resolvedType = "vendor"; + resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : session.CompanyName ?? "Vendor"; + } + + return new PortalCommentDTO + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = resolvedCommenter, + CommentType = resolvedType, + CreatedDate = c.CreatedDate + }; + }).ToList(); + + return new VendorDispatchDetailDTO + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + Status = dispatch.Status, + NTEAmount = dispatch.NTEAmount, + Description = dispatch.Description, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + AcknowledgedAt = dispatch.AcknowledgedAt, + WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO + { + Id = dispatch.WorkOrder.Id, + InternalWONumber = dispatch.WorkOrder.InternalWONumber, + WorkerOrderTitle = dispatch.WorkOrder.WorkerOrderTitle, + Description = dispatch.WorkOrder.Description, + Priority = dispatch.WorkOrder.Priority, + DueDate = dispatch.WorkOrder.DueDate, + Trade = dispatch.WorkOrder.Trade, + SubTrade = dispatch.WorkOrder.SubTrade, + Problem = dispatch.WorkOrder.Problem + }, + Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO + { + Name = dispatch.WorkOrder.Locations.Name, + Address1 = dispatch.WorkOrder.Locations.Address1, + City = dispatch.WorkOrder.Locations.City, + State = dispatch.WorkOrder.Locations.State, + Zip = dispatch.WorkOrder.Locations.Zip + }, + Checklist = checklist.Select(c => new PortalChecklistItemDTO + { + Id = c.Id, + ItemText = c.ItemText, + IsCompleted = c.IsCompleted, + CompletedBy = c.CompletedBy, + CompletedAt = c.CompletedAt + }), + Signoffs = signoffs.Select(s => new PortalSignoffDTO + { + Id = s.Id, + SignoffType = s.SignoffType, + Name = s.Name, + SignatureMethod = s.SignatureMethod, + SignedAt = s.SignedAt + }), + Comments = comments, + UpliftRequests = upliftRequests + }; + } + + public async Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status != "Sent") + { + throw new InvalidOperationException($"Cannot accept a dispatch with status '{dispatch.Status}'"); + } + + var now = DateTime.UtcNow; + dispatch.Status = "Acknowledged"; + dispatch.AcknowledgedAt = now; + dispatch.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = "Sent", + NewValue = "Acknowledged", + Action = "vendor_accept", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt }; + } + + public async Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + var from = dispatch.Status; + if (!IsAllowedVendorTransition(from, to)) + { + throw new InvalidOperationException($"Transition from '{from}' to '{to}' is not allowed"); + } + + var now = DateTime.UtcNow; + dispatch.Status = to; + dispatch.LastModificationTime = now; + if (to == "Completed") dispatch.CompletedDate = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = from, + NewValue = to, + Action = "vendor_status_change", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new ChangeStatusResultDTO { Id = dispatch.Id, Status = dispatch.Status, CompletedDate = dispatch.CompletedDate }; + } + + public async Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch"); + } + + var now = DateTime.UtcNow; + var reasonText = string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason!.Trim(); + + await _commentData.StageAsync(new Comments + { + DispatchId = id, + WorkerOrderId = dispatch.WorkOrderId, + Commenter = session.CompanyName, + CommentType = "vendor", + RecordType = "cancel_request", + Commenttext = $"Vendor requested cancellation: {reasonText}", + CreatedDate = now + }, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber}", + OldValue = dispatch.Status, + NewValue = "Cancel Requested", + Action = "vendor_request_cancel", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException("Dispatch is locked"); + } + + var item = await _dispatchData.GetChecklistItemForDispatchAsync(itemId, id, cancellationToken); + if (item == null) throw new KeyNotFoundException("Checklist item not found"); + + var now = DateTime.UtcNow; + item.IsCompleted = isCompleted; + item.CompletedBy = isCompleted ? session.CompanyName : null; + item.CompletedAt = isCompleted ? now : null; + item.LastModificationTime = now; + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new ChecklistItemResultDTO + { + Id = item.Id, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }; + } + + public async Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") + { + throw new InvalidOperationException("Signoffs only allowed on In Progress or Completed dispatches"); + } + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) + { + throw new InvalidOperationException("Name and signature are required"); + } + + var normalizedSignoffType = (signoffType ?? "vendor").Trim().ToLowerInvariant(); + if (normalizedSignoffType != "vendor" && normalizedSignoffType != "customer") + { + throw new InvalidOperationException("signoffType must be 'vendor' or 'customer'"); + } + + var existing = await _dispatchData.HasSignoffTypeAsync(id, normalizedSignoffType); + if (existing) + { + throw new InvalidOperationException($"A {normalizedSignoffType} signoff already exists for this dispatch"); + } + + var now = DateTime.UtcNow; + var signoff = new DispatchSignoff + { + DispatchId = id, + SignoffType = normalizedSignoffType, + Name = name, + Signature = signature, + SignatureMethod = signatureMethod ?? "drawn", + SignedAt = now, + CreatedDate = now + }; + await _dispatchData.StageSignoffAsync(signoff, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", + OldValue = null, + NewValue = $"{(normalizedSignoffType == "customer" ? "Customer" : "Vendor")}: {name}", + Action = normalizedSignoffType == "customer" ? "customer_signoff" : "vendor_signoff", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new SignoffResultDTO { Id = signoff.Id, SignedAt = signoff.SignedAt }; + } + + public async Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (string.IsNullOrWhiteSpace(commentText)) + { + throw new InvalidOperationException("Comment cannot be empty"); + } + + var now = DateTime.UtcNow; + var comment = new Comments + { + DispatchId = id, + WorkerOrderId = dispatch.WorkOrderId, + Commenter = session.CompanyName, + CommentType = "vendor", + RecordType = "comment", + Commenttext = commentText, + CreatedDate = now + }; + await _commentData.StageAsync(comment, cancellationToken); + await _dispatchData.SaveChangesAsync(cancellationToken); + + return new CommentResultDTO + { + Id = comment.Id, + Commenttext = comment.Commenttext, + Commenter = comment.Commenter, + CreatedDate = comment.CreatedDate + }; + } + + public async Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); + } + + if (requestedNTE <= 0) + { + throw new InvalidOperationException("Requested NTE must be greater than zero"); + } + + var current = dispatch.NTEAmount ?? 0m; + if (requestedNTE <= current) + { + throw new InvalidOperationException("Requested NTE must be greater than the current NTE"); + } + + var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken); + if (pendingExists) + { + throw new InvalidOperationException("A pending uplift request already exists for this dispatch"); + } + + var tier1Max = _config.GetValue("Approvals:UpliftTier1MaxUsd") ?? 2500m; + var delta = requestedNTE - current; + var requiredTier = delta > tier1Max ? 2 : 1; + + var now = DateTime.UtcNow; + var req = new DispatchUpliftRequest + { + DispatchId = id, + CurrentNTE = current, + RequestedNTE = requestedNTE, + VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(), + Status = "Pending", + RequiredTier = requiredTier, + RequestedByVendorName = session.CompanyName, + CreatedDate = now + }; + await _upliftData.StageAsync(req, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = $"${current:F2}", + NewValue = $"${requestedNTE:F2}", + Action = "uplift_requested", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + + await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); + + return new UpliftRequestResultDTO + { + Id = req.Id, + Status = req.Status, + RequiredTier = req.RequiredTier, + CurrentNTE = req.CurrentNTE, + RequestedNTE = req.RequestedNTE + }; + } + + public async Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + + if (req.Status != "Pending") + { + throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request"); + } + + var now = DateTime.UtcNow; + req.Status = "Cancelled"; + req.DecidedAt = now; + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = "Pending", + NewValue = "Cancelled", + Action = "uplift_cancelled", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status }; + } + + private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken) + { + try + { + var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken); + if (string.IsNullOrWhiteSpace(dispatcherUserId)) return; + + var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); + if (string.IsNullOrWhiteSpace(dispatcherEmail)) return; + + var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; + var workOrderLink = dispatch.WorkOrderId.HasValue + ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" + : frontendBase; + + var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor"); + var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); + var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); + var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; + + var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; + var body = $@" +

Vendor Uplift Request

+

Dispatch: {dispatchNum}

+

Vendor: {vendorName}

+ + + + + +
Current NTE${req.CurrentNTE ?? 0m:F2}
Requested NTE${req.RequestedNTE:F2}
Delta${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}
Required Approval{tierText}
+

Vendor Reason

+

{reason}

+ "; + + await _emailSender.SendEmailAsync(dispatcherEmail, subject, body); + } + catch + { + } + } + + private static bool IsAllowedVendorTransition(string? from, string? to) + { + if (from == "Acknowledged" && to == "In Progress") return true; + if (from == "In Progress" && to == "Completed") return true; + return false; + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalTokenService.cs b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs new file mode 100644 index 0000000..22a3af5 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs @@ -0,0 +1,92 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Configuration; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Cryptography; + +namespace SeaHaven.Services.Implementation +{ + public class VendorPortalTokenService : IVendorPortalTokenService + { + private readonly IVendorDataService _vendorDataService; + private readonly int _lifetimeDays; + + public VendorPortalTokenService(IVendorDataService vendorDataService, IConfiguration config) + { + _vendorDataService = vendorDataService; + _lifetimeDays = config.GetValue("VendorPortal:TokenLifetimeDays") ?? 365; + } + + public async Task GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var existing = await _vendorDataService.GetActiveTokenForVendorAsync(vendorId, cancellationToken); + if (existing != null) return MapToken(existing); + + var token = new VendorAccessToken + { + VendorId = vendorId, + Token = GenerateToken(), + IssuedAt = now, + ExpiresAt = now.AddDays(_lifetimeDays), + CreatedDate = now + }; + return MapToken(await _vendorDataService.AddTokenAsync(token, cancellationToken)); + } + + public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(token)) return null; + var now = DateTime.UtcNow; + var record = await _vendorDataService.GetTokenWithVendorAsync(token, cancellationToken); + + if (record?.Vendor == null) return null; + + record.LastUsedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + return new VendorPortalSession + { + Id = record.Vendor.Id, + CompanyName = record.Vendor.CompanyName, + ContactName = record.Vendor.ContactName, + Email = record.Vendor.Email, + Phone = record.Vendor.Phone + }; + } + + public async Task RotateAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken); + foreach (var t in active) t.RevokedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + return await GetOrCreateActiveTokenAsync(vendorId, cancellationToken); + } + + public async Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken); + foreach (var t in active) t.RevokedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + } + + private static string GenerateToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + private static VendorPortalTokenStateDTO MapToken(VendorAccessToken token) => new() + { + Token = token.Token, + IssuedAt = token.IssuedAt, + ExpiresAt = token.ExpiresAt, + LastUsedAt = token.LastUsedAt + }; + } +} diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 009a9e4..871dd17 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using FluentValidation.Results; +using Microsoft.Extensions.Configuration; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; @@ -16,17 +17,26 @@ namespace SeaHaven.Services.Implementation { "completed", "cancelled", "canceled" }; private readonly IVendorDataService _vendorDataService; + private readonly IVendorPortalTokenService _vendorTokenService; + private readonly IZipCodeDistance _zipDistance; + private readonly IConfiguration _config; private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; public VendorService( IVendorDataService vendorDataService, + IVendorPortalTokenService vendorTokenService, + IZipCodeDistance zipDistance, + IConfiguration config, ICreateVendorValidation createValidator, IUpdateVendorValidation updateValidator, IWorkOrderVendorUpdateValidation workOrderUpdateValidator) { _vendorDataService = vendorDataService; + _vendorTokenService = vendorTokenService; + _zipDistance = zipDistance; + _config = config; _createValidator = createValidator; _updateValidator = updateValidator; _workOrderUpdateValidator = workOrderUpdateValidator; @@ -519,5 +529,136 @@ namespace SeaHaven.Services.Implementation .Distinct() .Count(); } + + public async Task> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken) + { + var vendors = await _vendorDataService.GetActiveVendorsAsync(cancellationToken); + + var result = vendors.Select(v => + { + var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip); + var addr = new[] { v.Address, v.City, v.State, v.Zip } + .Where(s => !string.IsNullOrWhiteSpace(s)); + return new VendorDropdownItemDTO + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + PreferredContact = v.PreferredContact, + TradeSpecialties = v.TradeSpecialties, + Address = string.Join(", ", addr), + DistanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null + }; + }).ToList(); + + if (!string.IsNullOrWhiteSpace(trade)) + { + var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList(); + var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList(); + result = tradeMatched.Concat(rest).ToList(); + } + + if (!string.IsNullOrWhiteSpace(siteZip)) + { + result = result.OrderBy(v => v.DistanceMiles ?? 99999).ToList(); + } + + return result; + } + + public async Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) + { + var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken); + + var companies = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) + .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(v => v.CompanyName) + .Select(v => new VendorFacetCompanyDTO + { + Name = v.CompanyName, + CompanyId = v.CompanyId, + CompanyPhone = v.CompanyPhone, + Email = v.Email, + PreferredContact = v.PreferredContact, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + GoogleMapsUrl = v.GoogleMapsUrl + }); + + var trades = vendors + .SelectMany(v => (v.TradeSpecialties ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value); + + var locations = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) + .Select(v => new VendorFacetLocationDTO + { + City = v.City, + State = v.State, + Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) + }) + .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) + .OrderBy(location => location.Label); + + return new VendorFacetsDTO + { + Companies = companies, + Trades = trades, + Locations = locations, + JobBuckets = new[] + { + new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" }, + new VendorJobBucketDTO { Id = "50-99", Label = "50–99" }, + new VendorJobBucketDTO { Id = "100-149", Label = "100–149" }, + new VendorJobBucketDTO { Id = "150-plus", Label = "150+" } + } + }; + } + + public async Task GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return null; + + var token = await _vendorTokenService.GetOrCreateActiveTokenAsync(vendorId, cancellationToken); + return BuildPortalTokenResponse(token); + } + + public async Task RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return null; + + var token = await _vendorTokenService.RotateAsync(vendorId, cancellationToken); + return BuildPortalTokenResponse(token); + } + + public async Task RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return false; + + await _vendorTokenService.RevokeAllAsync(vendorId, cancellationToken); + return true; + } + + private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token) + { + var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; + return new VendorPortalTokenDTO + { + Token = token.Token, + IssuedAt = token.IssuedAt, + ExpiresAt = token.ExpiresAt, + LastUsedAt = token.LastUsedAt, + PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard" + }; + } } } diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs new file mode 100644 index 0000000..3fb0b53 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IUpliftService + { + Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); + Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken); + Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + bool CanApprove(ClaimsPrincipal user, int tier); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorPortalService.cs b/SeaHaven.Services/Interfaces/IVendorPortalService.cs new file mode 100644 index 0000000..1ba36c9 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorPortalService.cs @@ -0,0 +1,19 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorPortalService + { + Task ResolveSessionAsync(string? token, CancellationToken cancellationToken); + Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); + Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); + Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken); + Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken); + Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken); + Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken); + Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken); + Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken); + Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs b/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs new file mode 100644 index 0000000..50a4e53 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs @@ -0,0 +1,12 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorPortalTokenService + { + Task GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken); + Task ResolveSessionAsync(string? token, CancellationToken cancellationToken); + Task RotateAsync(int vendorId, CancellationToken cancellationToken); + Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 2ab4c67..18dd029 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -23,5 +23,10 @@ namespace SeaHaven.Services.Interfaces Task GetTotalVendorCountAsync(); Task GetDeactivationImpactAsync(int vendorId); Task UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId); + Task> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken); + Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken); + Task GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken); + Task RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken); + Task RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IZipCodeDistance.cs b/SeaHaven.Services/Interfaces/IZipCodeDistance.cs new file mode 100644 index 0000000..d69ab50 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IZipCodeDistance.cs @@ -0,0 +1,7 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface IZipCodeDistance + { + double? GetDistanceMiles(string? zip1, string? zip2); + } +}