mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-10-07 16:19:07 +00:00
Merge 6f6c7e04cd into 4b4ab04615
This commit is contained in:
commit
35476e956d
164 changed files with 13473 additions and 4722 deletions
51
.github/workflows/architecture-quality.yml
vendored
Normal file
51
.github/workflows/architecture-quality.yml
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
name: Architecture and changed-file quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, dev]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
architecture:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "8.0.x"
|
||||||
|
|
||||||
|
- name: Restore
|
||||||
|
run: dotnet restore SeaHavenIndustries.sln
|
||||||
|
|
||||||
|
- name: Verify architecture boundaries
|
||||||
|
run: >-
|
||||||
|
dotnet test
|
||||||
|
Api.SeaHavenIndustries.Tests/Api.SeaHavenIndustries.Tests.csproj
|
||||||
|
--no-restore
|
||||||
|
--filter FullyQualifiedName~ArchitectureTests
|
||||||
|
|
||||||
|
- name: Verify formatting of changed C# files
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mapfile -t files < <(
|
||||||
|
git diff --name-only --diff-filter=ACMR \
|
||||||
|
"${{ github.event.pull_request.base.sha }}" \
|
||||||
|
"${{ github.event.pull_request.head.sha }}" \
|
||||||
|
-- '*.cs'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (( ${#files[@]} == 0 )); then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
dotnet format SeaHavenIndustries.sln \
|
||||||
|
--no-restore \
|
||||||
|
--verify-no-changes \
|
||||||
|
--include "${files[@]}"
|
||||||
76
Api.SeaHavenIndustries.Tests/ArchitectureTests.cs
Normal file
76
Api.SeaHavenIndustries.Tests/ArchitectureTests.cs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class ArchitectureTests
|
||||||
|
{
|
||||||
|
private static readonly Assembly ApiAssembly = typeof(VendorPortalController).Assembly;
|
||||||
|
private static readonly Assembly ServicesAssembly = typeof(VendorService).Assembly;
|
||||||
|
private static readonly Assembly DataServicesAssembly = typeof(VendorDataService).Assembly;
|
||||||
|
|
||||||
|
private const string ServiceImplementationNamespace = "SeaHaven.Services.Implementation";
|
||||||
|
private const string DataServiceImplementationNamespace = "SeaHaven.DataServices.Implementation";
|
||||||
|
|
||||||
|
private static IEnumerable<Type> ApiControllers =>
|
||||||
|
ApiAssembly.GetTypes()
|
||||||
|
.Where(t => t.IsClass && !t.IsAbstract && typeof(ControllerBase).IsAssignableFrom(t));
|
||||||
|
|
||||||
|
private static IEnumerable<Type> BusinessServiceImplementations =>
|
||||||
|
ServicesAssembly.GetTypes()
|
||||||
|
.Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType)
|
||||||
|
.Where(t => t.GetInterfaces().Any(i => i.Name == "I" + t.Name));
|
||||||
|
|
||||||
|
private static bool IsConcrete(Type type) => type.IsClass && !type.IsAbstract && !type.IsInterface;
|
||||||
|
|
||||||
|
private static bool IsDbContext(Type type) => typeof(DbContext).IsAssignableFrom(type);
|
||||||
|
|
||||||
|
private static bool IsConcreteServiceImplementation(Type type) =>
|
||||||
|
type.Namespace == ServiceImplementationNamespace && IsConcrete(type);
|
||||||
|
|
||||||
|
private static bool IsConcreteDataService(Type type) =>
|
||||||
|
type.Namespace == DataServiceImplementationNamespace && IsConcrete(type);
|
||||||
|
|
||||||
|
private static IEnumerable<ParameterInfo> ParametersOf(IEnumerable<Type> types) =>
|
||||||
|
types.SelectMany(t => t.GetConstructors().SelectMany(c => c.GetParameters()));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Controllers_DoNotInject_DbContext_ConcreteDataServices_Or_ConcreteServiceImplementations()
|
||||||
|
{
|
||||||
|
ApiControllers.Should().NotBeEmpty("the API must expose controllers for this invariant to be meaningful");
|
||||||
|
|
||||||
|
var violations = ParametersOf(ApiControllers)
|
||||||
|
.Where(p => IsDbContext(p.ParameterType)
|
||||||
|
|| IsConcreteDataService(p.ParameterType)
|
||||||
|
|| IsConcreteServiceImplementation(p.ParameterType))
|
||||||
|
.Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
violations.Should().BeEmpty(
|
||||||
|
"controllers must depend only on interfaces/abstractions, never on DbContext, concrete data services, or concrete service implementations");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BusinessServices_DoNotInject_DbContext_Or_IConfiguration()
|
||||||
|
{
|
||||||
|
BusinessServiceImplementations.Should().NotBeEmpty(
|
||||||
|
"business services must be discovered for this invariant to be meaningful");
|
||||||
|
|
||||||
|
var violations = ParametersOf(BusinessServiceImplementations)
|
||||||
|
.Where(p => IsDbContext(p.ParameterType)
|
||||||
|
|| typeof(IConfiguration).IsAssignableFrom(p.ParameterType))
|
||||||
|
.Select(p => $"{p.Member.DeclaringType!.Name} depends on {p.ParameterType.FullName} ({p.Name})")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
violations.Should().BeEmpty(
|
||||||
|
"business services must depend on data-service interfaces and typed options, never on DbContext or IConfiguration");
|
||||||
|
}
|
||||||
|
}
|
||||||
231
Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs
Normal file
231
Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
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 System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class AuthenticationControllerTests
|
||||||
|
{
|
||||||
|
private static string Json(object? value) =>
|
||||||
|
JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object));
|
||||||
|
|
||||||
|
private static AuthenticationController NewController(Mock<IAuthenticationService> service, string? userId = null)
|
||||||
|
{
|
||||||
|
var controller = new AuthenticationController(service.Object);
|
||||||
|
if (userId != null)
|
||||||
|
{
|
||||||
|
controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||||
|
}, "Test"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WithSuccessfulResult_MapsLowercaseTokenPayload()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.LoginAsync("alice", "pw", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new LoginResultDTO
|
||||||
|
{
|
||||||
|
Token = "tok",
|
||||||
|
Expiration = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc),
|
||||||
|
Email = "alice@example.com",
|
||||||
|
UserRole = "Admin",
|
||||||
|
PhoneNumber = "555",
|
||||||
|
Fullname = "Alice Q",
|
||||||
|
Id = "user-1"
|
||||||
|
});
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.Login(new LoginModel { Username = "alice", Password = "pw" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var json = Json(ok.Value);
|
||||||
|
json.Should().Contain("\"token\":\"tok\"");
|
||||||
|
json.Should().Contain("\"expiration\":\"");
|
||||||
|
json.Should().Contain("\"email\":\"alice@example.com\"");
|
||||||
|
json.Should().Contain("\"userRoles\":\"Admin\"");
|
||||||
|
json.Should().Contain("\"phoneNumber\":\"555\"");
|
||||||
|
json.Should().Contain("\"fullname\":\"Alice Q\"");
|
||||||
|
json.Should().Contain("\"id\":\"user-1\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_WithNullResult_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.LoginAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((LoginResultDTO?)null);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.Login(new LoginModel(), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<UnauthorizedResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_Success_ReturnsSuccessMessageWithTrailingSpace()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var controller = NewController(service, "42");
|
||||||
|
|
||||||
|
var result = await controller.ChangePassword(new ChangePasswords { Currentpassword = "old", Confirmpassword = "new" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Success ");
|
||||||
|
response.Message.Should().Be("Password successfully changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.ChangePasswordAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var controller = NewController(service, "42");
|
||||||
|
|
||||||
|
var result = await controller.ChangePassword(new ChangePasswords(), CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Old Password is incorrect");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_MapsProfileDataIntoDataResponse()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.UpdateProfileAsync("42", It.IsAny<UpdateProfileRequestDTO>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new UserProfileDTO { FirstName = "Bob", Email = "bob@x.com", Contact = "111" });
|
||||||
|
|
||||||
|
var controller = NewController(service, "42");
|
||||||
|
|
||||||
|
var form = new User_DTO { Name = "Bob", Email = "bob@x.com", Contact = "111" };
|
||||||
|
var result = await controller.UserProfileUpdate(form, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("200");
|
||||||
|
response.Message.Should().Be("Introduction Updated Successfully");
|
||||||
|
var json = Json((object?)response.Data);
|
||||||
|
json.Should().Contain("\"FirstName\":\"Bob\"");
|
||||||
|
json.Should().Contain("\"Email\":\"bob@x.com\"");
|
||||||
|
json.Should().Contain("\"Contact\":\"111\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgetPassword_Found_ReturnsCheckEmailMessage()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.ForgetPassword("a@b.com", CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Success ");
|
||||||
|
response.Message.Should().Be("Please check your email for code");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgetPassword_NotFound_ReturnsNoSuchEmailMessage()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.ForgetPasswordAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.ForgetPassword("x@y.com", CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Error");
|
||||||
|
response.Message.Should().Be("No such email is registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true, "Success ", "Code Matched")]
|
||||||
|
[InlineData(false, "Error", "Code Not Matched")]
|
||||||
|
public async Task VerificationCode_MapsServiceResult(bool matched, string expectedStatus, string expectedMessage)
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny<CancellationToken>())).ReturnsAsync(matched);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.VerificationCode("123456", CancellationToken.None);
|
||||||
|
|
||||||
|
if (matched)
|
||||||
|
{
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var r = ok.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
r.Status.Should().Be(expectedStatus);
|
||||||
|
r.Message.Should().Be(expectedMessage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var r = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
r.Status.Should().Be(expectedStatus);
|
||||||
|
r.Message.Should().Be(expectedMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.ResetPasswordAsync("a@b.com", "123", "new", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com", Code = "123", Password = "new" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Success ");
|
||||||
|
response.Message.Should().Be("password changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetPassword_NotMatched_ReturnsEmailOrCodeNotFoundMessage()
|
||||||
|
{
|
||||||
|
var service = new Mock<IAuthenticationService>();
|
||||||
|
service.Setup(s => s.ResetPasswordAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.ResetPassword(new ForgetPassword_Dto { Email = "a@b.com" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Message.Should().Be("Your email or code not found please check");
|
||||||
|
}
|
||||||
|
}
|
||||||
171
Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs
Normal file
171
Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class AuthenticationServiceTests
|
||||||
|
{
|
||||||
|
private static AuthenticationService NewService(
|
||||||
|
Mock<IUserDataService> userData,
|
||||||
|
Mock<IForgetPasswordDataService> forget,
|
||||||
|
Mock<IEmailSender> email,
|
||||||
|
out Mock<IUserRoleStore<ApplicationUser>> store,
|
||||||
|
out Mock<IPasswordHasher<ApplicationUser>> hasher)
|
||||||
|
{
|
||||||
|
var (manager, s, h) = IdentityTestHelpers.CreateUserManager();
|
||||||
|
store = s;
|
||||||
|
hasher = h;
|
||||||
|
return new AuthenticationService(manager, Options.Create(JwtOptions), userData.Object, forget.Object, email.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JwtOptions JwtOptions => new()
|
||||||
|
{
|
||||||
|
Secret = new string('x', 64),
|
||||||
|
ValidIssuer = "issuer",
|
||||||
|
ValidAudience = "audience"
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_UnknownUser_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out _);
|
||||||
|
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
|
var result = await service.LoginAsync("nobody", "pw", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_DeletedUser_RejectedBeforePasswordCheck()
|
||||||
|
{
|
||||||
|
var deletedUser = IdentityTestHelpers.User(isDeleted: true);
|
||||||
|
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
|
||||||
|
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(deletedUser);
|
||||||
|
store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string>());
|
||||||
|
|
||||||
|
var result = await service.LoginAsync("alice", "pw", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
hasher.Verify(h => h.VerifyHashedPassword(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity()
|
||||||
|
{
|
||||||
|
var user = IdentityTestHelpers.User();
|
||||||
|
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
|
||||||
|
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||||
|
store.Setup(s => s.GetRolesAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync(new List<string> { "Admin", "Manager" });
|
||||||
|
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
|
||||||
|
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
|
||||||
|
hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "pw")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Success);
|
||||||
|
|
||||||
|
var result = await service.LoginAsync("alice", "pw", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Id.Should().Be(user.Id);
|
||||||
|
result.Email.Should().Be(user.Email);
|
||||||
|
result.PhoneNumber.Should().Be(user.PhoneNumber);
|
||||||
|
result.Fullname.Should().Be("Alice Q");
|
||||||
|
result.UserRole.Should().Be("Admin");
|
||||||
|
result.Token.Should().NotBeNullOrEmpty();
|
||||||
|
result.Expiration.Should().BeAfter(DateTime.Now.AddDays(9));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_BadPassword_ReturnsNull()
|
||||||
|
{
|
||||||
|
var user = IdentityTestHelpers.User();
|
||||||
|
var service = NewService(new Mock<IUserDataService>(), new Mock<IForgetPasswordDataService>(), new Mock<IEmailSender>(), out var store, out var hasher);
|
||||||
|
store.Setup(s => s.FindByNameAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||||
|
store.As<Microsoft.AspNetCore.Identity.IUserPasswordStore<ApplicationUser>>()
|
||||||
|
.Setup(s => s.GetPasswordHashAsync(user, It.IsAny<CancellationToken>())).ReturnsAsync("hash");
|
||||||
|
hasher.Setup(h => h.VerifyHashedPassword(user, "hash", "wrong")).Returns(Microsoft.AspNetCore.Identity.PasswordVerificationResult.Failed);
|
||||||
|
|
||||||
|
var result = await service.LoginAsync("alice", "wrong", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgetPassword_RegisteredEmail_ReplacesCodeAndSendsEmail()
|
||||||
|
{
|
||||||
|
var user = IdentityTestHelpers.User();
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||||
|
var forget = new Mock<IForgetPasswordDataService>();
|
||||||
|
var email = new Mock<IEmailSender>();
|
||||||
|
|
||||||
|
var service = NewService(userData, forget, email, out _, out _);
|
||||||
|
|
||||||
|
var found = await service.ForgetPasswordAsync("alice@example.com", CancellationToken.None);
|
||||||
|
|
||||||
|
found.Should().BeTrue();
|
||||||
|
forget.Verify(
|
||||||
|
f => f.ReplaceCodeAsync(
|
||||||
|
user.Email!,
|
||||||
|
user.Id,
|
||||||
|
It.Is<string>(code => code.Length == 6 && code.All(char.IsDigit)),
|
||||||
|
It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is<string>(b => b.Contains("Your Password Reset Code is:"))), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode()
|
||||||
|
{
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
var forget = new Mock<IForgetPasswordDataService>();
|
||||||
|
var email = new Mock<IEmailSender>();
|
||||||
|
|
||||||
|
var service = NewService(userData, forget, email, out _, out _);
|
||||||
|
|
||||||
|
var found = await service.ForgetPasswordAsync("nope@example.com", CancellationToken.None);
|
||||||
|
|
||||||
|
found.Should().BeFalse();
|
||||||
|
forget.Verify(f => f.ReplaceCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
email.Verify(e => e.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(true)]
|
||||||
|
[InlineData(false)]
|
||||||
|
public async Task VerifyCode_ForwardsDataServiceResult(bool exists)
|
||||||
|
{
|
||||||
|
var forget = new Mock<IForgetPasswordDataService>();
|
||||||
|
forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny<CancellationToken>())).ReturnsAsync(exists);
|
||||||
|
|
||||||
|
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out _, out _);
|
||||||
|
|
||||||
|
var result = await service.VerifyCodeAsync("abc", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().Be(exists);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResetPassword_NoMatchingCode_ReturnsFalseWithoutReset()
|
||||||
|
{
|
||||||
|
var forget = new Mock<IForgetPasswordDataService>();
|
||||||
|
forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||||
|
|
||||||
|
var service = NewService(new Mock<IUserDataService>(), forget, new Mock<IEmailSender>(), out var store, out _);
|
||||||
|
|
||||||
|
var result = await service.ResetPasswordAsync("a@b.com", "999", "new", CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeFalse();
|
||||||
|
store.Verify(s => s.FindByIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
forget.Verify(f => f.RemoveByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,34 +3,16 @@ using Api.SeaHavenIndustries.Controllers;
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
public class CalendarControllerTests
|
public class CalendarControllerTests
|
||||||
{
|
{
|
||||||
private static ApplicationDbContext NewContext()
|
|
||||||
{
|
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
|
||||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
|
||||||
.Options;
|
|
||||||
return new ApplicationDbContext(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static UserManager<ApplicationUser> MockUserManager()
|
|
||||||
{
|
|
||||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
|
||||||
return new Mock<UserManager<ApplicationUser>>(
|
|
||||||
store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CalendarController NewController(ApplicationDbContext ctx) =>
|
|
||||||
new(MockUserManager(), ctx);
|
|
||||||
|
|
||||||
private static EditEvent_DTO ValidDto(int? id = null, string title = "Standup") => new()
|
private static EditEvent_DTO ValidDto(int? id = null, string title = "Standup") => new()
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
|
|
@ -43,164 +25,166 @@ public class CalendarControllerTests
|
||||||
EndTime = "08:30 AM"
|
EndTime = "08:30 AM"
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false)
|
private static CalendarController NewController(Mock<ICalendarService> service) =>
|
||||||
{
|
new(service.Object);
|
||||||
var ev = new Events
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
EventColor = "primary",
|
|
||||||
ReminderMinutes = 15,
|
|
||||||
StartDate = startDate,
|
|
||||||
EndDate = startDate,
|
|
||||||
IsDeleted = isDeleted,
|
|
||||||
CreatedDate = DateTime.Now
|
|
||||||
};
|
|
||||||
ctx.Events.Add(ev);
|
|
||||||
ctx.SaveChanges();
|
|
||||||
return ev;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static object Prop(object source, string name) =>
|
private static object Prop(object source, string name) =>
|
||||||
source.GetType().GetProperty(name)!.GetValue(source)!;
|
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk()
|
public async Task AddEvent_WhenServiceSucceeds_ReturnsOkWithCreatedMessage()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
var result = await NewController(ctx).AddEvent(ValidDto());
|
var result = await NewController(service).AddEvent(ValidDto(), CancellationToken.None);
|
||||||
|
|
||||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
ok.Value.Should().BeOfType<DataResponse>().Which.Status.Should().Be("200");
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("200");
|
||||||
var saved = ctx.Events.Single();
|
response.Message.Should().Be("Event Created Successfully");
|
||||||
saved.Title.Should().Be("Standup");
|
service.Verify(s => s.CreateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
saved.IsDeleted.Should().Be(false);
|
|
||||||
saved.CreatedDate.Should().NotBeNull();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EditEvent_WhenIdMissing_ReturnsBadRequest()
|
public async Task AddEvent_WhenServiceThrows_ReturnsBadRequestWithMessage()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
var result = await NewController(ctx).EditEvent(ValidDto(id: null));
|
service.Setup(s => s.CreateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new InvalidOperationException("boom"));
|
||||||
|
|
||||||
result.Should().BeOfType<BadRequestObjectResult>();
|
var result = await NewController(service).AddEvent(ValidDto(), CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Message.Should().Be("boom");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EditEvent_WhenEventNotFound_ReturnsNotFound()
|
public async Task EditEvent_WhenIdMissing_ReturnsBadRequestWithoutCallingService()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
var result = await NewController(ctx).EditEvent(ValidDto(id: 999));
|
var result = await NewController(service).EditEvent(ValidDto(id: null), CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Message.Should().Be("Event ID is required for editing");
|
||||||
|
service.Verify(s => s.UpdateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditEvent_WhenServiceReturnsFalse_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var service = new Mock<ICalendarService>();
|
||||||
|
service.Setup(s => s.UpdateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await NewController(service).EditEvent(ValidDto(id: 5), CancellationToken.None);
|
||||||
|
|
||||||
|
var nf = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||||
|
var response = nf.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Message.Should().Be("Event not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditEvent_WhenServiceReturnsTrue_ReturnsOkWithUpdatedMessage()
|
||||||
|
{
|
||||||
|
var service = new Mock<ICalendarService>();
|
||||||
|
service.Setup(s => s.UpdateEventAsync(It.IsAny<EditEventInput>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var result = await NewController(service).EditEvent(ValidDto(id: 5, title: "New"), CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.Value.Should().BeOfType<DataResponse>().Which.Message.Should().Be("Event Updated Successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventById_WhenServiceReturnsNull_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var service = new Mock<ICalendarService>();
|
||||||
|
service.Setup(s => s.GetEventByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync((EventDTO?)null);
|
||||||
|
|
||||||
|
var result = await NewController(service).GetEventById(7, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<NotFoundObjectResult>();
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified()
|
public async Task GetEventById_WhenServiceReturnsDto_ReturnsOkWithEventDto()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
|
service.Setup(s => s.GetEventByIdAsync(7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new EventDTO { Id = 7, Title = "Review", createdby = "abc" });
|
||||||
|
|
||||||
var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New"));
|
var result = await NewController(service).GetEventById(7, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<OkObjectResult>();
|
|
||||||
var updated = ctx.Events.Single();
|
|
||||||
updated.Title.Should().Be("New");
|
|
||||||
updated.LastModificationTime.Should().NotBeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task GetEventById_WhenExists_ReturnsEventDto()
|
|
||||||
{
|
|
||||||
using var ctx = NewContext();
|
|
||||||
var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1));
|
|
||||||
|
|
||||||
var result = await NewController(ctx).GetEventById(existing.Id);
|
|
||||||
|
|
||||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
var dto = ok.Value.Should().BeOfType<DataResponse>().Subject.Data as Event_DTO;
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
var dto = response.Data as Event_DTO;
|
||||||
|
dto.Should().NotBeNull();
|
||||||
dto!.Title.Should().Be("Review");
|
dto!.Title.Should().Be("Review");
|
||||||
|
dto!.createdby.Should().Be("abc");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound()
|
public async Task DeleteEvent_WhenServiceReturnsFalse_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
|
service.Setup(s => s.DeleteEventAsync(9, It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||||
|
|
||||||
var result = await NewController(ctx).GetEventById(deleted.Id);
|
var result = await NewController(service).DeleteEvent(9, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<NotFoundObjectResult>();
|
result.Should().BeOfType<NotFoundObjectResult>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow()
|
public async Task DeleteEvent_WhenServiceReturnsTrue_ReturnsOkWithDeletedMessage()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
|
service.Setup(s => s.DeleteEventAsync(9, It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||||
|
|
||||||
var result = await NewController(ctx).DeleteEvent(existing.Id);
|
var result = await NewController(service).DeleteEvent(9, CancellationToken.None);
|
||||||
|
|
||||||
result.Should().BeOfType<OkObjectResult>();
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
var row = ctx.Events.Single();
|
ok.Value.Should().BeOfType<DataResponse>().Which.Message.Should().Be("Event Deleted Successfully");
|
||||||
row.IsDeleted.Should().Be(true);
|
|
||||||
row.DeletionTime.Should().NotBeNull();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteEvent_WhenNotFound_ReturnsNotFound()
|
public async Task GetEventList_ReturnsOkWithRowsAndTotalCount()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
var result = await NewController(ctx).DeleteEvent(12345);
|
service.Setup(s => s.GetEventListAsync("alpha", 1, 10, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new PagedEventsResult
|
||||||
|
{
|
||||||
|
TotalCount = 2,
|
||||||
|
Rows = new List<EventDTO>
|
||||||
|
{
|
||||||
|
new() { Id = 1, Title = "Alpha meeting" },
|
||||||
|
new() { Id = 2, Title = "Alpha review" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
result.Should().BeOfType<NotFoundObjectResult>();
|
var result = await NewController(service).GetEventList(search: "alpha", page: 1, pageSize: 10);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetEventList_ExcludesDeletedAndCountsAfterFilter()
|
|
||||||
{
|
|
||||||
using var ctx = NewContext();
|
|
||||||
SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1));
|
|
||||||
SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2));
|
|
||||||
SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3));
|
|
||||||
SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true);
|
|
||||||
|
|
||||||
var result = NewController(ctx).GetEventList(search: "alpha", page: 1, pageSize: 10);
|
|
||||||
|
|
||||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data!;
|
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data!;
|
||||||
((int)Prop(data, "totalCount")).Should().Be(2);
|
((int)Prop(data, "totalCount")).Should().Be(2);
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void GetEventList_OrdersByStartDateDescending()
|
|
||||||
{
|
|
||||||
using var ctx = NewContext();
|
|
||||||
SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1));
|
|
||||||
SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10));
|
|
||||||
SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5));
|
|
||||||
|
|
||||||
var result = NewController(ctx).GetEventList(page: 1, pageSize: 10);
|
|
||||||
|
|
||||||
var data = (result as OkObjectResult)!.Value.As<DataResponse>().Data!;
|
|
||||||
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().ToList();
|
var rows = ((IEnumerable)Prop(data, "rows")).Cast<Event_DTO>().ToList();
|
||||||
rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest");
|
rows.Should().HaveCount(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange()
|
public async Task GetEventsForCalendar_ReturnsOkWithEvents()
|
||||||
{
|
{
|
||||||
using var ctx = NewContext();
|
var service = new Mock<ICalendarService>();
|
||||||
SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15));
|
service.Setup(s => s.GetEventsForCalendarAsync(It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<CancellationToken>()))
|
||||||
SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1));
|
.ReturnsAsync(new List<EventDTO> { new() { Id = 1, Title = "InRange" } });
|
||||||
SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1));
|
|
||||||
|
|
||||||
var result = NewController(ctx).GetEventsForCalendar(
|
var result = await NewController(service).GetEventsForCalendar(
|
||||||
start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30));
|
start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30), CancellationToken.None);
|
||||||
|
|
||||||
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
var events = ok.Value.As<DataResponse>().Data as IEnumerable<Event_DTO>;
|
var data = ok.Value.Should().BeOfType<DataResponse>().Subject.Data;
|
||||||
events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
|
var events = ((IEnumerable)data!).Cast<Event_DTO>().ToList();
|
||||||
|
events.Single().Title.Should().Be("InRange");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs
Normal file
188
Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class CalendarServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CalendarService NewService(ApplicationDbContext ctx) =>
|
||||||
|
new(new CalendarDataService(ctx));
|
||||||
|
|
||||||
|
private static EditEventInput ValidInput(int? id = null, string title = "Standup") => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Title = title,
|
||||||
|
ReminderMinutes = 15,
|
||||||
|
EventColor = "primary",
|
||||||
|
StartDate = new DateTime(2026, 6, 1),
|
||||||
|
StartTime = "08:00 AM",
|
||||||
|
EndDate = new DateTime(2026, 6, 1),
|
||||||
|
EndTime = "08:30 AM"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Events SeedEvent(ApplicationDbContext ctx, string title, DateTime startDate, bool? isDeleted = false)
|
||||||
|
{
|
||||||
|
var ev = new Events
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
EventColor = "primary",
|
||||||
|
ReminderMinutes = 15,
|
||||||
|
StartDate = startDate,
|
||||||
|
EndDate = startDate,
|
||||||
|
IsDeleted = isDeleted,
|
||||||
|
CreatedDate = DateTime.Now
|
||||||
|
};
|
||||||
|
ctx.Events.Add(ev);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object Prop(object source, string name) =>
|
||||||
|
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateEventAsync_PersistsEventWithMappedFieldsAndCreatedDate()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
await NewService(ctx).CreateEventAsync(ValidInput(), CancellationToken.None);
|
||||||
|
|
||||||
|
var saved = ctx.Events.Single();
|
||||||
|
saved.Title.Should().Be("Standup");
|
||||||
|
saved.IsDeleted.Should().Be(false);
|
||||||
|
saved.CreatedDate.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateEventAsync_WhenNotFound_ReturnsFalse()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: 999), CancellationToken.None);
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateEventAsync_WhenSoftDeleted_ReturnsFalse()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1), isDeleted: true);
|
||||||
|
|
||||||
|
var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: existing.Id, title: "New"), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateEventAsync_WhenEventExists_UpdatesFieldsAndStampsModified()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1));
|
||||||
|
|
||||||
|
var result = await NewService(ctx).UpdateEventAsync(ValidInput(id: existing.Id, title: "New"), CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
var updated = ctx.Events.Single();
|
||||||
|
updated.Title.Should().Be("New");
|
||||||
|
updated.LastModificationTime.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventByIdAsync_WhenExists_ReturnsMappedDto()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedEvent(ctx, "Review", new DateTime(2026, 6, 1));
|
||||||
|
|
||||||
|
var dto = await NewService(ctx).GetEventByIdAsync(existing.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
dto.Should().NotBeNull();
|
||||||
|
dto!.Title.Should().Be("Review");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventByIdAsync_WhenSoftDeleted_ReturnsNull()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true);
|
||||||
|
|
||||||
|
var dto = await NewService(ctx).GetEventByIdAsync(deleted.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
dto.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteEventAsync_WhenExists_SoftDeletesAndKeepsRow()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1));
|
||||||
|
|
||||||
|
var result = await NewService(ctx).DeleteEventAsync(existing.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
var row = ctx.Events.Single();
|
||||||
|
row.IsDeleted.Should().Be(true);
|
||||||
|
row.DeletionTime.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteEventAsync_WhenNotFound_ReturnsFalse()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewService(ctx).DeleteEventAsync(12345, CancellationToken.None);
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventListAsync_ExcludesDeletedAndCountsAfterFilter()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedEvent(ctx, "Alpha meeting", new DateTime(2026, 6, 1));
|
||||||
|
SeedEvent(ctx, "Beta meeting", new DateTime(2026, 6, 2));
|
||||||
|
SeedEvent(ctx, "Alpha review", new DateTime(2026, 6, 3));
|
||||||
|
SeedEvent(ctx, "Alpha archived", new DateTime(2026, 6, 4), isDeleted: true);
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetEventListAsync("alpha", 1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
result.TotalCount.Should().Be(2);
|
||||||
|
result.Rows.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventListAsync_OrdersByStartDateDescending()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedEvent(ctx, "Earliest", new DateTime(2026, 6, 1));
|
||||||
|
SeedEvent(ctx, "Latest", new DateTime(2026, 6, 10));
|
||||||
|
SeedEvent(ctx, "Middle", new DateTime(2026, 6, 5));
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetEventListAsync(null, 1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetEventsForCalendarAsync_ReturnsOnlyEventsOverlappingRange()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedEvent(ctx, "InRange", new DateTime(2026, 6, 15));
|
||||||
|
SeedEvent(ctx, "BeforeRange", new DateTime(2026, 5, 1));
|
||||||
|
SeedEvent(ctx, "AfterRange", new DateTime(2026, 7, 1));
|
||||||
|
|
||||||
|
var events = await NewService(ctx).GetEventsForCalendarAsync(
|
||||||
|
new DateTime(2026, 6, 1), new DateTime(2026, 6, 30), CancellationToken.None);
|
||||||
|
|
||||||
|
events.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs
Normal file
49
Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class DashboardServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DashboardService NewService(ApplicationDbContext ctx) =>
|
||||||
|
new(new DashboardDataService(ctx));
|
||||||
|
|
||||||
|
private static WorkOrder Wo(string? status, bool? isTemplate = false, string? assignTo = null) =>
|
||||||
|
new() { Status = status, istemplate = isTemplate, AssignTo = assignTo };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatsAsync_CountsByStatusExcludingTemplates()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.workOrders.AddRange(
|
||||||
|
Wo("Open", isTemplate: false),
|
||||||
|
Wo("Open", isTemplate: false, assignTo: "u1"),
|
||||||
|
Wo("In Progress"),
|
||||||
|
Wo("On Hold"),
|
||||||
|
Wo("Done"),
|
||||||
|
Wo("Done"),
|
||||||
|
Wo("Open", isTemplate: true),
|
||||||
|
Wo("Cancelled", isTemplate: false)
|
||||||
|
);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var stats = await NewService(ctx).GetStatsAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
stats.Total.Should().Be(7);
|
||||||
|
stats.Open.Should().Be(4);
|
||||||
|
stats.NotDispatched.Should().Be(1);
|
||||||
|
stats.Completed.Should().Be(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
223
Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs
Normal file
223
Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class DispatchBatchingBehaviorTests
|
||||||
|
{
|
||||||
|
private static WorkOrderDispatchService BuildService(
|
||||||
|
Mock<IWorkOrderDataService>? woData = null,
|
||||||
|
Mock<IVendorDataService>? vendorData = null,
|
||||||
|
Mock<ITaskListTemplateDataService>? templateData = null,
|
||||||
|
Mock<IDispatchDataService>? dispatchData = null)
|
||||||
|
{
|
||||||
|
var dd = dispatchData ?? new Mock<IDispatchDataService>();
|
||||||
|
dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1");
|
||||||
|
dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1");
|
||||||
|
dd.Setup(d => d.AddAsync(It.IsAny<Dispatch>()))
|
||||||
|
.ReturnsAsync((Dispatch d) => d);
|
||||||
|
|
||||||
|
var emailPort = new Mock<IDispatchEmailPort>();
|
||||||
|
emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var tokenPort = new Mock<IVendorTokenPort>();
|
||||||
|
tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny<int>())).ReturnsAsync("token");
|
||||||
|
|
||||||
|
return new WorkOrderDispatchService(
|
||||||
|
(woData ?? new Mock<IWorkOrderDataService>()).Object,
|
||||||
|
dd.Object,
|
||||||
|
(vendorData ?? new Mock<IVendorDataService>()).Object,
|
||||||
|
Mock.Of<IUserDataService>(),
|
||||||
|
(templateData ?? new Mock<ITaskListTemplateDataService>()).Object,
|
||||||
|
Mock.Of<ICommentDataService>(),
|
||||||
|
emailPort.Object,
|
||||||
|
tokenPort.Object,
|
||||||
|
Options.Create(new FrontendOptions()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(params WorkOrder[] workOrders) =>
|
||||||
|
WorkOrderDataReturning(new Mock<IWorkOrderDataService>(), workOrders);
|
||||||
|
|
||||||
|
private static Mock<IWorkOrderDataService> WorkOrderDataReturning(Mock<IWorkOrderDataService> mock, params WorkOrder[] workOrders)
|
||||||
|
{
|
||||||
|
mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync(
|
||||||
|
It.IsAny<IReadOnlyCollection<int>>(),
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(workOrders.ToList());
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IVendorDataService> VendorDataReturning(params Vendor[] vendors)
|
||||||
|
{
|
||||||
|
var mock = new Mock<IVendorDataService>();
|
||||||
|
mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(vendors.ToList());
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatch_PrimaryWorkOrderFollowsReturnedOrder()
|
||||||
|
{
|
||||||
|
var woData = WorkOrderDataReturning(
|
||||||
|
new WorkOrder { Id = 30, InternalWONumber = "WO-30" },
|
||||||
|
new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
|
||||||
|
var dispatchData = new Mock<IDispatchDataService>();
|
||||||
|
var service = BuildService(woData, VendorDataReturning(new Vendor { Id = 1, CompanyName = "V", Email = "v@x.io" }), dispatchData: dispatchData);
|
||||||
|
|
||||||
|
Dispatch? saved = null;
|
||||||
|
dispatchData.Setup(d => d.AddAsync(It.IsAny<Dispatch>()))
|
||||||
|
.ReturnsAsync((Dispatch d) => { saved = d; return d; });
|
||||||
|
|
||||||
|
await service.DispatchToVendorAsync(new DispatchToVendorInput
|
||||||
|
{
|
||||||
|
WorkOrderIds = new() { 30, 10 },
|
||||||
|
VendorIds = new() { 1 }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
saved.Should().NotBeNull();
|
||||||
|
saved!.WorkOrderId.Should().Be(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatch_DeduplicatesVendorsAndSkipsMissing()
|
||||||
|
{
|
||||||
|
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
|
||||||
|
var vendorData = new Mock<IVendorDataService>();
|
||||||
|
vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<Vendor> { new() { Id = 2, CompanyName = "Only Co", Email = "v@x.io" } });
|
||||||
|
|
||||||
|
var service = BuildService(woData, vendorData);
|
||||||
|
|
||||||
|
var result = await service.DispatchToVendorAsync(new DispatchToVendorInput
|
||||||
|
{
|
||||||
|
WorkOrderIds = new() { 10 },
|
||||||
|
VendorIds = new() { 2, 2, 999 }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Dispatches.Should().HaveCount(1);
|
||||||
|
result.Dispatches.Single().VendorName.Should().Be("Only Co");
|
||||||
|
vendorData.Verify(d => d.GetByIdsOrderedAsync(
|
||||||
|
It.Is<IReadOnlyCollection<int>>(ids => ids.SequenceEqual(new[] { 2, 999 })),
|
||||||
|
It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount()
|
||||||
|
{
|
||||||
|
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
|
||||||
|
var templateData = new Mock<ITaskListTemplateDataService>();
|
||||||
|
templateData.Setup(d => d.GetByIdWithItemsAsync(7))
|
||||||
|
.ReturnsAsync(new TaskListTemplate
|
||||||
|
{
|
||||||
|
Items = new List<TaskListTemplateItem>
|
||||||
|
{
|
||||||
|
new() { ItemText = "A", SortOrder = 0 },
|
||||||
|
new() { ItemText = "B", SortOrder = 1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var vendorData = VendorDataReturning(
|
||||||
|
new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" },
|
||||||
|
new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" });
|
||||||
|
|
||||||
|
var service = BuildService(woData, vendorData, templateData);
|
||||||
|
|
||||||
|
await service.DispatchToVendorAsync(new DispatchToVendorInput
|
||||||
|
{
|
||||||
|
WorkOrderIds = new() { 10 },
|
||||||
|
VendorIds = new() { 1, 2 },
|
||||||
|
TaskListTemplateId = 7
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
templateData.Verify(d => d.GetByIdWithItemsAsync(7), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatch_PerformsSingleBatchedVendorLookup()
|
||||||
|
{
|
||||||
|
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
|
||||||
|
var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" });
|
||||||
|
|
||||||
|
var service = BuildService(woData, vendorData);
|
||||||
|
|
||||||
|
await service.DispatchToVendorAsync(new DispatchToVendorInput
|
||||||
|
{
|
||||||
|
WorkOrderIds = new() { 10 },
|
||||||
|
VendorIds = new() { 1, 2, 3 }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
vendorData.Verify(d => d.GetByIdsOrderedAsync(It.IsAny<IReadOnlyCollection<int>>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
vendorData.Verify(d => d.GetByIdAsync(It.IsAny<int>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatch_BatchesAuditLogsOncePerVendorAndNeverSingles()
|
||||||
|
{
|
||||||
|
var woData = WorkOrderDataReturning(
|
||||||
|
new WorkOrder { Id = 10, InternalWONumber = "WO-10" },
|
||||||
|
new WorkOrder { Id = 11, InternalWONumber = "WO-11" });
|
||||||
|
var vendorData = VendorDataReturning(
|
||||||
|
new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" },
|
||||||
|
new Vendor { Id = 2, CompanyName = "V2", Email = "b@x.io" });
|
||||||
|
|
||||||
|
var service = BuildService(woData, vendorData);
|
||||||
|
|
||||||
|
await service.DispatchToVendorAsync(new DispatchToVendorInput
|
||||||
|
{
|
||||||
|
WorkOrderIds = new() { 10, 11 },
|
||||||
|
VendorIds = new() { 1, 2 }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
woData.Verify(d => d.AddAuditLogsAsync(It.Is<IReadOnlyCollection<WorkOrderAuditLog>>(logs => logs.Count == 2)), Times.Exactly(2));
|
||||||
|
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatch_NoWorkOrders_Throws()
|
||||||
|
{
|
||||||
|
var woData = WorkOrderDataReturning();
|
||||||
|
var service = BuildService(woData, VendorDataReturning());
|
||||||
|
|
||||||
|
var act = () => service.DispatchToVendorAsync(new DispatchToVendorInput
|
||||||
|
{
|
||||||
|
WorkOrderIds = new() { 404 },
|
||||||
|
VendorIds = new() { 1 }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dispatch_ForwardsSameCancellationTokenToBothBatchDataServices()
|
||||||
|
{
|
||||||
|
var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" });
|
||||||
|
var vendorData = VendorDataReturning(new Vendor { Id = 1, CompanyName = "V1", Email = "a@x.io" });
|
||||||
|
var service = BuildService(woData, vendorData);
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
var token = cts.Token;
|
||||||
|
|
||||||
|
await service.DispatchToVendorAsync(new DispatchToVendorInput
|
||||||
|
{
|
||||||
|
WorkOrderIds = new() { 10 },
|
||||||
|
VendorIds = new() { 1 }
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
woData.Verify(d => d.GetByRequestedIdsWithDetailsAsync(
|
||||||
|
It.IsAny<IReadOnlyCollection<int>>(),
|
||||||
|
token), Times.Once);
|
||||||
|
vendorData.Verify(d => d.GetByIdsOrderedAsync(
|
||||||
|
It.IsAny<IReadOnlyCollection<int>>(),
|
||||||
|
token), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs
Normal file
77
Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class DispatchControllerTests
|
||||||
|
{
|
||||||
|
private static DispatchController NewController(Mock<IDispatchService> service) =>
|
||||||
|
new(service.Object);
|
||||||
|
|
||||||
|
private static object Prop(object source, string name) =>
|
||||||
|
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task List_ReturnsDataResponseWithPaginationShape()
|
||||||
|
{
|
||||||
|
var service = new Mock<IDispatchService>();
|
||||||
|
service.Setup(s => s.GetDispatchListAsync("a", null, null, null, null, "dispatchedAt", "desc", 1, 25, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new DispatchListResultDTO
|
||||||
|
{
|
||||||
|
Total = 2,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 25,
|
||||||
|
Items = new List<DispatchListEntryDTO>
|
||||||
|
{
|
||||||
|
new() { Id = 1, DispatchNumber = "D1" },
|
||||||
|
new() { Id = 2, DispatchNumber = "D2" }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).List("a", null, null, null, null, "dispatchedAt", "desc", 1, 25, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("Success");
|
||||||
|
object data = response.Data!;
|
||||||
|
Prop(data, "total").Should().Be(2);
|
||||||
|
Prop(data, "page").Should().Be(1);
|
||||||
|
Prop(data, "pageSize").Should().Be(25);
|
||||||
|
((System.Collections.IEnumerable)Prop(data, "items")).Cast<object>().Count().Should().Be(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_WhenMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var service = new Mock<IDispatchService>();
|
||||||
|
service.Setup(s => s.GetDispatchDetailAsync(99, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((DispatchDetailResultDTO?)null);
|
||||||
|
|
||||||
|
var result = await NewController(service).GetById(99, CancellationToken.None);
|
||||||
|
|
||||||
|
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||||
|
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Dispatch not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_WhenFound_ReturnsDetailAsData()
|
||||||
|
{
|
||||||
|
var service = new Mock<IDispatchService>();
|
||||||
|
var detail = new DispatchDetailResultDTO { Id = 5, DispatchNumber = "D5", Status = "Pending" };
|
||||||
|
service.Setup(s => s.GetDispatchDetailAsync(5, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(detail);
|
||||||
|
|
||||||
|
var result = await NewController(service).GetById(5, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("Success");
|
||||||
|
((object?)response.Data).Should().BeSameAs(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs
Normal file
117
Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class DispatchServiceTests
|
||||||
|
{
|
||||||
|
private static DispatchService NewService(Mock<IDispatchDataService> data) =>
|
||||||
|
new(data.Object, new CreateDispatchValidation(), new UpdateDispatchValidation());
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDispatchListAsync_MapsDataReadModelToServiceDtoPreservingFields()
|
||||||
|
{
|
||||||
|
var data = new Mock<IDispatchDataService>();
|
||||||
|
var source = new DispatchListResult
|
||||||
|
{
|
||||||
|
Total = 1,
|
||||||
|
Page = 2,
|
||||||
|
PageSize = 25,
|
||||||
|
Items = new List<DispatchListItemDTO>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = 7,
|
||||||
|
DispatchNumber = "DISP-7",
|
||||||
|
PONumber = "PO-1",
|
||||||
|
NTEAmount = 200m,
|
||||||
|
Status = "Pending",
|
||||||
|
VendorId = 9,
|
||||||
|
VendorName = "Acme",
|
||||||
|
WorkOrderId = 3,
|
||||||
|
PrimaryWOInternalNumber = "WO-3",
|
||||||
|
PrimaryWOTitle = "Fix AC",
|
||||||
|
LocationName = "Site A",
|
||||||
|
WorkOrderCount = 2,
|
||||||
|
PendingUpliftCount = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
data.Setup(d => d.GetListAsync(It.IsAny<DispatchListQuery>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(source);
|
||||||
|
|
||||||
|
var result = await NewService(data).GetDispatchListAsync("a", "Pending", 9, null, null, "ponumber", "asc", 2, 25, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<DispatchListResultDTO>();
|
||||||
|
result.Total.Should().Be(1);
|
||||||
|
result.Page.Should().Be(2);
|
||||||
|
result.PageSize.Should().Be(25);
|
||||||
|
var row = result.Items.Single();
|
||||||
|
row.Id.Should().Be(7);
|
||||||
|
row.DispatchNumber.Should().Be("DISP-7");
|
||||||
|
row.PONumber.Should().Be("PO-1");
|
||||||
|
row.NTEAmount.Should().Be(200m);
|
||||||
|
row.Status.Should().Be("Pending");
|
||||||
|
row.VendorId.Should().Be(9);
|
||||||
|
row.VendorName.Should().Be("Acme");
|
||||||
|
row.WorkOrderId.Should().Be(3);
|
||||||
|
row.PrimaryWOInternalNumber.Should().Be("WO-3");
|
||||||
|
row.PrimaryWOTitle.Should().Be("Fix AC");
|
||||||
|
row.LocationName.Should().Be("Site A");
|
||||||
|
row.WorkOrderCount.Should().Be(2);
|
||||||
|
row.PendingUpliftCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDispatchDetailAsync_MapsNestedCollectionsPreservingShape()
|
||||||
|
{
|
||||||
|
var data = new Mock<IDispatchDataService>();
|
||||||
|
var detail = new DispatchDetailDTO
|
||||||
|
{
|
||||||
|
Id = 42,
|
||||||
|
DispatchNumber = "DISP-42",
|
||||||
|
PONumber = "PO-9",
|
||||||
|
NTEAmount = 500m,
|
||||||
|
Status = "Dispatched",
|
||||||
|
Description = "desc",
|
||||||
|
WorkOrderId = 11,
|
||||||
|
EmailSent = true,
|
||||||
|
Vendor = new DispatchDetailVendorDTO { Id = 1, CompanyName = "VendorCo", Email = "v@x.com" },
|
||||||
|
PrimaryLocation = new DispatchPrimaryLocationDTO { Id = 5, Name = "Loc", City = "Austin" },
|
||||||
|
WorkOrders = new List<DispatchLinkedWorkOrderDTO> { new() { WorkOrderId = 11, Title = "T", Priority = "High" } },
|
||||||
|
Checklist = new List<DispatchChecklistDTO> { new() { Id = 1, ItemText = "Check", IsCompleted = true, SortOrder = 1 } },
|
||||||
|
Signoffs = new List<DispatchSignoffDTO> { new() { Id = 1, SignoffType = "Vendor", Name = "J" } },
|
||||||
|
Comments = new List<DispatchCommentDTO> { new() { Id = 1, Commenttext = "Hi", UserName = "Sara" } },
|
||||||
|
UpliftRequests = new List<DispatchUpliftRequestDTO> { new() { Id = 1, RequestedNTE = 600m, CurrentNTE = 500m, Delta = 100m, Status = "Pending" } },
|
||||||
|
AuditLog = new List<DispatchAuditLogDTO> { new() { Id = 1, Action = "Updated", FieldName = "DISP-42:Status" } }
|
||||||
|
};
|
||||||
|
data.Setup(d => d.GetDetailAsync(42, It.IsAny<CancellationToken>())).ReturnsAsync(detail);
|
||||||
|
data.Setup(d => d.GetDetailAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((DispatchDetailDTO?)null);
|
||||||
|
|
||||||
|
var service = NewService(data);
|
||||||
|
|
||||||
|
var found = await service.GetDispatchDetailAsync(42, CancellationToken.None);
|
||||||
|
var missing = await service.GetDispatchDetailAsync(999, CancellationToken.None);
|
||||||
|
|
||||||
|
missing.Should().BeNull();
|
||||||
|
found.Should().BeOfType<DispatchDetailResultDTO>();
|
||||||
|
found!.Id.Should().Be(42);
|
||||||
|
found.DispatchNumber.Should().Be("DISP-42");
|
||||||
|
found.EmailSent.Should().BeTrue();
|
||||||
|
found.Vendor!.CompanyName.Should().Be("VendorCo");
|
||||||
|
found.PrimaryLocation!.Name.Should().Be("Loc");
|
||||||
|
found.WorkOrders.Single().Title.Should().Be("T");
|
||||||
|
found.Checklist.Single().IsCompleted.Should().BeTrue();
|
||||||
|
found.Signoffs.Single().Name.Should().Be("J");
|
||||||
|
found.Comments.Single().UserName.Should().Be("Sara");
|
||||||
|
found.UpliftRequests.Single().Delta.Should().Be(100m);
|
||||||
|
found.AuditLog.Single().Action.Should().Be("Updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
146
Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs
Normal file
146
Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class DropdownOptionsServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DropdownOptionsService NewService(ApplicationDbContext ctx) =>
|
||||||
|
new(new DropdownOptionsDataService(ctx));
|
||||||
|
|
||||||
|
private static DropdownOption Option(string category, string value, string? parent = null, int sort = 0, bool active = true) =>
|
||||||
|
new() { Category = category, Value = value, ParentValue = parent, SortOrder = sort, IsActive = active };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_FiltersActiveAndOrdersBySortThenValue()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.DropdownOptions.AddRange(
|
||||||
|
Option("Color", "Red", sort: 2),
|
||||||
|
Option("Color", "Blue", sort: 1),
|
||||||
|
Option("Color", "Green", sort: 1, active: false),
|
||||||
|
Option("Shape", "Circle")
|
||||||
|
);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = (await NewService(ctx).GetAllAsync(null, CancellationToken.None)).ToList();
|
||||||
|
|
||||||
|
result.Should().HaveCount(3);
|
||||||
|
result.Select(d => d.Value).Should().ContainInOrder("Blue", "Red", "Circle");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByCategoryAsync_FiltersByCategoryAndParent()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.DropdownOptions.AddRange(
|
||||||
|
Option("State", "TX", parent: "US"),
|
||||||
|
Option("State", "CA", parent: "US"),
|
||||||
|
Option("State", "ON", parent: "CA")
|
||||||
|
);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = (await NewService(ctx).GetByCategoryAsync("State", "US", CancellationToken.None)).ToList();
|
||||||
|
|
||||||
|
result.Select(d => d.Value).Should().BeEquivalentTo(new[] { "TX", "CA" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_WhenCategoryOrValueMissing_Throws()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var act = () => NewService(ctx).CreateAsync(new DropdownOptionDTO { Category = "", Value = "X" }, CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_WhenDuplicate_Throws()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.DropdownOptions.Add(Option("Color", "Red", parent: "Primary"));
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var act = () => NewService(ctx).CreateAsync(new DropdownOptionDTO { Category = "Color", Value = "Red", ParentValue = "Primary" }, CancellationToken.None);
|
||||||
|
|
||||||
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_ForcesActiveTrueAndReturnsDtoWithId()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var dto = await NewService(ctx).CreateAsync(
|
||||||
|
new DropdownOptionDTO { Category = "Color", Value = "Red", IsActive = false }, CancellationToken.None);
|
||||||
|
|
||||||
|
dto.Id.Should().BeGreaterThan(0);
|
||||||
|
dto.IsActive.Should().BeTrue();
|
||||||
|
ctx.DropdownOptions.Single().IsActive.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_WhenNotFound_ThrowsKeyNotFound()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var act = () => NewService(ctx).UpdateAsync(404, new DropdownOptionDTO { Category = "Color", Value = "Red" }, CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_UpdatesValueParentSortActiveButNotCategory()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = Option("Color", "Red");
|
||||||
|
ctx.DropdownOptions.Add(existing);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateAsync(existing.Id, new DropdownOptionDTO
|
||||||
|
{
|
||||||
|
Category = "Ignored",
|
||||||
|
Value = "Crimson",
|
||||||
|
ParentValue = "Warm",
|
||||||
|
SortOrder = 5,
|
||||||
|
IsActive = false
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = ctx.DropdownOptions.Single();
|
||||||
|
row.Category.Should().Be("Color");
|
||||||
|
row.Value.Should().Be("Crimson");
|
||||||
|
row.ParentValue.Should().Be("Warm");
|
||||||
|
row.SortOrder.Should().Be(5);
|
||||||
|
row.IsActive.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_WhenNotFound_ThrowsKeyNotFound()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var act = () => NewService(ctx).DeleteAsync(404, CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_RemovesOption()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = Option("Color", "Red");
|
||||||
|
ctx.DropdownOptions.Add(existing);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
await NewService(ctx).DeleteAsync(existing.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
ctx.DropdownOptions.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs
Normal file
134
Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
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 FollowUpControllerTests
|
||||||
|
{
|
||||||
|
private static FollowUpController NewController(Mock<IFollowUpService> service)
|
||||||
|
{
|
||||||
|
var controller = new FollowUpController(service.Object)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, "user-1")
|
||||||
|
}, "Test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FollowUpController NewControllerNoUser(Mock<IFollowUpService> service) =>
|
||||||
|
new(service.Object)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static object Prop(object source, string name) =>
|
||||||
|
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFollowUps_ReturnsPaginationEnvelopeWithMappedRows()
|
||||||
|
{
|
||||||
|
var service = new Mock<IFollowUpService>();
|
||||||
|
service.Setup(s => s.GetFollowUpListAsync(1, 12, "", "Open", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new PagedResult<FollowUpListRowDTO>
|
||||||
|
{
|
||||||
|
Items = new List<FollowUpListRowDTO> { new() { Id = 1, Reason = "R", CreatedByName = "Me" } },
|
||||||
|
TotalCount = 1,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 12
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).GetFollowUps();
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
envelope.PageSize.Should().Be(12);
|
||||||
|
envelope.TotalCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetById_WhenMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var service = new Mock<IFollowUpService>();
|
||||||
|
service.Setup(s => s.GetFollowUpDetailAsync(9, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((FollowUpDetailDTO?)null);
|
||||||
|
|
||||||
|
var result = await NewController(service).GetById(9, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WhenNoUser_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var service = new Mock<IFollowUpService>();
|
||||||
|
var result = await NewControllerNoUser(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var unauthorized = result.Should().BeOfType<UnauthorizedObjectResult>().Subject;
|
||||||
|
unauthorized.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("User not authenticated");
|
||||||
|
service.Verify(s => s.CreateOpenFollowUpAsync(It.IsAny<FollowUpCreateRequestDTO>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Create_WhenAuthenticated_ReturnsCreatedWithId()
|
||||||
|
{
|
||||||
|
var service = new Mock<IFollowUpService>();
|
||||||
|
service.Setup(s => s.CreateOpenFollowUpAsync(It.IsAny<FollowUpCreateRequestDTO>(), "user-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(42);
|
||||||
|
|
||||||
|
var result = await NewController(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("200");
|
||||||
|
response.Message.Should().Be("Follow Up created");
|
||||||
|
((object?)response.Data).Should().Be(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Complete_WhenMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var service = new Mock<IFollowUpService>();
|
||||||
|
service.Setup(s => s.SetFollowUpStatusAsync(3, "Completed", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await NewController(service).Complete(3, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<NotFoundResult>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Delete_WhenMissing_ReturnsNotFoundEnvelope()
|
||||||
|
{
|
||||||
|
var service = new Mock<IFollowUpService>();
|
||||||
|
service.Setup(s => s.RemoveFollowUpAsync(8, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await NewController(service).Delete(8, CancellationToken.None);
|
||||||
|
|
||||||
|
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||||
|
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Follow Up not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
162
Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs
Normal file
162
Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class FollowUpServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FollowUpService NewService(ApplicationDbContext ctx) =>
|
||||||
|
new(new FollowUpDataService(ctx), new CreateFollowUpValidation(), new UpdateFollowUpValidation());
|
||||||
|
|
||||||
|
private static FollowUps SeedFollowUp(ApplicationDbContext ctx, string reason, string status = "Open")
|
||||||
|
{
|
||||||
|
var f = new FollowUps
|
||||||
|
{
|
||||||
|
Reason = reason,
|
||||||
|
ScheduleDate = new DateTime(2026, 8, 1),
|
||||||
|
ScheduleStartTime = new TimeSpan(9, 0, 0),
|
||||||
|
Status = status,
|
||||||
|
createdby = "user-1",
|
||||||
|
CreatedDate = DateTime.Now
|
||||||
|
};
|
||||||
|
ctx.FollowUps.Add(f);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateOpenFollowUpAsync_ForcesOpenStatusAndPersistsFields()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var service = NewService(ctx);
|
||||||
|
|
||||||
|
var id = await service.CreateOpenFollowUpAsync(new FollowUpCreateRequestDTO
|
||||||
|
{
|
||||||
|
Reason = "Call back",
|
||||||
|
ScheduleDate = new DateTime(2026, 9, 1),
|
||||||
|
ScheduleStartTime = new TimeSpan(10, 30, 0),
|
||||||
|
EmployeeId = 5,
|
||||||
|
WorkOrderId = 7,
|
||||||
|
AccountId = 9,
|
||||||
|
LocationId = 11
|
||||||
|
}, "user-9", CancellationToken.None);
|
||||||
|
|
||||||
|
var entity = ctx.FollowUps.Single();
|
||||||
|
entity.Id.Should().Be(id);
|
||||||
|
entity.Status.Should().Be("Open");
|
||||||
|
entity.Reason.Should().Be("Call back");
|
||||||
|
entity.createdby.Should().Be("user-9");
|
||||||
|
entity.EmployeeId.Should().Be(5);
|
||||||
|
entity.WorkOrderId.Should().Be(7);
|
||||||
|
entity.CreatedDate.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFollowUpListAsync_DefaultsToOpenAndMapsShape()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedFollowUp(ctx, "Open one", "Open");
|
||||||
|
SeedFollowUp(ctx, "Done one", "Completed");
|
||||||
|
|
||||||
|
var page = await NewService(ctx).GetFollowUpListAsync(1, 12, "", null, CancellationToken.None);
|
||||||
|
|
||||||
|
page.Items.Should().HaveCount(1);
|
||||||
|
page.Items.Single().Reason.Should().Be("Open one");
|
||||||
|
page.Items.Single().Status.Should().Be("Open");
|
||||||
|
page.PageSize.Should().Be(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFollowUpDetailAsync_MapsRelatedNamesAndResolvesCreator()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var employee = new Employee { FirstName = "Sara", LastName = "Lee" };
|
||||||
|
ctx.Employees.Add(employee);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var account = new Accounts { Name = "Acme" };
|
||||||
|
ctx.Accounts.Add(account);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var user = new ApplicationUser { Id = "user-1", FirstName = "Creator", LastName = "One", UserName = "cone" };
|
||||||
|
var fu = new FollowUps
|
||||||
|
{
|
||||||
|
Reason = "Detail",
|
||||||
|
ScheduleDate = new DateTime(2026, 8, 2),
|
||||||
|
ScheduleStartTime = new TimeSpan(8, 0, 0),
|
||||||
|
Status = "Open",
|
||||||
|
EmployeeId = employee.Id,
|
||||||
|
AccountId = account.Id,
|
||||||
|
createdby = "user-1",
|
||||||
|
CreatedDate = DateTime.Now
|
||||||
|
};
|
||||||
|
ctx.Users.Add(user);
|
||||||
|
ctx.FollowUps.Add(fu);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var detail = await NewService(ctx).GetFollowUpDetailAsync(fu.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
detail.Should().NotBeNull();
|
||||||
|
detail!.AssignedTo.Should().Be("Sara Lee");
|
||||||
|
detail.AccountName.Should().Be("Acme");
|
||||||
|
detail.CreatedById.Should().Be("user-1");
|
||||||
|
detail.CreatedByName.Should().Be("Creator One");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateFollowUpFieldsAsync_UpdatesAndThrowsWhenMissing()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedFollowUp(ctx, "Old reason");
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id, new FollowUpUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Id = existing.Id,
|
||||||
|
Reason = "New reason",
|
||||||
|
ScheduleDate = new DateTime(2026, 10, 1),
|
||||||
|
ScheduleStartTime = new TimeSpan(11, 0, 0)
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = ctx.FollowUps.Single();
|
||||||
|
row.Reason.Should().Be("New reason");
|
||||||
|
|
||||||
|
var act = () => NewService(ctx).UpdateFollowUpFieldsAsync(existing.Id + 999, new FollowUpUpdateRequestDTO { Id = existing.Id + 999 }, CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetFollowUpStatusAsync_TogglesStatusAndReturnsFalseWhenMissing()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedFollowUp(ctx, "To complete");
|
||||||
|
|
||||||
|
(await NewService(ctx).SetFollowUpStatusAsync(existing.Id, "Completed", CancellationToken.None)).Should().BeTrue();
|
||||||
|
ctx.FollowUps.Single().Status.Should().Be("Completed");
|
||||||
|
(await NewService(ctx).SetFollowUpStatusAsync(existing.Id + 999, "Completed", CancellationToken.None)).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveFollowUpAsync_RemovesAndReturnsFalseWhenMissing()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedFollowUp(ctx, "Gone");
|
||||||
|
|
||||||
|
(await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeTrue();
|
||||||
|
(await NewService(ctx).RemoveFollowUpAsync(existing.Id, CancellationToken.None)).Should().BeFalse();
|
||||||
|
ctx.FollowUps.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs
Normal file
42
Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
internal static class IdentityTestHelpers
|
||||||
|
{
|
||||||
|
public static (UserManager<ApplicationUser> manager, Mock<IUserRoleStore<ApplicationUser>> store, Mock<IPasswordHasher<ApplicationUser>> hasher) CreateUserManager()
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserRoleStore<ApplicationUser>>();
|
||||||
|
var hasher = new Mock<IPasswordHasher<ApplicationUser>>();
|
||||||
|
|
||||||
|
store.As<IUserPasswordStore<ApplicationUser>>();
|
||||||
|
|
||||||
|
var manager = new UserManager<ApplicationUser>(
|
||||||
|
store.Object,
|
||||||
|
Options.Create(new IdentityOptions()),
|
||||||
|
hasher.Object,
|
||||||
|
new List<IUserValidator<ApplicationUser>>(),
|
||||||
|
new List<IPasswordValidator<ApplicationUser>>(),
|
||||||
|
new Mock<ILookupNormalizer>().Object,
|
||||||
|
new IdentityErrorDescriber(),
|
||||||
|
new Mock<IServiceProvider>().Object,
|
||||||
|
new Mock<ILogger<UserManager<ApplicationUser>>>().Object);
|
||||||
|
|
||||||
|
return (manager, store, hasher);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ApplicationUser User(string id = "u1", string? userName = "alice", bool? isDeleted = false) => new()
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
UserName = userName,
|
||||||
|
Email = userName + "@example.com",
|
||||||
|
FirstName = "Alice",
|
||||||
|
LastName = "Q",
|
||||||
|
PhoneNumber = "555",
|
||||||
|
IsDeleted = isDeleted
|
||||||
|
};
|
||||||
|
}
|
||||||
104
Api.SeaHavenIndustries.Tests/LocationControllerTests.cs
Normal file
104
Api.SeaHavenIndustries.Tests/LocationControllerTests.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using FluentValidation;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class LocationControllerTests
|
||||||
|
{
|
||||||
|
private static LocationController NewController(Mock<ILocationService> service) =>
|
||||||
|
new(service.Object);
|
||||||
|
|
||||||
|
private static object Prop(object source, string name) =>
|
||||||
|
source.GetType().GetProperty(name)!.GetValue(source)!;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationList_ReturnsPaginationEnvelopeWithServiceData()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new PagedResult<LocationDTO>
|
||||||
|
{
|
||||||
|
Items = new List<LocationDTO> { new() { Id = 1, Name = "Site" } },
|
||||||
|
TotalCount = 1,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 10
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await NewController(service).GetLocationList("a", 1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
envelope.PageNumber.Should().Be(1);
|
||||||
|
envelope.PageSize.Should().Be(10);
|
||||||
|
envelope.TotalCount.Should().Be(1);
|
||||||
|
envelope.TotalPages.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationById_WhenMissing_ReturnsNotFoundAndDoesNotLeakEntity()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationDetailAsync(5, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((LocationDTO?)null);
|
||||||
|
|
||||||
|
var result = await NewController(service).GetLocationById(5, CancellationToken.None);
|
||||||
|
|
||||||
|
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||||
|
var response = notFound.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Message.Should().Be("Location not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationById_WhenFound_MapsServiceDtoToAliasedShape()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.GetLocationDetailAsync(7, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new LocationDTO { Id = 7, Name = "N", Address1 = "A", Zip = "Z", PhoneNumber = "P", Email = "E", Status = "Active" });
|
||||||
|
|
||||||
|
var result = await NewController(service).GetLocationById(7, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var body = ok.Value!;
|
||||||
|
Prop(body, "Id").Should().Be(7);
|
||||||
|
Prop(body, "Name").Should().Be("N");
|
||||||
|
Prop(body, "Address").Should().Be("A");
|
||||||
|
Prop(body, "ZipCode").Should().Be("Z");
|
||||||
|
Prop(body, "Phone").Should().Be("P");
|
||||||
|
Prop(body, "ContactEmail").Should().Be("E");
|
||||||
|
Prop(body, "Status").Should().Be("Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddLocation_WhenServiceSucceeds_ReturnsCreatedResponse()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
var result = await NewController(service).AddLocation(new Location_DTO { Name = "X" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("200");
|
||||||
|
response.Message.Should().Be("Location Created Successfully");
|
||||||
|
service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny<LocationCreateRequestDTO>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditLocation_WhenMissing_ReturnsNotFound()
|
||||||
|
{
|
||||||
|
var service = new Mock<ILocationService>();
|
||||||
|
service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny<LocationUpdateRequestDTO>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ThrowsAsync(new KeyNotFoundException());
|
||||||
|
|
||||||
|
var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var notFound = result.Should().BeOfType<NotFoundObjectResult>().Subject;
|
||||||
|
notFound.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Location not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
133
Api.SeaHavenIndustries.Tests/LocationServiceTests.cs
Normal file
133
Api.SeaHavenIndustries.Tests/LocationServiceTests.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class LocationServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationService NewService(ApplicationDbContext ctx) =>
|
||||||
|
new(new LocationDataService(ctx), new CreateLocationValidation(), new UpdateLocationValidation());
|
||||||
|
|
||||||
|
private static Locations SeedLocation(ApplicationDbContext ctx, string name, string? city = null, string? status = "Active")
|
||||||
|
{
|
||||||
|
var loc = new Locations { Name = name, City = city, Status = status, CreatedDate = DateTime.Now };
|
||||||
|
ctx.Locations.Add(loc);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
return loc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateLocationFromRequestAsync_PersistsMappedFields()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var service = NewService(ctx);
|
||||||
|
|
||||||
|
await service.CreateLocationFromRequestAsync(new LocationCreateRequestDTO
|
||||||
|
{
|
||||||
|
Name = "Warehouse",
|
||||||
|
Title = "Main WH",
|
||||||
|
Address = "1 Depot Rd",
|
||||||
|
City = "Austin",
|
||||||
|
State = "TX",
|
||||||
|
ZipCode = "73301",
|
||||||
|
Phone = "555-1000",
|
||||||
|
ContactEmail = "wh@example.com",
|
||||||
|
Status = "Active"
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var entity = ctx.Locations.Single();
|
||||||
|
entity.Name.Should().Be("Warehouse");
|
||||||
|
entity.Title.Should().Be("Main WH");
|
||||||
|
entity.Address1.Should().Be("1 Depot Rd");
|
||||||
|
entity.City.Should().Be("Austin");
|
||||||
|
entity.State.Should().Be("TX");
|
||||||
|
entity.Zip.Should().Be("73301");
|
||||||
|
entity.PhoneNumber.Should().Be("555-1000");
|
||||||
|
entity.Email.Should().Be("wh@example.com");
|
||||||
|
entity.Status.Should().Be("Active");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationListPagedAsync_FiltersBySearchAndMapsShape()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
SeedLocation(ctx, "Alpha Site", "Austin");
|
||||||
|
SeedLocation(ctx, "Beta Site", "Dallas");
|
||||||
|
SeedLocation(ctx, "Gamma Yard", "Austin");
|
||||||
|
|
||||||
|
var page = await NewService(ctx).GetLocationListPagedAsync(1, 10, "Austin", CancellationToken.None);
|
||||||
|
|
||||||
|
page.Items.Should().HaveCount(2);
|
||||||
|
page.TotalCount.Should().Be(2);
|
||||||
|
page.Items.All(l => l.City == "Austin").Should().BeTrue();
|
||||||
|
page.Items.Select(l => l.Name).Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetLocationDetailAsync_ReturnsMappedDtoOrNull()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Depot", "Houston");
|
||||||
|
|
||||||
|
var service = NewService(ctx);
|
||||||
|
var found = await service.GetLocationDetailAsync(existing.Id, CancellationToken.None);
|
||||||
|
var missing = await service.GetLocationDetailAsync(existing.Id + 999, CancellationToken.None);
|
||||||
|
|
||||||
|
missing.Should().BeNull();
|
||||||
|
found.Should().NotBeNull();
|
||||||
|
found!.Name.Should().Be("Depot");
|
||||||
|
found.City.Should().Be("Houston");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateLocationFromRequestAsync_UpdatesFieldsAndThrowsWhenMissing()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Old", "Round Rock");
|
||||||
|
|
||||||
|
await NewService(ctx).UpdateLocationFromRequestAsync(existing.Id, new LocationUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Name = "New",
|
||||||
|
Address = "9 New St",
|
||||||
|
City = "Plano",
|
||||||
|
Status = "Inactive"
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
var row = ctx.Locations.Single();
|
||||||
|
row.Name.Should().Be("New");
|
||||||
|
row.Address1.Should().Be("9 New St");
|
||||||
|
row.City.Should().Be("Plano");
|
||||||
|
row.Status.Should().Be("Inactive");
|
||||||
|
|
||||||
|
var act = () => NewService(ctx).UpdateLocationFromRequestAsync(existing.Id + 999, new LocationUpdateRequestDTO { Name = "X" }, CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteLocationByIdAsync_RemovesAndReturnsFalseWhenMissing()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var existing = SeedLocation(ctx, "Gone", "Cedar Park");
|
||||||
|
|
||||||
|
var removed = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
|
||||||
|
var again = await NewService(ctx).DeleteLocationByIdAsync(existing.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
removed.Should().BeTrue();
|
||||||
|
again.Should().BeFalse();
|
||||||
|
ctx.Locations.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
281
Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs
Normal file
281
Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Data.SeaHavenIndustries.Enums;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using SeaHaven.Services.Validation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
internal static class TestAsyncEnumerableHelper
|
||||||
|
{
|
||||||
|
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IEnumerable<T> source)
|
||||||
|
{
|
||||||
|
foreach (var item in source)
|
||||||
|
yield return item;
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SyncServiceBehaviorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncWorkOrders_CreatesNewAndUpdatesExisting()
|
||||||
|
{
|
||||||
|
var data = new Mock<ISyncDataService>();
|
||||||
|
var external = new Mock<ISyncExternalSource>();
|
||||||
|
|
||||||
|
var existingWO = new WorkOrder { Id = 5, ExternalWorkOrderId = "EXT-1", WorkerOrderTitle = "Old" };
|
||||||
|
|
||||||
|
data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync("10000005");
|
||||||
|
data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(existingWO);
|
||||||
|
data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-2", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((WorkOrder?)null);
|
||||||
|
data.Setup(d => d.ResolveLocationAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((int?)null);
|
||||||
|
data.Setup(d => d.GetLocationCountAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(3);
|
||||||
|
|
||||||
|
var items = new List<Dictionary<string, string?>>
|
||||||
|
{
|
||||||
|
new() { ["work_order_id"] = "EXT-1", ["description"] = "Updated Title", ["wo_status"] = "in_progress" },
|
||||||
|
new() { ["work_order_id"] = "EXT-2", ["description"] = "New WO", ["wo_status"] = "new", ["severity"] = "3" }
|
||||||
|
};
|
||||||
|
|
||||||
|
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(items.ToAsyncEnumerable());
|
||||||
|
|
||||||
|
var service = new SyncService(data.Object, external.Object);
|
||||||
|
var result = await service.SyncWorkOrdersAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Synced);
|
||||||
|
Assert.Equal(1, result.Created);
|
||||||
|
Assert.Equal(1, result.Updated);
|
||||||
|
Assert.Equal(3, result.LocationsCreated);
|
||||||
|
Assert.Equal("Updated Title", existingWO.WorkerOrderTitle);
|
||||||
|
Assert.Equal("In Progress", existingWO.Status);
|
||||||
|
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncWorkOrders_MapsStatusAndSeverity()
|
||||||
|
{
|
||||||
|
var data = new Mock<ISyncDataService>();
|
||||||
|
var external = new Mock<ISyncExternalSource>();
|
||||||
|
|
||||||
|
data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny<CancellationToken>())).ReturnsAsync((string?)null);
|
||||||
|
data.Setup(d => d.GetWorkOrderByExternalIdAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())).ReturnsAsync((WorkOrder?)null);
|
||||||
|
data.Setup(d => d.ResolveLocationAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>())).ReturnsAsync((int?)null);
|
||||||
|
data.Setup(d => d.GetLocationCountAsync(It.IsAny<CancellationToken>())).ReturnsAsync(0);
|
||||||
|
|
||||||
|
var items = new List<Dictionary<string, string?>>
|
||||||
|
{
|
||||||
|
new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2" }
|
||||||
|
};
|
||||||
|
external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
|
||||||
|
|
||||||
|
await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
data.Verify(d => d.EnqueueWorkOrder(It.Is<WorkOrder>(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncComments_SkipsExistingAndOrphans()
|
||||||
|
{
|
||||||
|
var data = new Mock<ISyncDataService>();
|
||||||
|
var external = new Mock<ISyncExternalSource>();
|
||||||
|
var wo = new WorkOrder { Id = 10, ExternalWorkOrderId = "WO-1" };
|
||||||
|
|
||||||
|
data.Setup(d => d.CommentExistsByExternalIdAsync("C-EXISTS", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||||
|
data.Setup(d => d.CommentExistsByExternalIdAsync("C-NEW", It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||||
|
data.Setup(d => d.CommentExistsByExternalIdAsync("C-ORPHAN", It.IsAny<CancellationToken>())).ReturnsAsync(false);
|
||||||
|
data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-1", It.IsAny<CancellationToken>())).ReturnsAsync(wo);
|
||||||
|
data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-MISSING", It.IsAny<CancellationToken>())).ReturnsAsync((WorkOrder?)null);
|
||||||
|
|
||||||
|
var items = new List<Dictionary<string, string?>>
|
||||||
|
{
|
||||||
|
new() { ["comment_id"] = "C-EXISTS", ["work_order_id"] = "WO-1" },
|
||||||
|
new() { ["comment_id"] = "C-NEW", ["work_order_id"] = "WO-1", ["text"] = "Hello" },
|
||||||
|
new() { ["comment_id"] = "C-ORPHAN", ["work_order_id"] = "WO-MISSING" }
|
||||||
|
};
|
||||||
|
external.Setup(e => e.ScanCommentsAsync(It.IsAny<CancellationToken>())).Returns(items.ToAsyncEnumerable());
|
||||||
|
|
||||||
|
var result = await new SyncService(data.Object, external.Object).SyncCommentsAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, result.Synced);
|
||||||
|
Assert.Equal(1, result.Created);
|
||||||
|
Assert.Equal(2, result.Skipped);
|
||||||
|
data.Verify(d => d.EnqueueComment(It.Is<Comments>(c => c.ExternalCommentId == "C-NEW")), Times.Once);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderDispatchServiceBehaviorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyDispatch_ThrowsWhenChecklistIncomplete()
|
||||||
|
{
|
||||||
|
var dispatchData = CreateVerifyDispatchMocks(
|
||||||
|
out var dispatch,
|
||||||
|
incompleteCount: 1,
|
||||||
|
checklistComplete: false,
|
||||||
|
hasCustomerSignoff: true,
|
||||||
|
hasVendorSignoff: true);
|
||||||
|
dispatch.Id = 1;
|
||||||
|
|
||||||
|
var service = CreateDispatchService(dispatchData);
|
||||||
|
var ex = await Assert.ThrowsAsync<DispatchVerificationException>(() => service.VerifyDispatchAsync(1, "user1"));
|
||||||
|
Assert.Contains("1 checklist item(s) incomplete", ex.Missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyDispatch_ThrowsWhenVendorSignoffMissing()
|
||||||
|
{
|
||||||
|
var dispatchData = CreateVerifyDispatchMocks(
|
||||||
|
out var dispatch,
|
||||||
|
incompleteCount: 0,
|
||||||
|
checklistComplete: true,
|
||||||
|
hasCustomerSignoff: true,
|
||||||
|
hasVendorSignoff: false);
|
||||||
|
dispatch.Id = 1;
|
||||||
|
|
||||||
|
var service = CreateDispatchService(dispatchData);
|
||||||
|
var ex = await Assert.ThrowsAsync<DispatchVerificationException>(() => service.VerifyDispatchAsync(1, "user1"));
|
||||||
|
Assert.Contains("Missing Vendor sign-off", ex.Missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task VerifyDispatch_SucceedsWhenAllComplete()
|
||||||
|
{
|
||||||
|
var dispatchData = CreateVerifyDispatchMocks(
|
||||||
|
out var dispatch,
|
||||||
|
incompleteCount: 0,
|
||||||
|
checklistComplete: true,
|
||||||
|
hasCustomerSignoff: true,
|
||||||
|
hasVendorSignoff: true);
|
||||||
|
dispatch.Id = 1;
|
||||||
|
|
||||||
|
var userMock = new Mock<IUserDataService>();
|
||||||
|
userMock.Setup(u => u.GetByIdAsync("user1")).ReturnsAsync(new ApplicationUser { FirstName = "Jane", LastName = "Doe" });
|
||||||
|
|
||||||
|
var service = CreateDispatchService(dispatchData, userMock: userMock);
|
||||||
|
var result = await service.VerifyDispatchAsync(1, "user1");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("Dispatch verified", result!.Message);
|
||||||
|
Assert.Equal("Verified", dispatch.Status);
|
||||||
|
Assert.Equal("Jane Doe", dispatch.VerifiedBy);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<IDispatchDataService> CreateVerifyDispatchMocks(
|
||||||
|
out Dispatch dispatch,
|
||||||
|
int incompleteCount,
|
||||||
|
bool checklistComplete,
|
||||||
|
bool hasCustomerSignoff,
|
||||||
|
bool hasVendorSignoff)
|
||||||
|
{
|
||||||
|
dispatch = new Dispatch { Id = 1, DispatchNumber = "DSP-00001", WorkOrderId = 10 };
|
||||||
|
var mock = new Mock<IDispatchDataService>();
|
||||||
|
mock.Setup(d => d.GetByIdAsync(1)).ReturnsAsync(dispatch);
|
||||||
|
|
||||||
|
var checklist = checklistComplete
|
||||||
|
? new List<DispatchChecklistItem> { new() { IsCompleted = true } }
|
||||||
|
: new List<DispatchChecklistItem> { new() { IsCompleted = false } };
|
||||||
|
mock.Setup(d => d.GetChecklistItemsAsync(1)).ReturnsAsync(checklist);
|
||||||
|
|
||||||
|
var signoffs = new List<DispatchSignoff>();
|
||||||
|
if (hasCustomerSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Customer" });
|
||||||
|
if (hasVendorSignoff) signoffs.Add(new DispatchSignoff { SignoffType = "Vendor" });
|
||||||
|
mock.Setup(d => d.GetSignoffsAsync(1)).ReturnsAsync(signoffs);
|
||||||
|
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderDispatchService CreateDispatchService(
|
||||||
|
Mock<IDispatchDataService>? dispatchDataMock = null,
|
||||||
|
Mock<IUserDataService>? userMock = null)
|
||||||
|
{
|
||||||
|
return new WorkOrderDispatchService(
|
||||||
|
Mock.Of<IWorkOrderDataService>(),
|
||||||
|
(dispatchDataMock ?? new Mock<IDispatchDataService>()).Object,
|
||||||
|
Mock.Of<IVendorDataService>(),
|
||||||
|
(userMock ?? new Mock<IUserDataService>()).Object,
|
||||||
|
Mock.Of<ITaskListTemplateDataService>(),
|
||||||
|
Mock.Of<ICommentDataService>(),
|
||||||
|
Mock.Of<IDispatchEmailPort>(),
|
||||||
|
Mock.Of<IVendorTokenPort>(),
|
||||||
|
Options.Create(new FrontendOptions()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderServiceBehaviorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangeStatus_AddsAuditWhenStatusDiffers()
|
||||||
|
{
|
||||||
|
var wo = new WorkOrder { Id = 5, Status = "Open" };
|
||||||
|
var woData = new Mock<IWorkOrderDataService>();
|
||||||
|
woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo);
|
||||||
|
|
||||||
|
var service = CreateWorkOrderService(woData);
|
||||||
|
var result = await service.ChangeStatusAsync(5, "In Progress", "user1");
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("In Progress", wo.Status);
|
||||||
|
woData.Verify(d => d.AddAuditLogAsync(It.Is<WorkOrderAuditLog>(a =>
|
||||||
|
a.FieldName == "Status" && a.OldValue == "Open" && a.NewValue == "In Progress")), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ChangeStatus_DoesNotAuditWhenStatusSame()
|
||||||
|
{
|
||||||
|
var wo = new WorkOrder { Id = 5, Status = "Open" };
|
||||||
|
var woData = new Mock<IWorkOrderDataService>();
|
||||||
|
woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo);
|
||||||
|
|
||||||
|
await CreateWorkOrderService(woData).ChangeStatusAsync(5, "Open", "user1");
|
||||||
|
|
||||||
|
woData.Verify(d => d.AddAuditLogAsync(It.IsAny<WorkOrderAuditLog>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesComments()
|
||||||
|
{
|
||||||
|
var woData = new Mock<IWorkOrderDataService>();
|
||||||
|
woData.Setup(d => d.ExistsAsync(5)).ReturnsAsync(true);
|
||||||
|
|
||||||
|
var commentData = new Mock<ICommentDataService>();
|
||||||
|
commentData.Setup(c => c.GetByWorkOrderIdAsync(5))
|
||||||
|
.ReturnsAsync(new List<Comments> { new() { Id = 1 }, new() { Id = 2 } });
|
||||||
|
|
||||||
|
var quotesData = new Mock<IQuotesDataService>();
|
||||||
|
|
||||||
|
var result = await CreateWorkOrderService(woData, commentData, quotesData).DeleteWorkOrderCascadeAsync(5, "user1");
|
||||||
|
|
||||||
|
Assert.True(result);
|
||||||
|
quotesData.Verify(q => q.UnlinkFromWorkOrderAsync(5), Times.Once);
|
||||||
|
commentData.Verify(c => c.DeleteAsync(1), Times.Once);
|
||||||
|
commentData.Verify(c => c.DeleteAsync(2), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkOrderService CreateWorkOrderService(
|
||||||
|
Mock<IWorkOrderDataService>? woData = null,
|
||||||
|
Mock<ICommentDataService>? commentData = null,
|
||||||
|
Mock<IQuotesDataService>? quotesData = null)
|
||||||
|
{
|
||||||
|
return new WorkOrderService(
|
||||||
|
(woData ?? new Mock<IWorkOrderDataService>()).Object,
|
||||||
|
(commentData ?? new Mock<ICommentDataService>()).Object,
|
||||||
|
Mock.Of<IUserDataService>(),
|
||||||
|
(quotesData ?? new Mock<IQuotesDataService>()).Object,
|
||||||
|
Mock.Of<IFileStoragePort>(),
|
||||||
|
Mock.Of<ICreateWorkOrderValidation>(),
|
||||||
|
Mock.Of<IUpdateWorkOrderValidation>());
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs
Normal file
75
Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System.Reflection;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SeaHaven.DataServices.DependencyInjection;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DependencyInjection;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class ServiceRegistrationTests
|
||||||
|
{
|
||||||
|
private static IServiceCollection BuildConventionServices()
|
||||||
|
{
|
||||||
|
var configuration = new ConfigurationBuilder().Build();
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddSingleton<IConfiguration>(configuration);
|
||||||
|
services.AddDataServices();
|
||||||
|
services.AddBusinessServices(configuration);
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<(Type Implementation, Type Interface)> ConventionCandidates(Assembly assembly)
|
||||||
|
{
|
||||||
|
return assembly.GetTypes()
|
||||||
|
.Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType)
|
||||||
|
.Select(t => (
|
||||||
|
Implementation: t,
|
||||||
|
Interface: t.GetInterfaces().FirstOrDefault(i => i.Name == $"I{t.Name}")))
|
||||||
|
.Where(pair => pair.Interface is not null)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AssertScopedConventionRegistrations(
|
||||||
|
IServiceCollection services,
|
||||||
|
IEnumerable<(Type Implementation, Type Interface)> candidates,
|
||||||
|
string assemblyLabel)
|
||||||
|
{
|
||||||
|
var candidateList = candidates.ToList();
|
||||||
|
candidateList.Should().NotBeEmpty(
|
||||||
|
"expected at least one convention candidate in {0}", assemblyLabel);
|
||||||
|
|
||||||
|
var missing = candidateList
|
||||||
|
.Where(pair => !services.Any(d =>
|
||||||
|
d.Lifetime == ServiceLifetime.Scoped &&
|
||||||
|
d.ServiceType == pair.Interface &&
|
||||||
|
d.ImplementationType == pair.Implementation))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
missing.Should().BeEmpty(
|
||||||
|
"expected a scoped descriptor mapping each convention candidate interface to its implementation in {0}, missing: {1}",
|
||||||
|
assemblyLabel,
|
||||||
|
string.Join("; ", missing.Select(p => $"{p.Interface!.FullName} -> {p.Implementation.FullName}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConventionDi_RegistersEveryServicesImplementationClassAsScoped()
|
||||||
|
{
|
||||||
|
var services = BuildConventionServices();
|
||||||
|
var candidates = ConventionCandidates(typeof(WorkOrderDispatchService).Assembly);
|
||||||
|
|
||||||
|
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.Services");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConventionDi_RegistersEveryDataServicesImplementationClassAsScoped()
|
||||||
|
{
|
||||||
|
var services = BuildConventionServices();
|
||||||
|
var candidates = ConventionCandidates(typeof(VendorDataService).Assembly);
|
||||||
|
|
||||||
|
AssertScopedConventionRegistrations(services, candidates, "SeaHaven.DataServices");
|
||||||
|
}
|
||||||
|
}
|
||||||
119
Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs
Normal file
119
Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Api.SeaHavenIndustries.Helper.ViewModel;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class SettingsControllerTests
|
||||||
|
{
|
||||||
|
private static SettingsController NewController(Mock<ISettingsService> service) =>
|
||||||
|
new(service.Object);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCategoryList_ReturnsPaginationEnvelopeWithServiceItems()
|
||||||
|
{
|
||||||
|
var service = new Mock<ISettingsService>();
|
||||||
|
service.Setup(s => s.GetCategoryListAsync(1, 10, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((new List<SettingsCategoryDTO> { new() { Id = 1, Name = "A" } }, 1));
|
||||||
|
|
||||||
|
var result = await NewController(service).GetCategoryList(1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
envelope.TotalCount.Should().Be(1);
|
||||||
|
envelope.TotalPages.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTemplates_ReturnsTypedServiceDtosInEnvelope()
|
||||||
|
{
|
||||||
|
var service = new Mock<ISettingsService>();
|
||||||
|
service.Setup(s => s.GetTemplatesAsync(1, 10, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((new List<SettingsTemplateDTO> { new() { Id = 1, WorkerOrderTitle = "T" } }, 1));
|
||||||
|
|
||||||
|
var result = await NewController(service).GetTemplates(1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
envelope.TotalCount.Should().Be(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddCategory_WhenServiceSucceeds_ReturnsSaved()
|
||||||
|
{
|
||||||
|
var service = new Mock<ISettingsService>();
|
||||||
|
var result = await NewController(service).AddCategory(new Category_DTO { Name = "X" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("200");
|
||||||
|
response.Message.Should().Be("Successfully Saved Data");
|
||||||
|
service.Verify(s => s.AddCategoryAsync("X", It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditCategory_WhenMissing_ReturnsBadRequest()
|
||||||
|
{
|
||||||
|
var service = new Mock<ISettingsService>();
|
||||||
|
service.Setup(s => s.UpdateCategoryAsync(9, "X", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await NewController(service).EditCategory(new EditCategory_DTO { Id = 9, Name = "X" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("Category not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCategory_WhenMissing_ReturnsIdNotMatched()
|
||||||
|
{
|
||||||
|
var service = new Mock<ISettingsService>();
|
||||||
|
service.Setup(s => s.DeleteCategoryAsync(9, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var result = await NewController(service).DeleteCategory(9, CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
bad.Value.Should().BeOfType<Response>().Subject.Message.Should().Be("ID not matched!");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUsers_MapsServiceUsersToUserRoleViewModel()
|
||||||
|
{
|
||||||
|
var service = new Mock<ISettingsService>();
|
||||||
|
service.Setup(s => s.GetUsersAsync("", 1, 10, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync((new List<SettingsUserDTO>
|
||||||
|
{
|
||||||
|
new() { Id = "u1", UserName = "Sara", Email = "s@x.com", RoleName = "User", Status = "Active", Date = "2026-01-01" }
|
||||||
|
}, 1));
|
||||||
|
|
||||||
|
var result = await NewController(service).GetUsers("", 1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var envelope = ok.Value.Should().BeOfType<Pagination_DTO>().Subject;
|
||||||
|
object data = envelope.Data!;
|
||||||
|
var rows = ((System.Collections.IEnumerable)data).Cast<UserRoleViewModel>().ToList();
|
||||||
|
rows.Should().ContainSingle(u => u.UserName == "Sara" && u.RoleName == "User" && u.Email == "s@x.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRoles_MapsServiceRolesToRolesDto()
|
||||||
|
{
|
||||||
|
var service = new Mock<ISettingsService>();
|
||||||
|
service.Setup(s => s.GetRolesAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<RoleDTO> { new() { Name = "Admin", Description = "Full Access" } });
|
||||||
|
|
||||||
|
var result = await NewController(service).GetRoles(CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var roles = ((System.Collections.IEnumerable)ok.Value!).Cast<Roles_DTO>().ToList();
|
||||||
|
roles.Should().ContainSingle(r => r.Name == "Admin" && r.Description == "Full Access");
|
||||||
|
}
|
||||||
|
}
|
||||||
168
Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs
Normal file
168
Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class SettingsServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SettingsService NewService(ApplicationDbContext ctx) =>
|
||||||
|
new(new SettingsDataService(ctx), null!);
|
||||||
|
|
||||||
|
private static (ApplicationDbContext ctx, UserManager<ApplicationUser> userManager, RoleManager<IdentityRole> roleManager) NewIdentityContext()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddDbContext<ApplicationDbContext>(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString()));
|
||||||
|
services.AddIdentity<ApplicationUser, IdentityRole>().AddEntityFrameworkStores<ApplicationDbContext>();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var scope = provider.CreateScope();
|
||||||
|
var ctx = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
|
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||||||
|
return (ctx, userManager, roleManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddCategoryAsync_PersistsName()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
await NewService(ctx).AddCategoryAsync("Plumbing", CancellationToken.None);
|
||||||
|
ctx.Categories.Single().Name.Should().Be("Plumbing");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateCategoryAsync_UpdatesNameAndReturnsFalseWhenMissing()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Categories.Add(new Category { Name = "Old" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var service = NewService(ctx);
|
||||||
|
(await service.UpdateCategoryAsync(1, "New", CancellationToken.None)).Should().BeTrue();
|
||||||
|
ctx.Categories.Single().Name.Should().Be("New");
|
||||||
|
(await service.UpdateCategoryAsync(999, "X", CancellationToken.None)).Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCategoryAsync_CascadesTemplatesAndWorkOrderCategories()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Categories.Add(new Category { Id = 1, Name = "C1" });
|
||||||
|
ctx.Templates.Add(new Template { CategoryId = 1, WorkerOrderTitle = "T" });
|
||||||
|
ctx.workOrderCategories.Add(new WorkOrderCategories { CategoryId = 1 });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var service = NewService(ctx);
|
||||||
|
(await service.DeleteCategoryAsync(1, CancellationToken.None)).Should().BeTrue();
|
||||||
|
(await service.DeleteCategoryAsync(1, CancellationToken.None)).Should().BeFalse();
|
||||||
|
|
||||||
|
ctx.Categories.Should().BeEmpty();
|
||||||
|
ctx.Templates.Should().BeEmpty();
|
||||||
|
ctx.workOrderCategories.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetCategoryListAsync_MapsToServiceDtoAndPages()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Categories.Add(new Category { Name = "A" });
|
||||||
|
ctx.Categories.Add(new Category { Name = "B" });
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var (items, total) = await NewService(ctx).GetCategoryListAsync(1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
total.Should().Be(2);
|
||||||
|
items.Should().OnlyContain(c => c is SettingsCategoryDTO);
|
||||||
|
items.Select(c => c.Name).Should().Contain(new[] { "A", "B" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTemplatesAsync_MapsWorkOrderScalarsToTypedDto()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var wo = new WorkOrder
|
||||||
|
{
|
||||||
|
istemplate = true,
|
||||||
|
InternalWONumber = "WO-100",
|
||||||
|
WorkerOrderTitle = "Template A",
|
||||||
|
Status = "Active",
|
||||||
|
Priority = "High",
|
||||||
|
Trade = "HVAC",
|
||||||
|
VendorNTE = 1500m,
|
||||||
|
Customer = "Cust"
|
||||||
|
};
|
||||||
|
ctx.workOrders.Add(wo);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var (items, total) = await NewService(ctx).GetTemplatesAsync(1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
total.Should().Be(1);
|
||||||
|
var dto = items.Single();
|
||||||
|
dto.Should().BeOfType<SettingsTemplateDTO>();
|
||||||
|
dto.InternalWONumber.Should().Be("WO-100");
|
||||||
|
dto.WorkerOrderTitle.Should().Be("Template A");
|
||||||
|
dto.Status.Should().Be("Active");
|
||||||
|
dto.Priority.Should().Be("High");
|
||||||
|
dto.Trade.Should().Be("HVAC");
|
||||||
|
dto.VendorNTE.Should().Be(1500m);
|
||||||
|
dto.Customer.Should().Be("Cust");
|
||||||
|
dto.istemplate.Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUsersAsync_ExcludesAdminsAndResolvesRole()
|
||||||
|
{
|
||||||
|
var (ctx, userManager, roleManager) = NewIdentityContext();
|
||||||
|
var t1 = roleManager.CreateAsync(new IdentityRole("Admin"));
|
||||||
|
var t2 = roleManager.CreateAsync(new IdentityRole("User"));
|
||||||
|
await Task.WhenAll(t1, t2);
|
||||||
|
|
||||||
|
var admin = new ApplicationUser { UserName = "admin1", Email = "a@x.com", FirstName = "Boss" };
|
||||||
|
var worker = new ApplicationUser { UserName = "worker1", Email = "w@x.com", FirstName = "Hand" };
|
||||||
|
await userManager.CreateAsync(admin);
|
||||||
|
await userManager.CreateAsync(worker);
|
||||||
|
await userManager.AddToRoleAsync(admin, "Admin");
|
||||||
|
await userManager.AddToRoleAsync(worker, "User");
|
||||||
|
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var service = new SettingsService(new SettingsDataService(ctx), userManager);
|
||||||
|
var (users, total) = await service.GetUsersAsync("", 1, 10, CancellationToken.None);
|
||||||
|
|
||||||
|
total.Should().BeGreaterThanOrEqualTo(2);
|
||||||
|
users.Should().NotContain(u => u.RoleName == "Admin");
|
||||||
|
users.Should().Contain(u => u.UserName == "Hand" && u.RoleName == "User");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetRolesAsync_MapsKnownRoleNamesToDescriptions()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.Roles.Add(new IdentityRole("Admin"));
|
||||||
|
ctx.Roles.Add(new IdentityRole("Manager"));
|
||||||
|
ctx.Roles.Add(new IdentityRole("Unknown"));
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var roles = await NewService(ctx).GetRolesAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
var byName = roles.ToDictionary(r => r.Name!, r => r.Description);
|
||||||
|
byName["Admin"].Should().Be("Full Access");
|
||||||
|
byName["Manager"].Should().Be("Full access accept quotes");
|
||||||
|
roles.Should().NotContain(r => r.Name == "Unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
146
Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs
Normal file
146
Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class TaskListTemplateServiceTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskListTemplateService NewService(ApplicationDbContext ctx) =>
|
||||||
|
new(new TaskListTemplateDataService(ctx));
|
||||||
|
|
||||||
|
private static TaskListTemplate Template(string name, bool active, params (string text, int sort)[] items)
|
||||||
|
{
|
||||||
|
var t = new TaskListTemplate { Name = name, IsActive = active };
|
||||||
|
t.Items = items.Select(i => new TaskListTemplateItem { ItemText = i.text, SortOrder = i.sort }).ToList();
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAllAsync_ReturnsOnlyActiveOrderedByNameWithOrderedItems()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
ctx.TaskListTemplates.AddRange(
|
||||||
|
Template("Zeta", true, ("b", 2), ("a", 1)),
|
||||||
|
Template("Alpha", true, ("first", 0)),
|
||||||
|
Template("Hidden", false)
|
||||||
|
);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = (await NewService(ctx).GetAllAsync(CancellationToken.None)).ToList();
|
||||||
|
|
||||||
|
result.Select(t => t.Name).Should().ContainInOrder("Alpha", "Zeta");
|
||||||
|
result.Single(t => t.Name == "Zeta").Items.Select(i => i.ItemText).Should().ContainInOrder("a", "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_WhenMissing_ReturnsNull()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewService(ctx).GetByIdAsync(99, CancellationToken.None);
|
||||||
|
result.Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdAsync_ReturnsTemplateWithOrderedItems()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var t = Template("Alpha", true, ("b", 2), ("a", 1));
|
||||||
|
ctx.TaskListTemplates.Add(t);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).GetByIdAsync(t.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result!.Items.Select(i => i.ItemText).Should().ContainInOrder("a", "b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateAsync_PersistsTemplateAndItemsAndReturnsId()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var input = new TaskListTemplateInput
|
||||||
|
{
|
||||||
|
Name = "New",
|
||||||
|
Description = "desc",
|
||||||
|
IsActive = true,
|
||||||
|
Items = new()
|
||||||
|
{
|
||||||
|
new() { ItemText = "two", SortOrder = 0 },
|
||||||
|
new() { ItemText = "one", SortOrder = 0 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var id = await NewService(ctx).CreateAsync(input, CancellationToken.None);
|
||||||
|
|
||||||
|
id.Should().BeGreaterThan(0);
|
||||||
|
var row = ctx.TaskListTemplates.Include(t => t.Items).Single();
|
||||||
|
row.Name.Should().Be("New");
|
||||||
|
row.Items.Should().HaveCount(2);
|
||||||
|
row.Items!.Select(i => i.SortOrder).Should().ContainInOrder(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_WhenNotFound_ReturnsFalse()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewService(ctx).UpdateAsync(404, new TaskListTemplateInput { Name = "x" }, CancellationToken.None);
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateAsync_ReplacesItems()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var t = Template("Alpha", true, ("old", 0));
|
||||||
|
ctx.TaskListTemplates.Add(t);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var updated = await NewService(ctx).UpdateAsync(t.Id, new TaskListTemplateInput
|
||||||
|
{
|
||||||
|
Name = "Renamed",
|
||||||
|
IsActive = false,
|
||||||
|
Items = new() { new() { ItemText = "new1", SortOrder = 1 }, new() { ItemText = "new2", SortOrder = 2 } }
|
||||||
|
}, CancellationToken.None);
|
||||||
|
|
||||||
|
updated.Should().BeTrue();
|
||||||
|
var row = ctx.TaskListTemplates.Include(x => x.Items).Single();
|
||||||
|
row.Name.Should().Be("Renamed");
|
||||||
|
row.IsActive.Should().BeFalse();
|
||||||
|
row.Items!.Select(i => i.ItemText).Should().BeEquivalentTo(new[] { "new1", "new2" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_SoftDeletesByDeactivating()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var t = Template("Alpha", true);
|
||||||
|
ctx.TaskListTemplates.Add(t);
|
||||||
|
ctx.SaveChanges();
|
||||||
|
|
||||||
|
var result = await NewService(ctx).DeleteAsync(t.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeTrue();
|
||||||
|
ctx.TaskListTemplates.Single().IsActive.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_WhenNotFound_ReturnsFalse()
|
||||||
|
{
|
||||||
|
using var ctx = NewContext();
|
||||||
|
var result = await NewService(ctx).DeleteAsync(404, CancellationToken.None);
|
||||||
|
result.Should().BeFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
177
Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs
Normal file
177
Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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;
|
||||||
|
var approveData = envelope.Data as UpliftApproveResultDTO;
|
||||||
|
approveData.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
190
Api.SeaHavenIndustries.Tests/UserControllerTests.cs
Normal file
190
Api.SeaHavenIndustries.Tests/UserControllerTests.cs
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
using Api.SeaHavenIndustries.Controllers;
|
||||||
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
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 System.Text.Json;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class UserControllerTests
|
||||||
|
{
|
||||||
|
private static string Json(object? value) =>
|
||||||
|
JsonSerializer.Serialize(value, value?.GetType() ?? typeof(object));
|
||||||
|
|
||||||
|
private static UserController NewController(Mock<IUserService> service, string? userId = null)
|
||||||
|
{
|
||||||
|
var controller = new UserController(service.Object);
|
||||||
|
if (userId != null)
|
||||||
|
{
|
||||||
|
controller.ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||||
|
{
|
||||||
|
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||||
|
}, "Test"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUsers_ReturnsServiceList()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.GetUsersAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<UserListRowDTO>
|
||||||
|
{
|
||||||
|
new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", Date = "Jan 01 2026" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.GetUsers(CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.Value.Should().BeAssignableTo<IEnumerable<UserListRowDTO>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddUser_Success_ReturnsUpdatedSuccessfully()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.AddUserAsync(It.IsAny<AddUserRequestDTO>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new AddUserOutcomeDTO { Success = true });
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.AddUser(new User_DTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var response = ok.Value.Should().BeOfType<DataResponse>().Subject;
|
||||||
|
response.Status.Should().Be("200");
|
||||||
|
response.Message.Should().Be("Updated Successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddUser_Failure_ReturnsBadRequestWithServiceError()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.AddUserAsync(It.IsAny<AddUserRequestDTO>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new AddUserOutcomeDTO { Success = false, Error = "boom" });
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.AddUser(new User_DTO(), CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Status.Should().Be("Error");
|
||||||
|
response.Message.Should().Be("boom");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditUser_NotFound_ReturnsUserNotFoundMessage()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.EditUserAsync(It.IsAny<EditUserRequestDTO>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Message.Should().Be("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditUser_Found_ReturnsUpdatedSuccessfully()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.EditUserAsync(It.IsAny<EditUserRequestDTO>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.Value.Should().BeOfType<DataResponse>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.DeleteUserAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None);
|
||||||
|
|
||||||
|
var bad = result.Should().BeOfType<BadRequestObjectResult>().Subject;
|
||||||
|
var response = bad.Value.Should().BeOfType<Response>().Subject;
|
||||||
|
response.Message.Should().Be("ID not matched!");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteUser_Found_DelegatesAndReturnsOk()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||||
|
|
||||||
|
var controller = NewController(service);
|
||||||
|
|
||||||
|
var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Should().BeOfType<OkObjectResult>();
|
||||||
|
service.Verify(s => s.DeleteUserAsync("5", It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCurrentUser_UsesClaimUserId()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.DeleteCurrentUserAsync("77", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var controller = NewController(service, "77");
|
||||||
|
|
||||||
|
var result = await controller.DeleteCurrentUser(CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
ok.Value.Should().BeOfType<DataResponse>();
|
||||||
|
service.Verify(s => s.DeleteCurrentUserAsync("77", It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UserProfile_PreservesLowercaseAddedDateKey()
|
||||||
|
{
|
||||||
|
var service = new Mock<IUserService>();
|
||||||
|
service.Setup(s => s.GetUserProfileAsync("77", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<UserProfileRowDTO>
|
||||||
|
{
|
||||||
|
new() { Id = "77", Name = "Me", AddedDate = new DateTime(2026, 1, 1), Email = "me@x.com", Contact = "321" }
|
||||||
|
});
|
||||||
|
|
||||||
|
var controller = NewController(service, "77");
|
||||||
|
|
||||||
|
var result = await controller.UserProfile(CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
|
var json = Json(ok.Value);
|
||||||
|
json.Should().Contain("\"Id\":\"77\"");
|
||||||
|
json.Should().Contain("\"Name\":\"Me\"");
|
||||||
|
json.Should().Contain("\"addeddate\":\"");
|
||||||
|
json.Should().Contain("\"Email\":\"me@x.com\"");
|
||||||
|
json.Should().Contain("\"Contact\":\"321\"");
|
||||||
|
}
|
||||||
|
}
|
||||||
195
Api.SeaHavenIndustries.Tests/UserServiceTests.cs
Normal file
195
Api.SeaHavenIndustries.Tests/UserServiceTests.cs
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Moq;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class UserServiceTests
|
||||||
|
{
|
||||||
|
private static UserService NewService(
|
||||||
|
Mock<IUserDataService> userData,
|
||||||
|
Mock<IEmailSender> email,
|
||||||
|
out Mock<IUserRoleStore<ApplicationUser>> store)
|
||||||
|
{
|
||||||
|
var (manager, s, _) = IdentityTestHelpers.CreateUserManager();
|
||||||
|
store = s;
|
||||||
|
return new UserService(manager, userData.Object, email.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUsers_FormatsCreatedDate_AndHandlesNullDate()
|
||||||
|
{
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetAllForListAsync(It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new List<UserListRowData>
|
||||||
|
{
|
||||||
|
new() { Id = "1", Name = "A", Email = "a@x.com", RoleName = "Admin", Status = "Active", CreatedDate = new DateTime(2026, 1, 5) },
|
||||||
|
new() { Id = "2", Name = "B", Email = "b@x.com", RoleName = "Tech", Status = "Active", CreatedDate = null }
|
||||||
|
});
|
||||||
|
|
||||||
|
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||||
|
|
||||||
|
var users = (await service.GetUsersAsync(CancellationToken.None)).ToList();
|
||||||
|
|
||||||
|
users.Should().HaveCount(2);
|
||||||
|
users[0].Date.Should().Be("Jan 05 2026");
|
||||||
|
users[0].RoleName.Should().Be("Admin");
|
||||||
|
users[0].Status.Should().Be("Active");
|
||||||
|
users[1].Date.Should().Be("");
|
||||||
|
users[1].Name.Should().Be("B");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteUser_NotFound_ReturnsFalseWithoutCascade()
|
||||||
|
{
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetForEditAsync("missing", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
|
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||||
|
|
||||||
|
var found = await service.DeleteUserAsync("missing", CancellationToken.None);
|
||||||
|
|
||||||
|
found.Should().BeFalse();
|
||||||
|
userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteUser_Found_PerformsCascade()
|
||||||
|
{
|
||||||
|
var user = IdentityTestHelpers.User();
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||||
|
|
||||||
|
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||||
|
|
||||||
|
var found = await service.DeleteUserAsync("u1", CancellationToken.None);
|
||||||
|
|
||||||
|
found.Should().BeTrue();
|
||||||
|
userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteCurrentUser_SetsIsDeletedFlag()
|
||||||
|
{
|
||||||
|
var user = IdentityTestHelpers.User();
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetForEditAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||||
|
|
||||||
|
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||||
|
|
||||||
|
await service.DeleteCurrentUserAsync("u1", CancellationToken.None);
|
||||||
|
|
||||||
|
user.IsDeleted.Should().BeTrue();
|
||||||
|
userData.Verify(u => u.UpdateUserAsync(user, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EditUser_NotFound_ReturnsFalse()
|
||||||
|
{
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetForEditAsync("x", It.IsAny<CancellationToken>())).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
|
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||||
|
|
||||||
|
var succeeded = await service.EditUserAsync(new EditUserRequestDTO { Id = "x", Email = "e@x.com", Name = "n", Role = "Admin" }, CancellationToken.None);
|
||||||
|
|
||||||
|
succeeded.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserProfile_MapsCreatedDateToAddedDate()
|
||||||
|
{
|
||||||
|
var user = new UserProfileData
|
||||||
|
{
|
||||||
|
Id = "u1",
|
||||||
|
FirstName = "Alice",
|
||||||
|
CreatedDate = new DateTime(2026, 3, 4)
|
||||||
|
};
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetProfileAsync("u1", It.IsAny<CancellationToken>())).ReturnsAsync(user);
|
||||||
|
|
||||||
|
var service = NewService(userData, new Mock<IEmailSender>(), out _);
|
||||||
|
|
||||||
|
var rows = await service.GetUserProfileAsync("u1", CancellationToken.None);
|
||||||
|
|
||||||
|
rows.Should().HaveCount(1);
|
||||||
|
rows[0].AddedDate.Should().Be(new DateTime(2026, 3, 4));
|
||||||
|
rows[0].Name.Should().Be("Alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddUser_CreateSuccess_PersistsRoleAndEmailsLoginInformation()
|
||||||
|
{
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetByIdAsync(It.IsAny<string>())).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
var email = new Mock<IEmailSender>();
|
||||||
|
|
||||||
|
var service = NewService(userData, email, out var store);
|
||||||
|
store.As<IUserPasswordStore<ApplicationUser>>()
|
||||||
|
.Setup(s => s.SetPasswordHashAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
store.Setup(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
|
store.Setup(s => s.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
|
||||||
|
var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Success.Should().BeTrue();
|
||||||
|
store.Verify(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
store.Verify(s => s.AddToRoleAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||||
|
email.Verify(
|
||||||
|
e => e.SendEmailAsync(
|
||||||
|
"n@x.com",
|
||||||
|
"Login Information",
|
||||||
|
It.Is<string>(body => ContainsStrongGeneratedPassword(body))),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddUser_CreateFails_ReturnsFirstError()
|
||||||
|
{
|
||||||
|
var userData = new Mock<IUserDataService>();
|
||||||
|
userData.Setup(u => u.GetByIdAsync(It.IsAny<string>())).ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
|
var service = NewService(userData, new Mock<IEmailSender>(), out var store);
|
||||||
|
store.As<IUserPasswordStore<ApplicationUser>>()
|
||||||
|
.Setup(s => s.SetPasswordHashAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||||
|
.Returns(Task.CompletedTask);
|
||||||
|
store.Setup(s => s.CreateAsync(It.IsAny<ApplicationUser>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "duplicate" }));
|
||||||
|
|
||||||
|
var outcome = await service.AddUserAsync(new AddUserRequestDTO { Name = "N", Email = "n@x.com", Role = "Admin" }, CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Success.Should().BeFalse();
|
||||||
|
outcome.Error.Should().Be("duplicate");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsStrongGeneratedPassword(string body)
|
||||||
|
{
|
||||||
|
const string marker = "Your password is: ";
|
||||||
|
var start = body.IndexOf(marker, StringComparison.Ordinal);
|
||||||
|
if (start < 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var password = body[(start + marker.Length)..]
|
||||||
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
return password is
|
||||||
|
{
|
||||||
|
Length: 8
|
||||||
|
}
|
||||||
|
&& password.Any(char.IsUpper)
|
||||||
|
&& password.Any(char.IsLower)
|
||||||
|
&& password.Any(char.IsDigit)
|
||||||
|
&& password.Any(character => !char.IsLetterOrDigit(character));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,10 @@ using Data.SeaHavenIndustries;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text.Json;
|
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Tests;
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
@ -18,12 +16,7 @@ public class VendorControllerTests
|
||||||
{
|
{
|
||||||
private static VendorController NewController(Mock<IVendorService> service)
|
private static VendorController NewController(Mock<IVendorService> service)
|
||||||
{
|
{
|
||||||
var controller = new VendorController(
|
var controller = new VendorController(service.Object);
|
||||||
service.Object,
|
|
||||||
db: null!,
|
|
||||||
zipDistance: null!,
|
|
||||||
vendorTokens: null!,
|
|
||||||
config: null!);
|
|
||||||
|
|
||||||
controller.ControllerContext = new ControllerContext
|
controller.ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|
@ -108,62 +101,178 @@ public class VendorControllerTests
|
||||||
"42"), Times.Once);
|
"42"), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData(true, "Active Co", "Inactive Co")]
|
public async Task GetFacets_DelegatesToServiceAndReturnsOk()
|
||||||
[InlineData(false, "Inactive Co", "Active Co")]
|
|
||||||
public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors(
|
|
||||||
bool isActive,
|
|
||||||
string expected,
|
|
||||||
string excluded)
|
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
var service = new Mock<IVendorService>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
var facets = new VendorFacetsDTO
|
||||||
.Options;
|
{
|
||||||
await using var context = new ApplicationDbContext(options);
|
Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } },
|
||||||
context.Vendors.AddRange(
|
Trades = new[] { "Plumbing" },
|
||||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } },
|
||||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } }
|
||||||
await context.SaveChangesAsync();
|
};
|
||||||
|
service.Setup(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(facets);
|
||||||
|
|
||||||
var controller = new VendorController(
|
var controller = NewController(service);
|
||||||
Mock.Of<IVendorService>(),
|
|
||||||
context,
|
|
||||||
zipDistance: null!,
|
|
||||||
vendorTokens: null!,
|
|
||||||
config: null!);
|
|
||||||
|
|
||||||
var result = await controller.GetFacets(isActive);
|
var result = await controller.GetFacets(true);
|
||||||
var payload = JsonSerializer.Serialize(
|
|
||||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
|
||||||
|
|
||||||
payload.Should().Contain(expected);
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
payload.Should().NotContain(excluded);
|
ok.Value.Should().Be(facets);
|
||||||
|
service.Verify(x => x.GetFacetsAsync(true, It.IsAny<CancellationToken>()), Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup()
|
public async Task GetDropdown_DelegatesToServiceAndReturnsOk()
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
var service = new Mock<IVendorService>();
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
var items = new[]
|
||||||
.Options;
|
{
|
||||||
await using var context = new ApplicationDbContext(options);
|
new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 }
|
||||||
context.Vendors.AddRange(
|
};
|
||||||
new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false },
|
service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny<CancellationToken>()))
|
||||||
new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false });
|
.ReturnsAsync(items);
|
||||||
await context.SaveChangesAsync();
|
|
||||||
|
|
||||||
var controller = new VendorController(
|
var controller = NewController(service);
|
||||||
Mock.Of<IVendorService>(),
|
|
||||||
context,
|
|
||||||
zipDistance: null!,
|
|
||||||
vendorTokens: null!,
|
|
||||||
config: null!);
|
|
||||||
|
|
||||||
var result = await controller.GetFacets();
|
var result = await controller.GetDropdown("Plumbing", "60601");
|
||||||
var payload = JsonSerializer.Serialize(
|
|
||||||
result.Should().BeOfType<OkObjectResult>().Subject.Value);
|
|
||||||
|
|
||||||
payload.Should().Contain("Active Co");
|
var ok = result.Should().BeOfType<OkObjectResult>().Subject;
|
||||||
payload.Should().Contain("Inactive Co");
|
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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
314
Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs
Normal file
314
Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
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");
|
||||||
|
var sessionData = envelope.Data as VendorPortalSession;
|
||||||
|
sessionData.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;
|
||||||
|
var acceptData = envelope.Data as AcceptDispatchResultDTO;
|
||||||
|
acceptData.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;
|
||||||
|
var upliftData = envelope.Data as UpliftRequestResultDTO;
|
||||||
|
upliftData.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;
|
||||||
|
var cancelData = envelope.Data as CancelUpliftResultDTO;
|
||||||
|
cancelData.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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,13 @@ using Data.SeaHavenIndustries;
|
||||||
using Data.SeaHavenIndustries.Enums;
|
using Data.SeaHavenIndustries.Enums;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Moq;
|
using Moq;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.Services.Configuration;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.Services.Implementation;
|
using SeaHaven.Services.Implementation;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.Validation;
|
using SeaHaven.Services.Validation;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
|
|
@ -14,7 +17,14 @@ namespace Api.SeaHavenIndustries.Tests;
|
||||||
public class VendorServiceTests
|
public class VendorServiceTests
|
||||||
{
|
{
|
||||||
private static VendorService NewService(Mock<IVendorDataService> data) =>
|
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>(),
|
||||||
|
Options.Create(new FrontendOptions()),
|
||||||
|
new CreateVendorValidation(),
|
||||||
|
new UpdateVendorValidation(),
|
||||||
|
new WorkOrderVendorUpdateValidation());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateVendor_PersistsCompleteProfile()
|
public async Task CreateVendor_PersistsCompleteProfile()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Implementation;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Tests;
|
||||||
|
|
||||||
|
public class WorkOrderDataServiceBatchingTests
|
||||||
|
{
|
||||||
|
private static ApplicationDbContext NewContext()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
return new ApplicationDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SeedAsync(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
context.Locations.Add(new Locations { Id = 100, Name = "Site A", Address1 = "1 Main St" });
|
||||||
|
context.workOrders.AddRange(
|
||||||
|
new WorkOrder { Id = 1, InternalWONumber = "WO-1", WorkerOrderTitle = "First", LocationId = 100 },
|
||||||
|
new WorkOrder { Id = 2, InternalWONumber = "WO-2", WorkerOrderTitle = "Second", LocationId = 100 },
|
||||||
|
new WorkOrder { Id = 3, InternalWONumber = "WO-3", WorkerOrderTitle = "Third", LocationId = 100 },
|
||||||
|
new WorkOrder { Id = 4, InternalWONumber = "WO-T", WorkerOrderTitle = "Template", LocationId = 100, istemplate = true });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByRequestedIdsWithDetailsAsync_ReturnsResultsInInputOrder()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
var service = new WorkOrderDataService(context);
|
||||||
|
|
||||||
|
var result = await service.GetByRequestedIdsWithDetailsAsync(
|
||||||
|
new[] { 3, 1, 2 },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Select(w => w.Id).Should().Equal(new[] { 3, 1, 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByRequestedIdsWithDetailsAsync_DeduplicatesIdsAndSkipsMissing()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
var service = new WorkOrderDataService(context);
|
||||||
|
|
||||||
|
var result = await service.GetByRequestedIdsWithDetailsAsync(
|
||||||
|
new[] { 2, 2, 999, 1 },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Select(w => w.Id).Should().Equal(new[] { 2, 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByRequestedIdsWithDetailsAsync_ExcludesTemplatesAndEagerLoadsLocation()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
var service = new WorkOrderDataService(context);
|
||||||
|
|
||||||
|
var result = await service.GetByRequestedIdsWithDetailsAsync(
|
||||||
|
new[] { 1, 4 },
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
result.Select(w => w.Id).Should().Equal(new[] { 1 });
|
||||||
|
result.Single().Locations.Should().NotBeNull();
|
||||||
|
result.Single().Locations!.Name.Should().Be("Site A");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AddAuditLogsAsync_PersistsAllRowsInSingleSave()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
await SeedAsync(context);
|
||||||
|
var service = new WorkOrderDataService(context);
|
||||||
|
|
||||||
|
await service.AddAuditLogsAsync(new[]
|
||||||
|
{
|
||||||
|
new WorkOrderAuditLog { WorkOrderId = 1, FieldName = "Dispatch", Action = "Dispatch" },
|
||||||
|
new WorkOrderAuditLog { WorkOrderId = 2, FieldName = "Dispatch", Action = "Dispatch" }
|
||||||
|
});
|
||||||
|
|
||||||
|
context.WorkOrderAuditLogs.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetByIdsOrderedAsync_PreservesInputOrderDeduplicatesAndSkipsMissing()
|
||||||
|
{
|
||||||
|
await using var context = NewContext();
|
||||||
|
context.Vendors.AddRange(
|
||||||
|
new Vendor { Id = 7, CompanyName = "V7", Email = "v7@x.io" },
|
||||||
|
new Vendor { Id = 8, CompanyName = "V8", Email = "v8@x.io" });
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
var service = new VendorDataService(context);
|
||||||
|
|
||||||
|
var result = await service.GetByIdsOrderedAsync(new[] { 8, 8, 7, 999 }, CancellationToken.None);
|
||||||
|
|
||||||
|
result.Select(v => v.Id).Should().Equal(new[] { 8, 7 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using SeaHaven.Services.DTOs;
|
||||||
using System.IdentityModel.Tokens.Jwt;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Text;
|
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
|
||||||
using Api.SeaHavenIndustries.Helper;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -16,55 +12,35 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/Authentication")]
|
[Route("api/Authentication")]
|
||||||
public class AuthenticationController : Controller
|
public class AuthenticationController : Controller
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly IAuthenticationService _authenticationService;
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
private readonly SendMessage _sendMessage;
|
|
||||||
|
|
||||||
public AuthenticationController(UserManager<ApplicationUser> userManager, IConfiguration configuration, ApplicationDbContext db, SendMessage sendMessage)
|
public AuthenticationController(IAuthenticationService authenticationService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_authenticationService = authenticationService;
|
||||||
_configuration = configuration;
|
|
||||||
_db = db;
|
|
||||||
_sendMessage = sendMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("login")]
|
[Route("login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginModel model)
|
public async Task<IActionResult> Login([FromBody] LoginModel model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByNameAsync(model.Username ?? "");
|
var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken);
|
||||||
if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, model.Password ?? ""))
|
if (result != null)
|
||||||
{
|
{
|
||||||
// Standard login without 2FA
|
|
||||||
var userRoles = await _userManager.GetRolesAsync(user);
|
|
||||||
var authClaims = new List<Claim>
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.Name, user.UserName ?? ""),
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, user.Id),
|
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
|
|
||||||
};
|
|
||||||
foreach (var userRole in userRoles)
|
|
||||||
{
|
|
||||||
authClaims.Add(new Claim(ClaimTypes.Role, userRole));
|
|
||||||
}
|
|
||||||
var token = GetToken(authClaims);
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
token = new JwtSecurityTokenHandler().WriteToken(token),
|
token = result.Token,
|
||||||
expiration = token.ValidTo,
|
expiration = result.Expiration,
|
||||||
email = user.Email,
|
email = result.Email,
|
||||||
userRoles = userRoles.FirstOrDefault(),
|
userRoles = result.UserRole,
|
||||||
phoneNumber = user.PhoneNumber,
|
phoneNumber = result.PhoneNumber,
|
||||||
fullname = user.FirstName + " " + user.LastName,
|
fullname = result.Fullname,
|
||||||
id = user.Id
|
id = result.Id
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalid credentials
|
|
||||||
return Unauthorized();
|
return Unauthorized();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -72,17 +48,16 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
return StatusCode(500, ex.Message);
|
return StatusCode(500, ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Route("ChangePassword")]
|
[Route("ChangePassword")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel)
|
public async Task<IActionResult> ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
||||||
var user = await _userManager.FindByIdAsync(userid);
|
var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken);
|
||||||
var result = await _userManager.ChangePasswordAsync(user, usermodel.Currentpassword ?? "", usermodel.Confirmpassword ?? "");
|
if (succeeded)
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
{
|
||||||
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
|
return Ok(new Response { Status = "Success ", Message = "Password successfully changed" });
|
||||||
}
|
}
|
||||||
|
|
@ -90,44 +65,31 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return BadRequest(new Response { Status = "Old Password is incorrect" });
|
return BadRequest(new Response { Status = "Old Password is incorrect" });
|
||||||
}
|
}
|
||||||
|
|
||||||
private JwtSecurityToken GetToken(List<Claim> authClaims)
|
|
||||||
{
|
|
||||||
var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JWT:Secret"] ?? ""));
|
|
||||||
var token = new JwtSecurityToken(
|
|
||||||
issuer: _configuration["JWT:ValidIssuer"],
|
|
||||||
audience: _configuration["JWT:ValidAudience"],
|
|
||||||
expires: DateTime.Now.AddDays(10),
|
|
||||||
claims: authClaims,
|
|
||||||
signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256)
|
|
||||||
);
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("UpdateProfile")]
|
[Route("UpdateProfile")]
|
||||||
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model)
|
public async Task<IActionResult> UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
||||||
ApplicationUser appuser = _db.Users.AsNoTracking().Where(u => u.Id == userid).FirstOrDefault() ?? new ApplicationUser();
|
var dto = new UpdateProfileRequestDTO
|
||||||
appuser.FirstName = model.Name;
|
{
|
||||||
appuser.Email = model.Email;
|
Name = model.Name,
|
||||||
appuser.Contact = model.Contact;
|
Email = model.Email,
|
||||||
_db.Users.Update(appuser);
|
Contact = model.Contact
|
||||||
await _db.SaveChangesAsync();
|
};
|
||||||
|
var data = await _authenticationService.UpdateProfileAsync(userid, dto, cancellationToken);
|
||||||
|
|
||||||
return Ok(new DataResponse
|
return Ok(new DataResponse
|
||||||
{
|
{
|
||||||
Message = "Introduction Updated Successfully",
|
Message = "Introduction Updated Successfully",
|
||||||
Status = "200",
|
Status = "200",
|
||||||
Data = _db.Users.Where(u => u.Id == userid).Select(s => new
|
Data = data == null ? null : new
|
||||||
{
|
{
|
||||||
s.FirstName,
|
data.FirstName,
|
||||||
//s.Location,
|
data.Email,
|
||||||
s.Email,
|
data.Contact
|
||||||
s.Contact
|
}
|
||||||
}).FirstOrDefault()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -141,25 +103,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost()]
|
[HttpPost()]
|
||||||
[Route("ForgetPassword")]
|
[Route("ForgetPassword")]
|
||||||
public async Task<IActionResult> ForgetPassword(string Email)
|
public async Task<IActionResult> ForgetPassword(string Email, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await _db.Users.Where(u => u.Email.ToLower().Trim() == Email.ToLower().ToLower()).FirstOrDefaultAsync();
|
var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken);
|
||||||
if (user != null)
|
if (found)
|
||||||
{
|
{
|
||||||
if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).Any())
|
|
||||||
{
|
|
||||||
_db.ForgetPasswordCodes.Remove(_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == Email.ToLower().Trim()).FirstOrDefault());
|
|
||||||
_db.SaveChanges();
|
|
||||||
}
|
|
||||||
ForgetPasswordCode forgetPasswordCode = new ForgetPasswordCode();
|
|
||||||
forgetPasswordCode.Email = user.Email ?? "";
|
|
||||||
forgetPasswordCode.UserId = user.Id;
|
|
||||||
forgetPasswordCode.Code = GenerateRandomNo();
|
|
||||||
|
|
||||||
_db.ForgetPasswordCodes.Add(forgetPasswordCode);
|
|
||||||
_db.SaveChanges();
|
|
||||||
string body = $"Your Password Reset Code is: " + forgetPasswordCode.Code;
|
|
||||||
await _sendMessage.SendEMail(user.Email, "Forget Password Request.", body);
|
|
||||||
return Ok(new Response { Status = "Success ", Message = "Please check your email for code" });
|
return Ok(new Response { Status = "Success ", Message = "Please check your email for code" });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -171,11 +119,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost()]
|
[HttpPost()]
|
||||||
[Route("VerificationCode")]
|
[Route("VerificationCode")]
|
||||||
public async Task<IActionResult> VerificationCode(string code)
|
public async Task<IActionResult> VerificationCode(string code, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (await _db.ForgetPasswordCodes.Where(u => u.Code == code).AnyAsync())
|
if (await _authenticationService.VerifyCodeAsync(code, cancellationToken))
|
||||||
{
|
{
|
||||||
|
|
||||||
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
|
return Ok(new Response { Status = "Success ", Message = "Code Matched" });
|
||||||
|
|
@ -195,16 +143,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost()]
|
[HttpPost()]
|
||||||
[Route("ResetPassword")]
|
[Route("ResetPassword")]
|
||||||
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto)
|
public async Task<IActionResult> ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim() && u.Code == fpdto.Code.Trim()).Any())
|
if (await _authenticationService.ResetPasswordAsync(fpdto.Email, fpdto.Code, fpdto.Password, cancellationToken))
|
||||||
{
|
{
|
||||||
var GetUser = _db.ForgetPasswordCodes.Where(u => u.Email.ToLower().Trim() == fpdto.Email.ToLower().Trim()).FirstOrDefault();
|
|
||||||
var user = await _userManager.FindByIdAsync(GetUser.UserId);
|
|
||||||
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
|
|
||||||
var result = await _userManager.ResetPasswordAsync(user, token, fpdto.Password);
|
|
||||||
return Ok(new Response { Status = "Success ", Message = "password changed" });
|
return Ok(new Response { Status = "Success ", Message = "password changed" });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -219,12 +163,6 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Random _random = new Random();
|
|
||||||
private string GenerateRandomNo()
|
|
||||||
{
|
|
||||||
return _random.Next(0, 999999).ToString("D6");
|
|
||||||
}
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -16,13 +12,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/Calendar")]
|
[Route("api/Calendar")]
|
||||||
public class CalendarController : Controller
|
public class CalendarController : Controller
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly ICalendarService _calendarService;
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
|
|
||||||
public CalendarController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
|
public CalendarController(ICalendarService calendarService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_calendarService = calendarService;
|
||||||
_db = db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
|
|
@ -30,31 +24,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("AddEvent")]
|
[Route("AddEvent")]
|
||||||
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto)
|
public async Task<IActionResult> AddEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var model = new Events
|
await _calendarService.CreateEventAsync(MapToInput(eventDto), cancellationToken);
|
||||||
{
|
|
||||||
Title = eventDto.Title,
|
|
||||||
Description = eventDto.Description,
|
|
||||||
Location = eventDto.Location,
|
|
||||||
RecordID = eventDto.RecordID,
|
|
||||||
RecordName = eventDto.RecordName,
|
|
||||||
RecordTargetType = eventDto.RecordTargetType,
|
|
||||||
ReminderMinutes = eventDto.ReminderMinutes,
|
|
||||||
EventColor = eventDto.EventColor,
|
|
||||||
StartDate = eventDto.StartDate,
|
|
||||||
StartTime = eventDto.StartTime,
|
|
||||||
EndDate = eventDto.EndDate,
|
|
||||||
EndTime = eventDto.EndTime,
|
|
||||||
AllDay = eventDto.AllDay,
|
|
||||||
CreatedDate = DateTime.Now,
|
|
||||||
IsDeleted = false
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.Events.Add(model);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
|
|
@ -69,7 +43,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("EditEvent")]
|
[Route("EditEvent")]
|
||||||
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto)
|
public async Task<IActionResult> EditEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -78,30 +52,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" });
|
return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var model = await _db.Events.FindAsync(eventDto.Id.Value);
|
var updated = await _calendarService.UpdateEventAsync(MapToInput(eventDto), cancellationToken);
|
||||||
if (model == null || model.IsDeleted == true)
|
if (!updated)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
model.Title = eventDto.Title;
|
|
||||||
model.Description = eventDto.Description;
|
|
||||||
model.Location = eventDto.Location;
|
|
||||||
model.RecordID = eventDto.RecordID;
|
|
||||||
model.RecordName = eventDto.RecordName;
|
|
||||||
model.RecordTargetType = eventDto.RecordTargetType;
|
|
||||||
model.ReminderMinutes = eventDto.ReminderMinutes;
|
|
||||||
model.EventColor = eventDto.EventColor;
|
|
||||||
model.StartDate = eventDto.StartDate;
|
|
||||||
model.StartTime = eventDto.StartTime;
|
|
||||||
model.EndDate = eventDto.EndDate;
|
|
||||||
model.EndTime = eventDto.EndTime;
|
|
||||||
model.AllDay = eventDto.AllDay;
|
|
||||||
model.LastModificationTime = DateTime.Now;
|
|
||||||
|
|
||||||
_db.Events.Update(model);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(new DataResponse { Message = "Event Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Event Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -115,40 +71,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetEventById/{id}")]
|
[Route("GetEventById/{id}")]
|
||||||
public async Task<IActionResult> GetEventById(int id)
|
public async Task<IActionResult> GetEventById(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var model = await _db.Events
|
var dto = await _calendarService.GetEventByIdAsync(id, cancellationToken);
|
||||||
.Where(e => e.Id == id && e.IsDeleted != true)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (model == null)
|
if (dto == null)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var dto = new Event_DTO
|
return Ok(new DataResponse { Message = "Success", Status = "200", Data = MapToResponse(dto) });
|
||||||
{
|
|
||||||
Id = model.Id,
|
|
||||||
Title = model.Title,
|
|
||||||
Description = model.Description,
|
|
||||||
Location = model.Location,
|
|
||||||
RecordID = model.RecordID,
|
|
||||||
RecordName = model.RecordName,
|
|
||||||
RecordTargetType = model.RecordTargetType,
|
|
||||||
ReminderMinutes = model.ReminderMinutes,
|
|
||||||
EventColor = model.EventColor,
|
|
||||||
StartDate = model.StartDate,
|
|
||||||
StartTime = model.StartTime,
|
|
||||||
EndDate = model.EndDate,
|
|
||||||
EndTime = model.EndTime,
|
|
||||||
AllDay = model.AllDay,
|
|
||||||
CreatedDate = model.CreatedDate,
|
|
||||||
createdby = model.createdby
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(new DataResponse { Message = "Success", Status = "200", Data = dto });
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -161,23 +95,16 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
[Route("DeleteEvent/{id}")]
|
[Route("DeleteEvent/{id}")]
|
||||||
public async Task<IActionResult> DeleteEvent(int id)
|
public async Task<IActionResult> DeleteEvent(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var model = await _db.Events.FindAsync(id);
|
var deleted = await _calendarService.DeleteEventAsync(id, cancellationToken);
|
||||||
if (model == null)
|
if (!deleted)
|
||||||
{
|
{
|
||||||
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
return NotFound(new Response { Status = "Error", Message = "Event not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Soft delete
|
|
||||||
model.IsDeleted = true;
|
|
||||||
model.DeletionTime = DateTime.Now;
|
|
||||||
|
|
||||||
_db.Events.Update(model);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(new DataResponse { Message = "Event Deleted Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Event Deleted Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -191,58 +118,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetEventList")]
|
[Route("GetEventList")]
|
||||||
public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetEventList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var query = _db.Events.Where(e => e.IsDeleted != true);
|
var result = await _calendarService.GetEventListAsync(search, page, pageSize, cancellationToken);
|
||||||
|
|
||||||
// Apply search filter if provided
|
var events = result.Rows.Select(MapToResponse).ToList();
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
|
||||||
{
|
|
||||||
var searchLower = search.ToLower();
|
|
||||||
query = query.Where(e =>
|
|
||||||
e.Title.ToLower().Contains(searchLower) ||
|
|
||||||
(e.Description != null && e.Description.ToLower().Contains(searchLower)) ||
|
|
||||||
(e.Location != null && e.Location.ToLower().Contains(searchLower))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// IMPORTANT: Calculate totalCount AFTER applying filters
|
|
||||||
var totalCount = query.Count();
|
|
||||||
|
|
||||||
// Apply pagination
|
|
||||||
var events = query
|
|
||||||
.OrderByDescending(e => e.StartDate)
|
|
||||||
.ThenByDescending(e => e.CreatedDate)
|
|
||||||
.Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.Select(e => new Event_DTO
|
|
||||||
{
|
|
||||||
Id = e.Id,
|
|
||||||
Title = e.Title,
|
|
||||||
Description = e.Description,
|
|
||||||
Location = e.Location,
|
|
||||||
RecordID = e.RecordID,
|
|
||||||
RecordName = e.RecordName,
|
|
||||||
RecordTargetType = e.RecordTargetType,
|
|
||||||
ReminderMinutes = e.ReminderMinutes,
|
|
||||||
EventColor = e.EventColor,
|
|
||||||
StartDate = e.StartDate,
|
|
||||||
StartTime = e.StartTime,
|
|
||||||
EndDate = e.EndDate,
|
|
||||||
EndTime = e.EndTime,
|
|
||||||
AllDay = e.AllDay,
|
|
||||||
CreatedDate = e.CreatedDate,
|
|
||||||
createdby = e.createdby
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Ok(new DataResponse
|
return Ok(new DataResponse
|
||||||
{
|
{
|
||||||
Message = "Success",
|
Message = "Success",
|
||||||
Status = "200",
|
Status = "200",
|
||||||
Data = new { rows = events, totalCount }
|
Data = new { rows = events, totalCount = result.TotalCount }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -256,51 +144,62 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
// ===================================================================================
|
// ===================================================================================
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetEventsForCalendar")]
|
[Route("GetEventsForCalendar")]
|
||||||
public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end)
|
public async Task<IActionResult> GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var query = _db.Events.Where(e => e.IsDeleted != true);
|
var events = await _calendarService.GetEventsForCalendarAsync(start, end, cancellationToken);
|
||||||
|
|
||||||
// Filter by date range if provided
|
return Ok(new DataResponse { Message = "Success", Status = "200", Data = events.Select(MapToResponse).ToList() });
|
||||||
if (start.HasValue && end.HasValue)
|
|
||||||
{
|
|
||||||
query = query.Where(e =>
|
|
||||||
(e.StartDate >= start.Value && e.StartDate <= end.Value) ||
|
|
||||||
(e.EndDate >= start.Value && e.EndDate <= end.Value) ||
|
|
||||||
(e.StartDate <= start.Value && e.EndDate >= end.Value)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var events = query
|
|
||||||
.Select(e => new Event_DTO
|
|
||||||
{
|
|
||||||
Id = e.Id,
|
|
||||||
Title = e.Title,
|
|
||||||
Description = e.Description,
|
|
||||||
Location = e.Location,
|
|
||||||
RecordID = e.RecordID,
|
|
||||||
RecordName = e.RecordName,
|
|
||||||
RecordTargetType = e.RecordTargetType,
|
|
||||||
ReminderMinutes = e.ReminderMinutes,
|
|
||||||
EventColor = e.EventColor,
|
|
||||||
StartDate = e.StartDate,
|
|
||||||
StartTime = e.StartTime,
|
|
||||||
EndDate = e.EndDate,
|
|
||||||
EndTime = e.EndTime,
|
|
||||||
AllDay = e.AllDay,
|
|
||||||
CreatedDate = e.CreatedDate,
|
|
||||||
createdby = e.createdby
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Ok(new DataResponse { Message = "Success", Status = "200", Data = events });
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static EditEventInput MapToInput(EditEvent_DTO eventDto)
|
||||||
|
{
|
||||||
|
return new EditEventInput
|
||||||
|
{
|
||||||
|
Id = eventDto.Id,
|
||||||
|
Title = eventDto.Title,
|
||||||
|
Description = eventDto.Description,
|
||||||
|
Location = eventDto.Location,
|
||||||
|
RecordID = eventDto.RecordID,
|
||||||
|
RecordName = eventDto.RecordName,
|
||||||
|
RecordTargetType = eventDto.RecordTargetType,
|
||||||
|
ReminderMinutes = eventDto.ReminderMinutes,
|
||||||
|
EventColor = eventDto.EventColor,
|
||||||
|
StartDate = eventDto.StartDate,
|
||||||
|
StartTime = eventDto.StartTime,
|
||||||
|
EndDate = eventDto.EndDate,
|
||||||
|
EndTime = eventDto.EndTime,
|
||||||
|
AllDay = eventDto.AllDay
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Event_DTO MapToResponse(EventDTO dto)
|
||||||
|
{
|
||||||
|
return new Event_DTO
|
||||||
|
{
|
||||||
|
Id = dto.Id,
|
||||||
|
Title = dto.Title,
|
||||||
|
Description = dto.Description,
|
||||||
|
Location = dto.Location,
|
||||||
|
RecordID = dto.RecordID,
|
||||||
|
RecordName = dto.RecordName,
|
||||||
|
RecordTargetType = dto.RecordTargetType,
|
||||||
|
ReminderMinutes = dto.ReminderMinutes,
|
||||||
|
EventColor = dto.EventColor,
|
||||||
|
StartDate = dto.StartDate,
|
||||||
|
StartTime = dto.StartTime,
|
||||||
|
EndDate = dto.EndDate,
|
||||||
|
EndTime = dto.EndTime,
|
||||||
|
AllDay = dto.AllDay,
|
||||||
|
CreatedDate = dto.CreatedDate,
|
||||||
|
createdby = dto.createdby
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,73 +1,49 @@
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
using Api.SeaHavenIndustries.Helper.ViewModel;
|
using Api.SeaHavenIndustries.Helper.ViewModel;
|
||||||
using Data.SeaHavenIndustries;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.Interfaces;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("[controller]")]
|
[Route("[controller]")]
|
||||||
|
|
||||||
public class CommonController : Controller
|
public class CommonController : Controller
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly ICommonService _commonService;
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
public CommonController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
|
public CommonController(ICommonService commonService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_commonService = commonService;
|
||||||
_db = db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetUsers")]
|
[Route("GetUsers")]
|
||||||
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var allUsersQuery = _db.Users
|
var result = await _commonService.GetUsersPagedAsync(search, page, pageSize, cancellationToken);
|
||||||
.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower()))
|
|
||||||
.OrderByDescending(d => d.Id);
|
|
||||||
|
|
||||||
var totalCount = allUsersQuery.Count();
|
var filteredUsers = result.Users.Select(u => new UserRoleViewModel
|
||||||
|
|
||||||
var paginatedUsers = allUsersQuery
|
|
||||||
.Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.ToList();
|
|
||||||
// Calculate the number of pages
|
|
||||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
||||||
|
|
||||||
var filteredUsers = new List<UserRoleViewModel>();
|
|
||||||
|
|
||||||
foreach (var user in paginatedUsers)
|
|
||||||
{
|
{
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
RoleName = u.RoleName,
|
||||||
if (!roles.Contains("Admin"))
|
Email = u.Email,
|
||||||
{
|
UserName = u.UserName,
|
||||||
filteredUsers.Add(new UserRoleViewModel
|
Date = u.Date,
|
||||||
{
|
Status = u.Status,
|
||||||
RoleName = roles.Count()>0?roles.First():"",
|
Id = u.Id
|
||||||
Email = user.Email,
|
}).ToList();
|
||||||
UserName = user.FirstName,
|
|
||||||
Date = user.CreatedDate?.Date.ToString(),
|
|
||||||
Status = user.UniqueName,
|
|
||||||
Id = user.Id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Create a view model containing the paged items and pagination metadata
|
|
||||||
var viewModel = new Pagination_DTO
|
var viewModel = new Pagination_DTO
|
||||||
{
|
{
|
||||||
Data = filteredUsers,
|
Data = filteredUsers,
|
||||||
PageNumber = page,
|
PageNumber = result.PageNumber,
|
||||||
PageSize = pageSize,
|
PageSize = result.PageSize,
|
||||||
TotalCount = totalCount,
|
TotalCount = result.TotalCount,
|
||||||
TotalPages = totalPages
|
TotalPages = result.TotalPages
|
||||||
};
|
};
|
||||||
return Ok(viewModel);
|
return Ok(viewModel);
|
||||||
}
|
}
|
||||||
|
|
@ -79,12 +55,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetRoles")]
|
[Route("GetRoles")]
|
||||||
public async Task<IActionResult> GetRoles()
|
public async Task<IActionResult> GetRoles(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = await _db.Roles.Select(s => new {Id=s.Name,s.Name}).ToListAsync();
|
var data = await _commonService.GetRolesAsync(cancellationToken);
|
||||||
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -96,41 +72,26 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetSitesName")]
|
[Route("GetSitesName")]
|
||||||
public async Task<IActionResult> GetSiteName()
|
public async Task<IActionResult> GetSiteName(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data =await _db.Locations.Select(n=> new
|
var data = await _commonService.GetSiteNamesAsync(cancellationToken);
|
||||||
{
|
|
||||||
n.Id,
|
|
||||||
n.Name,
|
|
||||||
n.Title
|
|
||||||
}).ToListAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetContacts")]
|
[Route("GetContacts")]
|
||||||
public async Task<IActionResult> GetContacts()
|
public async Task<IActionResult> GetContacts(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data =await _db.Contacts.Select(n=> new
|
var data = await _commonService.GetContactsAsync(cancellationToken);
|
||||||
{
|
|
||||||
n.Id,
|
|
||||||
Name = (n.FirstName + " " + n.LastName).Trim(),
|
|
||||||
email=n.Email,
|
|
||||||
phone=n.PhoneNumber
|
|
||||||
}).ToListAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetCategories")]
|
[Route("GetCategories")]
|
||||||
public async Task<IActionResult> GetCategories()
|
public async Task<IActionResult> GetCategories(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = await _db.Categories.Select(s=> new
|
var data = await _commonService.GetCategoriesAsync(cancellationToken);
|
||||||
{
|
|
||||||
s.Id,
|
|
||||||
s.Name
|
|
||||||
}).ToListAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -10,29 +9,24 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class DashboardController : Controller
|
public class DashboardController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db;
|
private readonly IDashboardService _dashboardService;
|
||||||
|
|
||||||
public DashboardController(ApplicationDbContext db)
|
public DashboardController(IDashboardService dashboardService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_dashboardService = dashboardService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("Stats")]
|
[HttpGet("Stats")]
|
||||||
public async Task<IActionResult> GetStats()
|
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var query = _db.workOrders.Where(w => w.istemplate != true);
|
var stats = await _dashboardService.GetStatsAsync(cancellationToken);
|
||||||
|
|
||||||
var total = await query.CountAsync();
|
|
||||||
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold");
|
|
||||||
var notDispatched = await query.CountAsync(w => (w.Status == "Open") && (w.AssignTo == null || w.AssignTo == ""));
|
|
||||||
var completed = await query.CountAsync(w => w.Status == "Done");
|
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
total,
|
total = stats.Total,
|
||||||
open,
|
open = stats.Open,
|
||||||
notDispatched,
|
notDispatched = stats.NotDispatched,
|
||||||
completed
|
completed = stats.Completed
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -14,12 +12,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
public class DispatchController : Controller
|
public class DispatchController : Controller
|
||||||
{
|
{
|
||||||
private readonly IDispatchService _dispatchService;
|
private readonly IDispatchService _dispatchService;
|
||||||
private readonly ApplicationDbContext _db; // Keep for complex queries
|
|
||||||
|
|
||||||
public DispatchController(IDispatchService dispatchService, ApplicationDbContext db)
|
public DispatchController(IDispatchService dispatchService)
|
||||||
{
|
{
|
||||||
_dispatchService = dispatchService;
|
_dispatchService = dispatchService;
|
||||||
_db = db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
@ -32,317 +28,29 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[FromQuery] string? sortBy = "dispatchedAt",
|
[FromQuery] string? sortBy = "dispatchedAt",
|
||||||
[FromQuery] string? sortDir = "desc",
|
[FromQuery] string? sortDir = "desc",
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
[FromQuery] int pageSize = 25)
|
[FromQuery] int pageSize = 25,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var q = _db.Dispatches
|
var result = await _dispatchService.GetDispatchListAsync(
|
||||||
.Include(d => d.Vendor)
|
search, status, vendorId, dateFrom, dateTo, sortBy, sortDir, page, pageSize, cancellationToken);
|
||||||
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
|
||||||
.Where(d => d.IsDeleted == null || d.IsDeleted == false);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
|
||||||
{
|
|
||||||
var s = search.ToLower();
|
|
||||||
q = q.Where(d =>
|
|
||||||
(d.DispatchNumber ?? "").ToLower().Contains(s) ||
|
|
||||||
(d.PONumber ?? "").ToLower().Contains(s) ||
|
|
||||||
(d.Vendor != null && (d.Vendor.CompanyName ?? "").ToLower().Contains(s)) ||
|
|
||||||
(d.WorkOrder != null && (d.WorkOrder.InternalWONumber ?? "").ToLower().Contains(s)) ||
|
|
||||||
(d.WorkOrder != null && (d.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(s)) ||
|
|
||||||
(d.WorkOrder != null && d.WorkOrder.Locations != null && (d.WorkOrder.Locations.Name ?? "").ToLower().Contains(s))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(status))
|
|
||||||
q = q.Where(d => d.Status == status);
|
|
||||||
|
|
||||||
if (vendorId.HasValue)
|
|
||||||
q = q.Where(d => d.VendorId == vendorId.Value);
|
|
||||||
|
|
||||||
if (dateFrom.HasValue)
|
|
||||||
q = q.Where(d => d.DispatchedAt >= dateFrom.Value);
|
|
||||||
|
|
||||||
if (dateTo.HasValue)
|
|
||||||
{
|
|
||||||
var end = dateTo.Value.Date.AddDays(1);
|
|
||||||
q = q.Where(d => d.DispatchedAt < end);
|
|
||||||
}
|
|
||||||
|
|
||||||
var total = await q.CountAsync();
|
|
||||||
|
|
||||||
var dir = (sortDir ?? "desc").ToLowerInvariant() == "asc";
|
|
||||||
switch ((sortBy ?? "").ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "ponumber":
|
|
||||||
q = dir ? q.OrderBy(d => d.PONumber) : q.OrderByDescending(d => d.PONumber);
|
|
||||||
break;
|
|
||||||
case "dispatchnumber":
|
|
||||||
q = dir ? q.OrderBy(d => d.DispatchNumber) : q.OrderByDescending(d => d.DispatchNumber);
|
|
||||||
break;
|
|
||||||
case "vendor":
|
|
||||||
q = dir ? q.OrderBy(d => d.Vendor!.CompanyName) : q.OrderByDescending(d => d.Vendor!.CompanyName);
|
|
||||||
break;
|
|
||||||
case "nte":
|
|
||||||
case "nteamount":
|
|
||||||
q = dir ? q.OrderBy(d => d.NTEAmount) : q.OrderByDescending(d => d.NTEAmount);
|
|
||||||
break;
|
|
||||||
case "status":
|
|
||||||
q = dir ? q.OrderBy(d => d.Status) : q.OrderByDescending(d => d.Status);
|
|
||||||
break;
|
|
||||||
case "scheduleddate":
|
|
||||||
q = dir ? q.OrderBy(d => d.ScheduledDate) : q.OrderByDescending(d => d.ScheduledDate);
|
|
||||||
break;
|
|
||||||
case "completeddate":
|
|
||||||
q = dir ? q.OrderBy(d => d.CompletedDate) : q.OrderByDescending(d => d.CompletedDate);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
q = dir ? q.OrderBy(d => d.DispatchedAt) : q.OrderByDescending(d => d.DispatchedAt);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows = await q
|
|
||||||
.Skip((Math.Max(page, 1) - 1) * pageSize)
|
|
||||||
.Take(pageSize)
|
|
||||||
.Select(d => new
|
|
||||||
{
|
|
||||||
d.Id,
|
|
||||||
d.DispatchNumber,
|
|
||||||
d.PONumber,
|
|
||||||
d.NTEAmount,
|
|
||||||
d.Status,
|
|
||||||
d.DispatchedAt,
|
|
||||||
d.ScheduledDate,
|
|
||||||
d.CompletedDate,
|
|
||||||
d.AcknowledgedAt,
|
|
||||||
d.VerifiedAt,
|
|
||||||
VendorId = d.VendorId,
|
|
||||||
VendorName = d.Vendor != null ? d.Vendor.CompanyName : null,
|
|
||||||
WorkOrderId = d.WorkOrderId,
|
|
||||||
PrimaryWOInternalNumber = d.WorkOrder != null ? d.WorkOrder.InternalWONumber : null,
|
|
||||||
PrimaryWOTitle = d.WorkOrder != null ? d.WorkOrder.WorkerOrderTitle : null,
|
|
||||||
LocationName = d.WorkOrder != null && d.WorkOrder.Locations != null ? d.WorkOrder.Locations.Name : null,
|
|
||||||
WorkOrderCount = _db.DispatchWorkOrders.Count(dwo => dwo.DispatchId == d.Id),
|
|
||||||
PendingUpliftCount = _db.DispatchUpliftRequests.Count(u => u.DispatchId == d.Id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false))
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
return Ok(new DataResponse
|
return Ok(new DataResponse
|
||||||
{
|
{
|
||||||
Status = "Success",
|
Status = "Success",
|
||||||
Data = new { total, page, pageSize, items = rows }
|
Data = new { total = result.Total, page = result.Page, pageSize = result.PageSize, items = result.Items }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}")]
|
[HttpGet("{id:int}")]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var dispatch = await _db.Dispatches
|
var dispatch = await _dispatchService.GetDispatchDetailAsync(id, cancellationToken);
|
||||||
.Include(d => d.Vendor)
|
|
||||||
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
|
||||||
.FirstOrDefaultAsync(d => d.Id == id);
|
|
||||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
|
|
||||||
var linkedWOs = await _db.DispatchWorkOrders
|
|
||||||
.Where(dwo => dwo.DispatchId == id)
|
|
||||||
.Select(dwo => new
|
|
||||||
{
|
|
||||||
dwo.WorkOrderId,
|
|
||||||
InternalWONumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : null,
|
|
||||||
Title = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : null,
|
|
||||||
Priority = dwo.WorkOrder != null ? dwo.WorkOrder.Priority : null,
|
|
||||||
Status = dwo.WorkOrder != null ? dwo.WorkOrder.Status : null,
|
|
||||||
LocationName = dwo.WorkOrder != null && dwo.WorkOrder.Locations != null ? dwo.WorkOrder.Locations.Name : null,
|
|
||||||
Trade = dwo.WorkOrder != null ? dwo.WorkOrder.Trade : null,
|
|
||||||
SubTrade = dwo.WorkOrder != null ? dwo.WorkOrder.SubTrade : null,
|
|
||||||
Problem = dwo.WorkOrder != null ? dwo.WorkOrder.Problem : null
|
|
||||||
}).ToListAsync();
|
|
||||||
|
|
||||||
if (linkedWOs.Count == 0 && dispatch.WorkOrderId.HasValue)
|
|
||||||
{
|
|
||||||
var wo = await _db.workOrders
|
|
||||||
.Include(w => w.Locations)
|
|
||||||
.FirstOrDefaultAsync(w => w.Id == dispatch.WorkOrderId.Value);
|
|
||||||
if (wo != null)
|
|
||||||
{
|
|
||||||
linkedWOs = new[] { new
|
|
||||||
{
|
|
||||||
WorkOrderId = wo.Id,
|
|
||||||
InternalWONumber = wo.InternalWONumber,
|
|
||||||
Title = wo.WorkerOrderTitle,
|
|
||||||
Priority = wo.Priority,
|
|
||||||
Status = wo.Status,
|
|
||||||
LocationName = wo.Locations != null ? wo.Locations.Name : null,
|
|
||||||
Trade = wo.Trade,
|
|
||||||
SubTrade = wo.SubTrade,
|
|
||||||
Problem = wo.Problem
|
|
||||||
}}.ToList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, c.SortOrder, c.WorkOrderId })
|
|
||||||
.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.Signature, s.SignatureMethod, s.SignedAt })
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var rawComments = await (from c in _db.Comments
|
|
||||||
where c.DispatchId == id && (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.RecordType,
|
|
||||||
c.CreatedDate,
|
|
||||||
c.UserId,
|
|
||||||
UserFirstName = u != null ? u.FirstName : null,
|
|
||||||
UserLastName = u != null ? u.LastName : null
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var comments = rawComments.Select(c => new
|
|
||||||
{
|
|
||||||
c.Id,
|
|
||||||
c.Commenttext,
|
|
||||||
c.Commenter,
|
|
||||||
c.CommentType,
|
|
||||||
c.RecordType,
|
|
||||||
c.CreatedDate,
|
|
||||||
UserName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
|
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
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,
|
|
||||||
RequestedAt = 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,
|
|
||||||
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
|
|
||||||
u.VendorReason,
|
|
||||||
u.Status,
|
|
||||||
u.RequiredTier,
|
|
||||||
u.RequestedByVendorName,
|
|
||||||
u.RequestedAt,
|
|
||||||
u.DecidedAt,
|
|
||||||
u.DecisionNote,
|
|
||||||
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
|
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
var dispatchNumber = dispatch.DispatchNumber ?? "";
|
|
||||||
var workOrderIds = linkedWOs.Select(l => l.WorkOrderId).Where(wid => wid > 0).ToList();
|
|
||||||
|
|
||||||
var rawAudit = await (from a in _db.WorkOrderAuditLogs
|
|
||||||
where workOrderIds.Contains(a.WorkOrderId)
|
|
||||||
&& a.FieldName != null
|
|
||||||
&& a.FieldName.Contains(dispatchNumber)
|
|
||||||
join u in _db.Users on a.UserId equals u.Id into users
|
|
||||||
from u in users.DefaultIfEmpty()
|
|
||||||
orderby a.CreatedAt descending
|
|
||||||
select new
|
|
||||||
{
|
|
||||||
a.Id,
|
|
||||||
a.Action,
|
|
||||||
a.FieldName,
|
|
||||||
a.OldValue,
|
|
||||||
a.NewValue,
|
|
||||||
a.CreatedAt,
|
|
||||||
a.UserId,
|
|
||||||
UserFirstName = u != null ? u.FirstName : null,
|
|
||||||
UserLastName = u != null ? u.LastName : null
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var auditLog = rawAudit.Select(a => new
|
|
||||||
{
|
|
||||||
Id = a.Id,
|
|
||||||
Action = a.Action,
|
|
||||||
FieldName = a.FieldName,
|
|
||||||
OldValue = a.OldValue,
|
|
||||||
NewValue = a.NewValue,
|
|
||||||
CreatedAt = a.CreatedAt,
|
|
||||||
UserName = string.Join(" ", new[] { a.UserFirstName, a.UserLastName }
|
|
||||||
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
return Ok(new DataResponse
|
return Ok(new DataResponse
|
||||||
{
|
{
|
||||||
Status = "Success",
|
Status = "Success",
|
||||||
Data = new
|
Data = dispatch
|
||||||
{
|
|
||||||
dispatch.Id,
|
|
||||||
dispatch.DispatchNumber,
|
|
||||||
dispatch.PONumber,
|
|
||||||
dispatch.NTEAmount,
|
|
||||||
dispatch.Description,
|
|
||||||
dispatch.Status,
|
|
||||||
dispatch.ScheduledDate,
|
|
||||||
dispatch.CompletedDate,
|
|
||||||
dispatch.DispatchedAt,
|
|
||||||
dispatch.AcknowledgedAt,
|
|
||||||
dispatch.VerifiedAt,
|
|
||||||
dispatch.VerifiedBy,
|
|
||||||
dispatch.EmailSent,
|
|
||||||
dispatch.WorkOrderId,
|
|
||||||
Vendor = dispatch.Vendor == null ? null : new
|
|
||||||
{
|
|
||||||
dispatch.Vendor.Id,
|
|
||||||
dispatch.Vendor.CompanyName,
|
|
||||||
dispatch.Vendor.ContactName,
|
|
||||||
dispatch.Vendor.Email,
|
|
||||||
dispatch.Vendor.Phone,
|
|
||||||
dispatch.Vendor.Address,
|
|
||||||
dispatch.Vendor.City,
|
|
||||||
dispatch.Vendor.State,
|
|
||||||
dispatch.Vendor.Zip,
|
|
||||||
dispatch.Vendor.TradeSpecialties
|
|
||||||
},
|
|
||||||
PrimaryLocation = dispatch.WorkOrder?.Locations == null ? null : new
|
|
||||||
{
|
|
||||||
dispatch.WorkOrder.Locations.Id,
|
|
||||||
dispatch.WorkOrder.Locations.Name,
|
|
||||||
dispatch.WorkOrder.Locations.Address1,
|
|
||||||
dispatch.WorkOrder.Locations.City,
|
|
||||||
dispatch.WorkOrder.Locations.State,
|
|
||||||
dispatch.WorkOrder.Locations.Zip
|
|
||||||
},
|
|
||||||
WorkOrders = linkedWOs,
|
|
||||||
Checklist = checklist,
|
|
||||||
Signoffs = signoffs,
|
|
||||||
Comments = comments,
|
|
||||||
UpliftRequests = upliftRequests,
|
|
||||||
AuditLog = auditLog
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -10,78 +11,80 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class DropdownOptionsController : Controller
|
public class DropdownOptionsController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db;
|
private readonly IDropdownOptionsService _dropdownOptionsService;
|
||||||
|
|
||||||
public DropdownOptionsController(ApplicationDbContext db)
|
public DropdownOptionsController(IDropdownOptionsService dropdownOptionsService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_dropdownOptionsService = dropdownOptionsService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] string? category = null)
|
public async Task<IActionResult> GetAll([FromQuery] string? category = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var query = _db.DropdownOptions.Where(d => d.IsActive);
|
var data = await _dropdownOptionsService.GetAllAsync(category, cancellationToken);
|
||||||
if (!string.IsNullOrWhiteSpace(category))
|
|
||||||
query = query.Where(d => d.Category == category);
|
|
||||||
|
|
||||||
var data = await query.OrderBy(d => d.Category).ThenBy(d => d.SortOrder).ThenBy(d => d.Value).ToListAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("ByCategory/{category}")]
|
[HttpGet("ByCategory/{category}")]
|
||||||
public async Task<IActionResult> GetByCategory(string category, [FromQuery] string? parent = null)
|
public async Task<IActionResult> GetByCategory(string category, [FromQuery] string? parent = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var query = _db.DropdownOptions.Where(d => d.IsActive && d.Category == category);
|
var data = await _dropdownOptionsService.GetByCategoryAsync(category, parent, cancellationToken);
|
||||||
if (!string.IsNullOrWhiteSpace(parent))
|
|
||||||
query = query.Where(d => d.ParentValue == parent);
|
|
||||||
|
|
||||||
var data = await query.OrderBy(d => d.SortOrder).ThenBy(d => d.Value).ToListAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] DropdownOption model)
|
public async Task<IActionResult> Create([FromBody] DropdownOption_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(model.Category) || string.IsNullOrWhiteSpace(model.Value))
|
try
|
||||||
return BadRequest(new { message = "Category and Value are required" });
|
{
|
||||||
|
var created = await _dropdownOptionsService.CreateAsync(MapToService(model), cancellationToken);
|
||||||
var existing = await _db.DropdownOptions
|
return Ok(created);
|
||||||
.AnyAsync(d => d.Category == model.Category && d.Value == model.Value && d.ParentValue == model.ParentValue);
|
}
|
||||||
if (existing)
|
catch (InvalidOperationException ex)
|
||||||
return BadRequest(new { message = "This option already exists" });
|
{
|
||||||
|
return BadRequest(new { message = ex.Message });
|
||||||
model.Id = 0;
|
}
|
||||||
model.IsActive = true;
|
|
||||||
_db.DropdownOptions.Add(model);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] DropdownOption model)
|
public async Task<IActionResult> Update(int id, [FromBody] DropdownOption_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var item = await _db.DropdownOptions.FindAsync(id);
|
try
|
||||||
if (item == null)
|
{
|
||||||
|
var updated = await _dropdownOptionsService.UpdateAsync(id, MapToService(model), cancellationToken);
|
||||||
|
return Ok(updated);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
return NotFound(new { message = "Option not found" });
|
return NotFound(new { message = "Option not found" });
|
||||||
|
}
|
||||||
item.Value = model.Value;
|
|
||||||
item.ParentValue = model.ParentValue;
|
|
||||||
item.SortOrder = model.SortOrder;
|
|
||||||
item.IsActive = model.IsActive;
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var item = await _db.DropdownOptions.FindAsync(id);
|
try
|
||||||
if (item == null)
|
{
|
||||||
|
await _dropdownOptionsService.DeleteAsync(id, cancellationToken);
|
||||||
|
return Ok(new { message = "Deleted" });
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
return NotFound(new { message = "Option not found" });
|
return NotFound(new { message = "Option not found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_db.DropdownOptions.Remove(item);
|
private static DropdownOptionDTO MapToService(DropdownOption_DTO model)
|
||||||
await _db.SaveChangesAsync();
|
{
|
||||||
return Ok(new { message = "Deleted" });
|
return new DropdownOptionDTO
|
||||||
|
{
|
||||||
|
Id = model.Id,
|
||||||
|
Category = model.Category,
|
||||||
|
Value = model.Value,
|
||||||
|
ParentValue = model.ParentValue,
|
||||||
|
SortOrder = model.SortOrder,
|
||||||
|
IsActive = model.IsActive
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,9 @@ using Api.SeaHavenIndustries.DTOs;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using SeaHaven.Services.Interfaces;
|
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
|
@ -17,16 +14,10 @@ using SeaHaven.DataServices.Interfaces;
|
||||||
public class FollowUpController : ControllerBase
|
public class FollowUpController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IFollowUpService _followUpService;
|
private readonly IFollowUpService _followUpService;
|
||||||
private readonly IFollowUpDataService _followUpDataService; // Keep for direct entity operations
|
|
||||||
private readonly ApplicationDbContext _db; // Keep for User lookups
|
|
||||||
private readonly UserManager<ApplicationUser> _users;
|
|
||||||
|
|
||||||
public FollowUpController(IFollowUpService followUpService, IFollowUpDataService followUpDataService, ApplicationDbContext db, UserManager<ApplicationUser> users)
|
public FollowUpController(IFollowUpService followUpService)
|
||||||
{
|
{
|
||||||
_followUpService = followUpService;
|
_followUpService = followUpService;
|
||||||
_followUpDataService = followUpDataService;
|
|
||||||
_db = db;
|
|
||||||
_users = users;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetFollowUpList")]
|
[HttpGet("GetFollowUpList")]
|
||||||
|
|
@ -34,70 +25,31 @@ public class FollowUpController : ControllerBase
|
||||||
string? search = "",
|
string? search = "",
|
||||||
string? status = "Open",
|
string? status = "Open",
|
||||||
int page = 1,
|
int page = 1,
|
||||||
int pageSize = 12)
|
int pageSize = 12,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
status = string.IsNullOrWhiteSpace(status) ? "Open" : status;
|
var pagedResult = await _followUpService.GetFollowUpListAsync(page, pageSize, search, status, cancellationToken);
|
||||||
|
|
||||||
var pagedResult = await _followUpService.GetFollowUpsPagedAsync(page, pageSize, search, status);
|
|
||||||
var items = pagedResult.Items;
|
var items = pagedResult.Items;
|
||||||
|
|
||||||
var rows = items.Select(x => new
|
var data = items.Select(x => new
|
||||||
{
|
{
|
||||||
Id = x.Id,
|
x.Id,
|
||||||
Reason = x.Reason,
|
x.Reason,
|
||||||
ScheduleDate = x.ScheduleDate,
|
x.ScheduleDate,
|
||||||
ScheduleStartTime = x.ScheduleStartTime,
|
x.ScheduleStartTime,
|
||||||
EmployeeId = x.EmployeeId,
|
x.EmployeeId,
|
||||||
WorkOrderId = x.WorkOrderId,
|
x.WorkOrderId,
|
||||||
AccountId = x.AccountId,
|
x.AccountId,
|
||||||
LocationId = x.LocationId,
|
x.LocationId,
|
||||||
AccountName = x.AccountName ?? "",
|
x.AccountName,
|
||||||
AssignedTo = x.EmployeeName ?? "",
|
x.AssignedTo,
|
||||||
LocationName = x.LocationName ?? "",
|
x.LocationName,
|
||||||
WorkOrderNumber = "",
|
x.WorkOrderNumber,
|
||||||
InternalWONumber = "",
|
x.InternalWONumber,
|
||||||
Status = x.Status,
|
x.Status,
|
||||||
CreatedDate = x.CreatedDate,
|
x.CreatedDate,
|
||||||
CreatedById = (string?)null
|
x.CreatedById,
|
||||||
}).ToList();
|
x.CreatedByName
|
||||||
|
|
||||||
var creatorIds = rows
|
|
||||||
.Select(r => r.CreatedById)
|
|
||||||
.Where(id => !string.IsNullOrEmpty(id))
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var creators = await _db.Users
|
|
||||||
.Where(u => creatorIds.Contains(u.Id))
|
|
||||||
.Select(u => new { u.Id, u.FirstName, u.LastName, u.UserName })
|
|
||||||
.ToDictionaryAsync(u => u.Id, u => u);
|
|
||||||
|
|
||||||
string NameFor(string? userId)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(userId) || !creators.TryGetValue(userId, out var u)) return "";
|
|
||||||
var full = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim();
|
|
||||||
return string.IsNullOrWhiteSpace(full) ? (u.UserName ?? "") : full;
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = rows.Select(r => new
|
|
||||||
{
|
|
||||||
r.Id,
|
|
||||||
r.Reason,
|
|
||||||
r.ScheduleDate,
|
|
||||||
r.ScheduleStartTime,
|
|
||||||
r.EmployeeId,
|
|
||||||
r.WorkOrderId,
|
|
||||||
r.AccountId,
|
|
||||||
r.LocationId,
|
|
||||||
r.AccountName,
|
|
||||||
r.AssignedTo,
|
|
||||||
r.LocationName,
|
|
||||||
r.WorkOrderNumber,
|
|
||||||
r.InternalWONumber,
|
|
||||||
r.Status,
|
|
||||||
r.CreatedDate,
|
|
||||||
r.CreatedById,
|
|
||||||
CreatedByName = NameFor(r.CreatedById)
|
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return Ok(new Pagination_DTO
|
return Ok(new Pagination_DTO
|
||||||
|
|
@ -111,26 +63,12 @@ public class FollowUpController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetById")]
|
[HttpGet("GetById")]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var model = await _followUpDataService.GetByIdWithDetailsAsync(id);
|
var model = await _followUpService.GetFollowUpDetailAsync(id, cancellationToken);
|
||||||
|
|
||||||
if (model == null) return NotFound();
|
if (model == null) return NotFound();
|
||||||
|
|
||||||
string createdByName = "";
|
|
||||||
if (!string.IsNullOrEmpty(model.createdby))
|
|
||||||
{
|
|
||||||
var u = await _db.Users
|
|
||||||
.Where(x => x.Id == model.createdby)
|
|
||||||
.Select(x => new { x.FirstName, x.LastName, x.UserName })
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (u != null)
|
|
||||||
{
|
|
||||||
var n = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim();
|
|
||||||
createdByName = string.IsNullOrWhiteSpace(n) ? (u.UserName ?? "") : n;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
model.Id,
|
model.Id,
|
||||||
|
|
@ -141,22 +79,20 @@ public class FollowUpController : ControllerBase
|
||||||
model.WorkOrderId,
|
model.WorkOrderId,
|
||||||
model.AccountId,
|
model.AccountId,
|
||||||
model.LocationId,
|
model.LocationId,
|
||||||
AccountName = model.Account?.Name ?? "",
|
model.AccountName,
|
||||||
AssignedTo = model.Employee != null
|
model.AssignedTo,
|
||||||
? (model.Employee.FirstName + " " + model.Employee.LastName)
|
model.LocationName,
|
||||||
: "",
|
model.WorkOrderNumber,
|
||||||
LocationName = model.Location?.Name ?? "",
|
model.InternalWONumber,
|
||||||
WorkOrderNumber = model.WorkOrder?.WorkerOrderNumber ?? "",
|
|
||||||
InternalWONumber = model.WorkOrder?.InternalWONumber ?? "",
|
|
||||||
model.Status,
|
model.Status,
|
||||||
model.CreatedDate,
|
model.CreatedDate,
|
||||||
CreatedById = model.createdby,
|
model.CreatedById,
|
||||||
CreatedByName = createdByName
|
model.CreatedByName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("Create")]
|
[HttpPost("Create")]
|
||||||
public async Task<IActionResult> Create(FollowUp_DTO dto)
|
public async Task<IActionResult> Create(FollowUp_DTO dto, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -164,23 +100,9 @@ public class FollowUpController : ControllerBase
|
||||||
if (userId == null)
|
if (userId == null)
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
||||||
|
|
||||||
var model = new FollowUps
|
var id = await _followUpService.CreateOpenFollowUpAsync(MapToCreateRequest(dto), userId, cancellationToken);
|
||||||
{
|
|
||||||
Reason = dto.Reason,
|
|
||||||
ScheduleDate = dto.ScheduleDate,
|
|
||||||
ScheduleStartTime = dto.ScheduleStartTime,
|
|
||||||
EmployeeId = dto.EmployeeId,
|
|
||||||
WorkOrderId = dto.WorkOrderId,
|
|
||||||
AccountId = dto.AccountId,
|
|
||||||
LocationId = dto.LocationId,
|
|
||||||
Status = "Open",
|
|
||||||
CreatedDate = DateTime.UtcNow,
|
|
||||||
createdby = userId
|
|
||||||
};
|
|
||||||
|
|
||||||
await _followUpDataService.AddAsync(model);
|
return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = id });
|
||||||
|
|
||||||
return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = model.Id });
|
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
{
|
{
|
||||||
|
|
@ -194,24 +116,11 @@ public class FollowUpController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("Update")]
|
[HttpPost("Update")]
|
||||||
public async Task<IActionResult> Update(FollowUp_DTO dto)
|
public async Task<IActionResult> Update(FollowUp_DTO dto, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var model = await _followUpDataService.GetByIdAsync(dto.Id);
|
await _followUpService.UpdateFollowUpFieldsAsync(dto.Id, MapToUpdateRequest(dto), cancellationToken);
|
||||||
if (model == null)
|
|
||||||
return NotFound(new Response { Status = "Error", Message = "Follow Up not found" });
|
|
||||||
|
|
||||||
model.Reason = dto.Reason;
|
|
||||||
model.ScheduleDate = dto.ScheduleDate;
|
|
||||||
model.ScheduleStartTime = dto.ScheduleStartTime;
|
|
||||||
model.EmployeeId = dto.EmployeeId;
|
|
||||||
model.WorkOrderId = dto.WorkOrderId;
|
|
||||||
model.AccountId = dto.AccountId;
|
|
||||||
model.LocationId = dto.LocationId;
|
|
||||||
model.LastModificationTime = DateTime.UtcNow;
|
|
||||||
|
|
||||||
await _followUpDataService.UpdateAsync(model);
|
|
||||||
return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" });
|
return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" });
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -230,42 +139,32 @@ public class FollowUpController : ControllerBase
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("Complete")]
|
[HttpPost("Complete")]
|
||||||
public async Task<IActionResult> Complete(int id)
|
public async Task<IActionResult> Complete(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var model = await _followUpDataService.GetByIdAsync(id);
|
var found = await _followUpService.SetFollowUpStatusAsync(id, "Completed", cancellationToken);
|
||||||
if (model == null) return NotFound();
|
if (!found) return NotFound();
|
||||||
|
|
||||||
model.Status = "Completed";
|
|
||||||
model.LastModificationTime = DateTime.UtcNow;
|
|
||||||
await _followUpDataService.UpdateAsync(model);
|
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("Cancel")]
|
[HttpPost("Cancel")]
|
||||||
public async Task<IActionResult> Cancel(int id)
|
public async Task<IActionResult> Cancel(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var model = await _followUpDataService.GetByIdAsync(id);
|
var found = await _followUpService.SetFollowUpStatusAsync(id, "Cancelled", cancellationToken);
|
||||||
if (model == null) return NotFound();
|
if (!found) return NotFound();
|
||||||
|
|
||||||
model.Status = "Cancelled";
|
|
||||||
model.LastModificationTime = DateTime.UtcNow;
|
|
||||||
await _followUpDataService.UpdateAsync(model);
|
|
||||||
|
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("Delete")]
|
[HttpPost("Delete")]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var model = await _followUpDataService.GetByIdAsync(id);
|
var found = await _followUpService.RemoveFollowUpAsync(id, cancellationToken);
|
||||||
if (model == null)
|
if (!found)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Follow Up not found" });
|
return NotFound(new Response { Status = "Error", Message = "Follow Up not found" });
|
||||||
|
|
||||||
await _followUpDataService.DeleteAsync(id);
|
|
||||||
|
|
||||||
return Ok(new DataResponse { Status = "200", Message = "Follow Up deleted" });
|
return Ok(new DataResponse { Status = "200", Message = "Follow Up deleted" });
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (KeyNotFoundException)
|
||||||
|
|
@ -277,4 +176,33 @@ public class FollowUpController : ControllerBase
|
||||||
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
|
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static FollowUpCreateRequestDTO MapToCreateRequest(FollowUp_DTO dto)
|
||||||
|
{
|
||||||
|
return new FollowUpCreateRequestDTO
|
||||||
|
{
|
||||||
|
Reason = dto.Reason,
|
||||||
|
ScheduleDate = dto.ScheduleDate,
|
||||||
|
ScheduleStartTime = dto.ScheduleStartTime,
|
||||||
|
EmployeeId = dto.EmployeeId,
|
||||||
|
WorkOrderId = dto.WorkOrderId,
|
||||||
|
AccountId = dto.AccountId,
|
||||||
|
LocationId = dto.LocationId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FollowUpUpdateRequestDTO MapToUpdateRequest(FollowUp_DTO dto)
|
||||||
|
{
|
||||||
|
return new FollowUpUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Id = dto.Id,
|
||||||
|
Reason = dto.Reason,
|
||||||
|
ScheduleDate = dto.ScheduleDate,
|
||||||
|
ScheduleStartTime = dto.ScheduleStartTime,
|
||||||
|
EmployeeId = dto.EmployeeId,
|
||||||
|
WorkOrderId = dto.WorkOrderId,
|
||||||
|
AccountId = dto.AccountId,
|
||||||
|
LocationId = dto.LocationId
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,8 @@ using Data.SeaHavenIndustries;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using SeaHaven.Services.Interfaces;
|
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Security.Claims;
|
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -18,18 +15,16 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
public class LocationController : Controller
|
public class LocationController : Controller
|
||||||
{
|
{
|
||||||
private readonly ILocationService _locationService;
|
private readonly ILocationService _locationService;
|
||||||
private readonly ILocationDataService _locationDataService; // Keep for direct entity operations
|
|
||||||
|
|
||||||
public LocationController(ILocationService locationService, ILocationDataService locationDataService)
|
public LocationController(ILocationService locationService)
|
||||||
{
|
{
|
||||||
_locationService = locationService;
|
_locationService = locationService;
|
||||||
_locationDataService = locationDataService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetLocationList")]
|
[HttpGet("GetLocationList")]
|
||||||
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetLocationList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var pagedResult = await _locationService.GetLocationsPagedAsync(page, pageSize, search);
|
var pagedResult = await _locationService.GetLocationListPagedAsync(page, pageSize, search, cancellationToken);
|
||||||
|
|
||||||
var data = pagedResult.Items.Select(l => new
|
var data = pagedResult.Items.Select(l => new
|
||||||
{
|
{
|
||||||
|
|
@ -61,9 +56,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public async Task<IActionResult> GetLocationById(int id)
|
public async Task<IActionResult> GetLocationById(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var location = await _locationDataService.GetByIdAsync(id);
|
var location = await _locationService.GetLocationDetailAsync(id, cancellationToken);
|
||||||
|
|
||||||
if (location == null)
|
if (location == null)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||||
|
|
@ -89,25 +84,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> AddLocation([FromBody] Location_DTO model)
|
public async Task<IActionResult> AddLocation([FromBody] Location_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var location = new Locations
|
await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken);
|
||||||
{
|
|
||||||
Name = model.Name,
|
|
||||||
Title = model.Title,
|
|
||||||
Address1 = model.Address,
|
|
||||||
City = model.City,
|
|
||||||
State = model.State,
|
|
||||||
Zip = model.ZipCode,
|
|
||||||
PhoneNumber = model.Phone,
|
|
||||||
Email = model.ContactEmail,
|
|
||||||
Status = model.Status,
|
|
||||||
CreatedDate = DateTime.Now
|
|
||||||
};
|
|
||||||
|
|
||||||
await _locationDataService.AddAsync(location);
|
|
||||||
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -122,26 +103,11 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> EditLocation(int id, [FromBody] EditLocation_DTO model)
|
public async Task<IActionResult> EditLocation(int id, [FromBody] EditLocation_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exist = await _locationDataService.GetByIdAsync(id);
|
await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), cancellationToken);
|
||||||
if (exist == null)
|
|
||||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
|
||||||
|
|
||||||
exist.Name = model.Name;
|
|
||||||
exist.Title = model.Title;
|
|
||||||
exist.Address1 = model.Address;
|
|
||||||
exist.City = model.City;
|
|
||||||
exist.State = model.State;
|
|
||||||
exist.Zip = model.ZipCode;
|
|
||||||
exist.PhoneNumber = model.Phone;
|
|
||||||
exist.Email = model.ContactEmail;
|
|
||||||
exist.Status = model.Status;
|
|
||||||
exist.LastModificationTime = DateTime.Now;
|
|
||||||
|
|
||||||
await _locationDataService.UpdateAsync(exist);
|
|
||||||
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (ValidationException vex)
|
catch (ValidationException vex)
|
||||||
|
|
@ -160,15 +126,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<IActionResult> DeleteLocation(int id)
|
public async Task<IActionResult> DeleteLocation(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exist = await _locationDataService.GetByIdAsync(id);
|
var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken);
|
||||||
if (exist == null)
|
if (!found)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
return NotFound(new Response { Status = "Error", Message = "Location not found" });
|
||||||
|
|
||||||
await _locationDataService.DeleteAsync(id);
|
|
||||||
return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Location Deleted Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (KeyNotFoundException)
|
catch (KeyNotFoundException)
|
||||||
|
|
@ -182,9 +147,41 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("DeleteLocation")]
|
[HttpGet("DeleteLocation")]
|
||||||
public async Task<IActionResult> DeleteLocationByQuery(int id)
|
public async Task<IActionResult> DeleteLocationByQuery(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await DeleteLocation(id);
|
return await DeleteLocation(id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationCreateRequestDTO MapToCreateRequest(Location_DTO model)
|
||||||
|
{
|
||||||
|
return new LocationCreateRequestDTO
|
||||||
|
{
|
||||||
|
Name = model.Name,
|
||||||
|
Title = model.Title,
|
||||||
|
Address = model.Address,
|
||||||
|
City = model.City,
|
||||||
|
State = model.State,
|
||||||
|
ZipCode = model.ZipCode,
|
||||||
|
Phone = model.Phone,
|
||||||
|
ContactEmail = model.ContactEmail,
|
||||||
|
Status = model.Status
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocationUpdateRequestDTO MapToUpdateRequest(EditLocation_DTO model)
|
||||||
|
{
|
||||||
|
return new LocationUpdateRequestDTO
|
||||||
|
{
|
||||||
|
Name = model.Name,
|
||||||
|
Title = model.Title,
|
||||||
|
Address = model.Address,
|
||||||
|
City = model.City,
|
||||||
|
State = model.State,
|
||||||
|
ZipCode = model.ZipCode,
|
||||||
|
Phone = model.Phone,
|
||||||
|
ContactEmail = model.ContactEmail,
|
||||||
|
Status = model.Status
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.SqlClient;
|
using SeaHaven.Services.DTOs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.Interfaces;
|
||||||
using System.Linq;
|
|
||||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -15,52 +12,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/Quotes")]
|
[Route("api/Quotes")]
|
||||||
public class QuotesController : Controller
|
public class QuotesController : Controller
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly IQuotesService _quotesService;
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
private readonly IWebHostEnvironment _webHostEnvironment;
|
public QuotesController(IQuotesService quotesService)
|
||||||
IHttpContextAccessor _httpContext;
|
|
||||||
public QuotesController(UserManager<ApplicationUser> userManager, ApplicationDbContext db, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext)
|
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_quotesService = quotesService;
|
||||||
_db = db;
|
|
||||||
_webHostEnvironment = webHostEnvironment;
|
|
||||||
_httpContext = httpContext;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("AddQuote")]
|
[Route("AddQuote")]
|
||||||
public async Task<IActionResult> AddQuote([FromBody] Quote_DTO model)
|
public async Task<IActionResult> AddQuote([FromBody] Quote_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Random rnd = new Random();
|
await _quotesService.CreateQuoteAsync(MapToCreateInput(model), cancellationToken);
|
||||||
int id = rnd.Next(1, 99999);
|
|
||||||
Quotes quotes = new Quotes();
|
|
||||||
quotes.QuotId = id.ToString();
|
|
||||||
quotes.ContactId = model.ContactId;
|
|
||||||
quotes.PO = model.PO;
|
|
||||||
quotes.TT = model.TT;
|
|
||||||
quotes.ProjectName = model.ProjectName;
|
|
||||||
quotes.Date = DateTime.Parse(model.Date);
|
|
||||||
quotes.WorkorderId = model.WorkorderId;
|
|
||||||
quotes.SiteName = model.SiteName;
|
|
||||||
quotes.SiteId = model.SiteId;
|
|
||||||
quotes.BillingAddress = model.BillingAddress;
|
|
||||||
quotes.LineItems = quotes.LineItems == null ? new List<QuotesLineItems>() : quotes.LineItems;
|
|
||||||
if (model.LineItems != null && model.LineItems.Any())
|
|
||||||
{
|
|
||||||
foreach (var lineItemDto in model.LineItems)
|
|
||||||
{
|
|
||||||
QuotesLineItems lineItem = new QuotesLineItems()
|
|
||||||
{
|
|
||||||
LineitemNumber = lineItemDto.LineitemNumber,
|
|
||||||
Lineitem = lineItemDto.Lineitem,
|
|
||||||
Descriptions = lineItemDto.Descriptions
|
|
||||||
};
|
|
||||||
quotes.LineItems.Add(lineItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_db.Quotes.Add(quotes);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -71,50 +37,14 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
[Route("EditQuote")]
|
[Route("EditQuote")]
|
||||||
public async Task<IActionResult> EditQuote([FromBody] EditQuote_DTO model)
|
public async Task<IActionResult> EditQuote([FromBody] EditQuote_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var quotes = _db.Quotes.Where(w => w.Id == model.id).FirstOrDefault();
|
var updated = await _quotesService.UpdateQuoteAsync(MapToUpdateInput(model), cancellationToken);
|
||||||
if (quotes != null)
|
if (!updated)
|
||||||
{
|
|
||||||
quotes.QuotId = model.QuotId;
|
|
||||||
quotes.ContactId = model.ContactId;
|
|
||||||
quotes.PO = model.PO;
|
|
||||||
quotes.TT = model.TT;
|
|
||||||
quotes.ProjectName = model.ProjectName;
|
|
||||||
quotes.Date = DateTime.Parse(model.Date);
|
|
||||||
quotes.WorkorderId = model.WorkorderId;
|
|
||||||
quotes.SiteName = model.SiteName;
|
|
||||||
quotes.SiteId = model.SiteId;
|
|
||||||
quotes.BillingAddress = model.BillingAddress;
|
|
||||||
|
|
||||||
var existQuoteLine = _db.QuotesLines.Where(w => w.QuoteId == model.id).ToList();
|
|
||||||
if (existQuoteLine.Count() > 0)
|
|
||||||
{
|
|
||||||
_db.QuotesLines.RemoveRange(existQuoteLine);
|
|
||||||
}
|
|
||||||
quotes.LineItems = quotes.LineItems == null ? new List<QuotesLineItems>() : quotes.LineItems;
|
|
||||||
if (model.LineItems != null && model.LineItems.Any())
|
|
||||||
{
|
|
||||||
foreach (var lineItemDto in model.LineItems)
|
|
||||||
{
|
|
||||||
QuotesLineItems lineItem = new QuotesLineItems()
|
|
||||||
{
|
|
||||||
LineitemNumber = lineItemDto.LineitemNumber,
|
|
||||||
Lineitem = lineItemDto.Lineitem,
|
|
||||||
Descriptions = lineItemDto.Descriptions
|
|
||||||
};
|
|
||||||
quotes.LineItems.Add(lineItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_db.Update(quotes);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return BadRequest("ID not matched!");
|
return BadRequest("ID not matched!");
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -125,26 +55,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
[Route("DeleteQuote")]
|
[Route("DeleteQuote")]
|
||||||
public IActionResult DeleteQuote(EditQuote_DTO model)
|
public async Task<IActionResult> DeleteQuote(EditQuote_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
int id = model.id;
|
int id = model.id;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = _db.Quotes.Where(w => w.Id == id).FirstOrDefault();
|
var deleted = await _quotesService.DeleteQuoteAsync(id, cancellationToken);
|
||||||
if (data != null)
|
if (!deleted)
|
||||||
{
|
|
||||||
var existlineitem = _db.QuotesLines.Where(w => w.QuoteId == id).ToList();
|
|
||||||
if (existlineitem.Any())
|
|
||||||
{
|
|
||||||
_db.QuotesLines.RemoveRange(existlineitem);
|
|
||||||
}
|
|
||||||
_db.Quotes.Remove(data);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return BadRequest("ID not matched!");
|
return BadRequest("ID not matched!");
|
||||||
}
|
|
||||||
_db.SaveChanges();
|
|
||||||
return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -155,38 +74,18 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetQuoutes")]
|
[Route("GetQuoutes")]
|
||||||
public IActionResult GetQuotes(string search = "", int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetQuotes(string search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var totalCount = _db.Quotes.Count();
|
var result = await _quotesService.GetQuotesPagedAsync(page, pageSize, search, cancellationToken);
|
||||||
var data = _db.Quotes.Include(s => s.WorkOrder).AsNoTracking().Where(w => (w.QuotId ?? "").ToLower().Contains(search.ToLower()) || (w.SiteName ?? "").ToLower().Contains(search.ToLower()) || (w.WorkOrder != null && (w.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(search.ToLower()))).Select(s => new
|
|
||||||
{
|
|
||||||
s.Id,
|
|
||||||
QuoteId = "QO-" + s.QuotId,
|
|
||||||
WorkOrder = s.WorkOrder != null ? s.WorkOrder.WorkerOrderNumber : "",
|
|
||||||
s.ProjectName,
|
|
||||||
TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString(),
|
|
||||||
s.SiteName,
|
|
||||||
s.SiteId,
|
|
||||||
s.PO,
|
|
||||||
s.TT,
|
|
||||||
s.BillingAddress,
|
|
||||||
s.Status,
|
|
||||||
s.ContactId,
|
|
||||||
s.Date,
|
|
||||||
lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem, q.LineitemNumber}),
|
|
||||||
}).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize).ToList();
|
|
||||||
|
|
||||||
// Calculate the number of pages
|
var totalPages = (int)Math.Ceiling(result.TotalCount / (double)pageSize);
|
||||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
||||||
|
|
||||||
// Create a view model containing the paged items and pagination metadata
|
|
||||||
var viewModel = new Pagination_DTO
|
var viewModel = new Pagination_DTO
|
||||||
{
|
{
|
||||||
Data = data,
|
Data = result.Items,
|
||||||
PageNumber = page,
|
PageNumber = page,
|
||||||
PageSize = pageSize,
|
PageSize = pageSize,
|
||||||
TotalCount = totalCount,
|
TotalCount = result.TotalCount,
|
||||||
TotalPages = totalPages
|
TotalPages = totalPages
|
||||||
};
|
};
|
||||||
return Ok(viewModel);
|
return Ok(viewModel);
|
||||||
|
|
@ -194,66 +93,85 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetQuoutesdetailsAgainstWorkOrder")]
|
[Route("GetQuoutesdetailsAgainstWorkOrder")]
|
||||||
public async Task<IActionResult> GetQuoutesdetailsAgainstWorkOrder(int woId)
|
public async Task<IActionResult> GetQuoutesdetailsAgainstWorkOrder(int woId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = await _db.workOrders.Include(s => s.Locations).Where(w => w.Id == woId).Select(s => new
|
var data = await _quotesService.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken);
|
||||||
{
|
|
||||||
s.Id,
|
|
||||||
s.PO,
|
|
||||||
s.TT,
|
|
||||||
Title = s.Locations != null ? s.Locations.Title : "",
|
|
||||||
Name = s.Locations != null ? s.Locations.Name : ""
|
|
||||||
}).ToListAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetQuoutesById")]
|
[Route("GetQuoutesById")]
|
||||||
public async Task<IActionResult> GetQuoutesById(int id)
|
public async Task<IActionResult> GetQuoutesById(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = await _db.Quotes.Where(s => s.Id == id).Include(s => s.WorkOrder).Select(s => new
|
var data = await _quotesService.GetQuoteDetailsByIdAsync(id, cancellationToken);
|
||||||
{
|
|
||||||
s.Id,
|
|
||||||
s.QuotId,
|
|
||||||
s.Date,
|
|
||||||
Name = s.Contacts != null ? (s.Contacts.FirstName + " " + s.Contacts.LastName).Trim() : "",
|
|
||||||
s.PO,
|
|
||||||
s.TT,
|
|
||||||
s.SiteName,
|
|
||||||
s.ProjectName,
|
|
||||||
s.SiteId,
|
|
||||||
s.BillingAddress,
|
|
||||||
s.ShippingAddress,
|
|
||||||
lineitems = s.LineItems.Select(q => new { q.Descriptions, q.Lineitem }),
|
|
||||||
TotalAmount = "$" + (s.LineItems != null ? s.LineItems.Sum(l => l.Lineitem ?? 0) : 0).ToString()
|
|
||||||
}).FirstOrDefaultAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetReporting")]
|
[Route("GetReporting")]
|
||||||
public async Task<IActionResult> GetReporting()
|
public async Task<IActionResult> GetReporting(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var totalCreatedWorkOrder = await _db.workOrders.CountAsync();
|
var data = await _quotesService.GetReportingAsync(cancellationToken);
|
||||||
var totalCompleted = _db.workOrders.Count(w => w.Status == "Done");
|
|
||||||
var totalSev1 = _db.workOrders.Count(w => w.Priority == "Sev 1");
|
|
||||||
var totalSev2 = _db.workOrders.Count(w => w.Priority == "Sev 2");
|
|
||||||
var totalSev3 = _db.workOrders.Count(w => w.Priority == "Sev 3");
|
|
||||||
var totalSev4 = _db.workOrders.Count(w => w.Priority == "Sev 4");
|
|
||||||
var totalSev5 = _db.workOrders.Count(w => w.Priority == "Sev 5");
|
|
||||||
|
|
||||||
var data = new Reporting_DTO
|
var reportingDto = new Reporting_DTO
|
||||||
{
|
{
|
||||||
TotalCreatedWorkOrder = totalCreatedWorkOrder,
|
TotalCreatedWorkOrder = data.TotalCreatedWorkOrder,
|
||||||
TotalCompleted = totalCompleted,
|
TotalCompleted = data.TotalCompleted,
|
||||||
TotalSev1 = totalSev1,
|
TotalSev1 = data.TotalSev1,
|
||||||
TotalSev2 = totalSev2,
|
TotalSev2 = data.TotalSev2,
|
||||||
TotalSev3 = totalSev3,
|
TotalSev3 = data.TotalSev3,
|
||||||
TotalSev4 = totalSev4,
|
TotalSev4 = data.TotalSev4,
|
||||||
TotalSev5 = totalSev5
|
TotalSev5 = data.TotalSev5
|
||||||
};
|
};
|
||||||
|
|
||||||
return Ok(data);
|
return Ok(reportingDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CreateQuoteInput MapToCreateInput(Quote_DTO dto)
|
||||||
|
{
|
||||||
|
return new CreateQuoteInput
|
||||||
|
{
|
||||||
|
QuotId = dto.QuotId,
|
||||||
|
ContactId = dto.ContactId,
|
||||||
|
PO = dto.PO,
|
||||||
|
TT = dto.TT,
|
||||||
|
ProjectName = dto.ProjectName,
|
||||||
|
Date = dto.Date != null ? DateTime.Parse(dto.Date) : null,
|
||||||
|
WorkorderId = dto.WorkorderId,
|
||||||
|
SiteName = dto.SiteName,
|
||||||
|
SiteId = dto.SiteId,
|
||||||
|
BillingAddress = dto.BillingAddress,
|
||||||
|
LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput
|
||||||
|
{
|
||||||
|
LineitemNumber = li.LineitemNumber,
|
||||||
|
Lineitem = li.Lineitem,
|
||||||
|
Descriptions = li.Descriptions
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UpdateQuoteInput MapToUpdateInput(EditQuote_DTO dto)
|
||||||
|
{
|
||||||
|
return new UpdateQuoteInput
|
||||||
|
{
|
||||||
|
Id = dto.id,
|
||||||
|
QuotId = dto.QuotId,
|
||||||
|
ContactId = dto.ContactId,
|
||||||
|
PO = dto.PO,
|
||||||
|
TT = dto.TT,
|
||||||
|
ProjectName = dto.ProjectName,
|
||||||
|
Date = dto.Date != null ? DateTime.Parse(dto.Date) : null,
|
||||||
|
WorkorderId = dto.WorkorderId,
|
||||||
|
SiteName = dto.SiteName,
|
||||||
|
SiteId = dto.SiteId,
|
||||||
|
BillingAddress = dto.BillingAddress,
|
||||||
|
LineItems = dto.LineItems?.Select(li => new QuoteLineItemInput
|
||||||
|
{
|
||||||
|
LineitemNumber = li.LineitemNumber,
|
||||||
|
Lineitem = li.Lineitem,
|
||||||
|
Descriptions = li.Descriptions
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@
|
||||||
using Api.SeaHavenIndustries.Helper.ViewModel;
|
using Api.SeaHavenIndustries.Helper.ViewModel;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.Interfaces;
|
||||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -14,24 +12,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("[controller]")]
|
[Route("[controller]")]
|
||||||
public class SettingsController : Controller
|
public class SettingsController : Controller
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly ISettingsService _settingsService;
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
public SettingsController(UserManager<ApplicationUser> userManager, ApplicationDbContext db)
|
public SettingsController(ISettingsService settingsService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_settingsService = settingsService;
|
||||||
_db = db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("AddCategory")]
|
[Route("AddCategory")]
|
||||||
public async Task<IActionResult> AddCategory(Category_DTO model)
|
public async Task<IActionResult> AddCategory(Category_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Category category = new Category();
|
await _settingsService.AddCategoryAsync(model.Name, cancellationToken);
|
||||||
category.Name = model.Name;
|
|
||||||
_db.Categories.Add(category);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Message = "Successfully Saved Data", Status = "200" });
|
return Ok(new DataResponse { Message = "Successfully Saved Data", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -42,16 +36,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("EditCategory")]
|
[Route("EditCategory")]
|
||||||
public async Task<IActionResult> EditCategory(EditCategory_DTO model)
|
public async Task<IActionResult> EditCategory(EditCategory_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exist = _db.Categories.Where(w => w.Id == model.Id).FirstOrDefault();
|
var found = await _settingsService.UpdateCategoryAsync(model.Id, model.Name, cancellationToken);
|
||||||
if (exist == null)
|
if (!found)
|
||||||
return BadRequest(new Response { Status = "Error", Message = "Category not found" });
|
return BadRequest(new Response { Status = "Error", Message = "Category not found" });
|
||||||
exist.Name = model.Name;
|
|
||||||
_db.Categories.Update(exist);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -62,30 +53,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("DeleteCategory")]
|
[Route("DeleteCategory")]
|
||||||
public async Task<IActionResult> DeleteCategory(int id)
|
public async Task<IActionResult> DeleteCategory(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exist = _db.Categories.Where(w => w.Id == id).FirstOrDefault();
|
var found = await _settingsService.DeleteCategoryAsync(id, cancellationToken);
|
||||||
if (exist != null)
|
if (!found)
|
||||||
{
|
|
||||||
var chkexist = _db.Templates.Where(w => w.CategoryId == id).ToList();
|
|
||||||
if (chkexist != null)
|
|
||||||
{
|
|
||||||
_db.RemoveRange(chkexist);
|
|
||||||
}
|
|
||||||
var chkworkordercategory = _db.workOrderCategories.Where(w => w.CategoryId == id).ToList();
|
|
||||||
if (chkworkordercategory != null)
|
|
||||||
{
|
|
||||||
_db.RemoveRange(chkworkordercategory);
|
|
||||||
}
|
|
||||||
_db.Remove(exist);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
|
return BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Message = "Deleted Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Deleted Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -93,28 +67,21 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetCategoryList")]
|
[Route("GetCategoryList")]
|
||||||
public IActionResult GetCategoryList(int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetCategoryList(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var totalCount = _db.Categories.Count();
|
var (items, total) = await _settingsService.GetCategoryListAsync(page, pageSize, cancellationToken);
|
||||||
var data = _db.Categories.Select(s => new
|
|
||||||
{
|
|
||||||
s.Id,
|
|
||||||
s.Name
|
|
||||||
}).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize).ToList();
|
|
||||||
|
|
||||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||||
|
|
||||||
var viewModel = new Pagination_DTO
|
var viewModel = new Pagination_DTO
|
||||||
{
|
{
|
||||||
Data = data,
|
Data = items,
|
||||||
PageNumber = page,
|
PageNumber = page,
|
||||||
PageSize = pageSize,
|
PageSize = pageSize,
|
||||||
TotalCount = totalCount,
|
TotalCount = total,
|
||||||
TotalPages = totalPages
|
TotalPages = totalPages
|
||||||
};
|
};
|
||||||
return Ok(viewModel);
|
return Ok(viewModel);
|
||||||
|
|
@ -122,24 +89,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetTemplates")]
|
[Route("GetTemplates")]
|
||||||
public IActionResult GetTemplates(int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetTemplates(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var totalCount = _db.workOrders.Where(w=> w.istemplate == true).Count();
|
var (items, total) = await _settingsService.GetTemplatesAsync(page, pageSize, cancellationToken);
|
||||||
var data = _db.workOrders.Where(w => w.istemplate == true).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize).ToList();
|
|
||||||
|
|
||||||
// Calculate the number of pages
|
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
||||||
|
|
||||||
// Create a view model containing the paged items and pagination metadata
|
|
||||||
var viewModel = new Pagination_DTO
|
var viewModel = new Pagination_DTO
|
||||||
{
|
{
|
||||||
Data = data,
|
Data = items,
|
||||||
PageNumber = page,
|
PageNumber = page,
|
||||||
PageSize = pageSize,
|
PageSize = pageSize,
|
||||||
TotalCount = totalCount,
|
TotalCount = total,
|
||||||
TotalPages = totalPages
|
TotalPages = totalPages
|
||||||
};
|
};
|
||||||
return Ok(viewModel);
|
return Ok(viewModel);
|
||||||
|
|
@ -152,42 +115,30 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetUsers")]
|
[Route("GetUsers")]
|
||||||
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10)
|
public async Task<IActionResult> GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var totalCount = _db.Users.Count();
|
var (users, total) = await _settingsService.GetUsersAsync(search, page, pageSize, cancellationToken);
|
||||||
var allUsers = _db.Users.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize)
|
|
||||||
.Take(pageSize).ToList();
|
|
||||||
|
|
||||||
// Calculate the number of pages
|
var filteredUsers = users.Select(u => new UserRoleViewModel
|
||||||
var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
||||||
|
|
||||||
var filteredUsers = new List<UserRoleViewModel>();
|
|
||||||
|
|
||||||
foreach (var user in allUsers)
|
|
||||||
{
|
{
|
||||||
var roles = await _userManager.GetRolesAsync(user);
|
RoleName = u.RoleName,
|
||||||
if (!roles.Contains("Admin"))
|
Email = u.Email,
|
||||||
{
|
UserName = u.UserName,
|
||||||
filteredUsers.Add(new UserRoleViewModel
|
Date = u.Date,
|
||||||
{
|
Status = u.Status,
|
||||||
RoleName = roles.Count() > 0 ? roles.First() : "",
|
Id = u.Id
|
||||||
Email = user.Email,
|
}).ToList();
|
||||||
UserName = user.FirstName,
|
|
||||||
Date = user.CreatedDate?.Date.ToString(),
|
var totalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||||
Status = user.UniqueName,
|
|
||||||
Id = user.Id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Create a view model containing the paged items and pagination metadata
|
|
||||||
var viewModel = new Pagination_DTO
|
var viewModel = new Pagination_DTO
|
||||||
{
|
{
|
||||||
Data = filteredUsers,
|
Data = filteredUsers,
|
||||||
PageNumber = page,
|
PageNumber = page,
|
||||||
PageSize = pageSize,
|
PageSize = pageSize,
|
||||||
TotalCount = totalCount,
|
TotalCount = total,
|
||||||
TotalPages = totalPages
|
TotalPages = totalPages
|
||||||
};
|
};
|
||||||
return Ok(viewModel);
|
return Ok(viewModel);
|
||||||
|
|
@ -200,35 +151,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("GetRoles")]
|
[Route("GetRoles")]
|
||||||
public async Task<IActionResult> GetRoles()
|
public async Task<IActionResult> GetRoles(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = _db.Roles.ToList();
|
var roles = await _settingsService.GetRolesAsync(cancellationToken);
|
||||||
List<Roles_DTO> roles = new List<Roles_DTO>();
|
var result = roles.Select(r => new Roles_DTO { Name = r.Name, Description = r.Description }).ToList();
|
||||||
foreach (var role in data)
|
return Ok(result);
|
||||||
{
|
|
||||||
if (role.Name == "Admin")
|
|
||||||
{
|
|
||||||
roles.Add(new Roles_DTO { Name = role.Name, Description = "Full Access" });
|
|
||||||
}
|
|
||||||
else if(role.Name == "Supervisor")
|
|
||||||
{
|
|
||||||
roles.Add(new Roles_DTO { Name = role.Name, Description = "Access to Team's Work Orders Only" });
|
|
||||||
|
|
||||||
}
|
|
||||||
else if(role.Name == "Manager")
|
|
||||||
{
|
|
||||||
roles.Add(new Roles_DTO { Name = role.Name, Description = "Full access accept quotes" });
|
|
||||||
|
|
||||||
}
|
|
||||||
else if(role.Name =="User")
|
|
||||||
{
|
|
||||||
roles.Add(new Roles_DTO { Name = role.Name, Description = "Access to Assigned Work Orders Only" });
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Ok(roles);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
using Amazon.DynamoDBv2;
|
|
||||||
using Amazon.DynamoDBv2.Model;
|
|
||||||
using Data.SeaHavenIndustries;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -12,412 +9,60 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class SyncController : Controller
|
public class SyncController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db;
|
private readonly ISyncService _syncService;
|
||||||
private readonly AmazonDynamoDBClient _dynamo;
|
|
||||||
private const string WO_TABLE = "WorkOrders";
|
|
||||||
private const string COMMENTS_TABLE = "WorkOrderComments";
|
|
||||||
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
|
|
||||||
|
|
||||||
public SyncController(ApplicationDbContext db)
|
public SyncController(ISyncService syncService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_syncService = syncService;
|
||||||
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("WorkOrders")]
|
[HttpPost("WorkOrders")]
|
||||||
public async Task<IActionResult> SyncWorkOrders()
|
public async Task<IActionResult> SyncWorkOrders(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var synced = 0;
|
var result = await _syncService.SyncWorkOrdersAsync(cancellationToken);
|
||||||
var created = 0;
|
return Ok(result);
|
||||||
var updated = 0;
|
|
||||||
var locationsCreated = 0;
|
|
||||||
|
|
||||||
var lastInternalWO = await _db.workOrders
|
|
||||||
.Where(w => w.InternalWONumber != null && w.InternalWONumber != "")
|
|
||||||
.OrderByDescending(w => w.InternalWONumber)
|
|
||||||
.Select(w => w.InternalWONumber)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
int nextInternal = 10000001;
|
|
||||||
if (lastInternalWO != null && int.TryParse(lastInternalWO, out var parsed))
|
|
||||||
nextInternal = parsed + 1;
|
|
||||||
|
|
||||||
Dictionary<string, AttributeValue>? lastKey = null;
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var request = new ScanRequest
|
|
||||||
{
|
|
||||||
TableName = WO_TABLE,
|
|
||||||
Limit = 100,
|
|
||||||
ExclusiveStartKey = lastKey
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _dynamo.ScanAsync(request);
|
|
||||||
|
|
||||||
foreach (var item in response.Items)
|
|
||||||
{
|
|
||||||
var externalId = GetString(item, "work_order_id");
|
|
||||||
if (string.IsNullOrEmpty(externalId)) continue;
|
|
||||||
|
|
||||||
var existing = await _db.workOrders
|
|
||||||
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId);
|
|
||||||
|
|
||||||
var locationId = await ResolveLocationId(
|
|
||||||
GetString(item, "site_code"),
|
|
||||||
GetString(item, "building"),
|
|
||||||
GetString(item, "address"));
|
|
||||||
|
|
||||||
if (locationId.HasValue && !locationsCreated.Equals(0))
|
|
||||||
locationsCreated++;
|
|
||||||
|
|
||||||
if (existing == null)
|
|
||||||
{
|
|
||||||
var wo = new WorkOrder
|
|
||||||
{
|
|
||||||
InternalWONumber = (nextInternal++).ToString("D8"),
|
|
||||||
ExternalWorkOrderId = externalId,
|
|
||||||
WorkerOrderNumber = externalId,
|
|
||||||
WorkerOrderTitle = GetString(item, "description"),
|
|
||||||
Description = GetString(item, "description"),
|
|
||||||
Status = MapStatus(GetString(item, "wo_status")),
|
|
||||||
Priority = MapSeverityToPriority(GetString(item, "severity")),
|
|
||||||
Severity = GetString(item, "severity"),
|
|
||||||
Customer = GetString(item, "customer"),
|
|
||||||
SiteCode = GetString(item, "site_code"),
|
|
||||||
Building = GetString(item, "building"),
|
|
||||||
LocationId = locationId,
|
|
||||||
DueDate = ParseDate(GetString(item, "due_date")),
|
|
||||||
DateReported = ParseDate(GetString(item, "date_reported")),
|
|
||||||
ScheduledStart = ParseDate(GetString(item, "scheduled_start")),
|
|
||||||
AssignTo = null,
|
|
||||||
SourceEmailS3Key = GetString(item, "source_email_s3_key"),
|
|
||||||
CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow,
|
|
||||||
istemplate = false
|
|
||||||
};
|
|
||||||
_db.workOrders.Add(wo);
|
|
||||||
created++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
existing.WorkerOrderTitle = GetString(item, "description") ?? existing.WorkerOrderTitle;
|
|
||||||
existing.Description = GetString(item, "description") ?? existing.Description;
|
|
||||||
existing.Status = MapStatus(GetString(item, "wo_status")) ?? existing.Status;
|
|
||||||
existing.Priority = MapSeverityToPriority(GetString(item, "severity")) ?? existing.Priority;
|
|
||||||
existing.Severity = GetString(item, "severity") ?? existing.Severity;
|
|
||||||
existing.SiteCode = GetString(item, "site_code") ?? existing.SiteCode;
|
|
||||||
existing.Building = GetString(item, "building") ?? existing.Building;
|
|
||||||
existing.LocationId = locationId ?? existing.LocationId;
|
|
||||||
existing.DueDate = ParseDate(GetString(item, "due_date")) ?? existing.DueDate;
|
|
||||||
existing.DateReported = ParseDate(GetString(item, "date_reported")) ?? existing.DateReported;
|
|
||||||
existing.ScheduledStart = ParseDate(GetString(item, "scheduled_start")) ?? existing.ScheduledStart;
|
|
||||||
existing.SourceEmailS3Key = GetString(item, "source_email_s3_key") ?? existing.SourceEmailS3Key;
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
synced++;
|
|
||||||
|
|
||||||
if (synced % 100 == 0)
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
lastKey = response.LastEvaluatedKey;
|
|
||||||
|
|
||||||
} while (lastKey != null && lastKey.Count > 0);
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
synced,
|
|
||||||
created,
|
|
||||||
updated,
|
|
||||||
locationsCreated = await _db.Locations.CountAsync()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("Comments")]
|
[HttpPost("Comments")]
|
||||||
public async Task<IActionResult> SyncComments()
|
public async Task<IActionResult> SyncComments(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var synced = 0;
|
var result = await _syncService.SyncCommentsAsync(cancellationToken);
|
||||||
var created = 0;
|
return Ok(result);
|
||||||
var skipped = 0;
|
|
||||||
Dictionary<string, AttributeValue>? lastKey = null;
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var request = new ScanRequest
|
|
||||||
{
|
|
||||||
TableName = COMMENTS_TABLE,
|
|
||||||
Limit = 100,
|
|
||||||
ExclusiveStartKey = lastKey
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _dynamo.ScanAsync(request);
|
|
||||||
|
|
||||||
foreach (var item in response.Items)
|
|
||||||
{
|
|
||||||
var externalCommentId = GetString(item, "comment_id");
|
|
||||||
var externalWoId = GetString(item, "work_order_id");
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(externalCommentId) || string.IsNullOrEmpty(externalWoId))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var alreadyExists = await _db.Comments
|
|
||||||
.AnyAsync(c => c.ExternalCommentId == externalCommentId);
|
|
||||||
|
|
||||||
if (alreadyExists)
|
|
||||||
{
|
|
||||||
skipped++;
|
|
||||||
synced++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var workOrder = await _db.workOrders
|
|
||||||
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalWoId);
|
|
||||||
|
|
||||||
if (workOrder == null)
|
|
||||||
{
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
var comment = new Comments
|
|
||||||
{
|
|
||||||
ExternalCommentId = externalCommentId,
|
|
||||||
WorkerOrderId = workOrder.Id,
|
|
||||||
Commenttext = GetString(item, "text"),
|
|
||||||
Commenter = GetString(item, "commenter"),
|
|
||||||
RecordType = GetString(item, "record_type"),
|
|
||||||
CommentType = "customer",
|
|
||||||
CreatedDate = ParseDate(GetString(item, "created_at")) ?? DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.Comments.Add(comment);
|
|
||||||
created++;
|
|
||||||
synced++;
|
|
||||||
|
|
||||||
if (synced % 100 == 0)
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
lastKey = response.LastEvaluatedKey;
|
|
||||||
|
|
||||||
} while (lastKey != null && lastKey.Count > 0);
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(new { synced, created, skipped });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("BackfillInternalWONumbers")]
|
[HttpPost("BackfillInternalWONumbers")]
|
||||||
public async Task<IActionResult> BackfillInternalWONumbers()
|
public async Task<IActionResult> BackfillInternalWONumbers(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var wosMissing = await _db.workOrders
|
var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken);
|
||||||
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
|
return Ok(result);
|
||||||
.OrderBy(w => w.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
if (wosMissing.Count == 0)
|
|
||||||
return Ok(new { updated = 0 });
|
|
||||||
|
|
||||||
var lastWO = await _db.workOrders
|
|
||||||
.Where(w => w.InternalWONumber != null && w.InternalWONumber != "")
|
|
||||||
.OrderByDescending(w => w.InternalWONumber)
|
|
||||||
.Select(w => w.InternalWONumber)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
int next = 10000001;
|
|
||||||
if (lastWO != null && int.TryParse(lastWO, out var lastNum))
|
|
||||||
next = lastNum + 1;
|
|
||||||
|
|
||||||
foreach (var wo in wosMissing)
|
|
||||||
{
|
|
||||||
wo.InternalWONumber = next.ToString("D8");
|
|
||||||
next++;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new { updated = wosMissing.Count });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("BackfillDispatchNumbers")]
|
[HttpPost("BackfillDispatchNumbers")]
|
||||||
public async Task<IActionResult> BackfillDispatchNumbers()
|
public async Task<IActionResult> BackfillDispatchNumbers(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var missing = await _db.Dispatches
|
var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken);
|
||||||
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
|
return Ok(result);
|
||||||
.OrderBy(d => d.Id)
|
|
||||||
.ToListAsync();
|
|
||||||
if (missing.Count == 0) return Ok(new { updated = 0 });
|
|
||||||
|
|
||||||
int next = 1;
|
|
||||||
var last = await _db.Dispatches
|
|
||||||
.Where(d => d.DispatchNumber != null && d.DispatchNumber != "")
|
|
||||||
.OrderByDescending(d => d.DispatchNumber)
|
|
||||||
.Select(d => d.DispatchNumber)
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
if (last != null && last.StartsWith("DSP-") && int.TryParse(last.Substring(4), out var n))
|
|
||||||
next = n + 1;
|
|
||||||
|
|
||||||
foreach (var d in missing)
|
|
||||||
{
|
|
||||||
d.DispatchNumber = $"DSP-{next:D5}";
|
|
||||||
next++;
|
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new { updated = missing.Count });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("VendorReplies")]
|
[HttpPost("VendorReplies")]
|
||||||
public async Task<IActionResult> SyncVendorReplies()
|
public async Task<IActionResult> SyncVendorReplies(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var synced = 0;
|
var result = await _syncService.SyncVendorRepliesAsync(cancellationToken);
|
||||||
var created = 0;
|
return Ok(result);
|
||||||
var skipped = 0;
|
|
||||||
Dictionary<string, AttributeValue>? lastKey = null;
|
|
||||||
|
|
||||||
do
|
|
||||||
{
|
|
||||||
var request = new ScanRequest
|
|
||||||
{
|
|
||||||
TableName = VENDOR_REPLIES_TABLE,
|
|
||||||
Limit = 100,
|
|
||||||
ExclusiveStartKey = lastKey
|
|
||||||
};
|
|
||||||
|
|
||||||
var response = await _dynamo.ScanAsync(request);
|
|
||||||
|
|
||||||
foreach (var item in response.Items)
|
|
||||||
{
|
|
||||||
var replyId = GetString(item, "reply_id");
|
|
||||||
var woNumber = GetString(item, "internal_wo_number");
|
|
||||||
var dispatchNumber = GetString(item, "dispatch_number");
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var alreadyExists = await _db.Comments
|
|
||||||
.AnyAsync(c => c.ExternalCommentId == replyId);
|
|
||||||
if (alreadyExists) { skipped++; synced++; continue; }
|
|
||||||
|
|
||||||
var workOrder = await _db.workOrders
|
|
||||||
.FirstOrDefaultAsync(w => w.InternalWONumber == woNumber);
|
|
||||||
if (workOrder == null) { skipped++; continue; }
|
|
||||||
|
|
||||||
int? dispatchId = null;
|
|
||||||
if (!string.IsNullOrWhiteSpace(dispatchNumber))
|
|
||||||
{
|
|
||||||
var dispatch = await _db.Dispatches
|
|
||||||
.FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrder.Id);
|
|
||||||
dispatchId = dispatch?.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
var comment = new Comments
|
|
||||||
{
|
|
||||||
ExternalCommentId = replyId,
|
|
||||||
WorkerOrderId = workOrder.Id,
|
|
||||||
DispatchId = dispatchId,
|
|
||||||
Commenttext = GetString(item, "reply_text"),
|
|
||||||
Commenter = GetString(item, "sender_email"),
|
|
||||||
CommentType = "vendor",
|
|
||||||
RecordType = "vendor_reply",
|
|
||||||
CreatedDate = ParseDate(GetString(item, "received_at")) ?? DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.Comments.Add(comment);
|
|
||||||
created++;
|
|
||||||
synced++;
|
|
||||||
|
|
||||||
if (synced % 50 == 0)
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
lastKey = response.LastEvaluatedKey;
|
|
||||||
} while (lastKey != null && lastKey.Count > 0);
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new { synced, created, skipped });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("BackfillCommentTypes")]
|
[HttpPost("BackfillCommentTypes")]
|
||||||
public async Task<IActionResult> BackfillCommentTypes()
|
public async Task<IActionResult> BackfillCommentTypes(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var updated = await _db.Database.ExecuteSqlRawAsync(
|
var result = await _syncService.BackfillCommentTypesAsync(cancellationToken);
|
||||||
"UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')");
|
return Ok(result);
|
||||||
return Ok(new { updated });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("All")]
|
[HttpPost("All")]
|
||||||
public async Task<IActionResult> SyncAll()
|
public async Task<IActionResult> SyncAll(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var woResult = await SyncWorkOrders() as OkObjectResult;
|
var result = await _syncService.SyncAllAsync(cancellationToken);
|
||||||
var commentResult = await SyncComments() as OkObjectResult;
|
return Ok(result);
|
||||||
|
|
||||||
return Ok(new
|
|
||||||
{
|
|
||||||
workOrders = woResult?.Value,
|
|
||||||
comments = commentResult?.Value
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<int?> ResolveLocationId(string? siteCode, string? building, string? address)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
var matchCode = siteCode ?? building;
|
|
||||||
var existing = await _db.Locations
|
|
||||||
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode);
|
|
||||||
|
|
||||||
if (existing != null)
|
|
||||||
return existing.Id;
|
|
||||||
|
|
||||||
var location = new Locations
|
|
||||||
{
|
|
||||||
Name = matchCode,
|
|
||||||
Title = building,
|
|
||||||
Address1 = address,
|
|
||||||
Status = "Active"
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.Locations.Add(location);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return location.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? GetString(Dictionary<string, AttributeValue> item, string key)
|
|
||||||
{
|
|
||||||
if (item.TryGetValue(key, out var val) && val.S != null)
|
|
||||||
return val.S;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static DateTime? ParseDate(string? value)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
|
||||||
if (DateTime.TryParse(value, out var dt)) return dt;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? MapStatus(string? dynamoStatus)
|
|
||||||
{
|
|
||||||
return dynamoStatus switch
|
|
||||||
{
|
|
||||||
"new" => "Open",
|
|
||||||
"assigned" => "Open",
|
|
||||||
"in_progress" => "In Progress",
|
|
||||||
"on_hold" => "On Hold",
|
|
||||||
"completed" => "Done",
|
|
||||||
"cancelled" => "Cancelled",
|
|
||||||
"unknown" => "Open",
|
|
||||||
_ => "Open"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? MapSeverityToPriority(string? severity)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(severity)) return null;
|
|
||||||
return $"Sev {severity}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ using Api.SeaHavenIndustries.DTOs;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -12,57 +13,24 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/tasklisttemplates")]
|
[Route("api/tasklisttemplates")]
|
||||||
public class TaskListTemplateController : Controller
|
public class TaskListTemplateController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db;
|
private readonly ITaskListTemplateService _taskListTemplateService;
|
||||||
|
|
||||||
public TaskListTemplateController(ApplicationDbContext db)
|
public TaskListTemplateController(ITaskListTemplateService taskListTemplateService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_taskListTemplateService = taskListTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll()
|
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var data = await _db.TaskListTemplates
|
var data = await _taskListTemplateService.GetAllAsync(cancellationToken);
|
||||||
.Where(t => t.IsActive)
|
|
||||||
.Include(t => t.Items.OrderBy(i => i.SortOrder))
|
|
||||||
.OrderBy(t => t.Name)
|
|
||||||
.Select(t => new
|
|
||||||
{
|
|
||||||
t.Id,
|
|
||||||
t.Name,
|
|
||||||
t.Description,
|
|
||||||
t.IsActive,
|
|
||||||
items = t.Items.OrderBy(i => i.SortOrder).Select(i => new
|
|
||||||
{
|
|
||||||
i.Id,
|
|
||||||
i.ItemText,
|
|
||||||
i.SortOrder
|
|
||||||
}).ToList()
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
|
||||||
return Ok(data);
|
return Ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
public async Task<IActionResult> GetById(int id)
|
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var template = await _db.TaskListTemplates
|
var template = await _taskListTemplateService.GetByIdAsync(id, cancellationToken);
|
||||||
.Include(t => t.Items.OrderBy(i => i.SortOrder))
|
|
||||||
.Where(t => t.Id == id)
|
|
||||||
.Select(t => new
|
|
||||||
{
|
|
||||||
t.Id,
|
|
||||||
t.Name,
|
|
||||||
t.Description,
|
|
||||||
t.IsActive,
|
|
||||||
items = t.Items.OrderBy(i => i.SortOrder).Select(i => new
|
|
||||||
{
|
|
||||||
i.Id,
|
|
||||||
i.ItemText,
|
|
||||||
i.SortOrder
|
|
||||||
}).ToList()
|
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync();
|
|
||||||
|
|
||||||
if (template == null)
|
if (template == null)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Template not found" });
|
return NotFound(new Response { Status = "Error", Message = "Template not found" });
|
||||||
|
|
@ -71,25 +39,13 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create([FromBody] TaskListTemplate_DTO model)
|
public async Task<IActionResult> Create([FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var template = new TaskListTemplate
|
var input = MapToInput(model);
|
||||||
{
|
var id = await _taskListTemplateService.CreateAsync(input, cancellationToken);
|
||||||
Name = model.Name,
|
return Ok(new { message = "Template created", id });
|
||||||
Description = model.Description,
|
|
||||||
IsActive = model.IsActive,
|
|
||||||
Items = model.Items.Select((item, idx) => new TaskListTemplateItem
|
|
||||||
{
|
|
||||||
ItemText = item.ItemText,
|
|
||||||
SortOrder = item.SortOrder > 0 ? item.SortOrder : idx
|
|
||||||
}).ToList()
|
|
||||||
};
|
|
||||||
|
|
||||||
_db.TaskListTemplates.Add(template);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new { message = "Template created", id = template.Id });
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|
@ -98,31 +54,15 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{id}")]
|
||||||
public async Task<IActionResult> Update(int id, [FromBody] TaskListTemplate_DTO model)
|
public async Task<IActionResult> Update(int id, [FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var template = await _db.TaskListTemplates
|
var input = MapToInput(model);
|
||||||
.Include(t => t.Items)
|
var updated = await _taskListTemplateService.UpdateAsync(id, input, cancellationToken);
|
||||||
.FirstOrDefaultAsync(t => t.Id == id);
|
if (!updated)
|
||||||
|
|
||||||
if (template == null)
|
|
||||||
return NotFound(new Response { Status = "Error", Message = "Template not found" });
|
return NotFound(new Response { Status = "Error", Message = "Template not found" });
|
||||||
|
|
||||||
template.Name = model.Name;
|
|
||||||
template.Description = model.Description;
|
|
||||||
template.IsActive = model.IsActive;
|
|
||||||
|
|
||||||
_db.TaskListTemplateItems.RemoveRange(template.Items);
|
|
||||||
|
|
||||||
template.Items = model.Items.Select((item, idx) => new TaskListTemplateItem
|
|
||||||
{
|
|
||||||
TaskListTemplateId = id,
|
|
||||||
ItemText = item.ItemText,
|
|
||||||
SortOrder = item.SortOrder > 0 ? item.SortOrder : idx
|
|
||||||
}).ToList();
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new { message = "Template updated" });
|
return Ok(new { message = "Template updated" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -132,15 +72,28 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
public async Task<IActionResult> Delete(int id)
|
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var template = await _db.TaskListTemplates.FindAsync(id);
|
var deleted = await _taskListTemplateService.DeleteAsync(id, cancellationToken);
|
||||||
if (template == null)
|
if (!deleted)
|
||||||
return NotFound(new Response { Status = "Error", Message = "Template not found" });
|
return NotFound(new Response { Status = "Error", Message = "Template not found" });
|
||||||
|
|
||||||
template.IsActive = false;
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new { message = "Template deleted" });
|
return Ok(new { message = "Template deleted" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static TaskListTemplateInput MapToInput(TaskListTemplate_DTO model)
|
||||||
|
{
|
||||||
|
return new TaskListTemplateInput
|
||||||
|
{
|
||||||
|
Name = model.Name,
|
||||||
|
Description = model.Description,
|
||||||
|
IsActive = model.IsActive,
|
||||||
|
Items = model.Items.Select(item => new TaskListTemplateInputItem
|
||||||
|
{
|
||||||
|
ItemText = item.ItemText,
|
||||||
|
SortOrder = item.SortOrder
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.DTOs;
|
||||||
using System.Security.Claims;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -12,225 +12,75 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/uplifts")]
|
[Route("api/uplifts")]
|
||||||
public class UpliftController : Controller
|
public class UpliftController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db;
|
private readonly IUpliftService _upliftService;
|
||||||
private readonly IConfiguration _config;
|
|
||||||
|
|
||||||
public UpliftController(ApplicationDbContext db, IConfiguration config)
|
public UpliftController(IUpliftService upliftService)
|
||||||
{
|
{
|
||||||
_db = db;
|
_upliftService = upliftService;
|
||||||
_config = config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[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
|
var result = await _upliftService.ListAsync(User, status, tier, page, pageSize, cancellationToken);
|
||||||
join d in _db.Dispatches on u.DispatchId equals d.Id
|
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||||
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 }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("dispatch/{dispatchId:int}")]
|
[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
|
var items = await _upliftService.ListForDispatchAsync(User, dispatchId, cancellationToken);
|
||||||
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();
|
|
||||||
|
|
||||||
return Ok(new DataResponse { Status = "Success", Data = items });
|
return Ok(new DataResponse { Status = "Success", Data = items });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/approve")]
|
[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);
|
try
|
||||||
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
|
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
var result = await _upliftService.ApproveAsync(User, id, body?.Note, cancellationToken);
|
||||||
UserId = userId,
|
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||||
FieldName = $"Dispatch {dispatch.DispatchNumber} NTE",
|
}
|
||||||
OldValue = $"${oldNTE:F2}",
|
catch (KeyNotFoundException ex)
|
||||||
NewValue = $"${req.RequestedNTE:F2}",
|
{
|
||||||
Action = "uplift_approved",
|
return NotFound(new Response { Status = "Error", Message = ex.Message });
|
||||||
CreatedAt = now
|
}
|
||||||
});
|
catch (UpliftForbiddenException ex)
|
||||||
|
{
|
||||||
await _db.SaveChangesAsync();
|
return StatusCode(403, new Response { Status = "Error", Message = ex.Message });
|
||||||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, dispatch.NTEAmount } });
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/deny")]
|
[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))
|
try
|
||||||
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
|
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch?.WorkOrderId ?? 0,
|
var result = await _upliftService.DenyAsync(User, id, body?.Note, cancellationToken);
|
||||||
UserId = userId,
|
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||||
FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift",
|
}
|
||||||
OldValue = "Pending",
|
catch (KeyNotFoundException ex)
|
||||||
NewValue = "Denied",
|
{
|
||||||
Action = "uplift_denied",
|
return NotFound(new Response { Status = "Error", Message = ex.Message });
|
||||||
CreatedAt = now
|
}
|
||||||
});
|
catch (UpliftForbiddenException ex)
|
||||||
|
{
|
||||||
await _db.SaveChangesAsync();
|
return StatusCode(403, new Response { Status = "Error", Message = ex.Message });
|
||||||
return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } });
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("can-approve")]
|
[HttpGet("can-approve")]
|
||||||
public IActionResult CanApprove([FromQuery] int tier)
|
public IActionResult CanApprove([FromQuery] int tier)
|
||||||
{
|
{
|
||||||
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = UserCanApprove(tier) } });
|
return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DecisionRequest
|
public class DecisionRequest
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,10 @@
|
||||||
using Api.SeaHavenIndustries.DTOs;
|
using Api.SeaHavenIndustries.DTOs;
|
||||||
using Api.SeaHavenIndustries.Helper;
|
|
||||||
using Api.SeaHavenIndustries.Models;
|
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.DTOs;
|
||||||
using PasswordGenerator;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SendGrid.Helpers.Mail;
|
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -18,107 +13,36 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[Route("api/User")]
|
[Route("api/User")]
|
||||||
public class UserController : Controller
|
public class UserController : Controller
|
||||||
{
|
{
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly IUserService _userService;
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
private readonly SendMessage _sendemail;
|
|
||||||
|
|
||||||
public UserController(UserManager<ApplicationUser> userManager, ApplicationDbContext db, SendMessage sendemail)
|
public UserController(IUserService userService)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userService = userService;
|
||||||
_db = db;
|
|
||||||
_sendemail = sendemail;
|
|
||||||
}
|
}
|
||||||
[Route("")]
|
[Route("")]
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetUsers()
|
public async Task<IActionResult> GetUsers(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_db.ChangeTracker.Clear();
|
var users = await _userService.GetUsersAsync(cancellationToken);
|
||||||
|
return Ok(users);
|
||||||
var allUsers = _db.Users.Select(s => new UserRoleViewModel
|
|
||||||
{
|
|
||||||
RoleName = s.PhoneNumber,
|
|
||||||
Email = s.Email,
|
|
||||||
Name = s.FirstName,
|
|
||||||
Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "",
|
|
||||||
Status = s.UniqueName,
|
|
||||||
Id = s.Id
|
|
||||||
}).AsQueryable();
|
|
||||||
return Ok(allUsers);
|
|
||||||
}
|
}
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("AddUser")]
|
[Route("AddUser")]
|
||||||
public async Task<IActionResult> AddUser(User_DTO user)
|
public async Task<IActionResult> AddUser(User_DTO user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|
||||||
var model = new ApplicationUser
|
|
||||||
{
|
|
||||||
UserName = user.Email,
|
|
||||||
FirstName = user.Name,
|
|
||||||
Email = user.Email,
|
|
||||||
};
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_db.ChangeTracker.Clear();
|
var dto = new AddUserRequestDTO
|
||||||
var exist = _db.Set<ApplicationUser>().Find(model.Id);
|
|
||||||
if (exist == null)
|
|
||||||
{
|
{
|
||||||
model.EmailConfirmed = true;
|
Name = user.Name,
|
||||||
model.UserName = model.Email;
|
Email = user.Email,
|
||||||
model.CreatedDate = DateTime.Now;
|
Contact = user.Contact,
|
||||||
model.UniqueName = "Active";
|
Role = user.Role
|
||||||
model.PhoneNumber = user.Role;
|
};
|
||||||
|
var outcome = await _userService.AddUserAsync(dto, cancellationToken);
|
||||||
var pwd = new Password(8).IncludeLowercase().IncludeUppercase().IncludeSpecial().IncludeNumeric();
|
if (!outcome.Success)
|
||||||
var password = pwd.Next();
|
|
||||||
|
|
||||||
var result = await _userManager.CreateAsync(model, password);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
await _userManager.AddToRoleAsync(model, user.Role ?? "");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return BadRequest(new Response { Status = "Error", Message = result.Errors.FirstOrDefault()?.Description ?? "error" });
|
|
||||||
}
|
|
||||||
var request = HttpContext?.Request;
|
|
||||||
//var domain = $"{request?.Scheme}://{request?.Host}";
|
|
||||||
var domain = "http://shoc-ui-app.s3-website-us-east-1.amazonaws.com/#";
|
|
||||||
string subject = "Login Information";
|
|
||||||
string greeting = $"Dear {model.FirstName},\n\n";
|
|
||||||
string loginLink = $"Click here to login:<a href='{domain}/Login'>Login</a>\n";
|
|
||||||
string passwordInfo = $"Your password is: {password}\n\n";
|
|
||||||
string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}";
|
|
||||||
await _sendemail.SendEMail(model?.Email ?? "", subject, body);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = outcome.Error });
|
||||||
var exist1 = await _userManager.Users.FirstOrDefaultAsync(w => w.Id == model.Id);
|
|
||||||
if (exist1 == null)
|
|
||||||
return BadRequest(new Response { Status = "Error", Message = "User not found" });
|
|
||||||
|
|
||||||
var existingRole = await _userManager.GetRolesAsync(exist1);
|
|
||||||
|
|
||||||
// Remove from roles if any exist
|
|
||||||
if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber)
|
|
||||||
{
|
|
||||||
await _userManager.RemoveFromRolesAsync(exist1, existingRole);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user details
|
|
||||||
exist1.FirstName = model.FirstName;
|
|
||||||
exist1.LastName = model.LastName;
|
|
||||||
exist1.Contact = model.Contact;
|
|
||||||
exist1.PhoneNumber = model.PhoneNumber;
|
|
||||||
|
|
||||||
// Optionally, detach the entity if you're about to attach it again
|
|
||||||
_db.Entry(exist1).State = EntityState.Detached;
|
|
||||||
|
|
||||||
// Add to role after setting properties
|
|
||||||
await _userManager.AddToRoleAsync(exist1, user.Role ?? "");
|
|
||||||
var result = await _userManager.UpdateAsync(exist1);
|
|
||||||
//await _context.SaveChangesAsync();
|
|
||||||
}
|
}
|
||||||
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
||||||
|
|
||||||
|
|
@ -131,31 +55,20 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
[HttpPut]
|
[HttpPut]
|
||||||
[Route("EditUser")]
|
[Route("EditUser")]
|
||||||
public async Task<IActionResult> EditUser(EditUser_DTO user)
|
public async Task<IActionResult> EditUser(EditUser_DTO user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var exist = _db.Users.Where(w => w.Id == user.Id).FirstOrDefault();
|
var dto = new EditUserRequestDTO
|
||||||
if (exist == null)
|
|
||||||
return BadRequest(new Response { Status = "Error", Message = "User not found" });
|
|
||||||
exist.FirstName = user.Name;
|
|
||||||
exist.EmailConfirmed = true;
|
|
||||||
exist.UserName = user.Email;
|
|
||||||
exist.Email = user.Email;
|
|
||||||
exist.NormalizedEmail = user.Email.ToUpper();
|
|
||||||
exist.NormalizedUserName = user.Email.ToUpper();
|
|
||||||
exist.CreatedDate = DateTime.Now;
|
|
||||||
exist.UniqueName = "Active";
|
|
||||||
exist.PhoneNumber = user.Role;
|
|
||||||
var existingRole = await _userManager.GetRolesAsync(exist);
|
|
||||||
|
|
||||||
if (existingRole != null && existingRole.Any())
|
|
||||||
{
|
{
|
||||||
await _userManager.RemoveFromRolesAsync(exist, existingRole);
|
Id = user.Id,
|
||||||
}
|
Name = user.Name,
|
||||||
await _userManager.AddToRoleAsync(exist, user.Role ?? "");
|
Email = user.Email,
|
||||||
|
Role = user.Role
|
||||||
await _db.SaveChangesAsync();
|
};
|
||||||
|
var succeeded = await _userService.EditUserAsync(dto, cancellationToken);
|
||||||
|
if (!succeeded)
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = "User not found" });
|
||||||
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -163,53 +76,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public class DeleteUserRequest
|
|
||||||
{
|
|
||||||
public string Id { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[HttpDelete]
|
[HttpDelete]
|
||||||
[Route("DeleteUser")]
|
[Route("DeleteUser")]
|
||||||
public async Task<IActionResult> DeleteUser(EditUser_DTO req)
|
public async Task<IActionResult> DeleteUser(EditUser_DTO req, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
string id = req.Id;
|
string id = req.Id ?? "";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var data = await _db.Users.Where(w => w.Id == id).FirstOrDefaultAsync();
|
var found = await _userService.DeleteUserAsync(id, cancellationToken);
|
||||||
|
if (!found)
|
||||||
if (data != null)
|
|
||||||
{
|
|
||||||
var existworkorder = _db.workOrders.Where(w => w.AssignTo.Contains(data.Id)).ToList();
|
|
||||||
foreach (var workorder in existworkorder)
|
|
||||||
{
|
|
||||||
var existdata = _db.workOrders.Where(w => w.Id == workorder.Id).FirstOrDefault();
|
|
||||||
existdata.AssignTo = null;
|
|
||||||
}
|
|
||||||
var existworkordercomments = _db.Comments.Where(w => w.UserId.Contains(data.Id)).ToList();
|
|
||||||
foreach (var workorderc in existworkordercomments)
|
|
||||||
{
|
|
||||||
var existdatacomm = _db.Comments.Where(w => w.Id == workorderc.Id).FirstOrDefault();
|
|
||||||
existdatacomm.UserId = null;
|
|
||||||
}
|
|
||||||
var existtemplate = _db.Templates.Where(w => w.AssignTo.Contains(data.Id)).ToList();
|
|
||||||
foreach (var workordert in existtemplate)
|
|
||||||
{
|
|
||||||
var existdattem = _db.Templates.Where(w => w.Id == workordert.Id).FirstOrDefault();
|
|
||||||
existdattem.AssignTo = null;
|
|
||||||
}
|
|
||||||
var existtempexistuserrolelate = _db.UserRoles.Where(w => w.UserId.Contains(data.Id)).ToList();
|
|
||||||
if (existtempexistuserrolelate.Count() > 0)
|
|
||||||
{
|
|
||||||
_db.UserRoles.RemoveRange(existtempexistuserrolelate);
|
|
||||||
}
|
|
||||||
_db.Users.Remove(data);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
{
|
||||||
return BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
|
return BadRequest(new Response { Status = "Error", Message = "ID not matched!" });
|
||||||
}
|
}
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -220,17 +99,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Route("DeleteCurrentUser")]
|
[Route("DeleteCurrentUser")]
|
||||||
public async Task<IActionResult> DeleteCurrentUser()
|
public async Task<IActionResult> DeleteCurrentUser(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
||||||
var exist = _db.Users.Where(w => w.Id == userid).FirstOrDefault();
|
await _userService.DeleteCurrentUserAsync(userid, cancellationToken);
|
||||||
if(exist != null)
|
|
||||||
{
|
|
||||||
exist.IsDeleted = true;
|
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|
@ -241,64 +115,19 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("UserProfile")]
|
[Route("UserProfile")]
|
||||||
public async Task<IActionResult> UserProfile()
|
public async Task<IActionResult> UserProfile(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "";
|
||||||
|
|
||||||
var data = await _db.Users.Where(w => w.Id == userid).Select(s => new
|
var data = await _userService.GetUserProfileAsync(userid, cancellationToken);
|
||||||
|
return Ok(data.Select(d => new
|
||||||
{
|
{
|
||||||
s.Id,
|
d.Id,
|
||||||
Name = s.FirstName,
|
Name = d.Name,
|
||||||
addeddate = s.CreatedDate,
|
addeddate = d.AddedDate,
|
||||||
s.Email,
|
d.Email,
|
||||||
s.Contact
|
d.Contact
|
||||||
}).ToListAsync();
|
}));
|
||||||
return Ok(data);
|
|
||||||
}
|
|
||||||
public static string GenerateRandomPassword(int length = 8)
|
|
||||||
{
|
|
||||||
const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?";
|
|
||||||
const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
||||||
const string digits = "1234567890";
|
|
||||||
const string symbols = "!@#$%^&*()_-+=<>?";
|
|
||||||
|
|
||||||
using (var rng = RandomNumberGenerator.Create())
|
|
||||||
{
|
|
||||||
var passwordChars = new char[length];
|
|
||||||
|
|
||||||
// Ensure at least one capital letter
|
|
||||||
passwordChars[0] = capitalLetters[GetNextNonNegativeIndex(rng, capitalLetters.Length)];
|
|
||||||
|
|
||||||
// Ensure at least one digit
|
|
||||||
passwordChars[1] = digits[GetNextNonNegativeIndex(rng, digits.Length)];
|
|
||||||
|
|
||||||
// Ensure at least one symbol
|
|
||||||
passwordChars[2] = symbols[GetNextNonNegativeIndex(rng, symbols.Length)];
|
|
||||||
|
|
||||||
// Fill the rest of the password with random characters
|
|
||||||
for (int i = 3; i < length; i++)
|
|
||||||
{
|
|
||||||
passwordChars[i] = validChars[GetNextNonNegativeIndex(rng, validChars.Length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shuffle the password characters, excluding the initial characters
|
|
||||||
Random random = new Random();
|
|
||||||
for (int i = length - 1; i > 2; i--)
|
|
||||||
{
|
|
||||||
int j = random.Next(2, i + 1);
|
|
||||||
(passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new string(passwordChars);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetNextNonNegativeIndex(RandomNumberGenerator rng, int maxValue)
|
|
||||||
{
|
|
||||||
byte[] randomBytes = new byte[4];
|
|
||||||
rng.GetBytes(randomBytes);
|
|
||||||
int nextIndex = BitConverter.ToInt32(randomBytes, 0) % maxValue;
|
|
||||||
return (nextIndex + maxValue) % maxValue; // Ensure the index is non-negative
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ using Data.SeaHavenIndustries;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
using SeaHaven.Services.DTOs;
|
using SeaHaven.Services.DTOs;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
@ -17,18 +16,10 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
public class VendorController : Controller
|
public class VendorController : Controller
|
||||||
{
|
{
|
||||||
private readonly IVendorService _vendorService;
|
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;
|
_vendorService = vendorService;
|
||||||
_db = db;
|
|
||||||
_zipDistance = zipDistance;
|
|
||||||
_vendorTokens = vendorTokens;
|
|
||||||
_config = config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("GetVendorList")]
|
[HttpGet("GetVendorList")]
|
||||||
|
|
@ -318,158 +309,41 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("Dropdown")]
|
[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 result = await _vendorService.GetDropdownAsync(trade, siteZip, cancellationToken);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("facets")]
|
[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
|
var result = await _vendorService.GetFacetsAsync(isActive, cancellationToken);
|
||||||
.AsNoTracking()
|
return Ok(result);
|
||||||
.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+" }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id:int}/portal-token")]
|
[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);
|
var result = await _vendorService.GetPortalTokenAsync(id, cancellationToken);
|
||||||
if (vendor == null) return NotFound();
|
if (result == null) return NotFound();
|
||||||
|
return Ok(result);
|
||||||
var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id);
|
|
||||||
return Ok(BuildPortalTokenResponse(token));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/portal-token/rotate")]
|
[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);
|
var result = await _vendorService.RotatePortalTokenAsync(id, cancellationToken);
|
||||||
if (vendor == null) return NotFound();
|
if (result == null) return NotFound();
|
||||||
|
return Ok(result);
|
||||||
var token = await _vendorTokens.RotateAsync(id);
|
|
||||||
return Ok(BuildPortalTokenResponse(token));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:int}/portal-token/revoke")]
|
[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);
|
var success = await _vendorService.RevokePortalTokenAsync(id, cancellationToken);
|
||||||
if (vendor == null) return NotFound();
|
if (!success) return NotFound();
|
||||||
|
|
||||||
await _vendorTokens.RevokeAllAsync(id);
|
|
||||||
return Ok(new { revoked = true });
|
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"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
using Api.SeaHavenIndustries.Helper;
|
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using SeaHaven.Services.DTOs;
|
||||||
using Microsoft.Extensions.Configuration;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
|
|
@ -13,650 +12,221 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
private const string TokenHeader = "X-Vendor-Token";
|
private const string TokenHeader = "X-Vendor-Token";
|
||||||
|
|
||||||
private static readonly string[] VendorViewableStatuses =
|
private readonly IVendorPortalService _portalService;
|
||||||
new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" };
|
|
||||||
|
|
||||||
private readonly ApplicationDbContext _db;
|
public VendorPortalController(IVendorPortalService portalService)
|
||||||
private readonly VendorPortalTokenService _tokens;
|
|
||||||
private readonly IConfiguration _config;
|
|
||||||
private readonly SendMessage _sendMessage;
|
|
||||||
|
|
||||||
public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage)
|
|
||||||
{
|
{
|
||||||
_db = db;
|
_portalService = portalService;
|
||||||
_tokens = tokens;
|
|
||||||
_config = config;
|
|
||||||
_sendMessage = sendMessage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("session")]
|
[HttpGet("session")]
|
||||||
public async Task<IActionResult> Session()
|
public async Task<IActionResult> Session(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var vendor = await ResolveVendorAsync();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
return Ok(new DataResponse
|
return Ok(new DataResponse
|
||||||
{
|
{
|
||||||
Status = "Success",
|
Status = "Success",
|
||||||
Data = new
|
Data = session
|
||||||
{
|
|
||||||
vendor.Id,
|
|
||||||
vendor.CompanyName,
|
|
||||||
vendor.ContactName,
|
|
||||||
vendor.Email,
|
|
||||||
vendor.Phone
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("dispatches")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == 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 dispatches = await _portalService.ListDispatchesAsync(session, status, cancellationToken);
|
||||||
return Ok(new DataResponse { Status = "Success", Data = dispatches });
|
return Ok(new DataResponse { Status = "Success", Data = dispatches });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("dispatches/{id:int}")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
var detail = await _portalService.GetDispatchDetailAsync(session, id, cancellationToken);
|
||||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
if (detail == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
|
|
||||||
var checklist = await _db.DispatchChecklistItems
|
return Ok(new DataResponse { Status = "Success", Data = detail });
|
||||||
.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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/accept")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
try
|
||||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
|
||||||
|
|
||||||
if (dispatch.Status != "Sent")
|
|
||||||
{
|
{
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
dispatch.Status = "Acknowledged";
|
|
||||||
dispatch.AcknowledgedAt = now;
|
|
||||||
dispatch.LastModificationTime = now;
|
|
||||||
|
|
||||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
}
|
||||||
OldValue = "Sent",
|
catch (InvalidOperationException ex)
|
||||||
NewValue = "Acknowledged",
|
{
|
||||||
Action = "vendor_accept",
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
CreatedAt = now
|
}
|
||||||
});
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/status")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
try
|
||||||
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))
|
|
||||||
{
|
{
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
dispatch.Status = to;
|
|
||||||
dispatch.LastModificationTime = now;
|
|
||||||
if (to == "Completed") dispatch.CompletedDate = now;
|
|
||||||
|
|
||||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
FieldName = $"Dispatch {dispatch.DispatchNumber} Status",
|
}
|
||||||
OldValue = from,
|
catch (InvalidOperationException ex)
|
||||||
NewValue = to,
|
{
|
||||||
Action = "vendor_status_change",
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
CreatedAt = now
|
}
|
||||||
});
|
|
||||||
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/request-cancel")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
try
|
||||||
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 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" });
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim();
|
|
||||||
|
|
||||||
_db.Comments.Add(new Comments
|
|
||||||
{
|
{
|
||||||
DispatchId = id,
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
WorkerOrderId = dispatch.WorkOrderId,
|
}
|
||||||
Commenter = vendor.CompanyName,
|
catch (InvalidOperationException ex)
|
||||||
CommentType = "vendor",
|
|
||||||
RecordType = "cancel_request",
|
|
||||||
Commenttext = $"Vendor requested cancellation: {reason}",
|
|
||||||
CreatedDate = now
|
|
||||||
});
|
|
||||||
|
|
||||||
_db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog
|
|
||||||
{
|
{
|
||||||
WorkOrderId = dispatch.WorkOrderId ?? 0,
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/checklist/{itemId:int}")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
try
|
||||||
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 = "Dispatch is locked" });
|
var result = await _portalService.UpdateChecklistItemAsync(session, id, itemId, body.IsCompleted, cancellationToken);
|
||||||
|
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
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
|
|
||||||
{
|
{
|
||||||
Status = "Success",
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }
|
}
|
||||||
});
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/signoff")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
try
|
||||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
|
||||||
|
|
||||||
if (dispatch.Status != "In Progress" && dispatch.Status != "Completed")
|
|
||||||
{
|
{
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature))
|
|
||||||
{
|
{
|
||||||
return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" });
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant();
|
|
||||||
if (signoffType != "vendor" && signoffType != "customer")
|
|
||||||
{
|
{
|
||||||
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")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
try
|
||||||
if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(body?.CommentText))
|
|
||||||
{
|
{
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var comment = new Comments
|
|
||||||
{
|
{
|
||||||
DispatchId = id,
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
WorkerOrderId = dispatch.WorkOrderId,
|
}
|
||||||
Commenter = vendor.CompanyName,
|
catch (InvalidOperationException ex)
|
||||||
CommentType = "vendor",
|
|
||||||
RecordType = "comment",
|
|
||||||
Commenttext = body!.CommentText,
|
|
||||||
CreatedDate = now
|
|
||||||
};
|
|
||||||
_db.Comments.Add(comment);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
return Ok(new DataResponse
|
|
||||||
{
|
{
|
||||||
Status = "Success",
|
return BadRequest(new Response { Status = "Error", Message = ex.Message });
|
||||||
Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate }
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("dispatches/{id:int}/uplift-request")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
||||||
|
|
||||||
var dispatch = await LoadVendorDispatchAsync(id, vendor.Id);
|
try
|
||||||
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 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 });
|
||||||
}
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
if (body == null || body.RequestedNTE <= 0)
|
|
||||||
{
|
{
|
||||||
return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" });
|
return NotFound(new Response { Status = "Error", Message = "Dispatch not found" });
|
||||||
}
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
var current = dispatch.NTEAmount ?? 0m;
|
|
||||||
if (body.RequestedNTE <= current)
|
|
||||||
{
|
{
|
||||||
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")]
|
[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();
|
var session = await ResolveSessionAsync(cancellationToken);
|
||||||
if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" });
|
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
|
try
|
||||||
{
|
{
|
||||||
var dispatchAudit = await _db.WorkOrderAuditLogs
|
var result = await _portalService.CancelUpliftRequestAsync(session, id, requestId, cancellationToken);
|
||||||
.Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId))
|
return Ok(new DataResponse { Status = "Success", Data = result });
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
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;
|
if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null;
|
||||||
var token = values.ToString();
|
var token = values.ToString();
|
||||||
return await _tokens.ResolveVendorAsync(token);
|
return await _portalService.ResolveSessionAsync(token, cancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ChangeStatusRequest
|
public class ChangeStatusRequest
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
12
Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs
Normal file
12
Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace Api.SeaHavenIndustries.DTOs
|
||||||
|
{
|
||||||
|
public class DropdownOption_DTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Category { get; set; } = "";
|
||||||
|
public string Value { get; set; } = "";
|
||||||
|
public string? ParentValue { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs
Normal file
20
Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
using Api.SeaHavenIndustries.Helper;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Helper
|
||||||
|
{
|
||||||
|
public class SendGridEmailSender : IEmailSender
|
||||||
|
{
|
||||||
|
private readonly SendMessage _sendMessage;
|
||||||
|
|
||||||
|
public SendGridEmailSender(SendMessage sendMessage)
|
||||||
|
{
|
||||||
|
_sendMessage = sendMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> SendEmailAsync(string emailTo, string subject, string body)
|
||||||
|
{
|
||||||
|
return _sendMessage.SendEMail(emailTo, subject, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
|
|
||||||
|
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
using SendGrid;
|
using SendGrid;
|
||||||
using SendGrid.Helpers.Mail;
|
using SendGrid.Helpers.Mail;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Helper
|
namespace Api.SeaHavenIndustries.Helper
|
||||||
{
|
{
|
||||||
public class SendMessage
|
public class SendMessage : IEmailSender
|
||||||
{
|
{
|
||||||
private IConfiguration _configuration;
|
private IConfiguration _configuration;
|
||||||
//string keysapi = "";
|
//string keysapi = "";
|
||||||
|
|
@ -58,6 +59,8 @@ namespace Api.SeaHavenIndustries.Helper
|
||||||
return false;
|
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)
|
public async Task<bool> SendEMailAttachment(string emailTo, string subject, byte[] pdfBytes)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -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('=');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Helper
|
namespace Api.SeaHavenIndustries.Helper
|
||||||
{
|
{
|
||||||
public class ZipCodeDistance
|
public class ZipCodeDistance : IZipCodeDistance
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, (double lat, double lng)> _coords = new();
|
private readonly Dictionary<string, (double lat, double lng)> _coords = new();
|
||||||
private bool _loaded;
|
private bool _loaded;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
using Amazon.DynamoDBv2;
|
||||||
|
using Amazon.DynamoDBv2.Model;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Infrastructure
|
||||||
|
{
|
||||||
|
public class DynamoSyncExternalSource : ISyncExternalSource
|
||||||
|
{
|
||||||
|
private readonly AmazonDynamoDBClient _dynamo;
|
||||||
|
private const string WO_TABLE = "WorkOrders";
|
||||||
|
private const string COMMENTS_TABLE = "WorkOrderComments";
|
||||||
|
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
|
||||||
|
|
||||||
|
public DynamoSyncExternalSource()
|
||||||
|
{
|
||||||
|
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Dictionary<string, string?>> ScanWorkOrdersAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Dictionary<string, AttributeValue>? lastKey = null;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var request = new ScanRequest
|
||||||
|
{
|
||||||
|
TableName = WO_TABLE,
|
||||||
|
Limit = 100,
|
||||||
|
ExclusiveStartKey = lastKey
|
||||||
|
};
|
||||||
|
var response = await _dynamo.ScanAsync(request);
|
||||||
|
foreach (var item in response.Items)
|
||||||
|
yield return ConvertItem(item);
|
||||||
|
lastKey = response.LastEvaluatedKey;
|
||||||
|
} while (lastKey != null && lastKey.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Dictionary<string, string?>> ScanCommentsAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Dictionary<string, AttributeValue>? lastKey = null;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var request = new ScanRequest
|
||||||
|
{
|
||||||
|
TableName = COMMENTS_TABLE,
|
||||||
|
Limit = 100,
|
||||||
|
ExclusiveStartKey = lastKey
|
||||||
|
};
|
||||||
|
var response = await _dynamo.ScanAsync(request);
|
||||||
|
foreach (var item in response.Items)
|
||||||
|
yield return ConvertItem(item);
|
||||||
|
lastKey = response.LastEvaluatedKey;
|
||||||
|
} while (lastKey != null && lastKey.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<Dictionary<string, string?>> ScanVendorRepliesAsync(
|
||||||
|
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Dictionary<string, AttributeValue>? lastKey = null;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var request = new ScanRequest
|
||||||
|
{
|
||||||
|
TableName = VENDOR_REPLIES_TABLE,
|
||||||
|
Limit = 100,
|
||||||
|
ExclusiveStartKey = lastKey
|
||||||
|
};
|
||||||
|
var response = await _dynamo.ScanAsync(request);
|
||||||
|
foreach (var item in response.Items)
|
||||||
|
yield return ConvertItem(item);
|
||||||
|
lastKey = response.LastEvaluatedKey;
|
||||||
|
} while (lastKey != null && lastKey.Count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string?> ConvertItem(Dictionary<string, AttributeValue> item)
|
||||||
|
{
|
||||||
|
var result = new Dictionary<string, string?>();
|
||||||
|
foreach (var kvp in item)
|
||||||
|
{
|
||||||
|
if (kvp.Value.S != null)
|
||||||
|
result[kvp.Key] = kvp.Value.S;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs
Normal file
75
Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Infrastructure
|
||||||
|
{
|
||||||
|
public class FileStorageAdapter : IFileStoragePort
|
||||||
|
{
|
||||||
|
private readonly IWebHostEnvironment _webHostEnvironment;
|
||||||
|
private readonly IHttpContextAccessor _httpContext;
|
||||||
|
|
||||||
|
public FileStorageAdapter(IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext)
|
||||||
|
{
|
||||||
|
_webHostEnvironment = webHostEnvironment;
|
||||||
|
_httpContext = httpContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> SaveFileAsync(IFormFile file)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(file.FileName);
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
throw new InvalidDataException("The uploaded file must have a valid name.");
|
||||||
|
|
||||||
|
var uniqueFileName = $"{Guid.NewGuid()}_{fileName}";
|
||||||
|
var uploadPath = Path.Combine("Assets", "Documents");
|
||||||
|
var webRoot = _webHostEnvironment.WebRootPath
|
||||||
|
?? Path.Combine(_webHostEnvironment.ContentRootPath, "wwwroot");
|
||||||
|
var fullPath = Path.Combine(webRoot, uploadPath, uniqueFileName);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||||
|
|
||||||
|
using (var stream = System.IO.File.Create(fullPath))
|
||||||
|
{
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = _httpContext.HttpContext?.Request
|
||||||
|
?? throw new InvalidOperationException("An active HTTP request is required to build the file URL.");
|
||||||
|
var domain = $"{request.Scheme}://{request.Host}";
|
||||||
|
return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchEmailAdapter : IDispatchEmailPort
|
||||||
|
{
|
||||||
|
private readonly Helper.SendMessage _sendMessage;
|
||||||
|
|
||||||
|
public DispatchEmailAdapter(Helper.SendMessage sendMessage)
|
||||||
|
{
|
||||||
|
_sendMessage = sendMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo)
|
||||||
|
{
|
||||||
|
return await _sendMessage.SendDispatchEmail(emailTo, subject, htmlBody, replyTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VendorTokenAdapter : IVendorTokenPort
|
||||||
|
{
|
||||||
|
private readonly IVendorPortalTokenService _tokenService;
|
||||||
|
|
||||||
|
public VendorTokenAdapter(IVendorPortalTokenService tokenService)
|
||||||
|
{
|
||||||
|
_tokenService = tokenService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetOrCreateActiveTokenAsync(int vendorId)
|
||||||
|
{
|
||||||
|
var token = await _tokenService.GetOrCreateActiveTokenAsync(
|
||||||
|
vendorId, CancellationToken.None);
|
||||||
|
return token.Token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,8 @@ builder.Services.AddCors(option =>
|
||||||
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
|
||||||
|
|
||||||
builder.Services.AddScoped<SendMessage>();
|
builder.Services.AddScoped<SendMessage>();
|
||||||
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.VendorPortalTokenService>();
|
builder.Services.AddScoped<Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
||||||
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IEmailSender, Api.SeaHavenIndustries.Helper.SendGridEmailSender>();
|
||||||
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp =>
|
||||||
{
|
{
|
||||||
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance();
|
||||||
|
|
@ -47,9 +48,15 @@ builder.Services.AddSingleton<Api.SeaHavenIndustries.Helper.ZipCodeDistance>(sp
|
||||||
if (File.Exists(csvPath)) zipService.Load(csvPath);
|
if (File.Exists(csvPath)) zipService.Load(csvPath);
|
||||||
return zipService;
|
return zipService;
|
||||||
});
|
});
|
||||||
|
builder.Services.AddSingleton<SeaHaven.Services.Interfaces.IZipCodeDistance>(
|
||||||
|
serviceProvider => serviceProvider.GetRequiredService<Api.SeaHavenIndustries.Helper.ZipCodeDistance>());
|
||||||
|
|
||||||
builder.Services.AddDataServices();
|
builder.Services.AddDataServices();
|
||||||
builder.Services.AddBusinessServices();
|
builder.Services.AddBusinessServices(configuration);
|
||||||
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.ISyncExternalSource, Api.SeaHavenIndustries.Infrastructure.DynamoSyncExternalSource>();
|
||||||
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IFileStoragePort, Api.SeaHavenIndustries.Infrastructure.FileStorageAdapter>();
|
||||||
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IDispatchEmailPort, Api.SeaHavenIndustries.Infrastructure.DispatchEmailAdapter>();
|
||||||
|
builder.Services.AddScoped<SeaHaven.Services.Interfaces.IVendorTokenPort, Api.SeaHavenIndustries.Infrastructure.VendorTokenAdapter>();
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -67,7 +74,9 @@ builder.Services.AddAuthentication(options =>
|
||||||
ValidateAudience = true,
|
ValidateAudience = true,
|
||||||
ValidAudience = configuration["JWT:ValidAudience"],
|
ValidAudience = configuration["JWT:ValidAudience"],
|
||||||
ValidIssuer = configuration["JWT:ValidIssuer"],
|
ValidIssuer = configuration["JWT:ValidIssuer"],
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Secret"]))
|
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(
|
||||||
|
configuration["JWT:Secret"]
|
||||||
|
?? throw new InvalidOperationException("JWT:Secret configuration is required")))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
|
|
||||||
|
|
@ -1,564 +1,106 @@
|
||||||
# Backend Architecture Guide
|
# Backend Architecture and Quality Rules
|
||||||
|
|
||||||
## 🏗️ Clean Architecture Overview
|
This repository uses a feature-oriented service boundary over Entity Framework Core:
|
||||||
|
|
||||||
The backend follows **Clean Architecture** with strict layer separation:
|
```text
|
||||||
|
HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ API LAYER (Controllers) │
|
|
||||||
│ - HTTP Request/Response │
|
|
||||||
│ - Authentication & Authorization │
|
|
||||||
│ - Exception Handling │
|
|
||||||
│ - Input Validation (basic) │
|
|
||||||
└─────────────────┬───────────────────────────────────────────┘
|
|
||||||
│ Calls
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ SERVICE LAYER (Business Logic) │
|
|
||||||
│ - Business Rules │
|
|
||||||
│ - FluentValidation (detailed validation) │
|
|
||||||
│ - Data Transformation │
|
|
||||||
│ - Orchestration │
|
|
||||||
└─────────────────┬───────────────────────────────────────────┘
|
|
||||||
│ Calls
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ DATA SERVICE LAYER (Persistence) │
|
|
||||||
│ - Database Access │
|
|
||||||
│ - Entity Operations (CRUD) │
|
|
||||||
│ - Query Building │
|
|
||||||
│ - Transaction Management │
|
|
||||||
└─────────────────┬───────────────────────────────────────────┘
|
|
||||||
│ Uses
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ DATA LAYER (EF Core) │
|
|
||||||
│ - DbContext │
|
|
||||||
│ - Entity Models │
|
|
||||||
│ - Database Schema │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
The interfaces are architectural seams, not a requirement to wrap every class or every
|
||||||
|
EF method. Add an interface when it separates HTTP, business, persistence, or external
|
||||||
## 📁 Project Structure
|
I/O responsibilities and enables behavior-focused testing.
|
||||||
|
|
||||||
```
|
## Layer responsibilities
|
||||||
backend/shoc-backend/
|
|
||||||
│
|
### API controllers
|
||||||
├── Api.SeaHavenIndustries/ # 🌐 API Layer
|
|
||||||
│ ├── Controllers/ # HTTP endpoints
|
- Parse HTTP input, authorize the caller, invoke a business-service interface, and map
|
||||||
│ ├── DTOs/ # Data Transfer Objects (API contracts)
|
the result to the established HTTP contract.
|
||||||
│ ├── Program.cs # App startup & DI registration
|
- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete
|
||||||
│ └── appsettings.json # Configuration
|
data-service implementations.
|
||||||
│
|
- Do not contain persistence queries or business workflows.
|
||||||
├── SeaHaven.Services/ # 💼 Service Layer (Business Logic)
|
- Do not expose exception messages to clients. Map known failures explicitly and let the
|
||||||
│ ├── Interfaces/ # Service contracts (IContactService, etc.)
|
centralized exception boundary handle unexpected failures.
|
||||||
│ ├── Implementation/ # Service implementations
|
|
||||||
│ ├── DTOs/ # Internal DTOs for service layer
|
### Business services
|
||||||
│ └── Validators/ # FluentValidation validators
|
|
||||||
│
|
- Own validation, business decisions, orchestration, and transaction intent.
|
||||||
├── SeaHaven.DataServices/ # 💾 Data Access Layer
|
- Depend on interfaces for persistence and external I/O.
|
||||||
│ ├── Interfaces/ # DataService contracts (IContactDataService, etc.)
|
- Use `IOptions<T>` for typed configuration. Do not inject `IConfiguration`.
|
||||||
│ ├── Implementation/ # DataService implementations
|
- Keep feature responsibilities cohesive. Split a service when it has independently
|
||||||
│ └── DTOs/ # Data layer DTOs
|
changing business reasons, not because it crossed an arbitrary line count.
|
||||||
│
|
- Preserve existing API and board-backed behavior unless the accepted requirement
|
||||||
└── Data.SeaHavenIndustries/ # 🗄️ Data Layer (EF Core)
|
explicitly changes it.
|
||||||
├── Models/ # Entity models (Contact, Employee, etc.)
|
|
||||||
├── ApplicationDbContext.cs # EF Core DbContext
|
### Data services
|
||||||
└── Migrations/ # Database migrations
|
|
||||||
```
|
- Own EF Core query shape, persistence, batching, and transaction implementation.
|
||||||
|
- Accept cancellation tokens on new I/O methods and pass them to EF Core.
|
||||||
---
|
- Use `AsNoTracking()` for read-only queries.
|
||||||
|
- Project only required columns for list/read models; include full graphs only when the
|
||||||
## 🔄 Data Flow Example (Creating a Contact)
|
caller needs them.
|
||||||
|
- Page unbounded collections at the database.
|
||||||
### 1️⃣ **API Layer** (`ContactController.cs`)
|
- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes
|
||||||
```csharp
|
while preserving the workflow's failure and transaction semantics.
|
||||||
[HttpPost("Create")]
|
- Do not introduce a generic repository over EF Core. Feature data services should expose
|
||||||
public async Task<IActionResult> AddContacts(Contact_DTO dto)
|
intent-revealing operations.
|
||||||
{
|
|
||||||
try
|
## Performance review
|
||||||
{
|
|
||||||
// Get authenticated user ID
|
For each changed hot path, document and test:
|
||||||
var userId = _userManager.GetUserId(User);
|
|
||||||
if (userId == null)
|
- input size variables;
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
- CPU and memory complexity;
|
||||||
|
- database round trips;
|
||||||
// Convert API DTO → Service DTO
|
- whether filtering, ordering, and paging execute in SQL;
|
||||||
var serviceDto = dto.ToServiceCreateDTO();
|
- whether tracking is necessary;
|
||||||
|
- whether repeated work can be hoisted out of loops;
|
||||||
// Call Service Layer
|
- the partial-failure and transaction behavior.
|
||||||
await _contactService.CreateContactAsync(serviceDto, userId);
|
|
||||||
|
Prefer dictionary or hash-set reconstruction when database results must follow caller
|
||||||
return Ok(new Response { Status = "Success", Message = "Contact created" });
|
order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory;
|
||||||
}
|
repeated `First`/`Single` scans over the result are `O(n²)`.
|
||||||
catch (ValidationException vex)
|
|
||||||
{
|
Do not optimize by weakening correctness. In particular, batching all writes into one
|
||||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
final save can change partial-success behavior, and adding a transaction can change
|
||||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
locking and retry semantics. Those are business decisions, not mechanical cleanup.
|
||||||
}
|
|
||||||
catch (KeyNotFoundException)
|
## Configuration
|
||||||
{
|
|
||||||
return NotFound(new Response { Status = "Error", Message = "Resource not found" });
|
Configuration is bound once during composition in
|
||||||
}
|
`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive
|
||||||
catch (Exception ex)
|
typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and
|
||||||
{
|
`VendorPortalOptions`.
|
||||||
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
|
|
||||||
}
|
Defaults must preserve current behavior. Startup validation should be introduced only
|
||||||
}
|
when every deployed environment is known to provide the required value.
|
||||||
```
|
|
||||||
|
## Enforcement
|
||||||
**Responsibilities:**
|
|
||||||
- ✅ Authenticate user
|
`ArchitectureTests` enforces mechanical dependency rules:
|
||||||
- ✅ Extract user ID from claims
|
|
||||||
- ✅ Map API DTO to Service DTO
|
- controllers cannot inject EF contexts or concrete service/data-service classes;
|
||||||
- ✅ Catch exceptions and return proper HTTP status codes
|
- business services cannot inject EF contexts or raw `IConfiguration`.
|
||||||
- ❌ NO business logic
|
|
||||||
- ❌ NO database access
|
The pull-request quality workflow runs those tests and verifies formatting for changed
|
||||||
|
C# files. The normal CI workflow builds the solution and runs the full test suite.
|
||||||
---
|
|
||||||
|
Architecture tests are appropriate for dependency direction. Behavior belongs in tests
|
||||||
### 2️⃣ **Service Layer** (`ContactService.cs`)
|
through public interfaces. Do not add tests merely to prove a linter or analyzer itself
|
||||||
|
works; test the product behavior the rule protects.
|
||||||
```csharp
|
|
||||||
public class ContactService : IContactService
|
## Review checklist
|
||||||
{
|
|
||||||
private readonly IContactDataService _dataService;
|
- Controller depends only on service abstractions.
|
||||||
private readonly IValidator<CreateContactDTO> _createValidator;
|
- Business logic is in a cohesive feature service.
|
||||||
|
- EF operations are behind a feature data-service abstraction.
|
||||||
public ContactService(
|
- No raw `IConfiguration` in a business service.
|
||||||
IContactDataService dataService,
|
- No unbounded load followed by in-memory filtering.
|
||||||
IValidator<CreateContactDTO> createValidator)
|
- No query/save loop where a set-based operation preserves semantics.
|
||||||
{
|
- Read-only EF queries use no tracking.
|
||||||
_dataService = dataService;
|
- New async I/O accepts and propagates cancellation where the public contract permits.
|
||||||
_createValidator = createValidator;
|
- Errors do not leak internal exception details.
|
||||||
}
|
- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing
|
||||||
|
records, and relevant failure boundaries.
|
||||||
public async Task<ContactDTO> CreateContactAsync(CreateContactDTO dto, string userId)
|
- Changed behavior has been checked against the authoritative ticket board. Missing board
|
||||||
{
|
access blocks readiness and merge claims.
|
||||||
// STEP 1: Validate using FluentValidation
|
|
||||||
var validationResult = await _createValidator.ValidateAsync(dto);
|
|
||||||
if (!validationResult.IsValid)
|
|
||||||
{
|
|
||||||
throw new ValidationException(validationResult.Errors);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STEP 2: Business logic (if any)
|
|
||||||
// Example: Check for duplicates, apply business rules, etc.
|
|
||||||
|
|
||||||
// STEP 3: Map Service DTO → Data DTO
|
|
||||||
var dataDto = new CreateContactDataDTO
|
|
||||||
{
|
|
||||||
FirstName = dto.FirstName,
|
|
||||||
LastName = dto.LastName,
|
|
||||||
Email = dto.Email,
|
|
||||||
Phone = dto.Phone
|
|
||||||
};
|
|
||||||
|
|
||||||
// STEP 4: Call Data Service Layer
|
|
||||||
var createdContact = await _dataService.CreateAsync(dataDto, userId);
|
|
||||||
|
|
||||||
// STEP 5: Map Data DTO → Service DTO and return
|
|
||||||
return new ContactDTO
|
|
||||||
{
|
|
||||||
Id = createdContact.Id,
|
|
||||||
FirstName = createdContact.FirstName,
|
|
||||||
LastName = createdContact.LastName,
|
|
||||||
Email = createdContact.Email,
|
|
||||||
Phone = createdContact.Phone
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responsibilities:**
|
|
||||||
- ✅ Validate input using FluentValidation
|
|
||||||
- ✅ Apply business rules
|
|
||||||
- ✅ Orchestrate operations
|
|
||||||
- ✅ Transform data between layers
|
|
||||||
- ❌ NO HTTP concerns
|
|
||||||
- ❌ NO direct database access
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3️⃣ **Data Service Layer** (`ContactDataService.cs`)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class ContactDataService : IContactDataService
|
|
||||||
{
|
|
||||||
private readonly ApplicationDbContext _db;
|
|
||||||
|
|
||||||
public ContactDataService(ApplicationDbContext db)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<ContactDataDTO> CreateAsync(CreateContactDataDTO dto, string userId)
|
|
||||||
{
|
|
||||||
// STEP 1: Create entity from DTO
|
|
||||||
var contact = new Contact
|
|
||||||
{
|
|
||||||
FirstName = dto.FirstName,
|
|
||||||
LastName = dto.LastName,
|
|
||||||
Email = dto.Email,
|
|
||||||
Phone = dto.Phone,
|
|
||||||
CreatedBy = userId,
|
|
||||||
CreatedDate = DateTime.UtcNow,
|
|
||||||
ModifiedBy = userId,
|
|
||||||
ModifiedDate = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
// STEP 2: Add to database
|
|
||||||
_db.Contacts.Add(contact);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
// STEP 3: Map entity → Data DTO and return
|
|
||||||
return new ContactDataDTO
|
|
||||||
{
|
|
||||||
Id = contact.Id,
|
|
||||||
FirstName = contact.FirstName,
|
|
||||||
LastName = contact.LastName,
|
|
||||||
Email = contact.Email,
|
|
||||||
Phone = contact.Phone
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Responsibilities:**
|
|
||||||
- ✅ Direct database operations (CRUD)
|
|
||||||
- ✅ Entity mapping
|
|
||||||
- ✅ Set audit fields (CreatedBy, CreatedDate, etc.)
|
|
||||||
- ✅ Transaction management
|
|
||||||
- ❌ NO validation
|
|
||||||
- ❌ NO business logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔌 Dependency Injection (DI)
|
|
||||||
|
|
||||||
### Automatic Assembly Scanning
|
|
||||||
|
|
||||||
Services are **automatically registered** by scanning assemblies using naming convention.
|
|
||||||
|
|
||||||
**In `Program.cs`:**
|
|
||||||
```csharp
|
|
||||||
builder.Services.AddDataServices(); // Auto-register all Data Services
|
|
||||||
builder.Services.AddBusinessServices(); // Auto-register all Business Services + Validators
|
|
||||||
```
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
**Module Files:**
|
|
||||||
- `SeaHaven.DataServices/DependencyInjection/DataServicesModule.cs`
|
|
||||||
- `SeaHaven.Services/DependencyInjection/ServicesModule.cs`
|
|
||||||
|
|
||||||
**Process:**
|
|
||||||
1. Scans assembly for all classes
|
|
||||||
2. Finds interface matching pattern `I{ClassName}`
|
|
||||||
3. Registers as Scoped service
|
|
||||||
4. Also auto-registers FluentValidation validators
|
|
||||||
|
|
||||||
### Naming Convention Required
|
|
||||||
|
|
||||||
| Interface | Implementation | Registered? |
|
|
||||||
|-----------|---------------|-------------|
|
|
||||||
| `IContactService` | `ContactService` | ✅ YES |
|
|
||||||
| `IContactDataService` | `ContactDataService` | ✅ YES |
|
|
||||||
| `ISomething` | `SomethingDifferent` | ❌ NO |
|
|
||||||
|
|
||||||
**Rule**: Interface must be `I` + Class name exactly
|
|
||||||
|
|
||||||
### Usage in Code
|
|
||||||
|
|
||||||
Once registered, inject via constructor:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class ContactController : Controller
|
|
||||||
{
|
|
||||||
private readonly IContactService _contactService;
|
|
||||||
|
|
||||||
public ContactController(IContactService contactService)
|
|
||||||
{
|
|
||||||
_contactService = contactService;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Benefits
|
|
||||||
|
|
||||||
✅ No manual registration - just follow naming convention
|
|
||||||
✅ Auto-discovery - new services work automatically
|
|
||||||
✅ FluentValidation auto-registered
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ FluentValidation
|
|
||||||
|
|
||||||
### Validator Example (`CreateContactDTOValidator.cs`)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class CreateContactDTOValidator : AbstractValidator<CreateContactDTO>
|
|
||||||
{
|
|
||||||
public CreateContactDTOValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.FirstName)
|
|
||||||
.NotEmpty().WithMessage("First name is required")
|
|
||||||
.MaximumLength(50).WithMessage("First name cannot exceed 50 characters");
|
|
||||||
|
|
||||||
RuleFor(x => x.LastName)
|
|
||||||
.NotEmpty().WithMessage("Last name is required")
|
|
||||||
.MaximumLength(50).WithMessage("Last name cannot exceed 50 characters");
|
|
||||||
|
|
||||||
RuleFor(x => x.Email)
|
|
||||||
.NotEmpty().WithMessage("Email is required")
|
|
||||||
.EmailAddress().WithMessage("Invalid email format")
|
|
||||||
.MaximumLength(100).WithMessage("Email cannot exceed 100 characters");
|
|
||||||
|
|
||||||
RuleFor(x => x.Phone)
|
|
||||||
.Matches(@"^\+?[1-9]\d{1,14}$").When(x => !string.IsNullOrEmpty(x.Phone))
|
|
||||||
.WithMessage("Invalid phone number format");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Where Validation Happens
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ Controller → NO detailed validation (only catches ValidationException)
|
|
||||||
✅ Service → YES - FluentValidation runs here
|
|
||||||
❌ DataService → NO validation
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Interface Examples
|
|
||||||
|
|
||||||
### Service Interface (`IContactService.cs`)
|
|
||||||
```csharp
|
|
||||||
public interface IContactService
|
|
||||||
{
|
|
||||||
Task<ContactDTO> CreateContactAsync(CreateContactDTO dto, string userId);
|
|
||||||
Task<ContactDTO> UpdateContactAsync(int id, UpdateContactDTO dto, string userId);
|
|
||||||
Task DeleteContactAsync(int id, string userId);
|
|
||||||
Task<ContactDTO> GetContactByIdAsync(int id);
|
|
||||||
Task<PagedResult<ContactDTO>> GetContactsPagedAsync(int page, int pageSize, string? search);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Service Interface (`IContactDataService.cs`)
|
|
||||||
```csharp
|
|
||||||
public interface IContactDataService
|
|
||||||
{
|
|
||||||
Task<ContactDataDTO> CreateAsync(CreateContactDataDTO dto, string userId);
|
|
||||||
Task<ContactDataDTO> UpdateAsync(int id, UpdateContactDataDTO dto, string userId);
|
|
||||||
Task DeleteAsync(int id, string userId);
|
|
||||||
Task<ContactDataDTO?> GetByIdAsync(int id);
|
|
||||||
Task<PagedResult<ContactDataDTO>> GetPagedAsync(int page, int pageSize, string? search);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 DTO Types
|
|
||||||
|
|
||||||
### 1. **API DTOs** (`Api.SeaHavenIndustries/DTOs/`)
|
|
||||||
- Used for HTTP requests/responses
|
|
||||||
- Map to frontend JSON structure
|
|
||||||
- Example: `Contact_DTO`, `EditContact_DTO`
|
|
||||||
|
|
||||||
### 2. **Service DTOs** (`SeaHaven.Services/DTOs/`)
|
|
||||||
- Used between Controller ↔ Service
|
|
||||||
- Business-focused structure
|
|
||||||
- Example: `CreateContactDTO`, `UpdateContactDTO`, `ContactDTO`
|
|
||||||
|
|
||||||
### 3. **Data DTOs** (`SeaHaven.DataServices/DTOs/`)
|
|
||||||
- Used between Service ↔ DataService
|
|
||||||
- Database-focused structure
|
|
||||||
- Example: `CreateContactDataDTO`, `ContactDataDTO`
|
|
||||||
|
|
||||||
### DTO Mapping Flow
|
|
||||||
```
|
|
||||||
API DTO → Service DTO → Data DTO → Entity
|
|
||||||
(HTTP) (Business) (Data) (Database)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Exception Handling Pattern
|
|
||||||
|
|
||||||
### Controller Exception Handling (STANDARD PATTERN)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[HttpPost("Create")]
|
|
||||||
public async Task<IActionResult> Create([FromBody] SomeDTO dto)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Get user ID
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (userId == null)
|
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
|
||||||
|
|
||||||
// Call service
|
|
||||||
await _service.CreateAsync(dto, userId);
|
|
||||||
|
|
||||||
return Ok(new Response { Status = "Success", Message = "Created successfully" });
|
|
||||||
}
|
|
||||||
catch (ValidationException vex) // ⚠️ CATCH THIS FIRST
|
|
||||||
{
|
|
||||||
var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage));
|
|
||||||
return BadRequest(new Response { Status = "Validation Error", Message = errors });
|
|
||||||
}
|
|
||||||
catch (KeyNotFoundException) // ⚠️ THEN THIS
|
|
||||||
{
|
|
||||||
return NotFound(new Response { Status = "Error", Message = "Not found" });
|
|
||||||
}
|
|
||||||
catch (Exception ex) // ⚠️ GENERIC LAST
|
|
||||||
{
|
|
||||||
return StatusCode(500, new Response { Status = "Error", Message = ex.Message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTP Status Code Mapping
|
|
||||||
- **200 OK** → Success
|
|
||||||
- **400 Bad Request** → Validation errors
|
|
||||||
- **401 Unauthorized** → Not authenticated
|
|
||||||
- **404 Not Found** → Resource not found
|
|
||||||
- **500 Internal Server Error** → Unexpected errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Authentication & User ID
|
|
||||||
|
|
||||||
### Getting User ID (Two Methods)
|
|
||||||
|
|
||||||
**Method 1: Using Claims (Preferred)**
|
|
||||||
```csharp
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (userId == null)
|
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
|
||||||
```
|
|
||||||
|
|
||||||
**Method 2: Using UserManager (for Identity)**
|
|
||||||
```csharp
|
|
||||||
var userId = _userManager.GetUserId(User);
|
|
||||||
if (userId == null)
|
|
||||||
return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" });
|
|
||||||
```
|
|
||||||
|
|
||||||
### Audit Fields
|
|
||||||
Every entity should track:
|
|
||||||
- `CreatedBy` (string - User ID GUID)
|
|
||||||
- `CreatedDate` (DateTime)
|
|
||||||
- `ModifiedBy` (string - User ID GUID)
|
|
||||||
- `ModifiedDate` (DateTime)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Naming Conventions
|
|
||||||
|
|
||||||
### Controllers
|
|
||||||
- Named after entity + "Controller"
|
|
||||||
- Example: `ContactController`, `EmployeeController`
|
|
||||||
|
|
||||||
### Services
|
|
||||||
- Interface: `I[Entity]Service`
|
|
||||||
- Implementation: `[Entity]Service`
|
|
||||||
- Example: `IContactService`, `ContactService`
|
|
||||||
|
|
||||||
### Data Services
|
|
||||||
- Interface: `I[Entity]DataService`
|
|
||||||
- Implementation: `[Entity]DataService`
|
|
||||||
- Example: `IContactDataService`, `ContactDataService`
|
|
||||||
|
|
||||||
### DTOs
|
|
||||||
- API Layer: `[Entity]_DTO`, `Edit[Entity]_DTO`
|
|
||||||
- Service Layer: `Create[Entity]DTO`, `Update[Entity]DTO`, `[Entity]DTO`
|
|
||||||
- Data Layer: `Create[Entity]DataDTO`, `[Entity]DataDTO`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Best Practices Checklist
|
|
||||||
|
|
||||||
### Controllers
|
|
||||||
- [ ] Use dependency injection for services
|
|
||||||
- [ ] Get user ID from `User.FindFirstValue(ClaimTypes.NameIdentifier)`
|
|
||||||
- [ ] Return 401 if userId is null
|
|
||||||
- [ ] Catch `ValidationException` BEFORE generic `Exception`
|
|
||||||
- [ ] Return appropriate HTTP status codes
|
|
||||||
- [ ] NO business logic in controllers
|
|
||||||
- [ ] NO direct database access (`_db`)
|
|
||||||
|
|
||||||
### Services
|
|
||||||
- [ ] Validate using FluentValidation
|
|
||||||
- [ ] Throw `ValidationException` for validation errors
|
|
||||||
- [ ] Throw `KeyNotFoundException` for not found
|
|
||||||
- [ ] Pass userId to DataService
|
|
||||||
- [ ] Transform DTOs between layers
|
|
||||||
- [ ] Implement business rules here
|
|
||||||
|
|
||||||
### Data Services
|
|
||||||
- [ ] Direct database access only
|
|
||||||
- [ ] Set audit fields (CreatedBy, CreatedDate, ModifiedBy, ModifiedDate)
|
|
||||||
- [ ] Use async/await for all database operations
|
|
||||||
- [ ] NO validation logic
|
|
||||||
- [ ] NO business logic
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 Quick Reference
|
|
||||||
|
|
||||||
| Layer | Purpose | Can Access | Cannot Access |
|
|
||||||
|-------|---------|-----------|---------------|
|
|
||||||
| **Controller** | HTTP handling, auth | Service layer | Database directly |
|
|
||||||
| **Service** | Business logic, validation | DataService layer | Database directly |
|
|
||||||
| **DataService** | Database operations | DbContext, Entities | HTTP context |
|
|
||||||
| **Data** | Entity definitions | Nothing (POCOs) | Application logic |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Example: Full CRUD Implementation
|
|
||||||
|
|
||||||
See these files for complete examples:
|
|
||||||
- **Contact**: `ContactController.cs`, `ContactService.cs`, `ContactDataService.cs`
|
|
||||||
- **Employee**: `EmployeeController.cs`, `EmployeeService.cs`, `EmployeeDataService.cs`
|
|
||||||
- **Asset**: `AssetController.cs`, `AssetService.cs`, `AssetDataService.cs`
|
|
||||||
|
|
||||||
All follow the same Clean Architecture pattern!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Configuration & Secrets
|
|
||||||
|
|
||||||
No secrets are stored in source. The committed `appsettings*.json` files hold only
|
|
||||||
`${PLACEHOLDER}` tokens; real values must be supplied at runtime through one of the
|
|
||||||
standard .NET configuration sources (later sources win):
|
|
||||||
|
|
||||||
1. `appsettings.json` / `appsettings.{Environment}.json` — committed, placeholders only.
|
|
||||||
2. **User Secrets** (local dev): `dotnet user-secrets set "Section:Key" "value"`.
|
|
||||||
3. **Environment variables** — use `__` (double underscore) as the section separator.
|
|
||||||
In AWS Elastic Beanstalk these are the environment properties.
|
|
||||||
|
|
||||||
See `.env.example` in the repo root for the full list. Required values:
|
|
||||||
|
|
||||||
| Setting | Env-var form | Used by | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` | Both projects | SQL Server connection string |
|
|
||||||
| `SendGrid:ApiKey` | `SendGrid__ApiKey` | Both projects | Transactional email |
|
|
||||||
| `JWT:Secret` | `JWT__Secret` | `Api.SeaHavenIndustries` | JWT signing key |
|
|
||||||
| `GoogleMaps:ApiKey` | `GoogleMaps__ApiKey` | `SeaHavenIndustries` (Blazor) | Maps/Places; read in `App.razor` + `Home.razor` |
|
|
||||||
|
|
||||||
### AWS credentials
|
|
||||||
|
|
||||||
`UploadFileHp` (S3 uploads) uses the **AWS SDK default credential chain** — it no longer
|
|
||||||
hardcodes access keys. Credentials resolve from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`
|
|
||||||
env vars, the shared profile/SSO, or (preferred in production) the EC2/ECS instance role.
|
|
||||||
The region comes from `AddDefaultAWSOptions` in `Program.cs`.
|
|
||||||
|
|
||||||
> ⚠️ The secrets previously committed to this repo (DB password, SendGrid key, JWT secret,
|
|
||||||
> Google Maps keys, AWS access key) should be considered compromised and **rotated**.
|
|
||||||
|
|
||||||
|
|
|
||||||
101
Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs
Normal file
101
Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
169
SeaHaven.DataServices/Dto/DispatchReadModels.cs
Normal file
169
SeaHaven.DataServices/Dto/DispatchReadModels.cs
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
namespace SeaHaven.DataServices.Dto
|
||||||
|
{
|
||||||
|
public class DispatchListItemDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? DispatchNumber { get; set; }
|
||||||
|
public string? PONumber { get; set; }
|
||||||
|
public decimal? NTEAmount { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public DateTime? DispatchedAt { get; set; }
|
||||||
|
public DateTime? ScheduledDate { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
public DateTime? AcknowledgedAt { get; set; }
|
||||||
|
public DateTime? VerifiedAt { get; set; }
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public int? WorkOrderId { get; set; }
|
||||||
|
public string? PrimaryWOInternalNumber { get; set; }
|
||||||
|
public string? PrimaryWOTitle { get; set; }
|
||||||
|
public string? LocationName { get; set; }
|
||||||
|
public int WorkOrderCount { get; set; }
|
||||||
|
public int PendingUpliftCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchListResult
|
||||||
|
{
|
||||||
|
public IReadOnlyList<DispatchListItemDTO> Items { get; set; } = Array.Empty<DispatchListItemDTO>();
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Page { get; set; }
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchListQuery
|
||||||
|
{
|
||||||
|
public string? Search { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public DateTime? DateFrom { get; set; }
|
||||||
|
public DateTime? DateTo { get; set; }
|
||||||
|
public string? SortBy { get; set; }
|
||||||
|
public string? SortDir { get; set; }
|
||||||
|
public int Page { get; set; }
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchDetailVendorDTO
|
||||||
|
{
|
||||||
|
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 string? Address { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? State { get; set; }
|
||||||
|
public string? Zip { get; set; }
|
||||||
|
public string? TradeSpecialties { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchPrimaryLocationDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
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 DispatchLinkedWorkOrderDTO
|
||||||
|
{
|
||||||
|
public int WorkOrderId { get; set; }
|
||||||
|
public string? InternalWONumber { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Priority { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public string? LocationName { get; set; }
|
||||||
|
public string? Trade { get; set; }
|
||||||
|
public string? SubTrade { get; set; }
|
||||||
|
public string? Problem { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchChecklistDTO
|
||||||
|
{
|
||||||
|
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 int SortOrder { get; set; }
|
||||||
|
public int? WorkOrderId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchSignoffDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? SignoffType { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Signature { get; set; }
|
||||||
|
public string? SignatureMethod { get; set; }
|
||||||
|
public DateTime? SignedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchCommentDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Commenttext { get; set; }
|
||||||
|
public string? Commenter { get; set; }
|
||||||
|
public string? CommentType { get; set; }
|
||||||
|
public string? RecordType { get; set; }
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
public string? UserId { get; set; }
|
||||||
|
public string UserName { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchUpliftRequestDTO
|
||||||
|
{
|
||||||
|
public int Id { 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 class DispatchAuditLogDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Action { get; set; }
|
||||||
|
public string? FieldName { get; set; }
|
||||||
|
public string? OldValue { get; set; }
|
||||||
|
public string? NewValue { get; set; }
|
||||||
|
public DateTime? CreatedAt { get; set; }
|
||||||
|
public string? UserId { get; set; }
|
||||||
|
public string UserName { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchDetailDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? DispatchNumber { get; set; }
|
||||||
|
public string? PONumber { get; set; }
|
||||||
|
public decimal? NTEAmount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public DateTime? ScheduledDate { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
public DateTime? DispatchedAt { get; set; }
|
||||||
|
public DateTime? AcknowledgedAt { get; set; }
|
||||||
|
public DateTime? VerifiedAt { get; set; }
|
||||||
|
public string? VerifiedBy { get; set; }
|
||||||
|
public bool EmailSent { get; set; }
|
||||||
|
public int? WorkOrderId { get; set; }
|
||||||
|
public DispatchDetailVendorDTO? Vendor { get; set; }
|
||||||
|
public DispatchPrimaryLocationDTO? PrimaryLocation { get; set; }
|
||||||
|
public IReadOnlyList<DispatchLinkedWorkOrderDTO> WorkOrders { get; set; } = Array.Empty<DispatchLinkedWorkOrderDTO>();
|
||||||
|
public IReadOnlyList<DispatchChecklistDTO> Checklist { get; set; } = Array.Empty<DispatchChecklistDTO>();
|
||||||
|
public IReadOnlyList<DispatchSignoffDTO> Signoffs { get; set; } = Array.Empty<DispatchSignoffDTO>();
|
||||||
|
public IReadOnlyList<DispatchCommentDTO> Comments { get; set; } = Array.Empty<DispatchCommentDTO>();
|
||||||
|
public IReadOnlyList<DispatchUpliftRequestDTO> UpliftRequests { get; set; } = Array.Empty<DispatchUpliftRequestDTO>();
|
||||||
|
public IReadOnlyList<DispatchAuditLogDTO> AuditLog { get; set; } = Array.Empty<DispatchAuditLogDTO>();
|
||||||
|
}
|
||||||
|
}
|
||||||
8
SeaHaven.DataServices/Dto/SettingsReadModels.cs
Normal file
8
SeaHaven.DataServices/Dto/SettingsReadModels.cs
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
namespace SeaHaven.DataServices.Dto
|
||||||
|
{
|
||||||
|
public class SettingsCategoryItemDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
21
SeaHaven.DataServices/Dto/UserReadModels.cs
Normal file
21
SeaHaven.DataServices/Dto/UserReadModels.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace SeaHaven.DataServices.Dto
|
||||||
|
{
|
||||||
|
public sealed class UserListRowData
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public string? RoleName { get; init; }
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? Name { get; init; }
|
||||||
|
public DateTime? CreatedDate { get; init; }
|
||||||
|
public string? Status { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UserProfileData
|
||||||
|
{
|
||||||
|
public required string Id { get; init; }
|
||||||
|
public string? FirstName { get; init; }
|
||||||
|
public string? Email { get; init; }
|
||||||
|
public string? Contact { get; init; }
|
||||||
|
public DateTime? CreatedDate { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
85
SeaHaven.DataServices/Implementation/CalendarDataService.cs
Normal file
85
SeaHaven.DataServices/Implementation/CalendarDataService.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class CalendarDataService : ICalendarDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public CalendarDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Events?> GetEventByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Events
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.Id == id && e.IsDeleted != true)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Events?> GetEventForEditAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Events.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Events> AddEventAsync(Events ev, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _context.Events.AddAsync(ev, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return ev;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateEventAsync(Events ev, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_context.Events.Update(ev);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<Events> Items, int TotalCount)> GetEventListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.Events.Where(e => e.IsDeleted != true);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var searchLower = search.ToLower();
|
||||||
|
query = query.Where(e =>
|
||||||
|
e.Title.ToLower().Contains(searchLower) ||
|
||||||
|
(e.Description != null && e.Description.ToLower().Contains(searchLower)) ||
|
||||||
|
(e.Location != null && e.Location.ToLower().Contains(searchLower)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var events = await query
|
||||||
|
.OrderByDescending(e => e.StartDate)
|
||||||
|
.ThenByDescending(e => e.CreatedDate)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (events, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Events>> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.Events.Where(e => e.IsDeleted != true);
|
||||||
|
|
||||||
|
if (start.HasValue && end.HasValue)
|
||||||
|
{
|
||||||
|
query = query.Where(e =>
|
||||||
|
(e.StartDate >= start.Value && e.StartDate <= end.Value) ||
|
||||||
|
(e.EndDate >= start.Value && e.EndDate <= end.Value) ||
|
||||||
|
(e.StartDate <= start.Value && e.EndDate >= end.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -71,5 +71,32 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
return await _context.Comments.AnyAsync(c => c.Id == id);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
81
SeaHaven.DataServices/Implementation/CommonDataService.cs
Normal file
81
SeaHaven.DataServices/Implementation/CommonDataService.cs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class CommonDataService : ICommonDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public CommonDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IEnumerable<ApplicationUser> Items, int TotalCount)> GetUsersPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.Users
|
||||||
|
.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower()))
|
||||||
|
.OrderByDescending(d => d.Id);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var paginatedUsers = await query
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (paginatedUsers, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<string?>> GetRoleNamesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Roles
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(s => s.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Locations>> GetSiteNamesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Locations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(n => new Locations
|
||||||
|
{
|
||||||
|
Id = n.Id,
|
||||||
|
Name = n.Name,
|
||||||
|
Title = n.Title
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Contacts>> GetContactsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Contacts
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(n => new Contacts
|
||||||
|
{
|
||||||
|
Id = n.Id,
|
||||||
|
FirstName = n.FirstName,
|
||||||
|
LastName = n.LastName,
|
||||||
|
Email = n.Email,
|
||||||
|
PhoneNumber = n.PhoneNumber
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<Category>> GetCategoriesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Categories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(s => new Category
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
Name = s.Name
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
SeaHaven.DataServices/Implementation/DashboardDataService.cs
Normal file
34
SeaHaven.DataServices/Implementation/DashboardDataService.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class DashboardDataService : IDashboardDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public DashboardDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.workOrders.Where(w => w.istemplate != true);
|
||||||
|
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var open = await query.CountAsync(w => w.Status == "Open" || w.Status == "In Progress" || w.Status == "On Hold", cancellationToken);
|
||||||
|
var notDispatched = await query.CountAsync(w => (w.Status == "Open") && (w.AssignTo == null || w.AssignTo == ""), cancellationToken);
|
||||||
|
var completed = await query.CountAsync(w => w.Status == "Done", cancellationToken);
|
||||||
|
|
||||||
|
return new DashboardCounts
|
||||||
|
{
|
||||||
|
Total = total,
|
||||||
|
Open = open,
|
||||||
|
NotDispatched = notDispatched,
|
||||||
|
Completed = completed
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -25,6 +27,14 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.FirstOrDefaultAsync(d => d.Id == id);
|
.FirstOrDefaultAsync(d => d.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Dispatch?> GetByIdWithVendorAndWorkOrderAsync(int id)
|
||||||
|
{
|
||||||
|
return await _context.Dispatches
|
||||||
|
.Include(dispatch => dispatch.Vendor)
|
||||||
|
.Include(dispatch => dispatch.WorkOrder)
|
||||||
|
.FirstOrDefaultAsync(dispatch => dispatch.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Dispatch>> GetAllAsync()
|
public async Task<IEnumerable<Dispatch>> GetAllAsync()
|
||||||
{
|
{
|
||||||
return await _context.Dispatches.ToListAsync();
|
return await _context.Dispatches.ToListAsync();
|
||||||
|
|
@ -45,9 +55,9 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<Dispatch> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IEnumerable<Dispatch> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search = null,
|
string? search = null,
|
||||||
string? status = null)
|
string? status = null)
|
||||||
{
|
{
|
||||||
var query = _context.Dispatches.AsQueryable();
|
var query = _context.Dispatches.AsQueryable();
|
||||||
|
|
@ -216,5 +226,576 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
await _context.DispatchWorkOrders.AddAsync(dispatchWorkOrder);
|
await _context.DispatchWorkOrders.AddAsync(dispatchWorkOrder);
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<DispatchListResult> GetListAsync(DispatchListQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var q = _context.Dispatches
|
||||||
|
.Include(d => d.Vendor)
|
||||||
|
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(d => d.IsDeleted == null || d.IsDeleted == false);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||||
|
{
|
||||||
|
var s = query.Search.ToLower();
|
||||||
|
q = q.Where(d =>
|
||||||
|
(d.DispatchNumber ?? "").ToLower().Contains(s) ||
|
||||||
|
(d.PONumber ?? "").ToLower().Contains(s) ||
|
||||||
|
(d.Vendor != null && (d.Vendor.CompanyName ?? "").ToLower().Contains(s)) ||
|
||||||
|
(d.WorkOrder != null && (d.WorkOrder.InternalWONumber ?? "").ToLower().Contains(s)) ||
|
||||||
|
(d.WorkOrder != null && (d.WorkOrder.WorkerOrderTitle ?? "").ToLower().Contains(s)) ||
|
||||||
|
(d.WorkOrder != null && d.WorkOrder.Locations != null && (d.WorkOrder.Locations.Name ?? "").ToLower().Contains(s))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(query.Status))
|
||||||
|
q = q.Where(d => d.Status == query.Status);
|
||||||
|
|
||||||
|
if (query.VendorId.HasValue)
|
||||||
|
q = q.Where(d => d.VendorId == query.VendorId.Value);
|
||||||
|
|
||||||
|
if (query.DateFrom.HasValue)
|
||||||
|
q = q.Where(d => d.DispatchedAt >= query.DateFrom.Value);
|
||||||
|
|
||||||
|
if (query.DateTo.HasValue)
|
||||||
|
{
|
||||||
|
var end = query.DateTo.Value.Date.AddDays(1);
|
||||||
|
q = q.Where(d => d.DispatchedAt < end);
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await q.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var dir = (query.SortDir ?? "desc").ToLowerInvariant() == "asc";
|
||||||
|
switch ((query.SortBy ?? "").ToLowerInvariant())
|
||||||
|
{
|
||||||
|
case "ponumber":
|
||||||
|
q = dir ? q.OrderBy(d => d.PONumber) : q.OrderByDescending(d => d.PONumber);
|
||||||
|
break;
|
||||||
|
case "dispatchnumber":
|
||||||
|
q = dir ? q.OrderBy(d => d.DispatchNumber) : q.OrderByDescending(d => d.DispatchNumber);
|
||||||
|
break;
|
||||||
|
case "vendor":
|
||||||
|
q = dir ? q.OrderBy(d => d.Vendor!.CompanyName) : q.OrderByDescending(d => d.Vendor!.CompanyName);
|
||||||
|
break;
|
||||||
|
case "nte":
|
||||||
|
case "nteamount":
|
||||||
|
q = dir ? q.OrderBy(d => d.NTEAmount) : q.OrderByDescending(d => d.NTEAmount);
|
||||||
|
break;
|
||||||
|
case "status":
|
||||||
|
q = dir ? q.OrderBy(d => d.Status) : q.OrderByDescending(d => d.Status);
|
||||||
|
break;
|
||||||
|
case "scheduleddate":
|
||||||
|
q = dir ? q.OrderBy(d => d.ScheduledDate) : q.OrderByDescending(d => d.ScheduledDate);
|
||||||
|
break;
|
||||||
|
case "completeddate":
|
||||||
|
q = dir ? q.OrderBy(d => d.CompletedDate) : q.OrderByDescending(d => d.CompletedDate);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
q = dir ? q.OrderBy(d => d.DispatchedAt) : q.OrderByDescending(d => d.DispatchedAt);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = await q
|
||||||
|
.Skip((Math.Max(query.Page, 1) - 1) * query.PageSize)
|
||||||
|
.Take(query.PageSize)
|
||||||
|
.Select(d => new DispatchListItemDTO
|
||||||
|
{
|
||||||
|
Id = d.Id,
|
||||||
|
DispatchNumber = d.DispatchNumber,
|
||||||
|
PONumber = d.PONumber,
|
||||||
|
NTEAmount = d.NTEAmount,
|
||||||
|
Status = d.Status,
|
||||||
|
DispatchedAt = d.DispatchedAt,
|
||||||
|
ScheduledDate = d.ScheduledDate,
|
||||||
|
CompletedDate = d.CompletedDate,
|
||||||
|
AcknowledgedAt = d.AcknowledgedAt,
|
||||||
|
VerifiedAt = d.VerifiedAt,
|
||||||
|
VendorId = d.VendorId,
|
||||||
|
VendorName = d.Vendor != null ? d.Vendor.CompanyName : null,
|
||||||
|
WorkOrderId = d.WorkOrderId,
|
||||||
|
PrimaryWOInternalNumber = d.WorkOrder != null ? d.WorkOrder.InternalWONumber : null,
|
||||||
|
PrimaryWOTitle = d.WorkOrder != null ? d.WorkOrder.WorkerOrderTitle : null,
|
||||||
|
LocationName = d.WorkOrder != null && d.WorkOrder.Locations != null ? d.WorkOrder.Locations.Name : null,
|
||||||
|
WorkOrderCount = _context.DispatchWorkOrders.Count(dwo => dwo.DispatchId == d.Id),
|
||||||
|
PendingUpliftCount = _context.DispatchUpliftRequests.Count(u => u.DispatchId == d.Id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false))
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new DispatchListResult
|
||||||
|
{
|
||||||
|
Items = rows,
|
||||||
|
Total = total,
|
||||||
|
Page = query.Page,
|
||||||
|
PageSize = query.PageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DispatchDetailDTO?> GetDetailAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dispatch = await _context.Dispatches
|
||||||
|
.Include(d => d.Vendor)
|
||||||
|
.Include(d => d.WorkOrder).ThenInclude(w => w!.Locations)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(d => d.Id == id, cancellationToken);
|
||||||
|
if (dispatch == null) return null;
|
||||||
|
|
||||||
|
var linked = await _context.DispatchWorkOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(dwo => dwo.DispatchId == id)
|
||||||
|
.Select(dwo => new DispatchLinkedWorkOrderDTO
|
||||||
|
{
|
||||||
|
WorkOrderId = dwo.WorkOrderId,
|
||||||
|
InternalWONumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : null,
|
||||||
|
Title = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : null,
|
||||||
|
Priority = dwo.WorkOrder != null ? dwo.WorkOrder.Priority : null,
|
||||||
|
Status = dwo.WorkOrder != null ? dwo.WorkOrder.Status : null,
|
||||||
|
LocationName = dwo.WorkOrder != null && dwo.WorkOrder.Locations != null ? dwo.WorkOrder.Locations.Name : null,
|
||||||
|
Trade = dwo.WorkOrder != null ? dwo.WorkOrder.Trade : null,
|
||||||
|
SubTrade = dwo.WorkOrder != null ? dwo.WorkOrder.SubTrade : null,
|
||||||
|
Problem = dwo.WorkOrder != null ? dwo.WorkOrder.Problem : null
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var linkedWOs = linked;
|
||||||
|
if (linkedWOs.Count == 0 && dispatch.WorkOrderId.HasValue)
|
||||||
|
{
|
||||||
|
var wo = await _context.workOrders
|
||||||
|
.Include(w => w.Locations)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(w => w.Id == dispatch.WorkOrderId.Value, cancellationToken);
|
||||||
|
if (wo != null)
|
||||||
|
{
|
||||||
|
linkedWOs = new List<DispatchLinkedWorkOrderDTO>
|
||||||
|
{
|
||||||
|
new DispatchLinkedWorkOrderDTO
|
||||||
|
{
|
||||||
|
WorkOrderId = wo.Id,
|
||||||
|
InternalWONumber = wo.InternalWONumber,
|
||||||
|
Title = wo.WorkerOrderTitle,
|
||||||
|
Priority = wo.Priority,
|
||||||
|
Status = wo.Status,
|
||||||
|
LocationName = wo.Locations != null ? wo.Locations.Name : null,
|
||||||
|
Trade = wo.Trade,
|
||||||
|
SubTrade = wo.SubTrade,
|
||||||
|
Problem = wo.Problem
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var checklist = await _context.DispatchChecklistItems
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false))
|
||||||
|
.OrderBy(c => c.SortOrder)
|
||||||
|
.Select(c => new DispatchChecklistDTO
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
ItemText = c.ItemText,
|
||||||
|
IsCompleted = c.IsCompleted,
|
||||||
|
CompletedBy = c.CompletedBy,
|
||||||
|
CompletedAt = c.CompletedAt,
|
||||||
|
SortOrder = c.SortOrder,
|
||||||
|
WorkOrderId = c.WorkOrderId
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var signoffs = await _context.DispatchSignoffs
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false))
|
||||||
|
.Select(s => new DispatchSignoffDTO
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
SignoffType = s.SignoffType,
|
||||||
|
Name = s.Name,
|
||||||
|
Signature = s.Signature,
|
||||||
|
SignatureMethod = s.SignatureMethod,
|
||||||
|
SignedAt = s.SignedAt
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var rawComments = await (from c in _context.Comments
|
||||||
|
where c.DispatchId == id && (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
|
||||||
|
{
|
||||||
|
c.Id,
|
||||||
|
c.Commenttext,
|
||||||
|
c.Commenter,
|
||||||
|
c.CommentType,
|
||||||
|
c.RecordType,
|
||||||
|
c.CreatedDate,
|
||||||
|
c.UserId,
|
||||||
|
UserFirstName = u != null ? u.FirstName : null,
|
||||||
|
UserLastName = u != null ? u.LastName : null
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var comments = rawComments.Select(c => new DispatchCommentDTO
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
Commenttext = c.Commenttext,
|
||||||
|
Commenter = c.Commenter,
|
||||||
|
CommentType = c.CommentType,
|
||||||
|
RecordType = c.RecordType,
|
||||||
|
CreatedDate = c.CreatedDate,
|
||||||
|
UserId = c.UserId,
|
||||||
|
UserName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName }
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var rawUplifts = await (from u in _context.DispatchUpliftRequests
|
||||||
|
where u.DispatchId == id && (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
|
||||||
|
{
|
||||||
|
u.Id,
|
||||||
|
u.CurrentNTE,
|
||||||
|
u.RequestedNTE,
|
||||||
|
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(cancellationToken);
|
||||||
|
|
||||||
|
var upliftRequests = rawUplifts.Select(u => new DispatchUpliftRequestDTO
|
||||||
|
{
|
||||||
|
Id = u.Id,
|
||||||
|
CurrentNTE = u.CurrentNTE,
|
||||||
|
RequestedNTE = u.RequestedNTE,
|
||||||
|
Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m),
|
||||||
|
VendorReason = u.VendorReason,
|
||||||
|
Status = u.Status,
|
||||||
|
RequiredTier = u.RequiredTier,
|
||||||
|
RequestedByVendorName = u.RequestedByVendorName,
|
||||||
|
RequestedAt = u.RequestedAt,
|
||||||
|
DecidedAt = u.DecidedAt,
|
||||||
|
DecisionNote = u.DecisionNote,
|
||||||
|
DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName }
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var dispatchNumber = dispatch.DispatchNumber ?? "";
|
||||||
|
var workOrderIds = linkedWOs.Select(l => l.WorkOrderId).Where(wid => wid > 0).ToList();
|
||||||
|
|
||||||
|
var rawAudit = await (from a in _context.WorkOrderAuditLogs
|
||||||
|
where workOrderIds.Contains(a.WorkOrderId)
|
||||||
|
&& a.FieldName != null
|
||||||
|
&& a.FieldName.Contains(dispatchNumber)
|
||||||
|
join u in _context.Users on a.UserId equals u.Id into users
|
||||||
|
from u in users.DefaultIfEmpty()
|
||||||
|
orderby a.CreatedAt descending
|
||||||
|
select new
|
||||||
|
{
|
||||||
|
a.Id,
|
||||||
|
a.Action,
|
||||||
|
a.FieldName,
|
||||||
|
a.OldValue,
|
||||||
|
a.NewValue,
|
||||||
|
a.CreatedAt,
|
||||||
|
a.UserId,
|
||||||
|
UserFirstName = u != null ? u.FirstName : null,
|
||||||
|
UserLastName = u != null ? u.LastName : null
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var auditLog = rawAudit.Select(a => new DispatchAuditLogDTO
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
Action = a.Action,
|
||||||
|
FieldName = a.FieldName,
|
||||||
|
OldValue = a.OldValue,
|
||||||
|
NewValue = a.NewValue,
|
||||||
|
CreatedAt = a.CreatedAt,
|
||||||
|
UserId = a.UserId,
|
||||||
|
UserName = string.Join(" ", new[] { a.UserFirstName, a.UserLastName }
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s))).Trim()
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
return new DispatchDetailDTO
|
||||||
|
{
|
||||||
|
Id = dispatch.Id,
|
||||||
|
DispatchNumber = dispatch.DispatchNumber,
|
||||||
|
PONumber = dispatch.PONumber,
|
||||||
|
NTEAmount = dispatch.NTEAmount,
|
||||||
|
Description = dispatch.Description,
|
||||||
|
Status = dispatch.Status,
|
||||||
|
ScheduledDate = dispatch.ScheduledDate,
|
||||||
|
CompletedDate = dispatch.CompletedDate,
|
||||||
|
DispatchedAt = dispatch.DispatchedAt,
|
||||||
|
AcknowledgedAt = dispatch.AcknowledgedAt,
|
||||||
|
VerifiedAt = dispatch.VerifiedAt,
|
||||||
|
VerifiedBy = dispatch.VerifiedBy,
|
||||||
|
EmailSent = dispatch.EmailSent,
|
||||||
|
WorkOrderId = dispatch.WorkOrderId,
|
||||||
|
Vendor = dispatch.Vendor == null ? null : new DispatchDetailVendorDTO
|
||||||
|
{
|
||||||
|
Id = dispatch.Vendor.Id,
|
||||||
|
CompanyName = dispatch.Vendor.CompanyName,
|
||||||
|
ContactName = dispatch.Vendor.ContactName,
|
||||||
|
Email = dispatch.Vendor.Email,
|
||||||
|
Phone = dispatch.Vendor.Phone,
|
||||||
|
Address = dispatch.Vendor.Address,
|
||||||
|
City = dispatch.Vendor.City,
|
||||||
|
State = dispatch.Vendor.State,
|
||||||
|
Zip = dispatch.Vendor.Zip,
|
||||||
|
TradeSpecialties = dispatch.Vendor.TradeSpecialties
|
||||||
|
},
|
||||||
|
PrimaryLocation = dispatch.WorkOrder?.Locations == null ? null : new DispatchPrimaryLocationDTO
|
||||||
|
{
|
||||||
|
Id = dispatch.WorkOrder.Locations.Id,
|
||||||
|
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
|
||||||
|
},
|
||||||
|
WorkOrders = linkedWOs,
|
||||||
|
Checklist = checklist,
|
||||||
|
Signoffs = signoffs,
|
||||||
|
Comments = comments,
|
||||||
|
UpliftRequests = upliftRequests,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DispatchDetailReadModel?> GetDispatchDetailAsync(int id)
|
||||||
|
{
|
||||||
|
return await _context.Dispatches
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(dispatch => dispatch.Id == id)
|
||||||
|
.Select(dispatch => new DispatchDetailReadModel
|
||||||
|
{
|
||||||
|
Id = dispatch.Id,
|
||||||
|
DispatchNumber = dispatch.DispatchNumber,
|
||||||
|
PONumber = dispatch.PONumber,
|
||||||
|
NTEAmount = dispatch.NTEAmount,
|
||||||
|
Description = dispatch.Description,
|
||||||
|
Status = dispatch.Status,
|
||||||
|
ScheduledDate = dispatch.ScheduledDate,
|
||||||
|
CompletedDate = dispatch.CompletedDate,
|
||||||
|
DispatchedAt = dispatch.DispatchedAt,
|
||||||
|
EmailSent = dispatch.EmailSent,
|
||||||
|
WorkOrderId = dispatch.WorkOrderId,
|
||||||
|
AcknowledgedAt = dispatch.AcknowledgedAt,
|
||||||
|
VerifiedBy = dispatch.VerifiedBy,
|
||||||
|
VerifiedAt = dispatch.VerifiedAt,
|
||||||
|
WoNumber = dispatch.WorkOrder != null
|
||||||
|
? dispatch.WorkOrder.InternalWONumber
|
||||||
|
: "",
|
||||||
|
WoTitle = dispatch.WorkOrder != null
|
||||||
|
? dispatch.WorkOrder.WorkerOrderTitle
|
||||||
|
: "",
|
||||||
|
VendorName = dispatch.Vendor != null ? dispatch.Vendor.CompanyName : "",
|
||||||
|
VendorEmail = dispatch.Vendor != null ? dispatch.Vendor.Email : "",
|
||||||
|
VendorId = dispatch.VendorId,
|
||||||
|
WorkOrders = _context.DispatchWorkOrders
|
||||||
|
.Where(link => link.DispatchId == dispatch.Id)
|
||||||
|
.Select(link => new DispatchWorkOrderReadModel
|
||||||
|
{
|
||||||
|
WorkOrderId = link.WorkOrderId,
|
||||||
|
WoNumber = link.WorkOrder != null
|
||||||
|
? link.WorkOrder.InternalWONumber
|
||||||
|
: "",
|
||||||
|
WoTitle = link.WorkOrder != null
|
||||||
|
? link.WorkOrder.WorkerOrderTitle
|
||||||
|
: ""
|
||||||
|
}).ToList(),
|
||||||
|
ChecklistItems = _context.DispatchChecklistItems
|
||||||
|
.Where(item => item.DispatchId == dispatch.Id)
|
||||||
|
.OrderBy(item => item.SortOrder)
|
||||||
|
.Select(item => new ChecklistItemReadModel
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
ItemText = item.ItemText,
|
||||||
|
IsCompleted = item.IsCompleted,
|
||||||
|
CompletedBy = item.CompletedBy,
|
||||||
|
CompletedAt = item.CompletedAt,
|
||||||
|
SortOrder = item.SortOrder,
|
||||||
|
WorkOrderId = item.WorkOrderId
|
||||||
|
}).ToList(),
|
||||||
|
Signoffs = _context.DispatchSignoffs
|
||||||
|
.Where(signoff => signoff.DispatchId == dispatch.Id)
|
||||||
|
.Select(signoff => new SignoffReadModel
|
||||||
|
{
|
||||||
|
Id = signoff.Id,
|
||||||
|
SignoffType = signoff.SignoffType,
|
||||||
|
Name = signoff.Name,
|
||||||
|
Signature = signoff.Signature,
|
||||||
|
SignatureMethod = signoff.SignatureMethod,
|
||||||
|
SignedAt = signoff.SignedAt
|
||||||
|
}).ToList(),
|
||||||
|
Comments = _context.Comments
|
||||||
|
.Where(comment => comment.DispatchId == dispatch.Id)
|
||||||
|
.OrderBy(comment => comment.CreatedDate)
|
||||||
|
.Select(comment => new DispatchCommentReadModel
|
||||||
|
{
|
||||||
|
Id = comment.Id,
|
||||||
|
CreatedDate = comment.CreatedDate,
|
||||||
|
Commenttext = comment.Commenttext,
|
||||||
|
Commenter = comment.Commenter,
|
||||||
|
CommentType = comment.CommentType,
|
||||||
|
UserName = comment.ApplicationUser != null
|
||||||
|
? comment.ApplicationUser.FirstName + " "
|
||||||
|
+ comment.ApplicationUser.LastName
|
||||||
|
: ""
|
||||||
|
}).ToList()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class DropdownOptionsDataService : IDropdownOptionsDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public DropdownOptionsDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<DropdownOption>> GetAllActiveAsync(string? category, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.DropdownOptions.Where(d => d.IsActive);
|
||||||
|
if (!string.IsNullOrWhiteSpace(category))
|
||||||
|
query = query.Where(d => d.Category == category);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(d => d.Category).ThenBy(d => d.SortOrder).ThenBy(d => d.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<DropdownOption>> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.DropdownOptions.Where(d => d.IsActive && d.Category == category);
|
||||||
|
if (!string.IsNullOrWhiteSpace(parent))
|
||||||
|
query = query.Where(d => d.ParentValue == parent);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(d => d.SortOrder).ThenBy(d => d.Value)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsAsync(string category, string value, string? parentValue, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.DropdownOptions
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(d => d.Category == category && d.Value == value && d.ParentValue == parentValue, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DropdownOption> AddAsync(DropdownOption option, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _context.DropdownOptions.AddAsync(option, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DropdownOption?> GetByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.DropdownOptions.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(DropdownOption option, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_context.DropdownOptions.Update(option);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveAsync(DropdownOption option, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_context.DropdownOptions.Remove(option);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -120,5 +120,138 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
return await _context.FollowUps.CountAsync();
|
return await _context.FollowUps.CountAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<FollowUps>> GetListPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search,
|
||||||
|
string? status,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
search ??= "";
|
||||||
|
status = string.IsNullOrWhiteSpace(status) ? "Open" : status;
|
||||||
|
|
||||||
|
var query = _context.FollowUps
|
||||||
|
.Include(x => x.Employee)
|
||||||
|
.Include(x => x.Account)
|
||||||
|
.Include(x => x.Location)
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Status == status);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var s = search.ToLower();
|
||||||
|
query = query.Where(x =>
|
||||||
|
(x.Reason ?? "").ToLower().Contains(s) ||
|
||||||
|
(x.Account != null && (x.Account.Name ?? "").ToLower().Contains(s)) ||
|
||||||
|
(x.Employee != null &&
|
||||||
|
(((x.Employee.FirstName ?? "") + " " + (x.Employee.LastName ?? ""))
|
||||||
|
.ToLower().Contains(s))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.OrderByDescending(f => f.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetListTotalAsync(string? search, string? status, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
search ??= "";
|
||||||
|
status = string.IsNullOrWhiteSpace(status) ? "Open" : status;
|
||||||
|
|
||||||
|
var query = _context.FollowUps
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Status == status);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
var s = search.ToLower();
|
||||||
|
query = query.Where(x =>
|
||||||
|
(x.Reason ?? "").ToLower().Contains(s) ||
|
||||||
|
(x.Account != null && (x.Account.Name ?? "").ToLower().Contains(s)) ||
|
||||||
|
(x.Employee != null &&
|
||||||
|
(((x.Employee.FirstName ?? "") + " " + (x.Employee.LastName ?? ""))
|
||||||
|
.ToLower().Contains(s))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await query.CountAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FollowUps?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.FollowUps
|
||||||
|
.Include(x => x.Employee)
|
||||||
|
.Include(x => x.Account)
|
||||||
|
.Include(x => x.Location)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FollowUps?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.FollowUps.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> GetUserNameAsync(string? userId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(userId))
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var u = await _context.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.Id == userId)
|
||||||
|
.Select(x => new { x.FirstName, x.LastName, x.UserName })
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (u == null)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var n = ((u.FirstName ?? "") + " " + (u.LastName ?? "")).Trim();
|
||||||
|
return string.IsNullOrWhiteSpace(n) ? (u.UserName ?? "") : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FollowUps> AddAsync(FollowUps followUp, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
followUp.CreatedDate = DateTime.Now;
|
||||||
|
await _context.FollowUps.AddAsync(followUp, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return followUp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
followUp.LastModificationTime = DateTime.Now;
|
||||||
|
_context.FollowUps.Update(followUp);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> SetStatusAsync(int id, string status, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.FollowUps.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
if (entity == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
entity.Status = status;
|
||||||
|
entity.LastModificationTime = DateTime.Now;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RemoveByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.FollowUps.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
if (entity == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_context.FollowUps.Remove(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class ForgetPasswordDataService : IForgetPasswordDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public ForgetPasswordDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedEmail = email.ToLower().Trim();
|
||||||
|
|
||||||
|
var existing = await _context.ForgetPasswordCodes
|
||||||
|
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
_context.ForgetPasswordCodes.Remove(existing);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var forgetPasswordCode = new ForgetPasswordCode
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
UserId = userId,
|
||||||
|
Code = code
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.ForgetPasswordCodes.Add(forgetPasswordCode);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.ForgetPasswordCodes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => u.Code == code)
|
||||||
|
.AnyAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedEmail = email.ToLower().Trim();
|
||||||
|
var trimmedCode = code.Trim();
|
||||||
|
|
||||||
|
return await _context.ForgetPasswordCodes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => u.Email.ToLower().Trim() == normalizedEmail && u.Code == trimmedCode)
|
||||||
|
.AnyAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedEmail = email.ToLower().Trim();
|
||||||
|
|
||||||
|
return await _context.ForgetPasswordCodes
|
||||||
|
.Where(u => u.Email.ToLower().Trim() == normalizedEmail)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveByEmailAsync(string email, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedEmail = email.ToLower().Trim();
|
||||||
|
var records = await _context.ForgetPasswordCodes
|
||||||
|
.Where(code => code.Email.ToLower().Trim() == normalizedEmail)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (records.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_context.ForgetPasswordCodes.RemoveRange(records);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,16 +37,16 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(
|
public async Task<(IEnumerable<Locations> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search = null)
|
string? search = null)
|
||||||
{
|
{
|
||||||
var query = _context.Locations.AsQueryable();
|
var query = _context.Locations.AsQueryable();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
query = query.Where(l =>
|
query = query.Where(l =>
|
||||||
l.Name!.Contains(search) ||
|
l.Name!.Contains(search) ||
|
||||||
l.City!.Contains(search));
|
l.City!.Contains(search));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,6 +95,69 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return await _context.Locations.CountAsync();
|
return await _context.Locations.CountAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
string? search,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _context.Locations.AsNoTracking();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
query = query.Where(l =>
|
||||||
|
l.Name!.Contains(search) ||
|
||||||
|
l.City!.Contains(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
var items = await query
|
||||||
|
.OrderBy(l => l.Name)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (items, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Locations
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(l => l.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Locations.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
location.CreatedDate = DateTime.Now;
|
||||||
|
await _context.Locations.AddAsync(location, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return location;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateAsync(Locations location, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
location.LastModificationTime = DateTime.Now;
|
||||||
|
_context.Locations.Update(location);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var entity = await _context.Locations.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
if (entity == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
_context.Locations.Remove(entity);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(
|
public async Task<(IEnumerable<object> Items, int TotalCount)> GetAddressbookPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -71,5 +72,108 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<QuoteListItemReadModel> Items, int TotalCount)> GetQuotesPagedAsync(
|
||||||
|
int page, int pageSize, string search, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedSearch = search.Trim();
|
||||||
|
var query = _context.Quotes
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => normalizedSearch.Length == 0 ||
|
||||||
|
(w.QuotId != null && w.QuotId.Contains(normalizedSearch)) ||
|
||||||
|
(w.SiteName != null && w.SiteName.Contains(normalizedSearch)) ||
|
||||||
|
(w.WorkOrder != null && w.WorkOrder.WorkerOrderTitle != null &&
|
||||||
|
w.WorkOrder.WorkerOrderTitle.Contains(normalizedSearch)));
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
var data = await query
|
||||||
|
.OrderByDescending(s => s.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(s => new QuoteListItemReadModel
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
QuoteId = "QO-" + s.QuotId,
|
||||||
|
WorkOrder = s.WorkOrder != null ? s.WorkOrder.WorkerOrderNumber : "",
|
||||||
|
ProjectName = s.ProjectName,
|
||||||
|
TotalAmount = s.LineItems!.Sum(l => l.Lineitem ?? 0),
|
||||||
|
SiteName = s.SiteName,
|
||||||
|
SiteId = s.SiteId,
|
||||||
|
PO = s.PO,
|
||||||
|
TT = s.TT,
|
||||||
|
BillingAddress = s.BillingAddress,
|
||||||
|
Status = s.Status,
|
||||||
|
ContactId = s.ContactId,
|
||||||
|
Date = s.Date,
|
||||||
|
Lineitems = s.LineItems!.Select(q => new QuoteLineItemReadModel
|
||||||
|
{
|
||||||
|
Descriptions = q.Descriptions,
|
||||||
|
Lineitem = q.Lineitem,
|
||||||
|
LineitemNumber = q.LineitemNumber
|
||||||
|
}).ToList(),
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (data, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<QuoteDetailReadModel?> GetQuoteDetailsByIdAsync(
|
||||||
|
int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Quotes
|
||||||
|
.Where(s => s.Id == id)
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(s => new QuoteDetailReadModel
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
QuotId = s.QuotId,
|
||||||
|
Date = s.Date,
|
||||||
|
Name = s.Contacts != null ? (s.Contacts.FirstName + " " + s.Contacts.LastName).Trim() : "",
|
||||||
|
PO = s.PO,
|
||||||
|
TT = s.TT,
|
||||||
|
SiteName = s.SiteName,
|
||||||
|
ProjectName = s.ProjectName,
|
||||||
|
SiteId = s.SiteId,
|
||||||
|
BillingAddress = s.BillingAddress,
|
||||||
|
ShippingAddress = s.ShippingAddress,
|
||||||
|
Lineitems = s.LineItems!.Select(q => new QuoteLineItemReadModel
|
||||||
|
{
|
||||||
|
Descriptions = q.Descriptions,
|
||||||
|
Lineitem = q.Lineitem
|
||||||
|
}).ToList(),
|
||||||
|
TotalAmount = s.LineItems!.Sum(l => l.Lineitem ?? 0)
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkOrderQuoteDetailReadModel>> GetWorkOrderQuoteDetailsAsync(
|
||||||
|
int woId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => w.Id == woId)
|
||||||
|
.Select(s => new WorkOrderQuoteDetailReadModel
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
PO = s.PO,
|
||||||
|
TT = s.TT,
|
||||||
|
Title = s.Locations != null ? s.Locations.Title : "",
|
||||||
|
Name = s.Locations != null ? s.Locations.Name : ""
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteLineItemsByQuoteIdAsync(int quoteId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = await _context.QuotesLines
|
||||||
|
.Where(w => w.QuoteId == quoteId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (items.Any())
|
||||||
|
{
|
||||||
|
_context.QuotesLines.RemoveRange(items);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
SeaHaven.DataServices/Implementation/SettingsDataService.cs
Normal file
125
SeaHaven.DataServices/Implementation/SettingsDataService.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class SettingsDataService : ISettingsDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public SettingsDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddCategoryAsync(string name, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var category = new Category { Name = name };
|
||||||
|
await _context.Categories.AddAsync(category, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateCategoryAsync(int id, string name, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var exist = await _context.Categories
|
||||||
|
.Where(w => w.Id == id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (exist == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
exist.Name = name;
|
||||||
|
_context.Categories.Update(exist);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeleteCategoryCascadeAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var exist = await _context.Categories
|
||||||
|
.Where(w => w.Id == id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (exist == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var templates = await _context.Templates.Where(w => w.CategoryId == id).ToListAsync(cancellationToken);
|
||||||
|
if (templates != null)
|
||||||
|
{
|
||||||
|
_context.RemoveRange(templates);
|
||||||
|
}
|
||||||
|
|
||||||
|
var workOrderCategories = await _context.workOrderCategories.Where(w => w.CategoryId == id).ToListAsync(cancellationToken);
|
||||||
|
if (workOrderCategories != null)
|
||||||
|
{
|
||||||
|
_context.RemoveRange(workOrderCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.Remove(exist);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<SettingsCategoryItemDTO> Items, int Total)> GetCategoryListPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var totalCount = await _context.Categories.CountAsync(cancellationToken);
|
||||||
|
var data = await _context.Categories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(s => new SettingsCategoryItemDTO
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
Name = s.Name
|
||||||
|
})
|
||||||
|
.OrderByDescending(d => d.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (data, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<WorkOrder> Items, int Total)> GetTemplatesPagedAsync(
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var totalCount = await _context.workOrders.CountAsync(w => w.istemplate == true, cancellationToken);
|
||||||
|
var data = await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => w.istemplate == true)
|
||||||
|
.OrderByDescending(d => d.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (data, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<(IReadOnlyList<ApplicationUser> Users, int Total)> GetUsersPagedAsync(
|
||||||
|
string? search,
|
||||||
|
int page,
|
||||||
|
int pageSize,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var totalCount = await _context.Users.CountAsync(cancellationToken);
|
||||||
|
var users = await _context.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower()))
|
||||||
|
.OrderByDescending(d => d.Id)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return (users, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> GetRoleNamesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var roles = await _context.Roles.AsNoTracking().ToListAsync(cancellationToken);
|
||||||
|
return roles.Select(r => r.Name!).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
127
SeaHaven.DataServices/Implementation/SyncDataService.cs
Normal file
127
SeaHaven.DataServices/Implementation/SyncDataService.cs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class SyncDataService : ISyncDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public SyncDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetLastInternalWONumberAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.workOrders
|
||||||
|
.Where(w => w.InternalWONumber != null && w.InternalWONumber != "")
|
||||||
|
.OrderByDescending(w => w.InternalWONumber)
|
||||||
|
.Select(w => w.InternalWONumber)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrder?> GetWorkOrderByExternalIdAsync(string externalId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueWorkOrder(WorkOrder workOrder)
|
||||||
|
{
|
||||||
|
_context.workOrders.Add(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueComment(Comments comment)
|
||||||
|
{
|
||||||
|
_context.Comments.Add(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SaveChangesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int?> ResolveLocationAsync(string? siteCode, string? building, string? address, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var matchCode = siteCode ?? building;
|
||||||
|
var existing = await _context.Locations
|
||||||
|
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken);
|
||||||
|
|
||||||
|
if (existing != null)
|
||||||
|
return existing.Id;
|
||||||
|
|
||||||
|
var location = new Locations
|
||||||
|
{
|
||||||
|
Name = matchCode,
|
||||||
|
Title = building,
|
||||||
|
Address1 = address,
|
||||||
|
Status = "Active"
|
||||||
|
};
|
||||||
|
|
||||||
|
_context.Locations.Add(location);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return location.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetLocationCountAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Locations.CountAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Comments
|
||||||
|
.AnyAsync(c => c.ExternalCommentId == externalId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrder?> GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.workOrders
|
||||||
|
.FirstOrDefaultAsync(w => w.InternalWONumber == internalNumber, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dispatch?> GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Dispatches
|
||||||
|
.FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrderId, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<WorkOrder>> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.workOrders
|
||||||
|
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
|
||||||
|
.OrderBy(w => w.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Dispatch>> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Dispatches
|
||||||
|
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
|
||||||
|
.OrderBy(d => d.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int> ExecuteBackfillCommentTypesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Database.ExecuteSqlRawAsync(
|
||||||
|
"UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> GetLastDispatchNumberAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Dispatches
|
||||||
|
.Where(d => d.DispatchNumber != null && d.DispatchNumber != "")
|
||||||
|
.OrderByDescending(d => d.DispatchNumber)
|
||||||
|
.Select(d => d.DispatchNumber)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,5 +57,58 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
return await _context.TaskListTemplates.AnyAsync(t => t.Id == id);
|
return await _context.TaskListTemplates.AnyAsync(t => t.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TaskListTemplate>> GetActiveTemplatesWithItemsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.TaskListTemplates
|
||||||
|
.Include(t => t.Items)
|
||||||
|
.Where(t => t.IsActive)
|
||||||
|
.OrderBy(t => t.Name)
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskListTemplate?> GetTemplateWithItemsReadOnlyAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.TaskListTemplates
|
||||||
|
.Include(t => t.Items)
|
||||||
|
.Where(t => t.Id == id)
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskListTemplate?> GetTemplateWithItemsForUpdateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.TaskListTemplates
|
||||||
|
.Include(t => t.Items)
|
||||||
|
.FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<TaskListTemplate> AddTemplateAsync(TaskListTemplate template, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _context.TaskListTemplates.AddAsync(template, cancellationToken);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReplaceTemplateItemsAsync(TaskListTemplate template, IEnumerable<TaskListTemplateItem> newItems, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (template.Items != null)
|
||||||
|
_context.TaskListTemplateItems.RemoveRange(template.Items);
|
||||||
|
|
||||||
|
template.Items = newItems.ToList();
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> DeactivateTemplateAsync(int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var template = await _context.TaskListTemplates.FindAsync(new object[] { id }, cancellationToken);
|
||||||
|
if (template == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
template.IsActive = false;
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
SeaHaven.DataServices/Implementation/UpliftDataService.cs
Normal file
138
SeaHaven.DataServices/Implementation/UpliftDataService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
@ -33,5 +34,122 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
return await _context.Users.AnyAsync(u => u.Id == id);
|
return await _context.Users.AnyAsync(u => u.Id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<UserListRowData>> GetAllForListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(user => new UserListRowData
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
RoleName = user.PhoneNumber,
|
||||||
|
Email = user.Email,
|
||||||
|
Name = user.FirstName,
|
||||||
|
CreatedDate = user.CreatedDate,
|
||||||
|
Status = user.UniqueName
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(u => u.Id == id)
|
||||||
|
.Select(user => new UserProfileData
|
||||||
|
{
|
||||||
|
Id = user.Id,
|
||||||
|
FirstName = user.FirstName,
|
||||||
|
Email = user.Email,
|
||||||
|
Contact = user.Contact,
|
||||||
|
CreatedDate = user.CreatedDate
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpdateProfileAsync(
|
||||||
|
string id,
|
||||||
|
string? name,
|
||||||
|
string? email,
|
||||||
|
string? contact,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var updated = await _context.Users
|
||||||
|
.Where(user => user.Id == id)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters
|
||||||
|
.SetProperty(user => user.FirstName, name)
|
||||||
|
.SetProperty(user => user.Email, email)
|
||||||
|
.SetProperty(user => user.Contact, contact),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
return updated == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await _context.Users
|
||||||
|
.Where(w => w.Id == id)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ApplicationUser?> GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var normalizedEmail = email.Trim().ToUpperInvariant();
|
||||||
|
return await _context.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(user => user.NormalizedEmail == normalizedEmail)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_context.Users.Update(user);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var id = user.Id;
|
||||||
|
await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _context.workOrders
|
||||||
|
.Where(workOrder => workOrder.AssignTo == id)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(workOrder => workOrder.AssignTo, (string?)null),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await _context.Comments
|
||||||
|
.Where(comment => comment.UserId == id)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(comment => comment.UserId, (string?)null),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await _context.Templates
|
||||||
|
.Where(template => template.AssignTo == id)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters.SetProperty(template => template.AssignTo, (string?)null),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await _context.UserRoles
|
||||||
|
.Where(role => role.UserId == id)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
await _context.Users
|
||||||
|
.Where(existingUser => existingUser.Id == id)
|
||||||
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,56 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var orderedIds = ids.Distinct().ToList();
|
||||||
|
if (orderedIds.Count == 0)
|
||||||
|
return Array.Empty<Vendor>();
|
||||||
|
|
||||||
|
var loaded = await _context.Vendors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(v => orderedIds.Contains(v.Id))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var byId = loaded.ToDictionary(v => v.Id);
|
||||||
|
var ordered = new List<Vendor>(orderedIds.Count);
|
||||||
|
foreach (var id in orderedIds)
|
||||||
|
{
|
||||||
|
if (byId.TryGetValue(id, out var vendor))
|
||||||
|
ordered.Add(vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<Vendor>> GetAllAsync()
|
public async Task<IEnumerable<Vendor>> GetAllAsync()
|
||||||
{
|
{
|
||||||
return await _context.Vendors.ToListAsync();
|
return await _context.Vendors.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
public async Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|
@ -284,16 +329,47 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return await _context.Vendors.AnyAsync(v => v.Id == id);
|
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
|
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
|
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()
|
public async Task<int> CountAsync()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderAuditDataService : IWorkOrderAuditDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public WorkOrderAuditDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueAuditLog(WorkOrderAuditLog auditLog)
|
||||||
|
{
|
||||||
|
_context.WorkOrderAuditLogs.Add(auditLog);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PersistAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex))
|
||||||
|
{
|
||||||
|
foreach (var entry in ex.Entries.Where(e => e.Entity is WorkOrderFieldLock))
|
||||||
|
entry.State = EntityState.Detached;
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsUniqueConstraintViolation(DbUpdateException ex)
|
||||||
|
{
|
||||||
|
var message = ex.InnerException?.Message ?? ex.Message;
|
||||||
|
return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using SeaHaven.DataServices.Interfaces;
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Implementation
|
namespace SeaHaven.DataServices.Implementation
|
||||||
{
|
{
|
||||||
|
|
@ -101,7 +102,7 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return (items, totalCount);
|
return (items, totalCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<(IEnumerable<object> Items, int TotalCount)> GetWorkOrderListPagedAsync(
|
public async Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search = null,
|
string? search = null,
|
||||||
|
|
@ -131,13 +132,12 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
var s = search.ToLower();
|
|
||||||
query = query.Where(w =>
|
query = query.Where(w =>
|
||||||
(w.InternalWONumber ?? "").ToLower().Contains(s) ||
|
(w.InternalWONumber != null && w.InternalWONumber.Contains(search)) ||
|
||||||
(w.WorkerOrderNumber ?? "").ToLower().Contains(s) ||
|
(w.WorkerOrderNumber != null && w.WorkerOrderNumber.Contains(search)) ||
|
||||||
(w.WorkerOrderTitle ?? "").ToLower().Contains(s) ||
|
(w.WorkerOrderTitle != null && w.WorkerOrderTitle.Contains(search)) ||
|
||||||
(w.Locations.Title ?? "").ToLower().Contains(s) ||
|
(w.Locations != null && w.Locations.Title != null && w.Locations.Title.Contains(search)) ||
|
||||||
(w.Locations.Name ?? "").ToLower().Contains(s)
|
(w.Locations != null && w.Locations.Name != null && w.Locations.Name.Contains(search))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,24 +160,24 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
var data = await ordered
|
var data = await ordered
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.Select(w => new
|
.Select(w => new WorkOrderListItemReadModel
|
||||||
{
|
{
|
||||||
id = w.Id,
|
Id = w.Id,
|
||||||
internalWONumber = w.InternalWONumber,
|
InternalWONumber = w.InternalWONumber,
|
||||||
workOrderNumber = w.WorkerOrderNumber,
|
WorkOrderNumber = w.WorkerOrderNumber,
|
||||||
number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
Number = w.InternalWONumber ?? w.WorkerOrderNumber,
|
||||||
title = w.WorkerOrderTitle,
|
Title = w.WorkerOrderTitle,
|
||||||
location = w.Locations != null ? w.Locations.Name : "",
|
Location = w.Locations != null ? w.Locations.Name : "",
|
||||||
locationName = w.Locations != null ? w.Locations.Name : "",
|
LocationName = w.Locations != null ? w.Locations.Name : "",
|
||||||
priority = w.Priority,
|
Priority = w.Priority,
|
||||||
status = w.Status,
|
Status = w.Status,
|
||||||
createdDate = w.CreatedDate,
|
CreatedDate = w.CreatedDate,
|
||||||
dueDate = w.DueDate,
|
DueDate = w.DueDate,
|
||||||
assignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "",
|
AssignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "",
|
||||||
employeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "",
|
EmployeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "",
|
||||||
lastUpdated = new[] {
|
LastUpdated = new[] {
|
||||||
w.LastModificationTime,
|
w.LastModificationTime,
|
||||||
w.Comments.Max(c => (DateTime?)c.CreatedDate),
|
w.Comments!.Max(c => (DateTime?)c.CreatedDate),
|
||||||
_context.WorkOrderAuditLogs.Where(a => a.WorkOrderId == w.Id).Max(a => (DateTime?)a.CreatedAt)
|
_context.WorkOrderAuditLogs.Where(a => a.WorkOrderId == w.Id).Max(a => (DateTime?)a.CreatedAt)
|
||||||
}.Where(d => d.HasValue).Max()
|
}.Where(d => d.HasValue).Max()
|
||||||
})
|
})
|
||||||
|
|
@ -283,6 +283,15 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
await _context.SaveChangesAsync();
|
await _context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task AddAuditLogsAsync(IReadOnlyCollection<WorkOrderAuditLog> auditLogs)
|
||||||
|
{
|
||||||
|
if (auditLogs.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await _context.WorkOrderAuditLogs.AddRangeAsync(auditLogs);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteWorkOrderContactsAsync(int workOrderId)
|
public async Task DeleteWorkOrderContactsAsync(int workOrderId)
|
||||||
{
|
{
|
||||||
var contacts = await _context.WorkOrderContacts
|
var contacts = await _context.WorkOrderContacts
|
||||||
|
|
@ -349,6 +358,11 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
return await _context.workOrders.CountAsync(w => w.Status == status);
|
return await _context.workOrders.CountAsync(w => w.Status == status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> CountByPriorityAsync(string priority)
|
||||||
|
{
|
||||||
|
return await _context.workOrders.CountAsync(w => w.Priority == priority);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<int> CountByLocationAsync(int locationId)
|
public async Task<int> CountByLocationAsync(int locationId)
|
||||||
{
|
{
|
||||||
return await _context.workOrders.CountAsync(w => w.LocationId == locationId);
|
return await _context.workOrders.CountAsync(w => w.LocationId == locationId);
|
||||||
|
|
@ -363,5 +377,146 @@ namespace SeaHaven.DataServices.Implementation
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<WorkOrder>> GetByRequestedIdsWithDetailsAsync(
|
||||||
|
IReadOnlyCollection<int> ids,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var orderedIds = ids.Distinct().ToList();
|
||||||
|
if (orderedIds.Count == 0)
|
||||||
|
return Array.Empty<WorkOrder>();
|
||||||
|
|
||||||
|
var loaded = await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => orderedIds.Contains(w.Id) && w.istemplate != true)
|
||||||
|
.Include(w => w.Locations)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var byId = loaded.ToDictionary(w => w.Id);
|
||||||
|
var ordered = new List<WorkOrder>(orderedIds.Count);
|
||||||
|
foreach (var id in orderedIds)
|
||||||
|
{
|
||||||
|
if (byId.TryGetValue(id, out var workOrder))
|
||||||
|
ordered.Add(workOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id)
|
||||||
|
{
|
||||||
|
return await _context.workOrders
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(w => w.Id == id)
|
||||||
|
.Select(s => new WorkOrderDetailReadModel
|
||||||
|
{
|
||||||
|
Id = s.Id,
|
||||||
|
InternalWONumber = s.InternalWONumber,
|
||||||
|
WorkerOrderNumber = s.WorkerOrderNumber,
|
||||||
|
Title = s.WorkerOrderTitle,
|
||||||
|
Description = s.Description,
|
||||||
|
CreatedDate = s.CreatedDate,
|
||||||
|
DueDate = s.DueDate,
|
||||||
|
ScheduledDate = s.ScheduledDate,
|
||||||
|
CompletedDate = s.CompletedDate,
|
||||||
|
Status = s.Status,
|
||||||
|
Assignee = s.AssignToUser != null ? s.AssignToUser.FirstName : "",
|
||||||
|
Location = s.Locations != null ? s.Locations.Name : "",
|
||||||
|
LocationId = s.LocationId,
|
||||||
|
LocationZip = s.Locations != null ? (s.Locations.Zip ?? ExtractZip(s.Locations.Address1)) : "",
|
||||||
|
Priority = s.Priority,
|
||||||
|
Problem = s.Problem,
|
||||||
|
Trade = s.Trade,
|
||||||
|
SubTrade = s.SubTrade,
|
||||||
|
VendorNTE = s.VendorNTE,
|
||||||
|
Source = s.Source,
|
||||||
|
PO = s.PO,
|
||||||
|
TT = s.TT,
|
||||||
|
Poc = s.WorkOrderContacts!.Select(c => new ContactReadModel
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
Name = c.POC != null ? (c.POC.FirstName + " " + c.POC.LastName).Trim() : "",
|
||||||
|
Email = c.POC != null ? c.POC.Email : "",
|
||||||
|
Phone = c.POC != null ? c.POC.PhoneNumber : ""
|
||||||
|
}).ToList(),
|
||||||
|
Category = s.WorkOrderCategories!.Select(c => new CategoryReadModel
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
Name = c.Category != null ? c.Category.Name : ""
|
||||||
|
}).ToList(),
|
||||||
|
Attachments = s.workOrderAttachments!.Select(a => new AttachmentReadModel
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
Attachment = a.Attachments
|
||||||
|
}).ToList(),
|
||||||
|
BeforPhotoIssue = s.BeforPhotoIssue,
|
||||||
|
BeforPhotoAttachment = s.BeforPhotoAttachment,
|
||||||
|
AfterPhotoIssue = s.AfterPhotoIssue,
|
||||||
|
AfterPhotoAttachment = s.AfterPhotoAttachment,
|
||||||
|
SignOffName = s.SignOffName,
|
||||||
|
SignOffAttachment = s.SignOffAttachment,
|
||||||
|
SignOffSignature = s.SignOffSignature,
|
||||||
|
Comments = s.Comments!.Select(c => new CommentReadModel
|
||||||
|
{
|
||||||
|
Id = c.Id,
|
||||||
|
CreatedDate = c.CreatedDate,
|
||||||
|
Commenttext = c.Commenttext,
|
||||||
|
Commenter = c.Commenter,
|
||||||
|
RecordType = c.RecordType,
|
||||||
|
CommentType = c.CommentType,
|
||||||
|
Documents = c.Documents,
|
||||||
|
UserName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : ""
|
||||||
|
}).ToList(),
|
||||||
|
Quotes = s.Quotes!.Select(q => new QuoteRefReadModel
|
||||||
|
{
|
||||||
|
Code = q.QuotId,
|
||||||
|
Id = q.Id
|
||||||
|
}).ToList(),
|
||||||
|
AuditLog = _context.WorkOrderAuditLogs
|
||||||
|
.Where(a => a.WorkOrderId == s.Id)
|
||||||
|
.OrderByDescending(a => a.CreatedAt)
|
||||||
|
.Select(a => new AuditLogReadModel
|
||||||
|
{
|
||||||
|
Id = a.Id,
|
||||||
|
FieldName = a.FieldName,
|
||||||
|
OldValue = a.OldValue,
|
||||||
|
NewValue = a.NewValue,
|
||||||
|
Action = a.Action,
|
||||||
|
CreatedAt = a.CreatedAt,
|
||||||
|
UserName = a.User != null ? a.User.FirstName + " " + a.User.LastName : ""
|
||||||
|
}).ToList(),
|
||||||
|
Dispatches = s.Dispatches!.Select(d => new DispatchReadModel
|
||||||
|
{
|
||||||
|
Id = d.Id,
|
||||||
|
DispatchNumber = d.DispatchNumber,
|
||||||
|
PONumber = d.PONumber,
|
||||||
|
NTEAmount = d.NTEAmount,
|
||||||
|
Description = d.Description,
|
||||||
|
Status = d.Status,
|
||||||
|
ScheduledDate = d.ScheduledDate,
|
||||||
|
CompletedDate = d.CompletedDate,
|
||||||
|
DispatchedAt = d.DispatchedAt,
|
||||||
|
EmailSent = d.EmailSent,
|
||||||
|
VendorName = d.Vendor != null ? d.Vendor.CompanyName : "",
|
||||||
|
VendorEmail = d.Vendor != null ? d.Vendor.Email : "",
|
||||||
|
VendorId = d.VendorId
|
||||||
|
}).ToList()
|
||||||
|
}).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync()
|
||||||
|
{
|
||||||
|
return await _context.workOrders
|
||||||
|
.Where(w => w.istemplate != true)
|
||||||
|
.Include(w => w.Locations)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractZip(string? address)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(address)) return null;
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(address, @"\b(\d{5})(?:-\d{4})?\b");
|
||||||
|
return match.Success ? match.Groups[1].Value : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using SeaHaven.DataServices.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderFieldLockDataService : IWorkOrderFieldLockDataService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _context;
|
||||||
|
|
||||||
|
public WorkOrderFieldLockDataService(ApplicationDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (IsTracked(workOrderId, fieldName))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return await _context.WorkOrderFieldLocks
|
||||||
|
.AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EnqueueLock(int workOrderId, string fieldName, string? userId)
|
||||||
|
{
|
||||||
|
if (IsTracked(workOrderId, fieldName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock
|
||||||
|
{
|
||||||
|
WorkOrderId = workOrderId,
|
||||||
|
FieldName = fieldName,
|
||||||
|
LockedAt = DateTime.UtcNow,
|
||||||
|
LockedByUserId = userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsTracked(int workOrderId, string fieldName)
|
||||||
|
=> _context.WorkOrderFieldLocks.Local.Any(l =>
|
||||||
|
l.WorkOrderId == workOrderId &&
|
||||||
|
string.Equals(l.FieldName, fieldName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
SeaHaven.DataServices/Interfaces/ICalendarDataService.cs
Normal file
14
SeaHaven.DataServices/Interfaces/ICalendarDataService.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface ICalendarDataService
|
||||||
|
{
|
||||||
|
Task<Events?> GetEventByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<Events?> GetEventForEditAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<Events> AddEventAsync(Events ev, CancellationToken cancellationToken);
|
||||||
|
Task UpdateEventAsync(Events ev, CancellationToken cancellationToken);
|
||||||
|
Task<(IEnumerable<Events> Items, int TotalCount)> GetEventListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
||||||
|
Task<IEnumerable<Events>> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,5 +12,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task UpdateAsync(Comments comment);
|
Task UpdateAsync(Comments comment);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
|
Task StageAsync(Comments comment, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<PortalCommentData>> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
SeaHaven.DataServices/Interfaces/ICommonDataService.cs
Normal file
13
SeaHaven.DataServices/Interfaces/ICommonDataService.cs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface ICommonDataService
|
||||||
|
{
|
||||||
|
Task<(IEnumerable<ApplicationUser> Items, int TotalCount)> GetUsersPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken);
|
||||||
|
Task<IEnumerable<string?>> GetRoleNamesAsync(CancellationToken cancellationToken);
|
||||||
|
Task<IEnumerable<Locations>> GetSiteNamesAsync(CancellationToken cancellationToken);
|
||||||
|
Task<IEnumerable<Contacts>> GetContactsAsync(CancellationToken cancellationToken);
|
||||||
|
Task<IEnumerable<Category>> GetCategoriesAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
SeaHaven.DataServices/Interfaces/IDashboardDataService.cs
Normal file
15
SeaHaven.DataServices/Interfaces/IDashboardDataService.cs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IDashboardDataService
|
||||||
|
{
|
||||||
|
Task<DashboardCounts> GetWorkOrderCountsAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DashboardCounts
|
||||||
|
{
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Open { get; set; }
|
||||||
|
public int NotDispatched { get; set; }
|
||||||
|
public int Completed { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Interfaces
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
|
|
@ -6,6 +8,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
Task<Dispatch?> GetByIdAsync(int id);
|
Task<Dispatch?> GetByIdAsync(int id);
|
||||||
Task<Dispatch?> GetByIdWithDetailsAsync(int id);
|
Task<Dispatch?> GetByIdWithDetailsAsync(int id);
|
||||||
|
Task<Dispatch?> GetByIdWithVendorAndWorkOrderAsync(int id);
|
||||||
Task<IEnumerable<Dispatch>> GetAllAsync();
|
Task<IEnumerable<Dispatch>> GetAllAsync();
|
||||||
Task<IEnumerable<Dispatch>> GetByVendorIdAsync(int vendorId);
|
Task<IEnumerable<Dispatch>> GetByVendorIdAsync(int vendorId);
|
||||||
Task<IEnumerable<Dispatch>> GetByWorkOrderIdAsync(int workOrderId);
|
Task<IEnumerable<Dispatch>> GetByWorkOrderIdAsync(int workOrderId);
|
||||||
|
|
@ -25,8 +28,29 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task UpdateChecklistItemAsync(DispatchChecklistItem item);
|
Task UpdateChecklistItemAsync(DispatchChecklistItem item);
|
||||||
Task<IEnumerable<DispatchSignoff>> GetSignoffsAsync(int dispatchId);
|
Task<IEnumerable<DispatchSignoff>> GetSignoffsAsync(int dispatchId);
|
||||||
Task AddSignoffAsync(DispatchSignoff signoff);
|
Task AddSignoffAsync(DispatchSignoff signoff);
|
||||||
|
Task StageSignoffAsync(DispatchSignoff signoff, CancellationToken cancellationToken);
|
||||||
Task<bool> HasSignoffTypeAsync(int dispatchId, string signoffType);
|
Task<bool> HasSignoffTypeAsync(int dispatchId, string signoffType);
|
||||||
Task<IEnumerable<DispatchWorkOrder>> GetDispatchWorkOrdersAsync(int dispatchId);
|
Task<IEnumerable<DispatchWorkOrder>> GetDispatchWorkOrdersAsync(int dispatchId);
|
||||||
Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder);
|
Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder);
|
||||||
|
|
||||||
|
Task<DispatchListResult> GetListAsync(DispatchListQuery query, CancellationToken cancellationToken);
|
||||||
|
Task<DispatchDetailDTO?> GetDetailAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<DispatchDetailReadModel?> GetDispatchDetailAsync(int id);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IDropdownOptionsDataService
|
||||||
|
{
|
||||||
|
Task<IEnumerable<DropdownOption>> GetAllActiveAsync(string? category, CancellationToken cancellationToken);
|
||||||
|
Task<IEnumerable<DropdownOption>> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken);
|
||||||
|
Task<bool> ExistsAsync(string category, string value, string? parentValue, CancellationToken cancellationToken);
|
||||||
|
Task<DropdownOption> AddAsync(DropdownOption option, CancellationToken cancellationToken);
|
||||||
|
Task<DropdownOption?> GetByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task UpdateAsync(DropdownOption option, CancellationToken cancellationToken);
|
||||||
|
Task RemoveAsync(DropdownOption option, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,5 +15,15 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
Task<int> CountAsync();
|
Task<int> CountAsync();
|
||||||
|
|
||||||
|
Task<List<FollowUps>> GetListPagedAsync(int page, int pageSize, string? search, string? status, CancellationToken cancellationToken);
|
||||||
|
Task<int> GetListTotalAsync(string? search, string? status, CancellationToken cancellationToken);
|
||||||
|
Task<FollowUps?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<FollowUps?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<string> GetUserNameAsync(string? userId, CancellationToken cancellationToken);
|
||||||
|
Task<FollowUps> AddAsync(FollowUps followUp, CancellationToken cancellationToken);
|
||||||
|
Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken);
|
||||||
|
Task<bool> SetStatusAsync(int id, string status, CancellationToken cancellationToken);
|
||||||
|
Task<bool> RemoveByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IForgetPasswordDataService
|
||||||
|
{
|
||||||
|
Task ReplaceCodeAsync(string email, string userId, string code, CancellationToken cancellationToken);
|
||||||
|
Task<bool> CodeExistsAsync(string code, CancellationToken cancellationToken);
|
||||||
|
Task<bool> ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken);
|
||||||
|
Task<ForgetPasswordCode?> GetByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
|
Task RemoveByEmailAsync(string email, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,5 +20,12 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
Task<int> CountAsync();
|
Task<int> CountAsync();
|
||||||
|
|
||||||
|
Task<(List<Locations> Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken);
|
||||||
|
Task<Locations?> GetDetailByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<Locations?> GetByIdForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<Locations> AddAsync(Locations location, CancellationToken cancellationToken);
|
||||||
|
Task UpdateAsync(Locations location, CancellationToken cancellationToken);
|
||||||
|
Task<bool> DeleteByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Interfaces
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
|
|
@ -12,5 +13,15 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
Task UnlinkFromWorkOrderAsync(int workOrderId);
|
Task UnlinkFromWorkOrderAsync(int workOrderId);
|
||||||
|
|
||||||
|
Task<(IReadOnlyList<QuoteListItemReadModel> Items, int TotalCount)> GetQuotesPagedAsync(
|
||||||
|
int page, int pageSize, string search, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<QuoteDetailReadModel?> GetQuoteDetailsByIdAsync(int id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<WorkOrderQuoteDetailReadModel>> GetWorkOrderQuoteDetailsAsync(
|
||||||
|
int woId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task DeleteLineItemsByQuoteIdAsync(int quoteId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
SeaHaven.DataServices/Interfaces/ISettingsDataService.cs
Normal file
16
SeaHaven.DataServices/Interfaces/ISettingsDataService.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface ISettingsDataService
|
||||||
|
{
|
||||||
|
Task AddCategoryAsync(string name, CancellationToken cancellationToken);
|
||||||
|
Task<bool> UpdateCategoryAsync(int id, string name, CancellationToken cancellationToken);
|
||||||
|
Task<bool> DeleteCategoryCascadeAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<(IReadOnlyList<SettingsCategoryItemDTO> Items, int Total)> GetCategoryListPagedAsync(int page, int pageSize, CancellationToken cancellationToken);
|
||||||
|
Task<(IReadOnlyList<WorkOrder> Items, int Total)> GetTemplatesPagedAsync(int page, int pageSize, CancellationToken cancellationToken);
|
||||||
|
Task<(IReadOnlyList<ApplicationUser> Users, int Total)> GetUsersPagedAsync(string? search, int page, int pageSize, CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<string>> GetRoleNamesAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
SeaHaven.DataServices/Interfaces/ISyncDataService.cs
Normal file
25
SeaHaven.DataServices/Interfaces/ISyncDataService.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface ISyncDataService
|
||||||
|
{
|
||||||
|
Task<string?> GetLastInternalWONumberAsync(CancellationToken cancellationToken);
|
||||||
|
Task<WorkOrder?> GetWorkOrderByExternalIdAsync(string externalId, CancellationToken cancellationToken);
|
||||||
|
void EnqueueWorkOrder(WorkOrder workOrder);
|
||||||
|
void EnqueueComment(Comments comment);
|
||||||
|
Task SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<int?> ResolveLocationAsync(string? siteCode, string? building, string? address, CancellationToken cancellationToken);
|
||||||
|
Task<int> GetLocationCountAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<bool> CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken);
|
||||||
|
Task<WorkOrder?> GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken);
|
||||||
|
Task<Dispatch?> GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<List<WorkOrder>> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken);
|
||||||
|
Task<List<Dispatch>> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken);
|
||||||
|
Task<int> ExecuteBackfillCommentTypesAsync(CancellationToken cancellationToken);
|
||||||
|
Task<string?> GetLastDispatchNumberAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,5 +11,13 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task UpdateAsync(TaskListTemplate template);
|
Task UpdateAsync(TaskListTemplate template);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
|
|
||||||
|
// Cancelable query/command operations used by the TaskListTemplate service layer
|
||||||
|
Task<IEnumerable<TaskListTemplate>> GetActiveTemplatesWithItemsAsync(CancellationToken cancellationToken);
|
||||||
|
Task<TaskListTemplate?> GetTemplateWithItemsReadOnlyAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<TaskListTemplate?> GetTemplateWithItemsForUpdateAsync(int id, CancellationToken cancellationToken);
|
||||||
|
Task<TaskListTemplate> AddTemplateAsync(TaskListTemplate template, CancellationToken cancellationToken);
|
||||||
|
Task ReplaceTemplateItemsAsync(TaskListTemplate template, IEnumerable<TaskListTemplateItem> newItems, CancellationToken cancellationToken);
|
||||||
|
Task<bool> DeactivateTemplateAsync(int id, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
SeaHaven.DataServices/Interfaces/IUpliftDataService.cs
Normal file
17
SeaHaven.DataServices/Interfaces/IUpliftDataService.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Dto;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Interfaces
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
|
|
@ -8,5 +9,14 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<IEnumerable<ApplicationUser>> GetAllAsync();
|
Task<IEnumerable<ApplicationUser>> GetAllAsync();
|
||||||
Task<ApplicationUser?> GetByEmailAsync(string email);
|
Task<ApplicationUser?> GetByEmailAsync(string email);
|
||||||
Task<bool> ExistsAsync(string id);
|
Task<bool> ExistsAsync(string id);
|
||||||
|
|
||||||
|
Task<IReadOnlyList<UserListRowData>> GetAllForListAsync(CancellationToken cancellationToken);
|
||||||
|
Task<UserProfileData?> GetProfileAsync(string id, CancellationToken cancellationToken);
|
||||||
|
Task<bool> UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken);
|
||||||
|
Task<ApplicationUser?> GetForEditAsync(string id, CancellationToken cancellationToken);
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
Task<Vendor?> GetByIdAsync(int id);
|
Task<Vendor?> GetByIdAsync(int id);
|
||||||
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
Task<Vendor?> GetByIdWithDetailsAsync(int id);
|
||||||
|
|
||||||
|
// Batched lookup: one query for the distinct requested ids, preserving input order
|
||||||
|
// and skipping any ids that are not present (no rows returned for missing ids).
|
||||||
|
Task<IReadOnlyList<Vendor>> GetByIdsOrderedAsync(IReadOnlyCollection<int> ids, CancellationToken cancellationToken);
|
||||||
Task<IEnumerable<Vendor>> GetAllAsync();
|
Task<IEnumerable<Vendor>> GetAllAsync();
|
||||||
|
Task<IReadOnlyList<Vendor>> GetActiveVendorsAsync(CancellationToken cancellationToken);
|
||||||
|
Task<IReadOnlyList<Vendor>> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken);
|
||||||
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
Task<(IEnumerable<Vendor> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
|
|
@ -20,8 +26,11 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task UpdateAsync(Vendor vendor);
|
Task UpdateAsync(Vendor vendor);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
Task<bool> ExistsAsync(int id);
|
Task<bool> ExistsAsync(int id);
|
||||||
Task<VendorAccessToken?> GetActiveTokenAsync(int vendorId);
|
Task<VendorAccessToken?> GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken);
|
||||||
Task<VendorAccessToken?> GetTokenByStringAsync(string token);
|
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<int> CountAsync();
|
||||||
|
|
||||||
Task<VendorCompany?> GetCompanyByIdAsync(int id);
|
Task<VendorCompany?> GetCompanyByIdAsync(int id);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderAuditDataService
|
||||||
|
{
|
||||||
|
void EnqueueAuditLog(WorkOrderAuditLog auditLog);
|
||||||
|
|
||||||
|
Task PersistAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
using SeaHaven.DataServices.Models;
|
||||||
|
|
||||||
namespace SeaHaven.DataServices.Interfaces
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
{
|
{
|
||||||
|
|
@ -13,7 +14,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task<IEnumerable<WorkOrder>> GetByAssigneeAsync(string assigneeId);
|
Task<IEnumerable<WorkOrder>> GetByAssigneeAsync(string assigneeId);
|
||||||
Task<IEnumerable<WorkOrder>> GetByStatusAsync(string status);
|
Task<IEnumerable<WorkOrder>> GetByStatusAsync(string status);
|
||||||
Task<IEnumerable<WorkOrder>> GetByPriorityAsync(string priority);
|
Task<IEnumerable<WorkOrder>> GetByPriorityAsync(string priority);
|
||||||
|
|
||||||
// Paged results
|
// Paged results
|
||||||
Task<(IEnumerable<WorkOrder> Items, int TotalCount)> GetPagedAsync(
|
Task<(IEnumerable<WorkOrder> Items, int TotalCount)> GetPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
|
|
@ -23,7 +24,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
string? priority = null);
|
string? priority = null);
|
||||||
|
|
||||||
// Paged list with detailed information (for GetWorkOrderList endpoint)
|
// Paged list with detailed information (for GetWorkOrderList endpoint)
|
||||||
Task<(IEnumerable<object> Items, int TotalCount)> GetWorkOrderListPagedAsync(
|
Task<(IEnumerable<WorkOrderListItemReadModel> Items, int TotalCount)> GetWorkOrderListPagedAsync(
|
||||||
int page,
|
int page,
|
||||||
int pageSize,
|
int pageSize,
|
||||||
string? search = null,
|
string? search = null,
|
||||||
|
|
@ -32,12 +33,12 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
int? locationId = null,
|
int? locationId = null,
|
||||||
string? sortBy = "lastUpdated",
|
string? sortBy = "lastUpdated",
|
||||||
string? sortDir = "desc");
|
string? sortDir = "desc");
|
||||||
|
|
||||||
// Command operations
|
// Command operations
|
||||||
Task<WorkOrder> AddAsync(WorkOrder workOrder);
|
Task<WorkOrder> AddAsync(WorkOrder workOrder);
|
||||||
Task UpdateAsync(WorkOrder workOrder);
|
Task UpdateAsync(WorkOrder workOrder);
|
||||||
Task DeleteAsync(int id);
|
Task DeleteAsync(int id);
|
||||||
|
|
||||||
// Related entities - Read
|
// Related entities - Read
|
||||||
Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId);
|
Task<IEnumerable<WorkOrderContacts>> GetWorkOrderContactsAsync(int workOrderId);
|
||||||
Task<IEnumerable<WorkOrderCategories>> GetWorkOrderCategoriesAsync(int workOrderId);
|
Task<IEnumerable<WorkOrderCategories>> GetWorkOrderCategoriesAsync(int workOrderId);
|
||||||
|
|
@ -50,6 +51,7 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable<int> categoryIds);
|
Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable<int> categoryIds);
|
||||||
Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment);
|
Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment);
|
||||||
Task AddAuditLogAsync(WorkOrderAuditLog auditLog);
|
Task AddAuditLogAsync(WorkOrderAuditLog auditLog);
|
||||||
|
Task AddAuditLogsAsync(IReadOnlyCollection<WorkOrderAuditLog> auditLogs);
|
||||||
|
|
||||||
// Related entities - Delete
|
// Related entities - Delete
|
||||||
Task DeleteWorkOrderContactsAsync(int workOrderId);
|
Task DeleteWorkOrderContactsAsync(int workOrderId);
|
||||||
|
|
@ -66,7 +68,18 @@ namespace SeaHaven.DataServices.Interfaces
|
||||||
// Statistics
|
// Statistics
|
||||||
Task<int> CountAsync();
|
Task<int> CountAsync();
|
||||||
Task<int> CountByStatusAsync(string status);
|
Task<int> CountByStatusAsync(string status);
|
||||||
|
Task<int> CountByPriorityAsync(string priority);
|
||||||
Task<int> CountByLocationAsync(int locationId);
|
Task<int> CountByLocationAsync(int locationId);
|
||||||
Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync();
|
Task<IEnumerable<WorkOrder>> GetAllWithDetailsAsync();
|
||||||
|
|
||||||
|
// Batched lookup: server-filters the distinct requested ids, excludes templates,
|
||||||
|
// eagerly loads the location detail, deduplicates, and returns results in input order.
|
||||||
|
Task<IReadOnlyList<WorkOrder>> GetByRequestedIdsWithDetailsAsync(
|
||||||
|
IReadOnlyCollection<int> ids,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
// Complex read models
|
||||||
|
Task<WorkOrderDetailReadModel?> GetWorkOrderDetailAsync(int id);
|
||||||
|
Task<List<WorkOrder>> GetNonTemplateWorkOrdersWithLocationsAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
|
||||||
|
namespace SeaHaven.DataServices.Interfaces
|
||||||
|
{
|
||||||
|
public interface IWorkOrderFieldLockDataService
|
||||||
|
{
|
||||||
|
Task<bool> IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
void EnqueueLock(int workOrderId, string fieldName, string? userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
SeaHaven.DataServices/Models/ReadModels.cs
Normal file
260
SeaHaven.DataServices/Models/ReadModels.cs
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
namespace SeaHaven.DataServices.Models
|
||||||
|
{
|
||||||
|
public class WorkOrderListItemReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? InternalWONumber { get; set; }
|
||||||
|
public string? WorkOrderNumber { get; set; }
|
||||||
|
public string? Number { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
|
public string? LocationName { get; set; }
|
||||||
|
public string? Priority { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public string? AssignedTo { get; set; }
|
||||||
|
public string? EmployeeName { get; set; }
|
||||||
|
public DateTime? LastUpdated { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderDetailReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? InternalWONumber { get; set; }
|
||||||
|
public string? WorkerOrderNumber { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public DateTime? ScheduledDate { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public string? Assignee { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
|
public int? LocationId { get; set; }
|
||||||
|
public string? LocationZip { get; set; }
|
||||||
|
public string? Priority { get; set; }
|
||||||
|
public string? Problem { get; set; }
|
||||||
|
public string? Trade { get; set; }
|
||||||
|
public string? SubTrade { get; set; }
|
||||||
|
public decimal? VendorNTE { get; set; }
|
||||||
|
public string? Source { get; set; }
|
||||||
|
public string? PO { get; set; }
|
||||||
|
public string? TT { get; set; }
|
||||||
|
public List<ContactReadModel> Poc { get; set; } = new();
|
||||||
|
public List<CategoryReadModel> Category { get; set; } = new();
|
||||||
|
public List<AttachmentReadModel> Attachments { get; set; } = new();
|
||||||
|
public string? BeforPhotoIssue { get; set; }
|
||||||
|
public string? BeforPhotoAttachment { get; set; }
|
||||||
|
public string? AfterPhotoIssue { get; set; }
|
||||||
|
public string? AfterPhotoAttachment { get; set; }
|
||||||
|
public string? SignOffName { get; set; }
|
||||||
|
public string? SignOffAttachment { get; set; }
|
||||||
|
public string? SignOffSignature { get; set; }
|
||||||
|
public List<CommentReadModel> Comments { get; set; } = new();
|
||||||
|
public List<QuoteRefReadModel> Quotes { get; set; } = new();
|
||||||
|
public List<AuditLogReadModel> AuditLog { get; set; } = new();
|
||||||
|
public List<DispatchReadModel> Dispatches { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ContactReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CategoryReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AttachmentReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Attachment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CommentReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
public string? Commenttext { get; set; }
|
||||||
|
public string? Commenter { get; set; }
|
||||||
|
public string? RecordType { get; set; }
|
||||||
|
public string? CommentType { get; set; }
|
||||||
|
public string? Documents { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuoteRefReadModel
|
||||||
|
{
|
||||||
|
public string? Code { get; set; }
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AuditLogReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? FieldName { get; set; }
|
||||||
|
public string? OldValue { get; set; }
|
||||||
|
public string? NewValue { get; set; }
|
||||||
|
public string? Action { get; set; }
|
||||||
|
public DateTime CreatedAt { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? DispatchNumber { get; set; }
|
||||||
|
public string? PONumber { get; set; }
|
||||||
|
public decimal? NTEAmount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public DateTime? ScheduledDate { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
public DateTime? DispatchedAt { get; set; }
|
||||||
|
public bool? EmailSent { get; set; }
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? VendorEmail { get; set; }
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchDetailReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? DispatchNumber { get; set; }
|
||||||
|
public string? PONumber { get; set; }
|
||||||
|
public decimal? NTEAmount { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public DateTime? ScheduledDate { get; set; }
|
||||||
|
public DateTime? CompletedDate { get; set; }
|
||||||
|
public DateTime? DispatchedAt { get; set; }
|
||||||
|
public bool? EmailSent { get; set; }
|
||||||
|
public int? WorkOrderId { get; set; }
|
||||||
|
public DateTime? AcknowledgedAt { get; set; }
|
||||||
|
public string? VerifiedBy { get; set; }
|
||||||
|
public DateTime? VerifiedAt { get; set; }
|
||||||
|
public string? WoNumber { get; set; }
|
||||||
|
public string? WoTitle { get; set; }
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
public string? VendorEmail { get; set; }
|
||||||
|
public int? VendorId { get; set; }
|
||||||
|
public List<DispatchWorkOrderReadModel> WorkOrders { get; set; } = new();
|
||||||
|
public List<ChecklistItemReadModel> ChecklistItems { get; set; } = new();
|
||||||
|
public List<SignoffReadModel> Signoffs { get; set; } = new();
|
||||||
|
public List<DispatchCommentReadModel> Comments { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchWorkOrderReadModel
|
||||||
|
{
|
||||||
|
public int? WorkOrderId { get; set; }
|
||||||
|
public string? WoNumber { get; set; }
|
||||||
|
public string? WoTitle { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChecklistItemReadModel
|
||||||
|
{
|
||||||
|
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 int? SortOrder { get; set; }
|
||||||
|
public int? WorkOrderId { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SignoffReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? SignoffType { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Signature { get; set; }
|
||||||
|
public string? SignatureMethod { get; set; }
|
||||||
|
public DateTime? SignedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DispatchCommentReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
public string? Commenttext { get; set; }
|
||||||
|
public string? Commenter { get; set; }
|
||||||
|
public string? CommentType { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FilteredWorkOrderReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? WorkerOrderTitle { get; set; }
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public string? Priority { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CommentListItemReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Commenttext { get; set; }
|
||||||
|
public string? FirstName { get; set; }
|
||||||
|
public string? Documents { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuoteListItemReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? QuoteId { get; set; }
|
||||||
|
public string? WorkOrder { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public string? SiteName { get; set; }
|
||||||
|
public string? SiteId { get; set; }
|
||||||
|
public string? PO { get; set; }
|
||||||
|
public string? TT { get; set; }
|
||||||
|
public string? BillingAddress { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public int? ContactId { get; set; }
|
||||||
|
public DateTime? Date { get; set; }
|
||||||
|
public List<QuoteLineItemReadModel> Lineitems { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuoteDetailReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? QuotId { get; set; }
|
||||||
|
public DateTime? Date { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? PO { get; set; }
|
||||||
|
public string? TT { get; set; }
|
||||||
|
public string? SiteName { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
public string? SiteId { get; set; }
|
||||||
|
public string? BillingAddress { get; set; }
|
||||||
|
public string? ShippingAddress { get; set; }
|
||||||
|
public decimal TotalAmount { get; set; }
|
||||||
|
public List<QuoteLineItemReadModel> Lineitems { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QuoteLineItemReadModel
|
||||||
|
{
|
||||||
|
public string? Descriptions { get; set; }
|
||||||
|
public decimal? Lineitem { get; set; }
|
||||||
|
public int? LineitemNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderQuoteDetailReadModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? PO { get; set; }
|
||||||
|
public string? TT { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
31
SeaHaven.Services/Configuration/ServiceOptions.cs
Normal file
31
SeaHaven.Services/Configuration/ServiceOptions.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
namespace SeaHaven.Services.Configuration;
|
||||||
|
|
||||||
|
public sealed class FrontendOptions
|
||||||
|
{
|
||||||
|
public string FrontendBaseUrl { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class JwtOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "JWT";
|
||||||
|
|
||||||
|
public string Secret { get; set; } = string.Empty;
|
||||||
|
public string? ValidIssuer { get; set; }
|
||||||
|
public string? ValidAudience { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ApprovalsOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Approvals";
|
||||||
|
|
||||||
|
public decimal? UpliftTier1MaxUsd { get; set; }
|
||||||
|
public string[] Tier1Roles { get; set; } = Array.Empty<string>();
|
||||||
|
public string[] Tier2Roles { get; set; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class VendorPortalOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "VendorPortal";
|
||||||
|
|
||||||
|
public int? TokenLifetimeDays { get; set; }
|
||||||
|
}
|
||||||
50
SeaHaven.Services/DTOs/CalendarDTOs.cs
Normal file
50
SeaHaven.Services/DTOs/CalendarDTOs.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
namespace SeaHaven.Services.DTOs
|
||||||
|
{
|
||||||
|
public class EventDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
|
|
||||||
|
public int? RecordID { get; set; }
|
||||||
|
public string? RecordName { get; set; }
|
||||||
|
public string? RecordTargetType { get; set; }
|
||||||
|
|
||||||
|
public int ReminderMinutes { get; set; }
|
||||||
|
public string EventColor { get; set; } = null!;
|
||||||
|
|
||||||
|
public DateTime StartDate { get; set; }
|
||||||
|
public string? StartTime { get; set; }
|
||||||
|
public DateTime EndDate { get; set; }
|
||||||
|
public string? EndTime { get; set; }
|
||||||
|
public bool AllDay { get; set; }
|
||||||
|
|
||||||
|
public DateTime? CreatedDate { get; set; }
|
||||||
|
public string? createdby { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PagedEventsResult
|
||||||
|
{
|
||||||
|
public List<EventDTO> Rows { get; set; } = new();
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EditEventInput
|
||||||
|
{
|
||||||
|
public int? Id { get; set; }
|
||||||
|
public string Title { get; set; } = null!;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
|
public int? RecordID { get; set; }
|
||||||
|
public string? RecordName { get; set; }
|
||||||
|
public string? RecordTargetType { get; set; }
|
||||||
|
public int ReminderMinutes { get; set; }
|
||||||
|
public string EventColor { get; set; } = null!;
|
||||||
|
public DateTime StartDate { get; set; }
|
||||||
|
public string? StartTime { get; set; }
|
||||||
|
public DateTime EndDate { get; set; }
|
||||||
|
public string? EndTime { get; set; }
|
||||||
|
public bool AllDay { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
48
SeaHaven.Services/DTOs/CommonDTOs.cs
Normal file
48
SeaHaven.Services/DTOs/CommonDTOs.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
namespace SeaHaven.Services.DTOs
|
||||||
|
{
|
||||||
|
public class UserListItemDTO
|
||||||
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
|
public string? UserName { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? RoleName { get; set; }
|
||||||
|
public string? Status { get; set; }
|
||||||
|
public string? Date { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PagedUsersResult
|
||||||
|
{
|
||||||
|
public List<UserListItemDTO> Users { get; set; } = new();
|
||||||
|
public int PageNumber { get; set; }
|
||||||
|
public int PageSize { get; set; }
|
||||||
|
public int TotalCount { get; set; }
|
||||||
|
public int TotalPages { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RoleListItemDTO
|
||||||
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SiteNameDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ContactListItemDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CategoryListItemDTO
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue