refactor(vendors): enforce service and data boundaries

This commit is contained in:
Alexandre Brandizzi 2026-07-24 11:12:00 -03:00
parent c440b2e3ee
commit 9cd1c7e7fc
36 changed files with 2698 additions and 1042 deletions

View file

@ -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<IConfiguration>(new ConfigurationBuilder().Build());
services.AddSingleton<IZipCodeDistance>(_ => new DummyZipCodeDistance());
services.AddSingleton<IEmailSender>(_ => new DummyEmailSender());
services.AddDbContext<ApplicationDbContext>(options =>
options.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()));
@ -76,4 +80,37 @@ public class ServiceRegistrationTests
provider.GetRequiredService<IDispatchDataService>().Should().BeOfType<DispatchDataService>();
provider.GetRequiredService<ISettingsDataService>().Should().BeOfType<SettingsDataService>();
}
[Fact]
public void ConventionDi_RegistersVendorAndUpliftServices()
{
using var provider = BuildProvider();
provider.GetRequiredService<IVendorService>().Should().BeOfType<VendorService>();
provider.GetRequiredService<IVendorPortalService>().Should().BeOfType<VendorPortalService>();
provider.GetRequiredService<IVendorPortalTokenService>()
.Should().BeOfType<SeaHaven.Services.Implementation.VendorPortalTokenService>();
provider.GetRequiredService<IUpliftService>().Should().BeOfType<UpliftService>();
}
[Fact]
public void ConventionDi_RegistersVendorAndUpliftDataServices()
{
using var provider = BuildProvider();
provider.GetRequiredService<IVendorDataService>().Should().BeOfType<VendorDataService>();
provider.GetRequiredService<IUpliftDataService>().Should().BeOfType<UpliftDataService>();
provider.GetRequiredService<ICommentDataService>().Should().BeOfType<CommentDataService>();
}
private sealed class DummyZipCodeDistance : IZipCodeDistance
{
public double? GetDistanceMiles(string? zip1, string? zip2) => null;
}
private sealed class DummyEmailSender : IEmailSender
{
public Task<bool> SendEmailAsync(string emailTo, string subject, string body) =>
Task.FromResult(true);
}
}

View file

@ -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<IUpliftService> service, params string[] roles)
{
var controller = new UpliftController(service.Object);
var claims = new List<Claim> { 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<IUpliftService>();
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<ClaimsPrincipal>(), "Pending", null, 1, 25, It.IsAny<CancellationToken>()))
.ReturnsAsync(dto);
var controller = NewController(service);
var result = await controller.List();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
}
[Fact]
public async Task Approve_NotFound_Returns404()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 99, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException("not found"));
var controller = NewController(service);
var result = await controller.Approve(99, null);
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
nf.Value.Should().BeOfType<Response>();
}
[Fact]
public async Task Approve_ForbiddenTier_Returns403()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new UpliftForbiddenException("requires Tier 2"));
var controller = NewController(service);
var result = await controller.Approve(5, null);
var status = result.Should().BeOfType<ObjectResult>().Subject;
status.StatusCode.Should().Be(403);
}
[Fact]
public async Task Approve_AlreadyDecided_Returns400()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("already decided"));
var controller = NewController(service);
var result = await controller.Approve(5, null);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Approve_Success_ReturnsApprovedResult()
{
var service = new Mock<IUpliftService>();
var approved = new UpliftApproveResultDTO { Id = 5, Status = "Approved", NTEAmount = 750m };
service.Setup(x => x.ApproveAsync(It.IsAny<ClaimsPrincipal>(), 5, "ok", It.IsAny<CancellationToken>()))
.ReturnsAsync(approved);
var controller = NewController(service);
var result = await controller.Approve(5, new UpliftController.DecisionRequest { Note = "ok" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((UpliftApproveResultDTO)envelope.Data).Should().Be(approved);
}
[Fact]
public async Task Deny_WithoutNote_Returns400()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, null, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("A note is required when denying"));
var controller = NewController(service);
var result = await controller.Deny(5, null);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task Deny_Success_ReturnsDeniedResult()
{
var service = new Mock<IUpliftService>();
var denied = new UpliftDenyResultDTO { Id = 5, Status = "Denied" };
service.Setup(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, "too high", It.IsAny<CancellationToken>()))
.ReturnsAsync(denied);
var controller = NewController(service);
var result = await controller.Deny(5, new UpliftController.DecisionRequest { Note = "too high" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
service.Verify(x => x.DenyAsync(It.IsAny<ClaimsPrincipal>(), 5, "too high", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public void CanApprove_DelegatesToService()
{
var service = new Mock<IUpliftService>();
service.Setup(x => x.CanApprove(It.IsAny<ClaimsPrincipal>(), 2))
.Returns(true);
var controller = NewController(service, "Manager");
var result = controller.CanApprove(2);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
service.Verify(x => x.CanApprove(It.Is<ClaimsPrincipal>(p => p.IsInRole("Manager")), 2), Times.Once);
}
[Fact]
public async Task ListForDispatch_ReturnsItems()
{
var service = new Mock<IUpliftService>();
var items = new[] { new UpliftForDispatchDTO { Id = 1, DispatchId = 10 } };
service.Setup(x => x.ListForDispatchAsync(It.IsAny<ClaimsPrincipal>(), 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
var controller = NewController(service);
var result = await controller.ListForDispatch(10);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
}
}

View file

@ -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<IVendorService> 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<ApplicationDbContext>()
.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<IVendorService>();
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<CancellationToken>()))
.ReturnsAsync(facets);
var controller = new VendorController(
Mock.Of<IVendorService>(),
context,
zipDistance: null!,
vendorTokens: null!,
config: null!);
var controller = NewController(service);
var result = await controller.GetFacets(isActive);
var payload = JsonSerializer.Serialize(
result.Should().BeOfType<OkObjectResult>().Subject.Value);
var result = await controller.GetFacets(true);
payload.Should().Contain(expected);
payload.Should().NotContain(excluded);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(facets);
service.Verify(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup()
public async Task GetDropdown_DelegatesToServiceAndReturnsOk()
{
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.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<IVendorService>();
var items = new[]
{
new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 }
};
service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny<CancellationToken>()))
.ReturnsAsync(items);
var controller = new VendorController(
Mock.Of<IVendorService>(),
context,
zipDistance: null!,
vendorTokens: null!,
config: null!);
var controller = NewController(service);
var result = await controller.GetFacets();
var payload = JsonSerializer.Serialize(
result.Should().BeOfType<OkObjectResult>().Subject.Value);
var result = await controller.GetDropdown("Plumbing", "60601");
payload.Should().Contain("Active Co");
payload.Should().Contain("Inactive Co");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(items);
}
[Fact]
public async Task GetPortalToken_ReturnsTokenDtoFromService()
{
var service = new Mock<IVendorService>();
var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" };
service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(token);
var controller = NewController(service);
var result = await controller.GetPortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(token);
}
[Fact]
public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorPortalTokenDTO?)null);
var controller = NewController(service);
var result = await controller.GetPortalToken(99);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task RotatePortalToken_ReturnsRotatedToken()
{
var service = new Mock<IVendorService>();
var token = new VendorPortalTokenDTO { Token = "new-token" };
service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(token);
var controller = NewController(service);
var result = await controller.RotatePortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().Be(token);
}
[Fact]
public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var controller = NewController(service);
var result = await controller.RevokePortalToken(5);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
ok.Value.Should().NotBeNull();
}
[Fact]
public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var controller = NewController(service);
var result = await controller.RevokePortalToken(99);
result.Should().BeOfType<NotFoundResult>();
}
[Fact]
public async Task Delete_MapsDeactivationBlocked_ToConflict()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
.ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List<LinkedWorkOrderDTO>()));
var controller = NewController(service);
var result = await controller.Delete(5);
result.Should().BeOfType<ConflictObjectResult>();
}
[Fact]
public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.DeleteVendorAsync(It.IsAny<int>(), It.IsAny<string>()))
.ThrowsAsync(new InvalidOperationException());
var controller = NewController(service);
var result = await controller.Delete(5);
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
nf.Value.Should().BeOfType<Response>();
}
[Fact]
public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull()
{
var service = new Mock<IVendorService>();
service.Setup(x => x.GetVendorByIdAsync(99))
.ReturnsAsync((VendorDTO?)null);
var controller = NewController(service);
var result = await controller.GetVendorById(99, null);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Update_UnauthenticatedUser_ReturnsUnauthorized()
{
var service = new Mock<IVendorService>();
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<UnauthorizedObjectResult>();
}
}

View file

@ -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<IVendorPortalService> 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<IVendorPortalService>();
var controller = NewController(service, token: null);
var result = await controller.Session();
result.Should().BeOfType<UnauthorizedObjectResult>();
}
[Fact]
public async Task Session_InvalidToken_ReturnsUnauthorized()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorPortalSession?)null);
var controller = NewController(service, token: "bad");
var result = await controller.Session();
result.Should().BeOfType<UnauthorizedObjectResult>();
}
[Fact]
public async Task Session_ValidToken_ReturnsSessionData()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var controller = NewController(service);
var result = await controller.Session();
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
envelope.Status.Should().Be("Success");
((VendorPortalSession)envelope.Data).Should().Be(Session);
}
[Fact]
public async Task ListDispatches_DelegatesToServiceWithStatus()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } };
service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()))
.ReturnsAsync(dispatches);
var controller = NewController(service);
var result = await controller.ListDispatches("Sent");
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task GetDispatch_NotFound_Returns404()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny<CancellationToken>()))
.ReturnsAsync((VendorDispatchDetailDTO?)null);
var controller = NewController(service);
var result = await controller.GetDispatch(99);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Accept_KeyNotFound_Returns404()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny<CancellationToken>()))
.ThrowsAsync(new KeyNotFoundException());
var controller = NewController(service);
var result = await controller.Accept(99);
result.Should().BeOfType<NotFoundObjectResult>();
}
[Fact]
public async Task Accept_InvalidOperation_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Cannot accept"));
var controller = NewController(service);
var result = await controller.Accept(10);
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
var resp = bad.Value.Should().BeOfType<Response>().Subject;
resp.Message.Should().Be("Cannot accept");
}
[Fact]
public async Task Accept_Success_ReturnsAcknowledgedResult()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" };
service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(acceptResult);
var controller = NewController(service);
var result = await controller.Accept(10);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((AcceptDispatchResultDTO)envelope.Data).Should().Be(acceptResult);
}
[Fact]
public async Task ChangeStatus_ForbiddenTransition_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>().Subject;
var resp = bad.Value.Should().BeOfType<Response>().Subject;
resp.Message.Should().Contain("Transition");
}
[Fact]
public async Task RequestCancel_Success_ReturnsSuccessWithMessage()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var controller = NewController(service);
var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" });
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().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<CancellationToken>()), Times.Once);
}
[Fact]
public async Task UpdateChecklistItem_LockedDispatch_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>();
}
[Fact]
public async Task AddSignoff_DuplicateSignoff_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_NTEBelowCurrent_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny<CancellationToken>()))
.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<BadRequestObjectResult>();
}
[Fact]
public async Task RequestUplift_Success_ReturnsResult()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<CancellationToken>()))
.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<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((UpliftRequestResultDTO)envelope.Data).Should().Be(upliftResult);
}
[Fact]
public async Task CancelUpliftRequest_NonPending_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request"));
var controller = NewController(service);
var result = await controller.CancelUpliftRequest(10, 77);
result.Should().BeOfType<BadRequestObjectResult>();
}
[Fact]
public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" };
service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny<CancellationToken>()))
.ReturnsAsync(cancelResult);
var controller = NewController(service);
var result = await controller.CancelUpliftRequest(10, 77);
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
var envelope = ok.Value.Should().BeOfType<DataResponse>().Subject;
((CancelUpliftResultDTO)envelope.Data).Should().Be(cancelResult);
}
[Fact]
public async Task AddComment_EmptyText_Returns400()
{
var service = new Mock<IVendorPortalService>();
service.Setup(x => x.ResolveSessionAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Session);
service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("Comment cannot be empty"));
var controller = NewController(service);
var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" });
result.Should().BeOfType<BadRequestObjectResult>();
}
}

View file

@ -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<IVendorDataService> data) =>
new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation());
new(
data.Object,
Mock.Of<IVendorPortalTokenService>(),
Mock.Of<IZipCodeDistance>(),
new ConfigurationBuilder().Build(),
new CreateVendorValidation(),
new UpdateVendorValidation(),
new WorkOrderVendorUpdateValidation());
[Fact]
public async Task CreateVendor_PersistsCompleteProfile()

View file

@ -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<IActionResult> List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25)
public async Task<IActionResult> 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<IActionResult> ListForDispatch(int dispatchId)
public async Task<IActionResult> 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<IActionResult> Approve(int id, [FromBody] DecisionRequest? body)
public async Task<IActionResult> 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<IActionResult> Deny(int id, [FromBody] DecisionRequest body)
public async Task<IActionResult> 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<string[]>() ?? Array.Empty<string>();
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

View file

@ -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<IActionResult> GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null)
public async Task<IActionResult> 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<IActionResult> GetFacets([FromQuery] bool? isActive = null)
public async Task<IActionResult> 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<IActionResult> GetPortalToken(int id)
public async Task<IActionResult> 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<IActionResult> RotatePortalToken(int id)
public async Task<IActionResult> 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<IActionResult> RevokePortalToken(int id)
public async Task<IActionResult> 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<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
return new
{
token.Token,
token.IssuedAt,
token.ExpiresAt,
token.LastUsedAt,
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
};
}
}
}

View file

@ -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<IActionResult> Session()
public async Task<IActionResult> 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<IActionResult> ListDispatches([FromQuery] string? status = null)
public async Task<IActionResult> 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<IActionResult> GetDispatch(int id)
public async Task<IActionResult> 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<IActionResult> Accept(int id)
public async Task<IActionResult> 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<IActionResult> ChangeStatus(int id, [FromBody] ChangeStatusRequest body)
public async Task<IActionResult> 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<IActionResult> RequestCancel(int id, [FromBody] RequestCancelRequest body)
public async Task<IActionResult> 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<IActionResult> UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body)
public async Task<IActionResult> 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<IActionResult> AddSignoff(int id, [FromBody] SignoffRequest body)
public async Task<IActionResult> 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<IActionResult> AddComment(int id, [FromBody] CommentRequest body)
public async Task<IActionResult> 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<IActionResult> RequestUplift(int id, [FromBody] UpliftRequestBody body)
public async Task<IActionResult> 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<decimal?>("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<IActionResult> CancelUpliftRequest(int id, int requestId)
public async Task<IActionResult> 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<string>("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 = $@"
<h2>Vendor Uplift Request</h2>
<p><strong>Dispatch:</strong> {dispatchNum}</p>
<p><strong>Vendor:</strong> {vendorName}</p>
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>
<p>{reason}</p>
<div style='margin:20px 0;'>
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
</div>";
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<Vendor?> ResolveVendorAsync()
private async Task<VendorPortalSession?> ResolveSessionAsync(CancellationToken cancellationToken)
{
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
var token = values.ToString();
return await _tokens.ResolveVendorAsync(token);
}
private Task<Dispatch?> 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

View file

@ -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<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
var portalUrl = $"{frontendBase}/v/{vendorToken.Token}/dashboard";

View file

@ -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<bool> SendEmailAsync(string emailTo, string subject, string htmlBody)
=> await SendEMail(emailTo, subject, htmlBody);
public async Task<bool> SendEMailAttachment(string emailTo, string subject, byte[] pdfBytes)
{
try

View file

@ -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<int?>("VendorPortal:TokenLifetimeDays") ?? 365;
}
public async Task<VendorAccessToken> 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<Vendor?> 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<VendorAccessToken> 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('=');
}
}
}

View file

@ -1,6 +1,8 @@
using SeaHaven.Services.Interfaces;
namespace Api.SeaHavenIndustries.Helper
{
public class ZipCodeDistance
public class ZipCodeDistance : IZipCodeDistance
{
private readonly Dictionary<string, (double lat, double lng)> _coords = new();
private bool _loaded;

View file

@ -40,7 +40,6 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All
builder.Services.AddScoped<SendMessage>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
{
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
@ -49,6 +48,8 @@ builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp
if (File.Exists(csvPath)) zipService.Load(csvPath);
return zipService;
});
builder.Services.AddSingleton<SeaHaven.Services.Interfaces.IZipCodeDistance>(
serviceProvider => serviceProvider.GetRequiredService<Api.SeaHavenIndustries.Helper.ZipCodeDistance>());
builder.Services.AddDataServices();
builder.Services.AddBusinessServices();

View file

@ -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; }
}
}

View file

@ -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<IReadOnlyList<PortalCommentData>> 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);
}
}
}

View file

@ -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<IReadOnlyList<VendorDispatchSummaryData>> 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<Dispatch?> 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<Dispatch?> 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<IReadOnlyList<PortalChecklistItemData>> 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<IReadOnlyList<PortalSignoffData>> 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<DispatchChecklistItem?> 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<string?> 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);
}
}
}

View file

@ -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<UpliftListItemData> 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<IReadOnlyList<UpliftForDispatchData>> 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<IReadOnlyList<PortalUpliftData>> 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<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
public async Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken)
{
return await _context.DispatchUpliftRequests
.FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == dispatchId, cancellationToken);
}
public async Task<bool> 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);
}
}
}

View file

@ -141,5 +141,15 @@ namespace SeaHaven.DataServices.Implementation
await transaction.CommitAsync(cancellationToken);
}
public async Task<string?> GetEmailByIdAsync(
string userId, CancellationToken cancellationToken)
{
return await _context.Users
.AsNoTracking()
.Where(user => user.Id == userId)
.Select(user => user.Email)
.FirstOrDefaultAsync(cancellationToken);
}
}
}

View file

@ -34,6 +34,29 @@ namespace SeaHaven.DataServices.Implementation
return await _context.Vendors.ToListAsync();
}
public async Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken)
{
return await _context.Vendors
.AsNoTracking()
.Where(v => v.IsActive)
.OrderBy(v => v.CompanyName)
.ToListAsync(cancellationToken);
}
public async Task<IReadOnlyList<Vendor>> 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<Vendor> 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<VendorAccessToken?> GetActiveTokenAsync(int vendorId)
public async Task<VendorAccessToken?> 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<VendorAccessToken?> GetTokenByStringAsync(string token)
public async Task<VendorAccessToken?> 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<VendorAccessToken> AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken)
{
await _context.VendorAccessTokens.AddAsync(token, cancellationToken);
await _context.SaveChangesAsync(cancellationToken);
return token;
}
public async Task<IReadOnlyList<VendorAccessToken>> 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<int> CountAsync()

View file

@ -12,5 +12,7 @@ namespace SeaHaven.DataServices.Interfaces
Task UpdateAsync(Comments comment);
Task DeleteAsync(int id);
Task<bool> ExistsAsync(int id);
Task StageAsync(Comments comment, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalCommentData>> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
}
}

View file

@ -26,11 +26,28 @@ namespace SeaHaven.DataServices.Interfaces
Task UpdateChecklistItemAsync(DispatchChecklistItem item);
Task<IEnumerable<DispatchSignoff>> GetSignoffsAsync(int dispatchId);
Task AddSignoffAsync(DispatchSignoff signoff);
Task StageSignoffAsync(DispatchSignoff signoff, CancellationToken cancellationToken);
Task<bool> HasSignoffTypeAsync(int dispatchId, string signoffType);
Task<IEnumerable<DispatchWorkOrder>> GetDispatchWorkOrdersAsync(int dispatchId);
Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder);
Task<DispatchListResult> GetListAsync(DispatchListQuery query, CancellationToken cancellationToken);
Task<DispatchDetailDTO?> GetDetailAsync(int id, CancellationToken cancellationToken);
Task<IReadOnlyList<VendorDispatchSummaryData>> GetVendorDispatchSummariesAsync(
int vendorId, string? status, CancellationToken cancellationToken);
Task<Dispatch?> GetVendorDispatchDetailAsync(
int id, int vendorId, CancellationToken cancellationToken);
Task<Dispatch?> GetVendorDispatchForMutationAsync(
int id, int vendorId, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalChecklistItemData>> GetPortalChecklistAsync(
int dispatchId, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalSignoffData>> GetPortalSignoffsAsync(
int dispatchId, CancellationToken cancellationToken);
Task<DispatchChecklistItem?> GetChecklistItemForDispatchAsync(
int itemId, int dispatchId, CancellationToken cancellationToken);
Task StageAuditLogAsync(WorkOrderAuditLog log, CancellationToken cancellationToken);
Task<string?> GetDispatchDispatcherUserIdAsync(
int workOrderId, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,17 @@
using Data.SeaHavenIndustries;
namespace SeaHaven.DataServices.Interfaces
{
public interface IUpliftDataService
{
Task<(int TotalCount, IReadOnlyList<UpliftListItemData> Items)> GetPagedAsync(
string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
Task<IReadOnlyList<UpliftForDispatchData>> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<IReadOnlyList<PortalUpliftData>> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAsync(int id, CancellationToken cancellationToken);
Task<DispatchUpliftRequest?> GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken);
Task<bool> HasPendingAsync(int dispatchId, CancellationToken cancellationToken);
Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
}
}

View file

@ -17,5 +17,6 @@ namespace SeaHaven.DataServices.Interfaces
Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken);
Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken);
Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken);
Task<string?> GetEmailByIdAsync(string userId, CancellationToken cancellationToken);
}
}

View file

@ -7,6 +7,8 @@ namespace SeaHaven.DataServices.Interfaces
Task<Vendor?> GetByIdAsync(int id);
Task<Vendor?> GetByIdWithDetailsAsync(int id);
Task<IEnumerable<Vendor>> GetAllAsync();
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
Task<(IEnumerable<Vendor> 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<bool> ExistsAsync(int id);
Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId);
Task<VendorAccessToken?> GetTokenByStringAsync(string token);
Task<VendorAccessToken?> GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken);
Task<VendorAccessToken?> GetTokenWithVendorAsync(string token, CancellationToken cancellationToken);
Task<VendorAccessToken> AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken);
Task<IReadOnlyList<VendorAccessToken>> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken);
Task SaveChangesAsync(CancellationToken cancellationToken);
Task<int> CountAsync();
Task<VendorCompany?> GetCompanyByIdAsync(int id);

View file

@ -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<UpliftListItemDTO> Items { get; set; } = Enumerable.Empty<UpliftListItemDTO>();
}
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) { }
}
}

View file

@ -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<VendorFacetCompanyDTO> Companies { get; set; } = Enumerable.Empty<VendorFacetCompanyDTO>();
public IEnumerable<string> Trades { get; set; } = Enumerable.Empty<string>();
public IEnumerable<VendorFacetLocationDTO> Locations { get; set; } = Enumerable.Empty<VendorFacetLocationDTO>();
public IEnumerable<VendorJobBucketDTO> JobBuckets { get; set; } = Enumerable.Empty<VendorJobBucketDTO>();
}
}

View file

@ -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<PortalChecklistItemDTO> Checklist { get; set; } = Enumerable.Empty<PortalChecklistItemDTO>();
public IEnumerable<PortalSignoffDTO> Signoffs { get; set; } = Enumerable.Empty<PortalSignoffDTO>();
public IEnumerable<PortalCommentDTO> Comments { get; set; } = Enumerable.Empty<PortalCommentDTO>();
public IEnumerable<PortalUpliftDTO> UpliftRequests { get; set; } = Enumerable.Empty<PortalUpliftDTO>();
}
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; }
}
}

View file

@ -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<UpliftListResultDTO> 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<IEnumerable<UpliftForDispatchDTO>> 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<UpliftApproveResultDTO> 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<UpliftDenyResultDTO> 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<string[]>() ?? Array.Empty<string>();
foreach (var r in roles)
{
if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true;
}
return false;
}
}
}

View file

@ -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<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken)
{
return await _tokens.ResolveSessionAsync(token, cancellationToken);
}
public async Task<IEnumerable<VendorDispatchSummaryDTO>> 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<VendorDispatchDetailDTO?> 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<AcceptDispatchResultDTO> 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<ChangeStatusResultDTO> 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<ChecklistItemResultDTO> 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<SignoffResultDTO> 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<CommentResultDTO> 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<UpliftRequestResultDTO> 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<decimal?>("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<CancelUpliftResultDTO> 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<string>("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 = $@"
<h2>Vendor Uplift Request</h2>
<p><strong>Dispatch:</strong> {dispatchNum}</p>
<p><strong>Vendor:</strong> {vendorName}</p>
<table style='border-collapse:collapse;font-family:Arial,sans-serif;'>
<tr><td style='padding:4px 10px;'><strong>Current NTE</strong></td><td style='padding:4px 10px;'>${req.CurrentNTE ?? 0m:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Requested NTE</strong></td><td style='padding:4px 10px;'>${req.RequestedNTE:F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Delta</strong></td><td style='padding:4px 10px;'>${(req.RequestedNTE - (req.CurrentNTE ?? 0m)):F2}</td></tr>
<tr><td style='padding:4px 10px;'><strong>Required Approval</strong></td><td style='padding:4px 10px;'>{tierText}</td></tr>
</table>
<h3>Vendor Reason</h3>
<p>{reason}</p>
<div style='margin:20px 0;'>
<a href='{workOrderLink}' style='display:inline-block;padding:10px 22px;background:#2563eb;color:white;text-decoration:none;border-radius:6px;font-weight:bold;'>Review in SHOC</a>
</div>";
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;
}
}
}

View file

@ -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<int?>("VendorPortal:TokenLifetimeDays") ?? 365;
}
public async Task<VendorPortalTokenStateDTO> 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<VendorPortalSession?> 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<VendorPortalTokenStateDTO> 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
};
}
}

View file

@ -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<IEnumerable<VendorDropdownItemDTO>> 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<VendorFacetsDTO> 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<VendorPortalTokenDTO?> 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<VendorPortalTokenDTO?> 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<bool> 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<string>("FrontendBaseUrl")?.TrimEnd('/') ?? "";
return new VendorPortalTokenDTO
{
Token = token.Token,
IssuedAt = token.IssuedAt,
ExpiresAt = token.ExpiresAt,
LastUsedAt = token.LastUsedAt,
PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard"
};
}
}
}

View file

@ -0,0 +1,14 @@
using System.Security.Claims;
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IUpliftService
{
Task<UpliftListResultDTO> ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken);
Task<IEnumerable<UpliftForDispatchDTO>> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken);
Task<UpliftApproveResultDTO> ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
Task<UpliftDenyResultDTO> DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken);
bool CanApprove(ClaimsPrincipal user, int tier);
}
}

View file

@ -0,0 +1,19 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IVendorPortalService
{
Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken);
Task<IEnumerable<VendorDispatchSummaryDTO>> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken);
Task<VendorDispatchDetailDTO?> GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
Task<AcceptDispatchResultDTO> AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken);
Task<ChangeStatusResultDTO> ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken);
Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken);
Task<ChecklistItemResultDTO> UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken);
Task<SignoffResultDTO> AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken);
Task<CommentResultDTO> AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken);
Task<UpliftRequestResultDTO> RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken);
Task<CancelUpliftResultDTO> CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,12 @@
using SeaHaven.Services.DTOs;
namespace SeaHaven.Services.Interfaces
{
public interface IVendorPortalTokenService
{
Task<VendorPortalTokenStateDTO> GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken);
Task<VendorPortalSession?> ResolveSessionAsync(string? token, CancellationToken cancellationToken);
Task<VendorPortalTokenStateDTO> RotateAsync(int vendorId, CancellationToken cancellationToken);
Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken);
}
}

View file

@ -23,5 +23,10 @@ namespace SeaHaven.Services.Interfaces
Task<int> GetTotalVendorCountAsync();
Task<VendorDeactivationImpactDTO> GetDeactivationImpactAsync(int vendorId);
Task<VendorDTO> UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId);
Task<IEnumerable<VendorDropdownItemDTO>> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken);
Task<VendorFacetsDTO> GetFacetsAsync(bool? isActive, CancellationToken cancellationToken);
Task<VendorPortalTokenDTO?> GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken);
Task<VendorPortalTokenDTO?> RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken);
Task<bool> RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken);
}
}

View file

@ -0,0 +1,7 @@
namespace SeaHaven.Services.Interfaces
{
public interface IZipCodeDistance
{
double? GetDistanceMiles(string? zip1, string? zip2);
}
}