diff --git a/.github/workflows/architecture-quality.yml b/.github/workflows/architecture-quality.yml new file mode 100644 index 0000000..ada851d --- /dev/null +++ b/.github/workflows/architecture-quality.yml @@ -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[@]}" diff --git a/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs new file mode 100644 index 0000000..b636e67 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ArchitectureTests.cs @@ -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 ApiControllers => + ApiAssembly.GetTypes() + .Where(t => t.IsClass && !t.IsAbstract && typeof(ControllerBase).IsAssignableFrom(t)); + + private static IEnumerable 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 ParametersOf(IEnumerable 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"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs new file mode 100644 index 0000000..d68c0ff --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AuthenticationControllerTests.cs @@ -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 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(); + service.Setup(s => s.LoginAsync("alice", "pw", It.IsAny())) + .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().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(); + service.Setup(s => s.LoginAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((LoginResultDTO?)null); + + var controller = NewController(service); + + var result = await controller.Login(new LoginModel(), CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task ChangePassword_Success_ReturnsSuccessMessageWithTrailingSpace() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync("42", "old", "new", It.IsAny())) + .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().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("Password successfully changed"); + } + + [Fact] + public async Task ChangePassword_Failure_ReturnsOldPasswordIncorrectStatus() + { + var service = new Mock(); + service.Setup(s => s.ChangePasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service, "42"); + + var result = await controller.ChangePassword(new ChangePasswords(), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Old Password is incorrect"); + } + + [Fact] + public async Task UpdateProfile_MapsProfileDataIntoDataResponse() + { + var service = new Mock(); + service.Setup(s => s.UpdateProfileAsync("42", It.IsAny(), It.IsAny())) + .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().Subject; + var response = ok.Value.Should().BeOfType().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(); + service.Setup(s => s.ForgetPasswordAsync("a@b.com", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.ForgetPassword("a@b.com", CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().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(); + service.Setup(s => s.ForgetPasswordAsync(It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.ForgetPassword("x@y.com", CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().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(); + service.Setup(s => s.VerifyCodeAsync("123456", It.IsAny())).ReturnsAsync(matched); + + var controller = NewController(service); + + var result = await controller.VerificationCode("123456", CancellationToken.None); + + if (matched) + { + var ok = result.Should().BeOfType().Subject; + var r = ok.Value.Should().BeOfType().Subject; + r.Status.Should().Be(expectedStatus); + r.Message.Should().Be(expectedMessage); + } + else + { + var bad = result.Should().BeOfType().Subject; + var r = bad.Value.Should().BeOfType().Subject; + r.Status.Should().Be(expectedStatus); + r.Message.Should().Be(expectedMessage); + } + } + + [Fact] + public async Task ResetPassword_Matched_ReturnsPasswordChangedMessage() + { + var service = new Mock(); + service.Setup(s => s.ResetPasswordAsync("a@b.com", "123", "new", It.IsAny())).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().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success "); + response.Message.Should().Be("password changed"); + } + + [Fact] + public async Task ResetPassword_NotMatched_ReturnsEmailOrCodeNotFoundMessage() + { + var service = new Mock(); + service.Setup(s => s.ResetPasswordAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .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().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Your email or code not found please check"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs new file mode 100644 index 0000000..71b592b --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/AuthenticationServiceTests.cs @@ -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 userData, + Mock forget, + Mock email, + out Mock> store, + out Mock> 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(), new Mock(), new Mock(), out var store, out _); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).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(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(deletedUser); + store.Setup(s => s.GetRolesAsync(deletedUser, It.IsAny())).ReturnsAsync(new List()); + + var result = await service.LoginAsync("alice", "pw", CancellationToken.None); + + result.Should().BeNull(); + hasher.Verify(h => h.VerifyHashedPassword(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Login_ValidUser_ReturnsTokenFirstRoleAndIdentity() + { + var user = IdentityTestHelpers.User(); + var service = NewService(new Mock(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + store.Setup(s => s.GetRolesAsync(user, It.IsAny())).ReturnsAsync(new List { "Admin", "Manager" }); + store.As>() + .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).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(), new Mock(), new Mock(), out var store, out var hasher); + store.Setup(s => s.FindByNameAsync(It.IsAny(), It.IsAny())).ReturnsAsync(user); + store.As>() + .Setup(s => s.GetPasswordHashAsync(user, It.IsAny())).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(); + userData.Setup(u => u.GetByEmailNormalizedAsync("alice@example.com", It.IsAny())).ReturnsAsync(user); + var forget = new Mock(); + var email = new Mock(); + + 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(code => code.Length == 6 && code.All(char.IsDigit)), + It.IsAny()), + Times.Once); + email.Verify(e => e.SendEmailAsync(user.Email!, "Forget Password Request.", It.Is(b => b.Contains("Your Password Reset Code is:"))), Times.Once); + } + + [Fact] + public async Task ForgetPassword_UnknownEmail_DoesNotEmailOrStoreCode() + { + var userData = new Mock(); + userData.Setup(u => u.GetByEmailNormalizedAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var forget = new Mock(); + var email = new Mock(); + + 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(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + email.Verify(e => e.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task VerifyCode_ForwardsDataServiceResult(bool exists) + { + var forget = new Mock(); + forget.Setup(f => f.CodeExistsAsync("abc", It.IsAny())).ReturnsAsync(exists); + + var service = NewService(new Mock(), forget, new Mock(), 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(); + forget.Setup(f => f.ExistsByEmailAndCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var service = NewService(new Mock(), forget, new Mock(), 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(), It.IsAny()), Times.Never); + forget.Verify(f => f.RemoveByEmailAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs index 571b606..306bece 100644 --- a/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/CalendarControllerTests.cs @@ -3,34 +3,16 @@ using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentAssertions; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; using Xunit; namespace Api.SeaHavenIndustries.Tests; public class CalendarControllerTests { - private static ApplicationDbContext NewContext() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) - .Options; - return new ApplicationDbContext(options); - } - - private static UserManager MockUserManager() - { - var store = new Mock>(); - return new Mock>( - 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() { Id = id, @@ -43,164 +25,166 @@ public class CalendarControllerTests 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 CalendarController NewController(Mock service) => + new(service.Object); private static object Prop(object source, string name) => source.GetType().GetProperty(name)!.GetValue(source)!; [Fact] - public async Task AddEvent_WhenValid_PersistsEventAndReturnsOk() + public async Task AddEvent_WhenServiceSucceeds_ReturnsOkWithCreatedMessage() { - using var ctx = NewContext(); - var result = await NewController(ctx).AddEvent(ValidDto()); + var service = new Mock(); + var result = await NewController(service).AddEvent(ValidDto(), CancellationToken.None); var ok = result.Should().BeOfType().Subject; - ok.Value.Should().BeOfType().Which.Status.Should().Be("200"); - - var saved = ctx.Events.Single(); - saved.Title.Should().Be("Standup"); - saved.IsDeleted.Should().Be(false); - saved.CreatedDate.Should().NotBeNull(); + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Event Created Successfully"); + service.Verify(s => s.CreateEventAsync(It.IsAny(), It.IsAny()), Times.Once); } [Fact] - public async Task EditEvent_WhenIdMissing_ReturnsBadRequest() + public async Task AddEvent_WhenServiceThrows_ReturnsBadRequestWithMessage() { - using var ctx = NewContext(); - var result = await NewController(ctx).EditEvent(ValidDto(id: null)); + var service = new Mock(); + service.Setup(s => s.CreateEventAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); - result.Should().BeOfType(); + var result = await NewController(service).AddEvent(ValidDto(), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("boom"); } [Fact] - public async Task EditEvent_WhenEventNotFound_ReturnsNotFound() + public async Task EditEvent_WhenIdMissing_ReturnsBadRequestWithoutCallingService() { - using var ctx = NewContext(); - var result = await NewController(ctx).EditEvent(ValidDto(id: 999)); + var service = new Mock(); + var result = await NewController(service).EditEvent(ValidDto(id: null), CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Event ID is required for editing"); + service.Verify(s => s.UpdateEventAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EditEvent_WhenServiceReturnsFalse_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.UpdateEventAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).EditEvent(ValidDto(id: 5), CancellationToken.None); + + var nf = result.Should().BeOfType().Subject; + var response = nf.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Event not found"); + } + + [Fact] + public async Task EditEvent_WhenServiceReturnsTrue_ReturnsOkWithUpdatedMessage() + { + var service = new Mock(); + service.Setup(s => s.UpdateEventAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var result = await NewController(service).EditEvent(ValidDto(id: 5, title: "New"), CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType().Which.Message.Should().Be("Event Updated Successfully"); + } + + [Fact] + public async Task GetEventById_WhenServiceReturnsNull_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.GetEventByIdAsync(7, It.IsAny())).ReturnsAsync((EventDTO?)null); + + var result = await NewController(service).GetEventById(7, CancellationToken.None); result.Should().BeOfType(); } [Fact] - public async Task EditEvent_WhenEventExists_UpdatesFieldsAndStampsModified() + public async Task GetEventById_WhenServiceReturnsDto_ReturnsOkWithEventDto() { - using var ctx = NewContext(); - var existing = SeedEvent(ctx, "Old", new DateTime(2026, 6, 1)); + var service = new Mock(); + service.Setup(s => s.GetEventByIdAsync(7, It.IsAny())) + .ReturnsAsync(new EventDTO { Id = 7, Title = "Review", createdby = "abc" }); - var result = await NewController(ctx).EditEvent(ValidDto(id: existing.Id, title: "New")); - - result.Should().BeOfType(); - 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 result = await NewController(service).GetEventById(7, CancellationToken.None); var ok = result.Should().BeOfType().Subject; - var dto = ok.Value.Should().BeOfType().Subject.Data as Event_DTO; + var response = ok.Value.Should().BeOfType().Subject; + var dto = response.Data as Event_DTO; + dto.Should().NotBeNull(); dto!.Title.Should().Be("Review"); + dto!.createdby.Should().Be("abc"); } [Fact] - public async Task GetEventById_WhenSoftDeleted_ReturnsNotFound() + public async Task DeleteEvent_WhenServiceReturnsFalse_ReturnsNotFound() { - using var ctx = NewContext(); - var deleted = SeedEvent(ctx, "Gone", new DateTime(2026, 6, 1), isDeleted: true); + var service = new Mock(); + service.Setup(s => s.DeleteEventAsync(9, It.IsAny())).ReturnsAsync(false); - var result = await NewController(ctx).GetEventById(deleted.Id); + var result = await NewController(service).DeleteEvent(9, CancellationToken.None); result.Should().BeOfType(); } [Fact] - public async Task DeleteEvent_WhenExists_SoftDeletesAndKeepsRow() + public async Task DeleteEvent_WhenServiceReturnsTrue_ReturnsOkWithDeletedMessage() { - using var ctx = NewContext(); - var existing = SeedEvent(ctx, "Cancel", new DateTime(2026, 6, 1)); + var service = new Mock(); + service.Setup(s => s.DeleteEventAsync(9, It.IsAny())).ReturnsAsync(true); - var result = await NewController(ctx).DeleteEvent(existing.Id); + var result = await NewController(service).DeleteEvent(9, CancellationToken.None); - result.Should().BeOfType(); - var row = ctx.Events.Single(); - row.IsDeleted.Should().Be(true); - row.DeletionTime.Should().NotBeNull(); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType().Which.Message.Should().Be("Event Deleted Successfully"); } [Fact] - public async Task DeleteEvent_WhenNotFound_ReturnsNotFound() + public async Task GetEventList_ReturnsOkWithRowsAndTotalCount() { - using var ctx = NewContext(); - var result = await NewController(ctx).DeleteEvent(12345); + var service = new Mock(); + service.Setup(s => s.GetEventListAsync("alpha", 1, 10, It.IsAny())) + .ReturnsAsync(new PagedEventsResult + { + TotalCount = 2, + Rows = new List + { + new() { Id = 1, Title = "Alpha meeting" }, + new() { Id = 2, Title = "Alpha review" } + } + }); - result.Should().BeOfType(); - } - - [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 result = await NewController(service).GetEventList(search: "alpha", page: 1, pageSize: 10); var ok = result.Should().BeOfType().Subject; var data = ok.Value.Should().BeOfType().Subject.Data!; ((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().Data!; var rows = ((IEnumerable)Prop(data, "rows")).Cast().ToList(); - rows.Select(r => r.Title).Should().ContainInOrder("Latest", "Middle", "Earliest"); + rows.Should().HaveCount(2); } [Fact] - public void GetEventsForCalendar_ReturnsOnlyEventsOverlappingRange() + public async Task GetEventsForCalendar_ReturnsOkWithEvents() { - 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 service = new Mock(); + service.Setup(s => s.GetEventsForCalendarAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { new() { Id = 1, Title = "InRange" } }); - var result = NewController(ctx).GetEventsForCalendar( - start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30)); + var result = await NewController(service).GetEventsForCalendar( + start: new DateTime(2026, 6, 1), end: new DateTime(2026, 6, 30), CancellationToken.None); var ok = result.Should().BeOfType().Subject; - var events = ok.Value.As().Data as IEnumerable; - events!.Select(e => e.Title).Should().BeEquivalentTo(new[] { "InRange" }); + var data = ok.Value.Should().BeOfType().Subject.Data; + var events = ((IEnumerable)data!).Cast().ToList(); + events.Single().Title.Should().Be("InRange"); } } diff --git a/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs new file mode 100644 index 0000000..bceb520 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/CalendarServiceTests.cs @@ -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() + .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" }); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs new file mode 100644 index 0000000..0af8f00 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -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() + .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); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs new file mode 100644 index 0000000..97de3a7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DispatchBatchingBehaviorTests.cs @@ -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? woData = null, + Mock? vendorData = null, + Mock? templateData = null, + Mock? dispatchData = null) + { + var dd = dispatchData ?? new Mock(); + dd.Setup(d => d.GenerateNextPONumberAsync()).ReturnsAsync("1"); + dd.Setup(d => d.GenerateNextDispatchNumberAsync()).ReturnsAsync("DSP-1"); + dd.Setup(d => d.AddAsync(It.IsAny())) + .ReturnsAsync((Dispatch d) => d); + + var emailPort = new Mock(); + emailPort.Setup(e => e.SendDispatchEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var tokenPort = new Mock(); + tokenPort.Setup(t => t.GetOrCreateActiveTokenAsync(It.IsAny())).ReturnsAsync("token"); + + return new WorkOrderDispatchService( + (woData ?? new Mock()).Object, + dd.Object, + (vendorData ?? new Mock()).Object, + Mock.Of(), + (templateData ?? new Mock()).Object, + Mock.Of(), + emailPort.Object, + tokenPort.Object, + Options.Create(new FrontendOptions())); + } + + private static Mock WorkOrderDataReturning(params WorkOrder[] workOrders) => + WorkOrderDataReturning(new Mock(), workOrders); + + private static Mock WorkOrderDataReturning(Mock mock, params WorkOrder[] workOrders) + { + mock.Setup(d => d.GetByRequestedIdsWithDetailsAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(workOrders.ToList()); + return mock; + } + + private static Mock VendorDataReturning(params Vendor[] vendors) + { + var mock = new Mock(); + mock.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) + .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(); + 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())) + .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(); + vendorData.Setup(d => d.GetByIdsOrderedAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(new List { 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>(ids => ids.SequenceEqual(new[] { 2, 999 })), + It.IsAny()), Times.Once); + vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Dispatch_FetchesTemplateOnceRegardlessOfVendorCount() + { + var woData = WorkOrderDataReturning(new WorkOrder { Id = 10, InternalWONumber = "WO-10" }); + var templateData = new Mock(); + templateData.Setup(d => d.GetByIdWithItemsAsync(7)) + .ReturnsAsync(new TaskListTemplate + { + Items = new List + { + 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>(), It.IsAny()), Times.Once); + vendorData.Verify(d => d.GetByIdAsync(It.IsAny()), 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>(logs => logs.Count == 2)), Times.Exactly(2)); + woData.Verify(d => d.AddAuditLogAsync(It.IsAny()), 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(); + } + + [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>(), + token), Times.Once); + vendorData.Verify(d => d.GetByIdsOrderedAsync( + It.IsAny>(), + token), Times.Once); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs b/Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs new file mode 100644 index 0000000..90b93ee --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DispatchControllerTests.cs @@ -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 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(); + service.Setup(s => s.GetDispatchListAsync("a", null, null, null, null, "dispatchedAt", "desc", 1, 25, It.IsAny())) + .ReturnsAsync(new DispatchListResultDTO + { + Total = 2, + Page = 1, + PageSize = 25, + Items = new List + { + 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().Subject; + var response = ok.Value.Should().BeOfType().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().Count().Should().Be(2); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.GetDispatchDetailAsync(99, It.IsAny())) + .ReturnsAsync((DispatchDetailResultDTO?)null); + + var result = await NewController(service).GetById(99, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Dispatch not found"); + } + + [Fact] + public async Task GetById_WhenFound_ReturnsDetailAsData() + { + var service = new Mock(); + var detail = new DispatchDetailResultDTO { Id = 5, DispatchNumber = "D5", Status = "Pending" }; + service.Setup(s => s.GetDispatchDetailAsync(5, It.IsAny())) + .ReturnsAsync(detail); + + var result = await NewController(service).GetById(5, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Success"); + ((object?)response.Data).Should().BeSameAs(detail); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs b/Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs new file mode 100644 index 0000000..b4f9654 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DispatchServiceTests.cs @@ -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 data) => + new(data.Object, new CreateDispatchValidation(), new UpdateDispatchValidation()); + + [Fact] + public async Task GetDispatchListAsync_MapsDataReadModelToServiceDtoPreservingFields() + { + var data = new Mock(); + var source = new DispatchListResult + { + Total = 1, + Page = 2, + PageSize = 25, + Items = new List + { + 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(), It.IsAny())) + .ReturnsAsync(source); + + var result = await NewService(data).GetDispatchListAsync("a", "Pending", 9, null, null, "ponumber", "asc", 2, 25, CancellationToken.None); + + result.Should().BeOfType(); + 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(); + 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 { new() { WorkOrderId = 11, Title = "T", Priority = "High" } }, + Checklist = new List { new() { Id = 1, ItemText = "Check", IsCompleted = true, SortOrder = 1 } }, + Signoffs = new List { new() { Id = 1, SignoffType = "Vendor", Name = "J" } }, + Comments = new List { new() { Id = 1, Commenttext = "Hi", UserName = "Sara" } }, + UpliftRequests = new List { new() { Id = 1, RequestedNTE = 600m, CurrentNTE = 500m, Delta = 100m, Status = "Pending" } }, + AuditLog = new List { new() { Id = 1, Action = "Updated", FieldName = "DISP-42:Status" } } + }; + data.Setup(d => d.GetDetailAsync(42, It.IsAny())).ReturnsAsync(detail); + data.Setup(d => d.GetDetailAsync(999, It.IsAny())).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(); + 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"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs b/Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs new file mode 100644 index 0000000..ce6a3f7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/DropdownOptionsServiceTests.cs @@ -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() + .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(); + } + + [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(); + } + + [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(); + } + + [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(); + } + + [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(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs b/Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs new file mode 100644 index 0000000..85124ee --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FollowUpControllerTests.cs @@ -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 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 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(); + service.Setup(s => s.GetFollowUpListAsync(1, 12, "", "Open", It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List { new() { Id = 1, Reason = "R", CreatedByName = "Me" } }, + TotalCount = 1, + Page = 1, + PageSize = 12 + }); + + var result = await NewController(service).GetFollowUps(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.PageSize.Should().Be(12); + envelope.TotalCount.Should().Be(1); + } + + [Fact] + public async Task GetById_WhenMissing_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.GetFollowUpDetailAsync(9, It.IsAny())) + .ReturnsAsync((FollowUpDetailDTO?)null); + + var result = await NewController(service).GetById(9, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Create_WhenNoUser_ReturnsUnauthorized() + { + var service = new Mock(); + var result = await NewControllerNoUser(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None); + + var unauthorized = result.Should().BeOfType().Subject; + unauthorized.Value.Should().BeOfType().Subject.Message.Should().Be("User not authenticated"); + service.Verify(s => s.CreateOpenFollowUpAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_WhenAuthenticated_ReturnsCreatedWithId() + { + var service = new Mock(); + service.Setup(s => s.CreateOpenFollowUpAsync(It.IsAny(), "user-1", It.IsAny())) + .ReturnsAsync(42); + + var result = await NewController(service).Create(new FollowUp_DTO { Reason = "x" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().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(); + service.Setup(s => s.SetFollowUpStatusAsync(3, "Completed", It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).Complete(3, CancellationToken.None); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_WhenMissing_ReturnsNotFoundEnvelope() + { + var service = new Mock(); + service.Setup(s => s.RemoveFollowUpAsync(8, It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).Delete(8, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Follow Up not found"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs b/Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs new file mode 100644 index 0000000..d5d7790 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/FollowUpServiceTests.cs @@ -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() + .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(); + } + + [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(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs new file mode 100644 index 0000000..469bedc --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/IdentityTestHelpers.cs @@ -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 manager, Mock> store, Mock> hasher) CreateUserManager() + { + var store = new Mock>(); + var hasher = new Mock>(); + + store.As>(); + + var manager = new UserManager( + store.Object, + Options.Create(new IdentityOptions()), + hasher.Object, + new List>(), + new List>(), + new Mock().Object, + new IdentityErrorDescriber(), + new Mock().Object, + new Mock>>().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 + }; +} diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs new file mode 100644 index 0000000..6a326c5 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationControllerTests.cs @@ -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 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(); + service.Setup(s => s.GetLocationListPagedAsync(1, 10, "a", It.IsAny())) + .ReturnsAsync(new PagedResult + { + Items = new List { 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().Subject; + var envelope = ok.Value.Should().BeOfType().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(); + service.Setup(s => s.GetLocationDetailAsync(5, It.IsAny())) + .ReturnsAsync((LocationDTO?)null); + + var result = await NewController(service).GetLocationById(5, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + var response = notFound.Value.Should().BeOfType().Subject; + response.Message.Should().Be("Location not found"); + } + + [Fact] + public async Task GetLocationById_WhenFound_MapsServiceDtoToAliasedShape() + { + var service = new Mock(); + service.Setup(s => s.GetLocationDetailAsync(7, It.IsAny())) + .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().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(); + var result = await NewController(service).AddLocation(new Location_DTO { Name = "X" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Location Created Successfully"); + service.Verify(s => s.CreateLocationFromRequestAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditLocation_WhenMissing_ReturnsNotFound() + { + var service = new Mock(); + service.Setup(s => s.UpdateLocationFromRequestAsync(99, It.IsAny(), It.IsAny())) + .ThrowsAsync(new KeyNotFoundException()); + + var result = await NewController(service).EditLocation(99, new EditLocation_DTO { Name = "X" }, CancellationToken.None); + + var notFound = result.Should().BeOfType().Subject; + notFound.Value.Should().BeOfType().Subject.Message.Should().Be("Location not found"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs new file mode 100644 index 0000000..5f3abe2 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationServiceTests.cs @@ -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() + .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(); + } + + [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(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs new file mode 100644 index 0000000..1c84083 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServiceBehaviorTests.cs @@ -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 ToAsyncEnumerable(this IEnumerable 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(); + var external = new Mock(); + + var existingWO = new WorkOrder { Id = 5, ExternalWorkOrderId = "EXT-1", WorkerOrderTitle = "Old" }; + + data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny())) + .ReturnsAsync("10000005"); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-1", It.IsAny())) + .ReturnsAsync(existingWO); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("EXT-2", It.IsAny())) + .ReturnsAsync((WorkOrder?)null); + data.Setup(d => d.ResolveLocationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int?)null); + data.Setup(d => d.GetLocationCountAsync(It.IsAny())) + .ReturnsAsync(3); + + var items = new List> + { + 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())) + .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(w => w.ExternalWorkOrderId == "EXT-2")), Times.Once); + } + + [Fact] + public async Task SyncWorkOrders_MapsStatusAndSeverity() + { + var data = new Mock(); + var external = new Mock(); + + data.Setup(d => d.GetLastInternalWONumberAsync(It.IsAny())).ReturnsAsync((string?)null); + data.Setup(d => d.GetWorkOrderByExternalIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((WorkOrder?)null); + data.Setup(d => d.ResolveLocationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((int?)null); + data.Setup(d => d.GetLocationCountAsync(It.IsAny())).ReturnsAsync(0); + + var items = new List> + { + new() { ["work_order_id"] = "W1", ["wo_status"] = "completed", ["severity"] = "2" } + }; + external.Setup(e => e.ScanWorkOrdersAsync(It.IsAny())).Returns(items.ToAsyncEnumerable()); + + await new SyncService(data.Object, external.Object).SyncWorkOrdersAsync(CancellationToken.None); + + data.Verify(d => d.EnqueueWorkOrder(It.Is(w => w.Status == "Done" && w.Priority == "Sev 2" && w.Severity == "2")), Times.Once); + } + + [Fact] + public async Task SyncComments_SkipsExistingAndOrphans() + { + var data = new Mock(); + var external = new Mock(); + var wo = new WorkOrder { Id = 10, ExternalWorkOrderId = "WO-1" }; + + data.Setup(d => d.CommentExistsByExternalIdAsync("C-EXISTS", It.IsAny())).ReturnsAsync(true); + data.Setup(d => d.CommentExistsByExternalIdAsync("C-NEW", It.IsAny())).ReturnsAsync(false); + data.Setup(d => d.CommentExistsByExternalIdAsync("C-ORPHAN", It.IsAny())).ReturnsAsync(false); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-1", It.IsAny())).ReturnsAsync(wo); + data.Setup(d => d.GetWorkOrderByExternalIdAsync("WO-MISSING", It.IsAny())).ReturnsAsync((WorkOrder?)null); + + var items = new List> + { + 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())).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(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(() => 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(() => 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(); + 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 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(); + mock.Setup(d => d.GetByIdAsync(1)).ReturnsAsync(dispatch); + + var checklist = checklistComplete + ? new List { new() { IsCompleted = true } } + : new List { new() { IsCompleted = false } }; + mock.Setup(d => d.GetChecklistItemsAsync(1)).ReturnsAsync(checklist); + + var signoffs = new List(); + 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? dispatchDataMock = null, + Mock? userMock = null) + { + return new WorkOrderDispatchService( + Mock.Of(), + (dispatchDataMock ?? new Mock()).Object, + Mock.Of(), + (userMock ?? new Mock()).Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + 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(); + 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(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(); + woData.Setup(d => d.GetByIdAsync(5)).ReturnsAsync(wo); + + await CreateWorkOrderService(woData).ChangeStatusAsync(5, "Open", "user1"); + + woData.Verify(d => d.AddAuditLogAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteWorkOrderCascade_UnlinksQuotesAndDeletesComments() + { + var woData = new Mock(); + woData.Setup(d => d.ExistsAsync(5)).ReturnsAsync(true); + + var commentData = new Mock(); + commentData.Setup(c => c.GetByWorkOrderIdAsync(5)) + .ReturnsAsync(new List { new() { Id = 1 }, new() { Id = 2 } }); + + var quotesData = new Mock(); + + 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? woData = null, + Mock? commentData = null, + Mock? quotesData = null) + { + return new WorkOrderService( + (woData ?? new Mock()).Object, + (commentData ?? new Mock()).Object, + Mock.Of(), + (quotesData ?? new Mock()).Object, + Mock.Of(), + Mock.Of(), + Mock.Of()); + } +} diff --git a/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs new file mode 100644 index 0000000..84da086 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/ServiceRegistrationTests.cs @@ -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(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"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs b/Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs new file mode 100644 index 0000000..aad00cd --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SettingsControllerTests.cs @@ -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 service) => + new(service.Object); + + [Fact] + public async Task GetCategoryList_ReturnsPaginationEnvelopeWithServiceItems() + { + var service = new Mock(); + service.Setup(s => s.GetCategoryListAsync(1, 10, It.IsAny())) + .ReturnsAsync((new List { new() { Id = 1, Name = "A" } }, 1)); + + var result = await NewController(service).GetCategoryList(1, 10, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.TotalCount.Should().Be(1); + envelope.TotalPages.Should().Be(1); + } + + [Fact] + public async Task GetTemplates_ReturnsTypedServiceDtosInEnvelope() + { + var service = new Mock(); + service.Setup(s => s.GetTemplatesAsync(1, 10, It.IsAny())) + .ReturnsAsync((new List { new() { Id = 1, WorkerOrderTitle = "T" } }, 1)); + + var result = await NewController(service).GetTemplates(1, 10, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.TotalCount.Should().Be(1); + } + + [Fact] + public async Task AddCategory_WhenServiceSucceeds_ReturnsSaved() + { + var service = new Mock(); + var result = await NewController(service).AddCategory(new Category_DTO { Name = "X" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Successfully Saved Data"); + service.Verify(s => s.AddCategoryAsync("X", It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditCategory_WhenMissing_ReturnsBadRequest() + { + var service = new Mock(); + service.Setup(s => s.UpdateCategoryAsync(9, "X", It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).EditCategory(new EditCategory_DTO { Id = 9, Name = "X" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Be("Category not found"); + } + + [Fact] + public async Task DeleteCategory_WhenMissing_ReturnsIdNotMatched() + { + var service = new Mock(); + service.Setup(s => s.DeleteCategoryAsync(9, It.IsAny())) + .ReturnsAsync(false); + + var result = await NewController(service).DeleteCategory(9, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + bad.Value.Should().BeOfType().Subject.Message.Should().Be("ID not matched!"); + } + + [Fact] + public async Task GetUsers_MapsServiceUsersToUserRoleViewModel() + { + var service = new Mock(); + service.Setup(s => s.GetUsersAsync("", 1, 10, It.IsAny())) + .ReturnsAsync((new List + { + 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().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + object data = envelope.Data!; + var rows = ((System.Collections.IEnumerable)data).Cast().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(); + service.Setup(s => s.GetRolesAsync(It.IsAny())) + .ReturnsAsync(new List { new() { Name = "Admin", Description = "Full Access" } }); + + var result = await NewController(service).GetRoles(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + var roles = ((System.Collections.IEnumerable)ok.Value!).Cast().ToList(); + roles.Should().ContainSingle(r => r.Name == "Admin" && r.Description == "Full Access"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs b/Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs new file mode 100644 index 0000000..b7c50a1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SettingsServiceTests.cs @@ -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() + .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 userManager, RoleManager roleManager) NewIdentityContext() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.AddIdentity().AddEntityFrameworkStores(); + var provider = services.BuildServiceProvider(); + var scope = provider.CreateScope(); + var ctx = scope.ServiceProvider.GetRequiredService(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + var roleManager = scope.ServiceProvider.GetRequiredService>(); + 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(); + 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"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs b/Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs new file mode 100644 index 0000000..7f96501 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TaskListTemplateServiceTests.cs @@ -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() + .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(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs new file mode 100644 index 0000000..7c2f89f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UpliftControllerTests.cs @@ -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 service, params string[] roles) + { + var controller = new UpliftController(service.Object); + var claims = new List { new(ClaimTypes.NameIdentifier, "42") }; + claims.AddRange(roles.Select(r => new Claim(ClaimTypes.Role, r))); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")) + } + }; + return controller; + } + + [Fact] + public async Task List_ReturnsPagedResult() + { + var service = new Mock(); + var dto = new UpliftListResultDTO + { + Total = 1, + Page = 1, + PageSize = 25, + Items = new[] { new UpliftListItemDTO { Id = 5, Status = "Pending" } } + }; + service.Setup(x => x.ListAsync(It.IsAny(), "Pending", null, 1, 25, It.IsAny())) + .ReturnsAsync(dto); + + var controller = NewController(service); + + var result = await controller.List(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + } + + [Fact] + public async Task Approve_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 99, null, It.IsAny())) + .ThrowsAsync(new KeyNotFoundException("not found")); + + var controller = NewController(service); + + var result = await controller.Approve(99, null); + + var nf = result.Should().BeOfType().Subject; + nf.Value.Should().BeOfType(); + } + + [Fact] + public async Task Approve_ForbiddenTier_Returns403() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new UpliftForbiddenException("requires Tier 2")); + + var controller = NewController(service); + + var result = await controller.Approve(5, null); + + var status = result.Should().BeOfType().Subject; + status.StatusCode.Should().Be(403); + } + + [Fact] + public async Task Approve_AlreadyDecided_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("already decided")); + + var controller = NewController(service); + + var result = await controller.Approve(5, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Approve_Success_ReturnsApprovedResult() + { + var service = new Mock(); + var approved = new UpliftApproveResultDTO { Id = 5, Status = "Approved", NTEAmount = 750m }; + service.Setup(x => x.ApproveAsync(It.IsAny(), 5, "ok", It.IsAny())) + .ReturnsAsync(approved); + + var controller = NewController(service); + + var result = await controller.Approve(5, new UpliftController.DecisionRequest { Note = "ok" }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var approveData = envelope.Data as UpliftApproveResultDTO; + approveData.Should().Be(approved); + } + + [Fact] + public async Task Deny_WithoutNote_Returns400() + { + var service = new Mock(); + service.Setup(x => x.DenyAsync(It.IsAny(), 5, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("A note is required when denying")); + + var controller = NewController(service); + + var result = await controller.Deny(5, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Deny_Success_ReturnsDeniedResult() + { + var service = new Mock(); + var denied = new UpliftDenyResultDTO { Id = 5, Status = "Denied" }; + service.Setup(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny())) + .ReturnsAsync(denied); + + var controller = NewController(service); + + var result = await controller.Deny(5, new UpliftController.DecisionRequest { Note = "too high" }); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.DenyAsync(It.IsAny(), 5, "too high", It.IsAny()), Times.Once); + } + + [Fact] + public void CanApprove_DelegatesToService() + { + var service = new Mock(); + service.Setup(x => x.CanApprove(It.IsAny(), 2)) + .Returns(true); + + var controller = NewController(service, "Manager"); + + var result = controller.CanApprove(2); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.CanApprove(It.Is(p => p.IsInRole("Manager")), 2), Times.Once); + } + + [Fact] + public async Task ListForDispatch_ReturnsItems() + { + var service = new Mock(); + var items = new[] { new UpliftForDispatchDTO { Id = 1, DispatchId = 10 } }; + service.Setup(x => x.ListForDispatchAsync(It.IsAny(), 10, It.IsAny())) + .ReturnsAsync(items); + + var controller = NewController(service); + + var result = await controller.ListForDispatch(10); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UserControllerTests.cs b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs new file mode 100644 index 0000000..1754479 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UserControllerTests.cs @@ -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 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(); + service.Setup(s => s.GetUsersAsync(It.IsAny())) + .ReturnsAsync(new List + { + 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().Subject; + ok.Value.Should().BeAssignableTo>(); + } + + [Fact] + public async Task AddUser_Success_ReturnsUpdatedSuccessfully() + { + var service = new Mock(); + service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + .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().Subject; + var response = ok.Value.Should().BeOfType().Subject; + response.Status.Should().Be("200"); + response.Message.Should().Be("Updated Successfully"); + } + + [Fact] + public async Task AddUser_Failure_ReturnsBadRequestWithServiceError() + { + var service = new Mock(); + service.Setup(s => s.AddUserAsync(It.IsAny(), It.IsAny())) + .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().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Status.Should().Be("Error"); + response.Message.Should().Be("boom"); + } + + [Fact] + public async Task EditUser_NotFound_ReturnsUserNotFoundMessage() + { + var service = new Mock(); + service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("User not found"); + } + + [Fact] + public async Task EditUser_Found_ReturnsUpdatedSuccessfully() + { + var service = new Mock(); + service.Setup(s => s.EditUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.EditUser(new EditUser_DTO { Id = "9" }, CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType(); + } + + [Fact] + public async Task DeleteUser_NotFound_ReturnsIdNotMatchedMessage() + { + var service = new Mock(); + service.Setup(s => s.DeleteUserAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.DeleteUser(new EditUser_DTO { Id = "missing" }, CancellationToken.None); + + var bad = result.Should().BeOfType().Subject; + var response = bad.Value.Should().BeOfType().Subject; + response.Message.Should().Be("ID not matched!"); + } + + [Fact] + public async Task DeleteUser_Found_DelegatesAndReturnsOk() + { + var service = new Mock(); + service.Setup(s => s.DeleteUserAsync("5", It.IsAny())).ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.DeleteUser(new EditUser_DTO { Id = "5" }, CancellationToken.None); + + result.Should().BeOfType(); + service.Verify(s => s.DeleteUserAsync("5", It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteCurrentUser_UsesClaimUserId() + { + var service = new Mock(); + service.Setup(s => s.DeleteCurrentUserAsync("77", It.IsAny())).Returns(Task.CompletedTask); + + var controller = NewController(service, "77"); + + var result = await controller.DeleteCurrentUser(CancellationToken.None); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().BeOfType(); + service.Verify(s => s.DeleteCurrentUserAsync("77", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UserProfile_PreservesLowercaseAddedDateKey() + { + var service = new Mock(); + service.Setup(s => s.GetUserProfileAsync("77", It.IsAny())) + .ReturnsAsync(new List + { + 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().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\""); + } +} diff --git a/Api.SeaHavenIndustries.Tests/UserServiceTests.cs b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs new file mode 100644 index 0000000..8047ad7 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/UserServiceTests.cs @@ -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 userData, + Mock email, + out Mock> 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(); + userData.Setup(u => u.GetAllForListAsync(It.IsAny())) + .ReturnsAsync(new List + { + 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(), 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(); + userData.Setup(u => u.GetForEditAsync("missing", It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out _); + + var found = await service.DeleteUserAsync("missing", CancellationToken.None); + + found.Should().BeFalse(); + userData.Verify(u => u.DeleteUserWithCascadeAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteUser_Found_PerformsCascade() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + var found = await service.DeleteUserAsync("u1", CancellationToken.None); + + found.Should().BeTrue(); + userData.Verify(u => u.DeleteUserWithCascadeAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteCurrentUser_SetsIsDeletedFlag() + { + var user = IdentityTestHelpers.User(); + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), out _); + + await service.DeleteCurrentUserAsync("u1", CancellationToken.None); + + user.IsDeleted.Should().BeTrue(); + userData.Verify(u => u.UpdateUserAsync(user, It.IsAny()), Times.Once); + } + + [Fact] + public async Task EditUser_NotFound_ReturnsFalse() + { + var userData = new Mock(); + userData.Setup(u => u.GetForEditAsync("x", It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), 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(); + userData.Setup(u => u.GetProfileAsync("u1", It.IsAny())).ReturnsAsync(user); + + var service = NewService(userData, new Mock(), 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(); + userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + var email = new Mock(); + + var service = NewService(userData, email, out var store); + store.As>() + .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(IdentityResult.Success); + store.Setup(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny()), Times.Once); + store.Verify(s => s.AddToRoleAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + email.Verify( + e => e.SendEmailAsync( + "n@x.com", + "Login Information", + It.Is(body => ContainsStrongGeneratedPassword(body))), + Times.Once); + } + + [Fact] + public async Task AddUser_CreateFails_ReturnsFirstError() + { + var userData = new Mock(); + userData.Setup(u => u.GetByIdAsync(It.IsAny())).ReturnsAsync((ApplicationUser?)null); + + var service = NewService(userData, new Mock(), out var store); + store.As>() + .Setup(s => s.SetPasswordHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .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)); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs index 2440734..2f73a12 100644 --- a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -4,12 +4,10 @@ using Data.SeaHavenIndustries; using FluentAssertions; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using Moq; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Text.Json; using Xunit; namespace Api.SeaHavenIndustries.Tests; @@ -18,12 +16,7 @@ public class VendorControllerTests { private static VendorController NewController(Mock service) { - var controller = new VendorController( - service.Object, - db: null!, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = new VendorController(service.Object); controller.ControllerContext = new ControllerContext { @@ -108,62 +101,178 @@ public class VendorControllerTests "42"), Times.Once); } - [Theory] - [InlineData(true, "Active Co", "Inactive Co")] - [InlineData(false, "Inactive Co", "Active Co")] - public async Task GetFacets_WithStatus_ReturnsOnlyMatchingVendors( - bool isActive, - string expected, - string excluded) + [Fact] + public async Task GetFacets_DelegatesToServiceAndReturnsOk() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using var context = new ApplicationDbContext(options); - context.Vendors.AddRange( - new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, - new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); - await context.SaveChangesAsync(); + var service = new Mock(); + var facets = new VendorFacetsDTO + { + Companies = new[] { new VendorFacetCompanyDTO { Name = "Active Co" } }, + Trades = new[] { "Plumbing" }, + Locations = new[] { new VendorFacetLocationDTO { Label = "Chicago, IL" } }, + JobBuckets = new[] { new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" } } + }; + service.Setup(x => x.GetFacetsAsync(true, It.IsAny())) + .ReturnsAsync(facets); - var controller = new VendorController( - Mock.Of(), - context, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = NewController(service); - var result = await controller.GetFacets(isActive); - var payload = JsonSerializer.Serialize( - result.Should().BeOfType().Subject.Value); + var result = await controller.GetFacets(true); - payload.Should().Contain(expected); - payload.Should().NotContain(excluded); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(facets); + service.Verify(x => x.GetFacetsAsync(true, It.IsAny()), Times.Once); } [Fact] - public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup() + public async Task GetDropdown_DelegatesToServiceAndReturnsOk() { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString()) - .Options; - await using var context = new ApplicationDbContext(options); - context.Vendors.AddRange( - new Vendor { CompanyName = "Active Co", IsActive = true, IsDeleted = false }, - new Vendor { CompanyName = "Inactive Co", IsActive = false, IsDeleted = false }); - await context.SaveChangesAsync(); + var service = new Mock(); + var items = new[] + { + new VendorDropdownItemDTO { Id = 1, CompanyName = "Vendor A", DistanceMiles = 5.2 } + }; + service.Setup(x => x.GetDropdownAsync("Plumbing", "60601", It.IsAny())) + .ReturnsAsync(items); - var controller = new VendorController( - Mock.Of(), - context, - zipDistance: null!, - vendorTokens: null!, - config: null!); + var controller = NewController(service); - var result = await controller.GetFacets(); - var payload = JsonSerializer.Serialize( - result.Should().BeOfType().Subject.Value); + var result = await controller.GetDropdown("Plumbing", "60601"); - payload.Should().Contain("Active Co"); - payload.Should().Contain("Inactive Co"); + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(items); + } + + [Fact] + public async Task GetPortalToken_ReturnsTokenDtoFromService() + { + var service = new Mock(); + var token = new VendorPortalTokenDTO { Token = "abc", PortalUrl = "https://example.com/v/abc/dashboard" }; + service.Setup(x => x.GetPortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(token); + + var controller = NewController(service); + + var result = await controller.GetPortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(token); + } + + [Fact] + public async Task GetPortalToken_ReturnsNotFound_WhenVendorDoesNotExist() + { + var service = new Mock(); + service.Setup(x => x.GetPortalTokenAsync(99, It.IsAny())) + .ReturnsAsync((VendorPortalTokenDTO?)null); + + var controller = NewController(service); + + var result = await controller.GetPortalToken(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RotatePortalToken_ReturnsRotatedToken() + { + var service = new Mock(); + var token = new VendorPortalTokenDTO { Token = "new-token" }; + service.Setup(x => x.RotatePortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(token); + + var controller = NewController(service); + + var result = await controller.RotatePortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().Be(token); + } + + [Fact] + public async Task RevokePortalToken_ReturnsRevokedTrue_OnSuccess() + { + var service = new Mock(); + service.Setup(x => x.RevokePortalTokenAsync(5, It.IsAny())) + .ReturnsAsync(true); + + var controller = NewController(service); + + var result = await controller.RevokePortalToken(5); + + var ok = result.Should().BeOfType().Subject; + ok.Value.Should().NotBeNull(); + } + + [Fact] + public async Task RevokePortalToken_ReturnsNotFound_WhenVendorDoesNotExist() + { + var service = new Mock(); + service.Setup(x => x.RevokePortalTokenAsync(99, It.IsAny())) + .ReturnsAsync(false); + + var controller = NewController(service); + + var result = await controller.RevokePortalToken(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_MapsDeactivationBlocked_ToConflict() + { + var service = new Mock(); + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new VendorDeactivationBlockedException("blocked", new List())); + + var controller = NewController(service); + + var result = await controller.Delete(5); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Delete_MapsNotFoundInvalidOperation_ToNotFound() + { + var service = new Mock(); + service.Setup(x => x.DeleteVendorAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException()); + + var controller = NewController(service); + + var result = await controller.Delete(5); + + var nf = result.Should().BeOfType().Subject; + nf.Value.Should().BeOfType(); + } + + [Fact] + public async Task GetVendorById_ReturnsNotFound_WhenVendorIsNull() + { + var service = new Mock(); + service.Setup(x => x.GetVendorByIdAsync(99)) + .ReturnsAsync((VendorDTO?)null); + + var controller = NewController(service); + + var result = await controller.GetVendorById(99, null); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Update_UnauthenticatedUser_ReturnsUnauthorized() + { + var service = new Mock(); + var controller = new VendorController(service.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal() } + }; + + var result = await controller.Update(5, new EditVendor_DTO { Id = 5, CompanyName = "X" }); + + result.Should().BeOfType(); } } diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs new file mode 100644 index 0000000..d2900c1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorPortalControllerTests.cs @@ -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 service, string? token = "valid-token") + { + var controller = new VendorPortalController(service.Object); + var context = new DefaultHttpContext(); + if (token != null) + context.Request.Headers["X-Vendor-Token"] = token; + controller.ControllerContext = new ControllerContext { HttpContext = context }; + return controller; + } + + private static readonly VendorPortalSession Session = new() + { + Id = 1, + CompanyName = "Test Co", + ContactName = "Sam", + Email = "sam@test.co", + Phone = "555-0100" + }; + + [Fact] + public async Task Session_MissingToken_ReturnsUnauthorized() + { + var service = new Mock(); + var controller = NewController(service, token: null); + + var result = await controller.Session(); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Session_InvalidToken_ReturnsUnauthorized() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync("bad", It.IsAny())) + .ReturnsAsync((VendorPortalSession?)null); + var controller = NewController(service, token: "bad"); + + var result = await controller.Session(); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Session_ValidToken_ReturnsSessionData() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync("valid-token", It.IsAny())) + .ReturnsAsync(Session); + var controller = NewController(service); + + var result = await controller.Session(); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + var sessionData = envelope.Data as VendorPortalSession; + sessionData.Should().Be(Session); + } + + [Fact] + public async Task ListDispatches_DelegatesToServiceWithStatus() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var dispatches = new[] { new VendorDispatchSummaryDTO { Id = 10, Status = "Sent" } }; + service.Setup(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny())) + .ReturnsAsync(dispatches); + var controller = NewController(service); + + var result = await controller.ListDispatches("Sent"); + + var ok = result.Should().BeOfType().Subject; + service.Verify(x => x.ListDispatchesAsync(Session, "Sent", It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetDispatch_NotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.GetDispatchDetailAsync(Session, 99, It.IsAny())) + .ReturnsAsync((VendorDispatchDetailDTO?)null); + var controller = NewController(service); + + var result = await controller.GetDispatch(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Accept_KeyNotFound_Returns404() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AcceptDispatchAsync(Session, 99, It.IsAny())) + .ThrowsAsync(new KeyNotFoundException()); + var controller = NewController(service); + + var result = await controller.Accept(99); + + result.Should().BeOfType(); + } + + [Fact] + public async Task Accept_InvalidOperation_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot accept")); + var controller = NewController(service); + + var result = await controller.Accept(10); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().Be("Cannot accept"); + } + + [Fact] + public async Task Accept_Success_ReturnsAcknowledgedResult() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var acceptResult = new AcceptDispatchResultDTO { Id = 10, Status = "Acknowledged" }; + service.Setup(x => x.AcceptDispatchAsync(Session, 10, It.IsAny())) + .ReturnsAsync(acceptResult); + var controller = NewController(service); + + var result = await controller.Accept(10); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var acceptData = envelope.Data as AcceptDispatchResultDTO; + acceptData.Should().Be(acceptResult); + } + + [Fact] + public async Task ChangeStatus_ForbiddenTransition_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.ChangeStatusAsync(Session, 10, "Completed", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Transition not allowed")); + var controller = NewController(service); + + var result = await controller.ChangeStatus(10, new VendorPortalController.ChangeStatusRequest { Status = "Completed" }); + + var bad = result.Should().BeOfType().Subject; + var resp = bad.Value.Should().BeOfType().Subject; + resp.Message.Should().Contain("Transition"); + } + + [Fact] + public async Task RequestCancel_Success_ReturnsSuccessWithMessage() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var controller = NewController(service); + + var result = await controller.RequestCancel(10, new VendorPortalController.RequestCancelRequest { Reason = "Parts delay" }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + envelope.Status.Should().Be("Success"); + envelope.Message.Should().Be("Cancel request sent to dispatcher"); + service.Verify(x => x.RequestCancelAsync(Session, 10, "Parts delay", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateChecklistItem_LockedDispatch_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.UpdateChecklistItemAsync(Session, 10, 5, true, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Dispatch is locked")); + var controller = NewController(service); + + var result = await controller.UpdateChecklistItem(10, 5, new VendorPortalController.ChecklistUpdateRequest { IsCompleted = true }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task AddSignoff_DuplicateSignoff_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AddSignoffAsync(Session, 10, "vendor", "Name", "sig", "drawn", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("A vendor signoff already exists")); + var controller = NewController(service); + + var result = await controller.AddSignoff(10, new VendorPortalController.SignoffRequest + { + SignoffType = "vendor", + Name = "Name", + Signature = "sig", + SignatureMethod = "drawn" + }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RequestUplift_NTEBelowCurrent_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.RequestUpliftAsync(Session, 10, 100m, null, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Requested NTE must be greater than the current NTE")); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody { RequestedNTE = 100m }); + + result.Should().BeOfType(); + } + + [Fact] + public async Task RequestUplift_Success_ReturnsResult() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var upliftResult = new UpliftRequestResultDTO { Id = 77, Status = "Pending", RequiredTier = 1 }; + service.Setup(x => x.RequestUpliftAsync(Session, 10, 500m, "Need more parts", It.IsAny())) + .ReturnsAsync(upliftResult); + var controller = NewController(service); + + var result = await controller.RequestUplift(10, new VendorPortalController.UpliftRequestBody + { + RequestedNTE = 500m, + Reason = "Need more parts" + }); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var upliftData = envelope.Data as UpliftRequestResultDTO; + upliftData.Should().Be(upliftResult); + } + + [Fact] + public async Task CancelUpliftRequest_NonPending_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Cannot cancel a 'Approved' uplift request")); + var controller = NewController(service); + + var result = await controller.CancelUpliftRequest(10, 77); + + result.Should().BeOfType(); + } + + [Fact] + public async Task CancelUpliftRequest_Success_ReturnsCancelledStatus() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + var cancelResult = new CancelUpliftResultDTO { Id = 77, Status = "Cancelled" }; + service.Setup(x => x.CancelUpliftRequestAsync(Session, 10, 77, It.IsAny())) + .ReturnsAsync(cancelResult); + var controller = NewController(service); + + var result = await controller.CancelUpliftRequest(10, 77); + + var ok = result.Should().BeOfType().Subject; + var envelope = ok.Value.Should().BeOfType().Subject; + var cancelData = envelope.Data as CancelUpliftResultDTO; + cancelData.Should().Be(cancelResult); + } + + [Fact] + public async Task AddComment_EmptyText_Returns400() + { + var service = new Mock(); + service.Setup(x => x.ResolveSessionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Session); + service.Setup(x => x.AddCommentAsync(Session, 10, "", It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Comment cannot be empty")); + var controller = NewController(service); + + var result = await controller.AddComment(10, new VendorPortalController.CommentRequest { CommentText = "" }); + + result.Should().BeOfType(); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 315bd18..3f4f6bc 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -2,10 +2,13 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentAssertions; using FluentValidation; +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; @@ -14,7 +17,14 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorServiceTests { private static VendorService NewService(Mock data) => - new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); + new( + data.Object, + Mock.Of(), + Mock.Of(), + Options.Create(new FrontendOptions()), + new CreateVendorValidation(), + new UpdateVendorValidation(), + new WorkOrderVendorUpdateValidation()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs new file mode 100644 index 0000000..a32815f --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderDataServiceBatchingTests.cs @@ -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() + .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 }); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 013cbfe..67a5d33 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -1,14 +1,10 @@ -using Data.SeaHavenIndustries; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.IdentityModel.Tokens; -using System.IdentityModel.Tokens.Jwt; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Text; -using Api.SeaHavenIndustries.DTOs; -using Api.SeaHavenIndustries.Helper; -using Microsoft.EntityFrameworkCore; namespace Api.SeaHavenIndustries.Controllers { @@ -16,55 +12,35 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Authentication")] public class AuthenticationController : Controller { - private readonly UserManager _userManager; - private readonly IConfiguration _configuration; - private readonly ApplicationDbContext _db; - private readonly SendMessage _sendMessage; + private readonly IAuthenticationService _authenticationService; - public AuthenticationController(UserManager userManager, IConfiguration configuration, ApplicationDbContext db, SendMessage sendMessage) + public AuthenticationController(IAuthenticationService authenticationService) { - _userManager = userManager; - _configuration = configuration; - _db = db; - _sendMessage = sendMessage; + _authenticationService = authenticationService; } [AllowAnonymous] [HttpPost] [Route("login")] - public async Task Login([FromBody] LoginModel model) + public async Task Login([FromBody] LoginModel model, CancellationToken cancellationToken) { try { - var user = await _userManager.FindByNameAsync(model.Username ?? ""); - if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, model.Password ?? "")) + var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken); + if (result != null) { - // Standard login without 2FA - var userRoles = await _userManager.GetRolesAsync(user); - var authClaims = new List - { - 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 { - token = new JwtSecurityTokenHandler().WriteToken(token), - expiration = token.ValidTo, - email = user.Email, - userRoles = userRoles.FirstOrDefault(), - phoneNumber = user.PhoneNumber, - fullname = user.FirstName + " " + user.LastName, - id = user.Id + token = result.Token, + expiration = result.Expiration, + email = result.Email, + userRoles = result.UserRole, + phoneNumber = result.PhoneNumber, + fullname = result.Fullname, + id = result.Id }); } - // Invalid credentials return Unauthorized(); } catch (Exception ex) @@ -72,17 +48,16 @@ namespace Api.SeaHavenIndustries.Controllers return StatusCode(500, ex.Message); } - + } [Route("ChangePassword")] [HttpPost] - public async Task ChangePassword(ChangePasswords usermodel) + public async Task ChangePassword(ChangePasswords usermodel, CancellationToken cancellationToken) { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var user = await _userManager.FindByIdAsync(userid); - var result = await _userManager.ChangePasswordAsync(user, usermodel.Currentpassword ?? "", usermodel.Confirmpassword ?? ""); - if (result.Succeeded) + var succeeded = await _authenticationService.ChangePasswordAsync(userid, usermodel.Currentpassword, usermodel.Confirmpassword, cancellationToken); + if (succeeded) { 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" }); } - private JwtSecurityToken GetToken(List 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] [Route("UpdateProfile")] - public async Task UserProfileUpdate([FromForm] User_DTO model) + public async Task UserProfileUpdate([FromForm] User_DTO model, CancellationToken cancellationToken) { try { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - ApplicationUser appuser = _db.Users.AsNoTracking().Where(u => u.Id == userid).FirstOrDefault() ?? new ApplicationUser(); - appuser.FirstName = model.Name; - appuser.Email = model.Email; - appuser.Contact = model.Contact; - _db.Users.Update(appuser); - await _db.SaveChangesAsync(); + var dto = new UpdateProfileRequestDTO + { + Name = model.Name, + Email = model.Email, + Contact = model.Contact + }; + var data = await _authenticationService.UpdateProfileAsync(userid, dto, cancellationToken); return Ok(new DataResponse { Message = "Introduction Updated Successfully", Status = "200", - Data = _db.Users.Where(u => u.Id == userid).Select(s => new + Data = data == null ? null : new { - s.FirstName, - //s.Location, - s.Email, - s.Contact - }).FirstOrDefault() + data.FirstName, + data.Email, + data.Contact + } }); } catch (Exception ex) @@ -141,25 +103,11 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("ForgetPassword")] - public async Task ForgetPassword(string Email) + public async Task ForgetPassword(string Email, CancellationToken cancellationToken) { - var user = await _db.Users.Where(u => u.Email.ToLower().Trim() == Email.ToLower().ToLower()).FirstOrDefaultAsync(); - if (user != null) + var found = await _authenticationService.ForgetPasswordAsync(Email, cancellationToken); + 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" }); } else @@ -171,11 +119,11 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("VerificationCode")] - public async Task VerificationCode(string code) + public async Task VerificationCode(string code, CancellationToken cancellationToken) { 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" }); @@ -195,16 +143,12 @@ namespace Api.SeaHavenIndustries.Controllers [AllowAnonymous] [HttpPost()] [Route("ResetPassword")] - public async Task ResetPassword(ForgetPassword_Dto fpdto) + public async Task ResetPassword(ForgetPassword_Dto fpdto, CancellationToken cancellationToken) { 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" }); } 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 } } diff --git a/Api.SeaHavenIndustries/Controllers/CalendarController.cs b/Api.SeaHavenIndustries/Controllers/CalendarController.cs index 2d7f85d..caa18be 100644 --- a/Api.SeaHavenIndustries/Controllers/CalendarController.cs +++ b/Api.SeaHavenIndustries/Controllers/CalendarController.cs @@ -1,13 +1,9 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -16,13 +12,11 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Calendar")] public class CalendarController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; + private readonly ICalendarService _calendarService; - public CalendarController(UserManager userManager, ApplicationDbContext db) + public CalendarController(ICalendarService calendarService) { - _userManager = userManager; - _db = db; + _calendarService = calendarService; } // =================================================================================== @@ -30,31 +24,11 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpPost] [Route("AddEvent")] - public async Task AddEvent(EditEvent_DTO eventDto) + public async Task AddEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken) { try { - var model = new Events - { - 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(); + await _calendarService.CreateEventAsync(MapToInput(eventDto), cancellationToken); return Ok(new DataResponse { Message = "Event Created Successfully", Status = "200" }); } @@ -69,7 +43,7 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpPost] [Route("EditEvent")] - public async Task EditEvent(EditEvent_DTO eventDto) + public async Task EditEvent(EditEvent_DTO eventDto, CancellationToken cancellationToken) { try { @@ -78,30 +52,12 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = "Event ID is required for editing" }); } - var model = await _db.Events.FindAsync(eventDto.Id.Value); - if (model == null || model.IsDeleted == true) + var updated = await _calendarService.UpdateEventAsync(MapToInput(eventDto), cancellationToken); + if (!updated) { 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" }); } catch (Exception ex) @@ -115,40 +71,18 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpGet] [Route("GetEventById/{id}")] - public async Task GetEventById(int id) + public async Task GetEventById(int id, CancellationToken cancellationToken) { try { - var model = await _db.Events - .Where(e => e.Id == id && e.IsDeleted != true) - .FirstOrDefaultAsync(); + var dto = await _calendarService.GetEventByIdAsync(id, cancellationToken); - if (model == null) + if (dto == null) { return NotFound(new Response { Status = "Error", Message = "Event not found" }); } - var dto = new Event_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 }); + return Ok(new DataResponse { Message = "Success", Status = "200", Data = MapToResponse(dto) }); } catch (Exception ex) { @@ -161,23 +95,16 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpDelete] [Route("DeleteEvent/{id}")] - public async Task DeleteEvent(int id) + public async Task DeleteEvent(int id, CancellationToken cancellationToken) { try { - var model = await _db.Events.FindAsync(id); - if (model == null) + var deleted = await _calendarService.DeleteEventAsync(id, cancellationToken); + if (!deleted) { 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" }); } catch (Exception ex) @@ -191,58 +118,19 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpGet] [Route("GetEventList")] - public IActionResult GetEventList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetEventList(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var query = _db.Events.Where(e => e.IsDeleted != true); + var result = await _calendarService.GetEventListAsync(search, page, pageSize, cancellationToken); - // Apply search filter if provided - 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(); + var events = result.Rows.Select(MapToResponse).ToList(); return Ok(new DataResponse { Message = "Success", Status = "200", - Data = new { rows = events, totalCount } + Data = new { rows = events, totalCount = result.TotalCount } }); } catch (Exception ex) @@ -256,51 +144,62 @@ namespace Api.SeaHavenIndustries.Controllers // =================================================================================== [HttpGet] [Route("GetEventsForCalendar")] - public IActionResult GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end) + public async Task GetEventsForCalendar([FromQuery] DateTime? start, [FromQuery] DateTime? end, CancellationToken cancellationToken) { try { - var query = _db.Events.Where(e => e.IsDeleted != true); + var events = await _calendarService.GetEventsForCalendarAsync(start, end, cancellationToken); - // Filter by date range if provided - 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 }); + return Ok(new DataResponse { Message = "Success", Status = "200", Data = events.Select(MapToResponse).ToList() }); } catch (Exception ex) { 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 + }; + } } } - diff --git a/Api.SeaHavenIndustries/Controllers/CommonController.cs b/Api.SeaHavenIndustries/Controllers/CommonController.cs index 2a44899..875834e 100644 --- a/Api.SeaHavenIndustries/Controllers/CommonController.cs +++ b/Api.SeaHavenIndustries/Controllers/CommonController.cs @@ -1,73 +1,49 @@ using Api.SeaHavenIndustries.DTOs; using Api.SeaHavenIndustries.Helper.ViewModel; -using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { [Authorize] [ApiController] [Route("[controller]")] - + public class CommonController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - public CommonController(UserManager userManager, ApplicationDbContext db) + private readonly ICommonService _commonService; + + public CommonController(ICommonService commonService) { - _userManager = userManager; - _db = db; + _commonService = commonService; } [HttpGet] [Route("GetUsers")] - public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10) + public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var allUsersQuery = _db.Users - .Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())) - .OrderByDescending(d => d.Id); + var result = await _commonService.GetUsersPagedAsync(search, page, pageSize, cancellationToken); - var totalCount = allUsersQuery.Count(); - - 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(); - - foreach (var user in paginatedUsers) + var filteredUsers = result.Users.Select(u => new UserRoleViewModel { - var roles = await _userManager.GetRolesAsync(user); - if (!roles.Contains("Admin")) - { - filteredUsers.Add(new UserRoleViewModel - { - RoleName = roles.Count()>0?roles.First():"", - Email = user.Email, - 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 + RoleName = u.RoleName, + Email = u.Email, + UserName = u.UserName, + Date = u.Date, + Status = u.Status, + Id = u.Id + }).ToList(); + var viewModel = new Pagination_DTO { Data = filteredUsers, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages + PageNumber = result.PageNumber, + PageSize = result.PageSize, + TotalCount = result.TotalCount, + TotalPages = result.TotalPages }; return Ok(viewModel); } @@ -79,12 +55,12 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetRoles")] - public async Task GetRoles() + public async Task GetRoles(CancellationToken cancellationToken) { try { - var data = await _db.Roles.Select(s => new {Id=s.Name,s.Name}).ToListAsync(); - + var data = await _commonService.GetRolesAsync(cancellationToken); + return Ok(data); } catch (Exception ex) @@ -96,41 +72,26 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetSitesName")] - public async Task GetSiteName() + public async Task GetSiteName(CancellationToken cancellationToken) { - var data =await _db.Locations.Select(n=> new - { - n.Id, - n.Name, - n.Title - }).ToListAsync(); + var data = await _commonService.GetSiteNamesAsync(cancellationToken); return Ok(data); } [HttpGet] [Route("GetContacts")] - public async Task GetContacts() + public async Task GetContacts(CancellationToken cancellationToken) { - var data =await _db.Contacts.Select(n=> new - { - n.Id, - Name = (n.FirstName + " " + n.LastName).Trim(), - email=n.Email, - phone=n.PhoneNumber - }).ToListAsync(); + var data = await _commonService.GetContactsAsync(cancellationToken); return Ok(data); } [HttpGet] [Route("GetCategories")] - public async Task GetCategories() + public async Task GetCategories(CancellationToken cancellationToken) { - var data = await _db.Categories.Select(s=> new - { - s.Id, - s.Name - }).ToListAsync(); + var data = await _commonService.GetCategoriesAsync(cancellationToken); return Ok(data); - } + } } } diff --git a/Api.SeaHavenIndustries/Controllers/DashboardController.cs b/Api.SeaHavenIndustries/Controllers/DashboardController.cs index b548af6..6955cc2 100644 --- a/Api.SeaHavenIndustries/Controllers/DashboardController.cs +++ b/Api.SeaHavenIndustries/Controllers/DashboardController.cs @@ -1,7 +1,6 @@ -using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -10,29 +9,24 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/[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")] - public async Task GetStats() + public async Task GetStats(CancellationToken cancellationToken) { - var query = _db.workOrders.Where(w => w.istemplate != true); - - 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"); + var stats = await _dashboardService.GetStatsAsync(cancellationToken); return Ok(new { - total, - open, - notDispatched, - completed + total = stats.Total, + open = stats.Open, + notDispatched = stats.NotDispatched, + completed = stats.Completed }); } } diff --git a/Api.SeaHavenIndustries/Controllers/DispatchController.cs b/Api.SeaHavenIndustries/Controllers/DispatchController.cs index 530be20..f114a4a 100644 --- a/Api.SeaHavenIndustries/Controllers/DispatchController.cs +++ b/Api.SeaHavenIndustries/Controllers/DispatchController.cs @@ -1,9 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using SeaHaven.Services.Interfaces; -using SeaHaven.Services.DTOs; namespace Api.SeaHavenIndustries.Controllers { @@ -14,12 +12,10 @@ namespace Api.SeaHavenIndustries.Controllers public class DispatchController : Controller { private readonly IDispatchService _dispatchService; - private readonly ApplicationDbContext _db; // Keep for complex queries - public DispatchController(IDispatchService dispatchService, ApplicationDbContext db) + public DispatchController(IDispatchService dispatchService) { _dispatchService = dispatchService; - _db = db; } [HttpGet] @@ -32,317 +28,29 @@ namespace Api.SeaHavenIndustries.Controllers [FromQuery] string? sortBy = "dispatchedAt", [FromQuery] string? sortDir = "desc", [FromQuery] int page = 1, - [FromQuery] int pageSize = 25) + [FromQuery] int pageSize = 25, + CancellationToken cancellationToken = default) { - var q = _db.Dispatches - .Include(d => d.Vendor) - .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(); + var result = await _dispatchService.GetDispatchListAsync( + search, status, vendorId, dateFrom, dateTo, sortBy, sortDir, page, pageSize, cancellationToken); return Ok(new DataResponse { 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}")] - public async Task GetById(int id) + public async Task GetById(int id, CancellationToken cancellationToken) { - var dispatch = await _db.Dispatches - .Include(d => d.Vendor) - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .FirstOrDefaultAsync(d => d.Id == id); + var dispatch = await _dispatchService.GetDispatchDetailAsync(id, cancellationToken); 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 { Status = "Success", - Data = new - { - 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 - } + Data = dispatch }); } } diff --git a/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs b/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs index 103ea27..1085c5d 100644 --- a/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs +++ b/Api.SeaHavenIndustries/Controllers/DropdownOptionsController.cs @@ -1,7 +1,8 @@ -using Data.SeaHavenIndustries; +using Api.SeaHavenIndustries.DTOs; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -10,78 +11,80 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/[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] - public async Task GetAll([FromQuery] string? category = null) + public async Task GetAll([FromQuery] string? category = null, CancellationToken cancellationToken = default) { - var query = _db.DropdownOptions.Where(d => d.IsActive); - 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(); + var data = await _dropdownOptionsService.GetAllAsync(category, cancellationToken); return Ok(data); } [HttpGet("ByCategory/{category}")] - public async Task GetByCategory(string category, [FromQuery] string? parent = null) + public async Task GetByCategory(string category, [FromQuery] string? parent = null, CancellationToken cancellationToken = default) { - var query = _db.DropdownOptions.Where(d => d.IsActive && d.Category == category); - if (!string.IsNullOrWhiteSpace(parent)) - query = query.Where(d => d.ParentValue == parent); - - var data = await query.OrderBy(d => d.SortOrder).ThenBy(d => d.Value).ToListAsync(); + var data = await _dropdownOptionsService.GetByCategoryAsync(category, parent, cancellationToken); return Ok(data); } [HttpPost] - public async Task Create([FromBody] DropdownOption model) + public async Task Create([FromBody] DropdownOption_DTO model, CancellationToken cancellationToken) { - if (string.IsNullOrWhiteSpace(model.Category) || string.IsNullOrWhiteSpace(model.Value)) - return BadRequest(new { message = "Category and Value are required" }); - - var existing = await _db.DropdownOptions - .AnyAsync(d => d.Category == model.Category && d.Value == model.Value && d.ParentValue == model.ParentValue); - if (existing) - return BadRequest(new { message = "This option already exists" }); - - model.Id = 0; - model.IsActive = true; - _db.DropdownOptions.Add(model); - await _db.SaveChangesAsync(); - return Ok(model); + try + { + var created = await _dropdownOptionsService.CreateAsync(MapToService(model), cancellationToken); + return Ok(created); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } } [HttpPut("{id}")] - public async Task Update(int id, [FromBody] DropdownOption model) + public async Task Update(int id, [FromBody] DropdownOption_DTO model, CancellationToken cancellationToken) { - var item = await _db.DropdownOptions.FindAsync(id); - if (item == null) + try + { + var updated = await _dropdownOptionsService.UpdateAsync(id, MapToService(model), cancellationToken); + return Ok(updated); + } + catch (KeyNotFoundException) + { 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}")] - public async Task Delete(int id) + public async Task Delete(int id, CancellationToken cancellationToken) { - var item = await _db.DropdownOptions.FindAsync(id); - if (item == null) + try + { + await _dropdownOptionsService.DeleteAsync(id, cancellationToken); + return Ok(new { message = "Deleted" }); + } + catch (KeyNotFoundException) + { return NotFound(new { message = "Option not found" }); + } + } - _db.DropdownOptions.Remove(item); - await _db.SaveChangesAsync(); - return Ok(new { message = "Deleted" }); + private static DropdownOptionDTO MapToService(DropdownOption_DTO model) + { + return new DropdownOptionDTO + { + Id = model.Id, + Category = model.Category, + Value = model.Value, + ParentValue = model.ParentValue, + SortOrder = model.SortOrder, + IsActive = model.IsActive + }; } } } diff --git a/Api.SeaHavenIndustries/Controllers/FollowUpController.cs b/Api.SeaHavenIndustries/Controllers/FollowUpController.cs index 02bdf0f..35099eb 100644 --- a/Api.SeaHavenIndustries/Controllers/FollowUpController.cs +++ b/Api.SeaHavenIndustries/Controllers/FollowUpController.cs @@ -3,12 +3,9 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; -using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Interfaces; [ApiController] [Authorize] @@ -17,16 +14,10 @@ using SeaHaven.DataServices.Interfaces; public class FollowUpController : ControllerBase { private readonly IFollowUpService _followUpService; - private readonly IFollowUpDataService _followUpDataService; // Keep for direct entity operations - private readonly ApplicationDbContext _db; // Keep for User lookups - private readonly UserManager _users; - public FollowUpController(IFollowUpService followUpService, IFollowUpDataService followUpDataService, ApplicationDbContext db, UserManager users) + public FollowUpController(IFollowUpService followUpService) { _followUpService = followUpService; - _followUpDataService = followUpDataService; - _db = db; - _users = users; } [HttpGet("GetFollowUpList")] @@ -34,70 +25,31 @@ public class FollowUpController : ControllerBase string? search = "", string? status = "Open", int page = 1, - int pageSize = 12) + int pageSize = 12, + CancellationToken cancellationToken = default) { - status = string.IsNullOrWhiteSpace(status) ? "Open" : status; - - var pagedResult = await _followUpService.GetFollowUpsPagedAsync(page, pageSize, search, status); + var pagedResult = await _followUpService.GetFollowUpListAsync(page, pageSize, search, status, cancellationToken); var items = pagedResult.Items; - var rows = items.Select(x => new + var data = items.Select(x => new { - Id = x.Id, - Reason = x.Reason, - ScheduleDate = x.ScheduleDate, - ScheduleStartTime = x.ScheduleStartTime, - EmployeeId = x.EmployeeId, - WorkOrderId = x.WorkOrderId, - AccountId = x.AccountId, - LocationId = x.LocationId, - AccountName = x.AccountName ?? "", - AssignedTo = x.EmployeeName ?? "", - LocationName = x.LocationName ?? "", - WorkOrderNumber = "", - InternalWONumber = "", - Status = x.Status, - CreatedDate = x.CreatedDate, - CreatedById = (string?)null - }).ToList(); - - 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) + x.Id, + x.Reason, + x.ScheduleDate, + x.ScheduleStartTime, + x.EmployeeId, + x.WorkOrderId, + x.AccountId, + x.LocationId, + x.AccountName, + x.AssignedTo, + x.LocationName, + x.WorkOrderNumber, + x.InternalWONumber, + x.Status, + x.CreatedDate, + x.CreatedById, + x.CreatedByName }).ToList(); return Ok(new Pagination_DTO @@ -111,26 +63,12 @@ public class FollowUpController : ControllerBase } [HttpGet("GetById")] - public async Task GetById(int id) + public async Task GetById(int id, CancellationToken cancellationToken) { - var model = await _followUpDataService.GetByIdWithDetailsAsync(id); + var model = await _followUpService.GetFollowUpDetailAsync(id, cancellationToken); 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 { model.Id, @@ -141,22 +79,20 @@ public class FollowUpController : ControllerBase model.WorkOrderId, model.AccountId, model.LocationId, - AccountName = model.Account?.Name ?? "", - AssignedTo = model.Employee != null - ? (model.Employee.FirstName + " " + model.Employee.LastName) - : "", - LocationName = model.Location?.Name ?? "", - WorkOrderNumber = model.WorkOrder?.WorkerOrderNumber ?? "", - InternalWONumber = model.WorkOrder?.InternalWONumber ?? "", + model.AccountName, + model.AssignedTo, + model.LocationName, + model.WorkOrderNumber, + model.InternalWONumber, model.Status, model.CreatedDate, - CreatedById = model.createdby, - CreatedByName = createdByName + model.CreatedById, + model.CreatedByName }); } [HttpPost("Create")] - public async Task Create(FollowUp_DTO dto) + public async Task Create(FollowUp_DTO dto, CancellationToken cancellationToken) { try { @@ -164,23 +100,9 @@ public class FollowUpController : ControllerBase if (userId == null) return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - var model = new FollowUps - { - 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 - }; + var id = await _followUpService.CreateOpenFollowUpAsync(MapToCreateRequest(dto), userId, cancellationToken); - await _followUpDataService.AddAsync(model); - - return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = model.Id }); + return Ok(new DataResponse { Status = "200", Message = "Follow Up created", Data = id }); } catch (ValidationException vex) { @@ -194,24 +116,11 @@ public class FollowUpController : ControllerBase } [HttpPost("Update")] - public async Task Update(FollowUp_DTO dto) + public async Task Update(FollowUp_DTO dto, CancellationToken cancellationToken) { try { - var model = await _followUpDataService.GetByIdAsync(dto.Id); - 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); + await _followUpService.UpdateFollowUpFieldsAsync(dto.Id, MapToUpdateRequest(dto), cancellationToken); return Ok(new DataResponse { Status = "200", Message = "Follow Up updated" }); } catch (ValidationException vex) @@ -230,42 +139,32 @@ public class FollowUpController : ControllerBase } [HttpPost("Complete")] - public async Task Complete(int id) + public async Task Complete(int id, CancellationToken cancellationToken) { - var model = await _followUpDataService.GetByIdAsync(id); - if (model == null) return NotFound(); - - model.Status = "Completed"; - model.LastModificationTime = DateTime.UtcNow; - await _followUpDataService.UpdateAsync(model); + var found = await _followUpService.SetFollowUpStatusAsync(id, "Completed", cancellationToken); + if (!found) return NotFound(); return Ok(); } [HttpPost("Cancel")] - public async Task Cancel(int id) + public async Task Cancel(int id, CancellationToken cancellationToken) { - var model = await _followUpDataService.GetByIdAsync(id); - if (model == null) return NotFound(); - - model.Status = "Cancelled"; - model.LastModificationTime = DateTime.UtcNow; - await _followUpDataService.UpdateAsync(model); + var found = await _followUpService.SetFollowUpStatusAsync(id, "Cancelled", cancellationToken); + if (!found) return NotFound(); return Ok(); } [HttpPost("Delete")] - public async Task Delete(int id) + public async Task Delete(int id, CancellationToken cancellationToken) { try { - var model = await _followUpDataService.GetByIdAsync(id); - if (model == null) + var found = await _followUpService.RemoveFollowUpAsync(id, cancellationToken); + if (!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" }); } catch (KeyNotFoundException) @@ -277,4 +176,33 @@ public class FollowUpController : ControllerBase 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 + }; + } } diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 4bfba79..b974264 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -3,11 +3,8 @@ using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; -using SeaHaven.DataServices.Interfaces; -using System.Security.Claims; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -18,18 +15,16 @@ namespace Api.SeaHavenIndustries.Controllers public class LocationController : Controller { 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; - _locationDataService = locationDataService; } [HttpGet("GetLocationList")] - public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10) + public async Task 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 { @@ -61,9 +56,9 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("{id}")] - public async Task GetLocationById(int id) + public async Task GetLocationById(int id, CancellationToken cancellationToken) { - var location = await _locationDataService.GetByIdAsync(id); + var location = await _locationService.GetLocationDetailAsync(id, cancellationToken); if (location == null) return NotFound(new Response { Status = "Error", Message = "Location not found" }); @@ -89,25 +84,11 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost] - public async Task AddLocation([FromBody] Location_DTO model) + public async Task AddLocation([FromBody] Location_DTO model, CancellationToken cancellationToken) { try { - var location = new Locations - { - 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); + await _locationService.CreateLocationFromRequestAsync(MapToCreateRequest(model), cancellationToken); return Ok(new DataResponse { Message = "Location Created Successfully", Status = "200" }); } catch (ValidationException vex) @@ -122,26 +103,11 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPut("{id}")] - public async Task EditLocation(int id, [FromBody] EditLocation_DTO model) + public async Task EditLocation(int id, [FromBody] EditLocation_DTO model, CancellationToken cancellationToken) { try { - var exist = await _locationDataService.GetByIdAsync(id); - 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); + await _locationService.UpdateLocationFromRequestAsync(id, MapToUpdateRequest(model), cancellationToken); return Ok(new DataResponse { Message = "Location Updated Successfully", Status = "200" }); } catch (ValidationException vex) @@ -160,15 +126,14 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpDelete("{id}")] - public async Task DeleteLocation(int id) + public async Task DeleteLocation(int id, CancellationToken cancellationToken) { try { - var exist = await _locationDataService.GetByIdAsync(id); - if (exist == null) + var found = await _locationService.DeleteLocationByIdAsync(id, cancellationToken); + if (!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" }); } catch (KeyNotFoundException) @@ -182,9 +147,41 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("DeleteLocation")] - public async Task DeleteLocationByQuery(int id) + public async Task 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 + }; } } } diff --git a/Api.SeaHavenIndustries/Controllers/QuotesController.cs b/Api.SeaHavenIndustries/Controllers/QuotesController.cs index 311fb33..6967159 100644 --- a/Api.SeaHavenIndustries/Controllers/QuotesController.cs +++ b/Api.SeaHavenIndustries/Controllers/QuotesController.cs @@ -1,12 +1,9 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore; -using System.Linq; -using static System.Runtime.InteropServices.JavaScript.JSType; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -15,52 +12,21 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/Quotes")] public class QuotesController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - private readonly IWebHostEnvironment _webHostEnvironment; - IHttpContextAccessor _httpContext; - public QuotesController(UserManager userManager, ApplicationDbContext db, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContext) + private readonly IQuotesService _quotesService; + + public QuotesController(IQuotesService quotesService) { - _userManager = userManager; - _db = db; - _webHostEnvironment = webHostEnvironment; - _httpContext = httpContext; + _quotesService = quotesService; } + [HttpPost] [Route("AddQuote")] - public async Task AddQuote([FromBody] Quote_DTO model) + public async Task AddQuote([FromBody] Quote_DTO model, CancellationToken cancellationToken) { try { - Random rnd = new Random(); - 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() : 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(); + await _quotesService.CreateQuoteAsync(MapToCreateInput(model), cancellationToken); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -71,50 +37,14 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPut] [Route("EditQuote")] - public async Task EditQuote([FromBody] EditQuote_DTO model) + public async Task EditQuote([FromBody] EditQuote_DTO model, CancellationToken cancellationToken) { try { - var quotes = _db.Quotes.Where(w => w.Id == model.id).FirstOrDefault(); - if (quotes != null) - { - 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() : 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 - { + var updated = await _quotesService.UpdateQuoteAsync(MapToUpdateInput(model), cancellationToken); + if (!updated) return BadRequest("ID not matched!"); - } - await _db.SaveChangesAsync(); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -125,26 +55,15 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete] [Route("DeleteQuote")] - public IActionResult DeleteQuote(EditQuote_DTO model) + public async Task DeleteQuote(EditQuote_DTO model, CancellationToken cancellationToken) { int id = model.id; - try + try { - var data = _db.Quotes.Where(w => w.Id == id).FirstOrDefault(); - if (data != null) - { - var existlineitem = _db.QuotesLines.Where(w => w.QuoteId == id).ToList(); - if (existlineitem.Any()) - { - _db.QuotesLines.RemoveRange(existlineitem); - } - _db.Quotes.Remove(data); - } - else - { + var deleted = await _quotesService.DeleteQuoteAsync(id, cancellationToken); + if (!deleted) return BadRequest("ID not matched!"); - } - _db.SaveChanges(); + return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); } catch (Exception ex) @@ -155,38 +74,18 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetQuoutes")] - public IActionResult GetQuotes(string search = "", int page = 1, int pageSize = 10) + public async Task GetQuotes(string search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { - var totalCount = _db.Quotes.Count(); - 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(); + var result = await _quotesService.GetQuotesPagedAsync(page, pageSize, search, cancellationToken); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(result.TotalCount / (double)pageSize); - // Create a view model containing the paged items and pagination metadata var viewModel = new Pagination_DTO { - Data = data, + Data = result.Items, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = result.TotalCount, TotalPages = totalPages }; return Ok(viewModel); @@ -194,66 +93,85 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetQuoutesdetailsAgainstWorkOrder")] - public async Task GetQuoutesdetailsAgainstWorkOrder(int woId) + public async Task GetQuoutesdetailsAgainstWorkOrder(int woId, CancellationToken cancellationToken) { - var data = await _db.workOrders.Include(s => s.Locations).Where(w => w.Id == woId).Select(s => new - { - s.Id, - s.PO, - s.TT, - Title = s.Locations != null ? s.Locations.Title : "", - Name = s.Locations != null ? s.Locations.Name : "" - }).ToListAsync(); + var data = await _quotesService.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken); return Ok(data); } [HttpGet] [Route("GetQuoutesById")] - public async Task GetQuoutesById(int id) + public async Task GetQuoutesById(int id, CancellationToken cancellationToken) { - var data = await _db.Quotes.Where(s => s.Id == id).Include(s => s.WorkOrder).Select(s => new - { - 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(); + var data = await _quotesService.GetQuoteDetailsByIdAsync(id, cancellationToken); return Ok(data); } [HttpGet] [Route("GetReporting")] - public async Task GetReporting() + public async Task GetReporting(CancellationToken cancellationToken) { - var totalCreatedWorkOrder = await _db.workOrders.CountAsync(); - 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 = await _quotesService.GetReportingAsync(cancellationToken); - var data = new Reporting_DTO + var reportingDto = new Reporting_DTO { - TotalCreatedWorkOrder = totalCreatedWorkOrder, - TotalCompleted = totalCompleted, - TotalSev1 = totalSev1, - TotalSev2 = totalSev2, - TotalSev3 = totalSev3, - TotalSev4 = totalSev4, - TotalSev5 = totalSev5 + TotalCreatedWorkOrder = data.TotalCreatedWorkOrder, + TotalCompleted = data.TotalCompleted, + TotalSev1 = data.TotalSev1, + TotalSev2 = data.TotalSev2, + TotalSev3 = data.TotalSev3, + TotalSev4 = data.TotalSev4, + 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() + }; } } } diff --git a/Api.SeaHavenIndustries/Controllers/SettingsController.cs b/Api.SeaHavenIndustries/Controllers/SettingsController.cs index 5a0510a..38c31b4 100644 --- a/Api.SeaHavenIndustries/Controllers/SettingsController.cs +++ b/Api.SeaHavenIndustries/Controllers/SettingsController.cs @@ -2,10 +2,8 @@ using Api.SeaHavenIndustries.Helper.ViewModel; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using static System.Runtime.InteropServices.JavaScript.JSType; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -14,24 +12,20 @@ namespace Api.SeaHavenIndustries.Controllers [Route("[controller]")] public class SettingsController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - public SettingsController(UserManager userManager, ApplicationDbContext db) + private readonly ISettingsService _settingsService; + + public SettingsController(ISettingsService settingsService) { - _userManager = userManager; - _db = db; + _settingsService = settingsService; } [HttpPost] [Route("AddCategory")] - public async Task AddCategory(Category_DTO model) + public async Task AddCategory(Category_DTO model, CancellationToken cancellationToken) { try { - Category category = new Category(); - category.Name = model.Name; - _db.Categories.Add(category); - await _db.SaveChangesAsync(); + await _settingsService.AddCategoryAsync(model.Name, cancellationToken); return Ok(new DataResponse { Message = "Successfully Saved Data", Status = "200" }); } catch (Exception ex) @@ -42,16 +36,13 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] [Route("EditCategory")] - public async Task EditCategory(EditCategory_DTO model) + public async Task EditCategory(EditCategory_DTO model, CancellationToken cancellationToken) { try { - var exist = _db.Categories.Where(w => w.Id == model.Id).FirstOrDefault(); - if (exist == null) + var found = await _settingsService.UpdateCategoryAsync(model.Id, model.Name, cancellationToken); + if (!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" }); } catch (Exception ex) @@ -62,30 +53,13 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("DeleteCategory")] - public async Task DeleteCategory(int id) + public async Task DeleteCategory(int id, CancellationToken cancellationToken) { try { - var exist = _db.Categories.Where(w => w.Id == id).FirstOrDefault(); - if (exist != null) - { - 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 - { + var found = await _settingsService.DeleteCategoryAsync(id, cancellationToken); + if (!found) return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); - } - await _db.SaveChangesAsync(); return Ok(new DataResponse { Message = "Deleted Successfully", Status = "200" }); } catch (Exception ex) @@ -93,28 +67,21 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } - [HttpGet] [Route("GetCategoryList")] - public IActionResult GetCategoryList(int page = 1, int pageSize = 10) + public async Task GetCategoryList(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { - var totalCount = _db.Categories.Count(); - var data = _db.Categories.Select(s => new - { - s.Id, - s.Name - }).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var (items, total) = await _settingsService.GetCategoryListAsync(page, pageSize, cancellationToken); - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(total / (double)pageSize); var viewModel = new Pagination_DTO { - Data = data, + Data = items, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = total, TotalPages = totalPages }; return Ok(viewModel); @@ -122,24 +89,20 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetTemplates")] - public IActionResult GetTemplates(int page = 1, int pageSize = 10) + public async Task GetTemplates(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var totalCount = _db.workOrders.Where(w=> w.istemplate == true).Count(); - var data = _db.workOrders.Where(w => w.istemplate == true).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var (items, total) = await _settingsService.GetTemplatesAsync(page, pageSize, cancellationToken); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + var totalPages = (int)Math.Ceiling(total / (double)pageSize); - // Create a view model containing the paged items and pagination metadata var viewModel = new Pagination_DTO { - Data = data, + Data = items, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = total, TotalPages = totalPages }; return Ok(viewModel); @@ -152,42 +115,30 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetUsers")] - public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10) + public async Task GetUsers(string? search = "", int page = 1, int pageSize = 10, CancellationToken cancellationToken = default) { try { - var totalCount = _db.Users.Count(); - var allUsers = _db.Users.Where(w => (w.FirstName ?? "").ToLower().Contains((search ?? "").ToLower())).OrderByDescending(d => d.Id).Skip((page - 1) * pageSize) - .Take(pageSize).ToList(); + var (users, total) = await _settingsService.GetUsersAsync(search, page, pageSize, cancellationToken); - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - var filteredUsers = new List(); - - foreach (var user in allUsers) + var filteredUsers = users.Select(u => new UserRoleViewModel { - var roles = await _userManager.GetRolesAsync(user); - if (!roles.Contains("Admin")) - { - filteredUsers.Add(new UserRoleViewModel - { - RoleName = roles.Count() > 0 ? roles.First() : "", - Email = user.Email, - 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 + RoleName = u.RoleName, + Email = u.Email, + UserName = u.UserName, + Date = u.Date, + Status = u.Status, + Id = u.Id + }).ToList(); + + var totalPages = (int)Math.Ceiling(total / (double)pageSize); + var viewModel = new Pagination_DTO { Data = filteredUsers, PageNumber = page, PageSize = pageSize, - TotalCount = totalCount, + TotalCount = total, TotalPages = totalPages }; return Ok(viewModel); @@ -200,35 +151,13 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetRoles")] - public async Task GetRoles() + public async Task GetRoles(CancellationToken cancellationToken) { try { - var data = _db.Roles.ToList(); - List roles = new List(); - foreach (var role in data) - { - 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); + var roles = await _settingsService.GetRolesAsync(cancellationToken); + var result = roles.Select(r => new Roles_DTO { Name = r.Name, Description = r.Description }).ToList(); + return Ok(result); } catch (Exception ex) { diff --git a/Api.SeaHavenIndustries/Controllers/SyncController.cs b/Api.SeaHavenIndustries/Controllers/SyncController.cs index 5d7f778..1be2b1e 100644 --- a/Api.SeaHavenIndustries/Controllers/SyncController.cs +++ b/Api.SeaHavenIndustries/Controllers/SyncController.cs @@ -1,9 +1,6 @@ -using Amazon.DynamoDBv2; -using Amazon.DynamoDBv2.Model; -using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -12,412 +9,60 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/[controller]")] public class SyncController : Controller { - private readonly ApplicationDbContext _db; - private readonly AmazonDynamoDBClient _dynamo; - private const string WO_TABLE = "WorkOrders"; - private const string COMMENTS_TABLE = "WorkOrderComments"; - private const string VENDOR_REPLIES_TABLE = "VendorReplies"; + private readonly ISyncService _syncService; - public SyncController(ApplicationDbContext db) + public SyncController(ISyncService syncService) { - _db = db; - _dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1); + _syncService = syncService; } [HttpPost("WorkOrders")] - public async Task SyncWorkOrders() + public async Task SyncWorkOrders(CancellationToken cancellationToken) { - var synced = 0; - var created = 0; - 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? 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() - }); + var result = await _syncService.SyncWorkOrdersAsync(cancellationToken); + return Ok(result); } [HttpPost("Comments")] - public async Task SyncComments() + public async Task SyncComments(CancellationToken cancellationToken) { - var synced = 0; - var created = 0; - var skipped = 0; - Dictionary? 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 }); + var result = await _syncService.SyncCommentsAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillInternalWONumbers")] - public async Task BackfillInternalWONumbers() + public async Task BackfillInternalWONumbers(CancellationToken cancellationToken) { - var wosMissing = await _db.workOrders - .Where(w => w.InternalWONumber == null || w.InternalWONumber == "") - .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 }); + var result = await _syncService.BackfillInternalWONumbersAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillDispatchNumbers")] - public async Task BackfillDispatchNumbers() + public async Task BackfillDispatchNumbers(CancellationToken cancellationToken) { - var missing = await _db.Dispatches - .Where(d => d.DispatchNumber == null || d.DispatchNumber == "") - .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 }); + var result = await _syncService.BackfillDispatchNumbersAsync(cancellationToken); + return Ok(result); } [HttpPost("VendorReplies")] - public async Task SyncVendorReplies() + public async Task SyncVendorReplies(CancellationToken cancellationToken) { - var synced = 0; - var created = 0; - var skipped = 0; - Dictionary? 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 }); + var result = await _syncService.SyncVendorRepliesAsync(cancellationToken); + return Ok(result); } [HttpPost("BackfillCommentTypes")] - public async Task BackfillCommentTypes() + public async Task BackfillCommentTypes(CancellationToken cancellationToken) { - var updated = await _db.Database.ExecuteSqlRawAsync( - "UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')"); - return Ok(new { updated }); + var result = await _syncService.BackfillCommentTypesAsync(cancellationToken); + return Ok(result); } [HttpPost("All")] - public async Task SyncAll() + public async Task SyncAll(CancellationToken cancellationToken) { - var woResult = await SyncWorkOrders() as OkObjectResult; - var commentResult = await SyncComments() as OkObjectResult; - - return Ok(new - { - workOrders = woResult?.Value, - comments = commentResult?.Value - }); - } - - private async Task 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 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}"; + var result = await _syncService.SyncAllAsync(cancellationToken); + return Ok(result); } } } diff --git a/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs b/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs index d424c9a..b4b62c4 100644 --- a/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs +++ b/Api.SeaHavenIndustries/Controllers/TaskListTemplateController.cs @@ -2,7 +2,8 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -12,57 +13,24 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/tasklisttemplates")] 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] - public async Task GetAll() + public async Task GetAll(CancellationToken cancellationToken) { - var data = await _db.TaskListTemplates - .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(); + var data = await _taskListTemplateService.GetAllAsync(cancellationToken); return Ok(data); } [HttpGet("{id}")] - public async Task GetById(int id) + public async Task GetById(int id, CancellationToken cancellationToken) { - var template = await _db.TaskListTemplates - .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(); + var template = await _taskListTemplateService.GetByIdAsync(id, cancellationToken); if (template == null) return NotFound(new Response { Status = "Error", Message = "Template not found" }); @@ -71,25 +39,13 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPost] - public async Task Create([FromBody] TaskListTemplate_DTO model) + public async Task Create([FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken) { try { - var template = new TaskListTemplate - { - Name = model.Name, - 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 }); + var input = MapToInput(model); + var id = await _taskListTemplateService.CreateAsync(input, cancellationToken); + return Ok(new { message = "Template created", id }); } catch (Exception ex) { @@ -98,31 +54,15 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPut("{id}")] - public async Task Update(int id, [FromBody] TaskListTemplate_DTO model) + public async Task Update(int id, [FromBody] TaskListTemplate_DTO model, CancellationToken cancellationToken) { try { - var template = await _db.TaskListTemplates - .Include(t => t.Items) - .FirstOrDefaultAsync(t => t.Id == id); - - if (template == null) + var input = MapToInput(model); + var updated = await _taskListTemplateService.UpdateAsync(id, input, cancellationToken); + if (!updated) 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" }); } catch (Exception ex) @@ -132,15 +72,28 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpDelete("{id}")] - public async Task Delete(int id) + public async Task Delete(int id, CancellationToken cancellationToken) { - var template = await _db.TaskListTemplates.FindAsync(id); - if (template == null) + var deleted = await _taskListTemplateService.DeleteAsync(id, cancellationToken); + if (!deleted) return NotFound(new Response { Status = "Error", Message = "Template not found" }); - template.IsActive = false; - await _db.SaveChangesAsync(); 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() + }; + } } } diff --git a/Api.SeaHavenIndustries/Controllers/UpliftController.cs b/Api.SeaHavenIndustries/Controllers/UpliftController.cs index 4b90ff2..73d7d5d 100644 --- a/Api.SeaHavenIndustries/Controllers/UpliftController.cs +++ b/Api.SeaHavenIndustries/Controllers/UpliftController.cs @@ -1,8 +1,8 @@ using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using System.Security.Claims; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -12,225 +12,75 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/uplifts")] public class UpliftController : Controller { - private readonly ApplicationDbContext _db; - private readonly IConfiguration _config; + private readonly IUpliftService _upliftService; - public UpliftController(ApplicationDbContext db, IConfiguration config) + public UpliftController(IUpliftService upliftService) { - _db = db; - _config = config; + _upliftService = upliftService; } [HttpGet] - public async Task List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25) + public async Task List([FromQuery] string? status = "Pending", [FromQuery] int? tier = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25, CancellationToken cancellationToken = default) { - var query = from u in _db.DispatchUpliftRequests - join d in _db.Dispatches on u.DispatchId equals d.Id - join v in _db.Vendors on d.VendorId equals v.Id into vendors - from v in vendors.DefaultIfEmpty() - where (u.IsDeleted == null || u.IsDeleted == false) - select new { u, d, v }; - - if (!string.IsNullOrWhiteSpace(status)) - query = query.Where(x => x.u.Status == status); - if (tier.HasValue) - query = query.Where(x => x.u.RequiredTier == tier.Value); - - var total = await query.CountAsync(); - - var raw = await query - .OrderByDescending(x => x.u.CreatedDate) - .Skip((Math.Max(page, 1) - 1) * pageSize) - .Take(pageSize) - .Select(x => new - { - x.u.Id, - DispatchId = x.d.Id, - x.d.DispatchNumber, - x.d.PONumber, - WorkOrderId = x.d.WorkOrderId, - VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, - x.u.CurrentNTE, - x.u.RequestedNTE, - x.u.VendorReason, - x.u.RequiredTier, - x.u.Status, - RequestedAt = x.u.CreatedDate, - x.u.DecidedAt, - x.u.DecisionNote - }) - .ToListAsync(); - - var items = raw.Select(r => new - { - r.Id, - r.DispatchId, - r.DispatchNumber, - r.PONumber, - r.WorkOrderId, - r.VendorCompanyName, - r.CurrentNTE, - r.RequestedNTE, - Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), - r.VendorReason, - r.RequiredTier, - r.Status, - r.RequestedAt, - r.DecidedAt, - r.DecisionNote, - CanDecide = UserCanApprove(r.RequiredTier) - }).ToList(); - - return Ok(new DataResponse - { - Status = "Success", - Data = new { total, page, pageSize, items } - }); + var result = await _upliftService.ListAsync(User, status, tier, page, pageSize, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } [HttpGet("dispatch/{dispatchId:int}")] - public async Task ListForDispatch(int dispatchId) + public async Task ListForDispatch(int dispatchId, CancellationToken cancellationToken = default) { - var rows = await (from u in _db.DispatchUpliftRequests - where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) - join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs - from dec in decs.DefaultIfEmpty() - orderby u.CreatedDate descending - select new - { - u.Id, - u.DispatchId, - u.CurrentNTE, - u.RequestedNTE, - Delta = u.RequestedNTE - (u.CurrentNTE ?? 0m), - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - RequestedAt = u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null - }).ToListAsync(); - - var items = rows.Select(r => new - { - r.Id, - r.DispatchId, - r.CurrentNTE, - r.RequestedNTE, - r.Delta, - r.VendorReason, - r.Status, - r.RequiredTier, - r.RequestedByVendorName, - r.RequestedAt, - r.DecidedAt, - r.DecisionNote, - DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), - CanDecide = UserCanApprove(r.RequiredTier) - }).ToList(); - + var items = await _upliftService.ListForDispatchAsync(User, dispatchId, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = items }); } [HttpPost("{id:int}/approve")] - public async Task Approve(int id, [FromBody] DecisionRequest? body) + public async Task Approve(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) { - var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot approve a '{req.Status}' request" }); - if (!UserCanApprove(req.RequiredTier)) - return StatusCode(403, new Response { Status = "Error", Message = $"Approval requires a Tier {req.RequiredTier} role" }); - - var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") - return BadRequest(new Response { Status = "Error", Message = $"Cannot approve uplift on a '{dispatch.Status}' dispatch" }); - - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; - var oldNTE = dispatch.NTEAmount ?? 0m; - - dispatch.NTEAmount = req.RequestedNTE; - dispatch.LastModificationTime = now; - - req.Status = "Approved"; - req.DecidedAt = now; - req.DecidedByUserId = userId; - req.DecisionNote = string.IsNullOrWhiteSpace(body?.Note) ? null : body!.Note!.Trim(); - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + try { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", - OldValue = $"${oldNTE:F2}", - NewValue = $"${req.RequestedNTE:F2}", - Action = "uplift_approved", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status, dispatch.NTEAmount } }); + var result = await _upliftService.ApproveAsync(User, id, body?.Note, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("{id:int}/deny")] - public async Task Deny(int id, [FromBody] DecisionRequest body) + public async Task Deny(int id, [FromBody] DecisionRequest? body, CancellationToken cancellationToken = default) { - if (body == null || string.IsNullOrWhiteSpace(body.Note)) - return BadRequest(new Response { Status = "Error", Message = "A note is required when denying" }); - - var req = await _db.DispatchUpliftRequests.FirstOrDefaultAsync(u => u.Id == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - if (req.Status != "Pending") return BadRequest(new Response { Status = "Error", Message = $"Cannot deny a '{req.Status}' request" }); - if (!UserCanApprove(req.RequiredTier)) - return StatusCode(403, new Response { Status = "Error", Message = $"Decision requires a Tier {req.RequiredTier} role" }); - - var dispatch = await _db.Dispatches.FirstOrDefaultAsync(d => d.Id == req.DispatchId); - - var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; - var now = DateTime.UtcNow; - - req.Status = "Denied"; - req.DecidedAt = now; - req.DecidedByUserId = userId; - req.DecisionNote = body.Note!.Trim(); - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + try { - WorkOrderId = dispatch?.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Denied", - Action = "uplift_denied", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); + var result = await _upliftService.DenyAsync(User, id, body?.Note, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); + } + catch (KeyNotFoundException ex) + { + return NotFound(new Response { Status = "Error", Message = ex.Message }); + } + catch (UpliftForbiddenException ex) + { + return StatusCode(403, new Response { Status = "Error", Message = ex.Message }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpGet("can-approve")] public IActionResult CanApprove([FromQuery] int tier) { - return Ok(new DataResponse { Status = "Success", Data = new { canApprove = UserCanApprove(tier) } }); - } - - private bool UserCanApprove(int requiredTier) - { - var key = $"Approvals:Tier{requiredTier}Roles"; - var roles = _config.GetSection(key).Get() ?? Array.Empty(); - foreach (var r in roles) - { - if (!string.IsNullOrWhiteSpace(r) && User.IsInRole(r)) return true; - } - return false; + return Ok(new DataResponse { Status = "Success", Data = new { canApprove = _upliftService.CanApprove(User, tier) } }); } public class DecisionRequest diff --git a/Api.SeaHavenIndustries/Controllers/UserController.cs b/Api.SeaHavenIndustries/Controllers/UserController.cs index 375649c..39aa1bf 100644 --- a/Api.SeaHavenIndustries/Controllers/UserController.cs +++ b/Api.SeaHavenIndustries/Controllers/UserController.cs @@ -1,15 +1,10 @@ using Api.SeaHavenIndustries.DTOs; -using Api.SeaHavenIndustries.Helper; -using Api.SeaHavenIndustries.Models; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using PasswordGenerator; -using SendGrid.Helpers.Mail; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; using System.Security.Claims; -using System.Security.Cryptography; namespace Api.SeaHavenIndustries.Controllers { @@ -18,107 +13,36 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/User")] public class UserController : Controller { - private readonly UserManager _userManager; - private readonly ApplicationDbContext _db; - private readonly SendMessage _sendemail; + private readonly IUserService _userService; - public UserController(UserManager userManager, ApplicationDbContext db, SendMessage sendemail) + public UserController(IUserService userService) { - _userManager = userManager; - _db = db; - _sendemail = sendemail; + _userService = userService; } [Route("")] [HttpGet] - public async Task GetUsers() + public async Task GetUsers(CancellationToken cancellationToken) { - _db.ChangeTracker.Clear(); - - 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); + var users = await _userService.GetUsersAsync(cancellationToken); + return Ok(users); } [HttpPost] [Route("AddUser")] - public async Task AddUser(User_DTO user) + public async Task AddUser(User_DTO user, CancellationToken cancellationToken) { - - var model = new ApplicationUser - { - UserName = user.Email, - FirstName = user.Name, - Email = user.Email, - }; - try { - _db.ChangeTracker.Clear(); - var exist = _db.Set().Find(model.Id); - if (exist == null) + var dto = new AddUserRequestDTO { - model.EmailConfirmed = true; - model.UserName = model.Email; - model.CreatedDate = DateTime.Now; - model.UniqueName = "Active"; - model.PhoneNumber = user.Role; - - var pwd = new Password(8).IncludeLowercase().IncludeUppercase().IncludeSpecial().IncludeNumeric(); - 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:Login\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 + Name = user.Name, + Email = user.Email, + Contact = user.Contact, + Role = user.Role + }; + var outcome = await _userService.AddUserAsync(dto, cancellationToken); + if (!outcome.Success) { - - 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 BadRequest(new Response { Status = "Error", Message = outcome.Error }); } return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); @@ -131,31 +55,20 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpPut] [Route("EditUser")] - public async Task EditUser(EditUser_DTO user) + public async Task EditUser(EditUser_DTO user, CancellationToken cancellationToken) { try { - var exist = _db.Users.Where(w => w.Id == user.Id).FirstOrDefault(); - 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()) + var dto = new EditUserRequestDTO { - await _userManager.RemoveFromRolesAsync(exist, existingRole); - } - await _userManager.AddToRoleAsync(exist, user.Role ?? ""); - - await _db.SaveChangesAsync(); + Id = user.Id, + Name = user.Name, + Email = user.Email, + Role = user.Role + }; + 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" }); } catch (Exception ex) @@ -163,53 +76,19 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } - public class DeleteUserRequest - { - public string Id { get; set; } - } - [HttpDelete] [Route("DeleteUser")] - public async Task DeleteUser(EditUser_DTO req) + public async Task DeleteUser(EditUser_DTO req, CancellationToken cancellationToken) { - string id = req.Id; + string id = req.Id ?? ""; try { - var data = await _db.Users.Where(w => w.Id == id).FirstOrDefaultAsync(); - - 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 + var found = await _userService.DeleteUserAsync(id, cancellationToken); + if (!found) { return BadRequest(new Response { Status = "Error", Message = "ID not matched!" }); } - await _db.SaveChangesAsync(); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -220,17 +99,12 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost] [Route("DeleteCurrentUser")] - public async Task DeleteCurrentUser() + public async Task DeleteCurrentUser(CancellationToken cancellationToken) { try { var userid = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? ""; - var exist = _db.Users.Where(w => w.Id == userid).FirstOrDefault(); - if(exist != null) - { - exist.IsDeleted = true; - } - await _db.SaveChangesAsync(); + await _userService.DeleteCurrentUserAsync(userid, cancellationToken); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (Exception ex) @@ -241,64 +115,19 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("UserProfile")] - public async Task UserProfile() + public async Task UserProfile(CancellationToken cancellationToken) { 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, - Name = s.FirstName, - addeddate = s.CreatedDate, - s.Email, - s.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 + d.Id, + Name = d.Name, + addeddate = d.AddedDate, + d.Email, + d.Contact + })); } } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 00763f0..9eafd77 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -3,7 +3,6 @@ using Data.SeaHavenIndustries; using FluentValidation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; using System.Security.Claims; @@ -17,18 +16,10 @@ namespace Api.SeaHavenIndustries.Controllers public class VendorController : Controller { private readonly IVendorService _vendorService; - private readonly ApplicationDbContext _db; // Keep for complex queries like nearby vendors - private readonly Helper.ZipCodeDistance _zipDistance; - private readonly Helper.VendorPortalTokenService _vendorTokens; - private readonly IConfiguration _config; - public VendorController(IVendorService vendorService, ApplicationDbContext db, Helper.ZipCodeDistance zipDistance, Helper.VendorPortalTokenService vendorTokens, IConfiguration config) + public VendorController(IVendorService vendorService) { _vendorService = vendorService; - _db = db; - _zipDistance = zipDistance; - _vendorTokens = vendorTokens; - _config = config; } [HttpGet("GetVendorList")] @@ -318,158 +309,41 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("Dropdown")] - public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null) + public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null, CancellationToken cancellationToken = default) { - var query = _db.Vendors.Where(v => v.IsActive); - - var vendors = await query.OrderBy(v => v.CompanyName) - .Select(v => new - { - v.Id, - v.CompanyName, - v.ContactName, - v.PreferredContact, - v.TradeSpecialties, - v.Address, - v.City, - v.State, - v.Zip - }) - .ToListAsync(); - - var result = vendors.Select(v => - { - var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip); - var addr = new[] { v.Address, v.City, v.State, v.Zip } - .Where(s => !string.IsNullOrWhiteSpace(s)); - return new - { - v.Id, - v.CompanyName, - v.ContactName, - v.PreferredContact, - v.TradeSpecialties, - address = string.Join(", ", addr), - distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null - }; - }).ToList(); - - if (!string.IsNullOrWhiteSpace(trade)) - { - var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList(); - var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList(); - result = tradeMatched.Concat(rest).ToList(); - } - - if (!string.IsNullOrWhiteSpace(siteZip)) - { - result = result.OrderBy(v => v.distanceMiles ?? 99999).ToList(); - } - + var result = await _vendorService.GetDropdownAsync(trade, siteZip, cancellationToken); return Ok(result); } [HttpGet("facets")] - public async Task GetFacets([FromQuery] bool? isActive = null) + public async Task GetFacets([FromQuery] bool? isActive = null, CancellationToken cancellationToken = default) { - var query = _db.Vendors - .AsNoTracking() - .Where(v => v.IsDeleted == null || v.IsDeleted == false); - - if (isActive.HasValue) - query = query.Where(v => v.IsActive == isActive.Value); - - var vendors = await query - .OrderByDescending(v => v.Id) - .ToListAsync(); - - var companies = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) - .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) - .Select(group => group.First()) - .OrderBy(v => v.CompanyName) - .Select(v => new - { - Name = v.CompanyName, - v.CompanyId, - v.CompanyPhone, - v.Email, - v.PreferredContact, - v.Address, - v.City, - v.State, - Zip = v.Zip, - v.GoogleMapsUrl - }); - - var trades = vendors - .SelectMany(v => (v.TradeSpecialties ?? "") - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(value => value); - - var locations = vendors - .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) - .Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) - .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) - .OrderBy(location => location.Label); - - return Ok(new - { - Companies = companies, - Trades = trades, - Locations = locations, - JobBuckets = new[] - { - new { Id = "under-50", Label = "Under 50" }, - new { Id = "50-99", Label = "50–99" }, - new { Id = "100-149", Label = "100–149" }, - new { Id = "150-plus", Label = "150+" } - } - }); + var result = await _vendorService.GetFacetsAsync(isActive, cancellationToken); + return Ok(result); } [HttpGet("{id:int}/portal-token")] - public async Task GetPortalToken(int id) + public async Task GetPortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - var token = await _vendorTokens.GetOrCreateActiveTokenAsync(id); - return Ok(BuildPortalTokenResponse(token)); + var result = await _vendorService.GetPortalTokenAsync(id, cancellationToken); + if (result == null) return NotFound(); + return Ok(result); } [HttpPost("{id:int}/portal-token/rotate")] - public async Task RotatePortalToken(int id) + public async Task RotatePortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - var token = await _vendorTokens.RotateAsync(id); - return Ok(BuildPortalTokenResponse(token)); + var result = await _vendorService.RotatePortalTokenAsync(id, cancellationToken); + if (result == null) return NotFound(); + return Ok(result); } [HttpPost("{id:int}/portal-token/revoke")] - public async Task RevokePortalToken(int id) + public async Task RevokePortalToken(int id, CancellationToken cancellationToken = default) { - var vendor = await _db.Vendors.FindAsync(id); - if (vendor == null) return NotFound(); - - await _vendorTokens.RevokeAllAsync(id); + var success = await _vendorService.RevokePortalTokenAsync(id, cancellationToken); + if (!success) return NotFound(); return Ok(new { revoked = true }); } - - private object BuildPortalTokenResponse(VendorAccessToken token) - { - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - return new - { - token.Token, - token.IssuedAt, - token.ExpiresAt, - token.LastUsedAt, - PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard" - }; - } } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 8841861..dd2d82d 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -1,8 +1,7 @@ -using Api.SeaHavenIndustries.Helper; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; namespace Api.SeaHavenIndustries.Controllers { @@ -13,650 +12,221 @@ namespace Api.SeaHavenIndustries.Controllers { private const string TokenHeader = "X-Vendor-Token"; - private static readonly string[] VendorViewableStatuses = - new[] { "Sent", "Acknowledged", "In Progress", "Completed", "Verified", "Cancelled" }; + private readonly IVendorPortalService _portalService; - private readonly ApplicationDbContext _db; - private readonly VendorPortalTokenService _tokens; - private readonly IConfiguration _config; - private readonly SendMessage _sendMessage; - - public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage) + public VendorPortalController(IVendorPortalService portalService) { - _db = db; - _tokens = tokens; - _config = config; - _sendMessage = sendMessage; + _portalService = portalService; } [HttpGet("session")] - public async Task Session() + public async Task Session(CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); return Ok(new DataResponse { Status = "Success", - Data = new - { - vendor.Id, - vendor.CompanyName, - vendor.ContactName, - vendor.Email, - vendor.Phone - } + Data = session }); } [HttpGet("dispatches")] - public async Task ListDispatches([FromQuery] string? status = null) + public async Task ListDispatches([FromQuery] string? status = null, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - - var query = _db.Dispatches - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .Where(d => d.VendorId == vendor.Id && (d.IsDeleted == null || d.IsDeleted == false)); - - if (!string.IsNullOrWhiteSpace(status)) - { - query = query.Where(d => d.Status == status); - } - - var dispatches = await query - .OrderByDescending(d => d.DispatchedAt ?? d.CreatedDate) - .Select(d => new - { - d.Id, - d.DispatchNumber, - d.PONumber, - d.Status, - d.NTEAmount, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.AcknowledgedAt, - WorkOrderTitle = d.WorkOrder!.WorkerOrderTitle, - InternalWONumber = d.WorkOrder!.InternalWONumber, - LocationName = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.Name : null, - LocationCity = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.City : null, - LocationState = d.WorkOrder!.Locations != null ? d.WorkOrder!.Locations.State : null - }) - .ToListAsync(); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var dispatches = await _portalService.ListDispatchesAsync(session, status, cancellationToken); return Ok(new DataResponse { Status = "Success", Data = dispatches }); } [HttpGet("dispatches/{id:int}")] - public async Task GetDispatch(int id) + public async Task GetDispatch(int id, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + var detail = await _portalService.GetDispatchDetailAsync(session, id, cancellationToken); + if (detail == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - var checklist = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == id && (c.IsDeleted == null || c.IsDeleted == false)) - .OrderBy(c => c.SortOrder) - .Select(c => new { c.Id, c.ItemText, c.IsCompleted, c.CompletedBy, c.CompletedAt }) - .ToListAsync(); - - var signoffs = await _db.DispatchSignoffs - .Where(s => s.DispatchId == id && (s.IsDeleted == null || s.IsDeleted == false)) - .Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt }) - .ToListAsync(); - - var rawUplifts = await (from u in _db.DispatchUpliftRequests - where u.DispatchId == id && (u.IsDeleted == null || u.IsDeleted == false) - join dec in _db.Users on u.DecidedByUserId equals dec.Id into decs - from dec in decs.DefaultIfEmpty() - orderby u.CreatedDate descending - select new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByFirstName = dec != null ? dec.FirstName : null, - DecidedByLastName = dec != null ? dec.LastName : null - }) - .ToListAsync(); - - var upliftRequests = rawUplifts.Select(u => new - { - u.Id, - u.CurrentNTE, - u.RequestedNTE, - u.VendorReason, - u.Status, - u.RequiredTier, - u.RequestedByVendorName, - RequestedAt = u.CreatedDate, - u.DecidedAt, - u.DecisionNote, - DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() - }).ToList(); - - var rawComments = await (from c in _db.Comments - where c.DispatchId == id - && c.CommentType != "internal" - && (c.IsDeleted == null || c.IsDeleted == false) - join u in _db.Users on c.UserId equals u.Id into users - from u in users.DefaultIfEmpty() - orderby c.CreatedDate - select new - { - c.Id, - c.Commenttext, - c.Commenter, - c.CommentType, - c.CreatedDate, - c.UserId, - UserFirstName = u != null ? u.FirstName : null, - UserLastName = u != null ? u.LastName : null - }) - .ToListAsync(); - - var comments = rawComments.Select(c => - { - var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); - var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } - .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); - - string resolvedType; - string resolvedCommenter; - if (hasShocUser) - { - resolvedType = "dispatcher"; - resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName - : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! - : "Dispatcher"; - } - else if (c.CommentType == "customer") - { - resolvedType = "customer"; - resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; - } - else - { - resolvedType = "vendor"; - resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : vendor.CompanyName ?? "Vendor"; - } - - return new - { - c.Id, - c.Commenttext, - Commenter = resolvedCommenter, - CommentType = resolvedType, - c.CreatedDate - }; - }).ToList(); - - return Ok(new DataResponse - { - Status = "Success", - Data = new - { - dispatch.Id, - dispatch.DispatchNumber, - dispatch.PONumber, - dispatch.Status, - dispatch.NTEAmount, - dispatch.Description, - dispatch.ScheduledDate, - dispatch.CompletedDate, - dispatch.DispatchedAt, - dispatch.AcknowledgedAt, - WorkOrder = dispatch.WorkOrder == null ? null : new - { - dispatch.WorkOrder.Id, - dispatch.WorkOrder.InternalWONumber, - dispatch.WorkOrder.WorkerOrderTitle, - dispatch.WorkOrder.Description, - dispatch.WorkOrder.Priority, - dispatch.WorkOrder.DueDate, - dispatch.WorkOrder.Trade, - dispatch.WorkOrder.SubTrade, - dispatch.WorkOrder.Problem - }, - Location = dispatch.WorkOrder?.Locations == null ? null : new - { - dispatch.WorkOrder.Locations.Name, - dispatch.WorkOrder.Locations.Address1, - dispatch.WorkOrder.Locations.City, - dispatch.WorkOrder.Locations.State, - dispatch.WorkOrder.Locations.Zip - }, - Checklist = checklist, - Signoffs = signoffs, - Comments = comments, - UpliftRequests = upliftRequests - } - }); + return Ok(new DataResponse { Status = "Success", Data = detail }); } [HttpPost("dispatches/{id:int}/accept")] - public async Task Accept(int id) + public async Task Accept(int id, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status != "Sent") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot accept a dispatch with status '{dispatch.Status}'" }); + var result = await _portalService.AcceptDispatchAsync(session, id, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - dispatch.Status = "Acknowledged"; - dispatch.AcknowledgedAt = now; - dispatch.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + catch (KeyNotFoundException) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = "Sent", - NewValue = "Acknowledged", - Action = "vendor_accept", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.AcknowledgedAt } }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/status")] - public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body) + public async Task ChangeStatus(int id, [FromBody] ChangeStatusRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - var from = dispatch.Status; - var to = body.Status; - if (!IsAllowedVendorTransition(from, to)) + try { - return BadRequest(new Response { Status = "Error", Message = $"Transition from '{from}' to '{to}' is not allowed" }); + var result = await _portalService.ChangeStatusAsync(session, id, body?.Status, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - dispatch.Status = to; - dispatch.LastModificationTime = now; - if (to == "Completed") dispatch.CompletedDate = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + catch (KeyNotFoundException) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = from, - NewValue = to, - Action = "vendor_status_change", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { dispatch.Id, dispatch.Status, dispatch.CompletedDate } }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/request-cancel")] - public async Task RequestCancel(int id, [FromBody] RequestCancelRequest body) + public async Task RequestCancel(int id, [FromBody] RequestCancelRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot request cancel on a '{dispatch.Status}' dispatch" }); + await _portalService.RequestCancelAsync(session, id, body?.Reason, cancellationToken); + return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" }); } - - var now = DateTime.UtcNow; - var reason = string.IsNullOrWhiteSpace(body?.Reason) ? "(no reason provided)" : body!.Reason!.Trim(); - - _db.Comments.Add(new Comments + catch (KeyNotFoundException) { - DispatchId = id, - WorkerOrderId = dispatch.WorkOrderId, - Commenter = vendor.CompanyName, - CommentType = "vendor", - RecordType = "cancel_request", - Commenttext = $"Vendor requested cancellation: {reason}", - CreatedDate = now - }); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber}", - OldValue = dispatch.Status, - NewValue = "Cancel Requested", - Action = "vendor_request_cancel", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Message = "Cancel request sent to dispatcher" }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/checklist/{itemId:int}")] - public async Task UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body) + public async Task UpdateChecklistItem(int id, int itemId, [FromBody] ChecklistUpdateRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = "Dispatch is locked" }); + var result = await _portalService.UpdateChecklistItemAsync(session, id, itemId, body.IsCompleted, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var item = await _db.DispatchChecklistItems - .FirstOrDefaultAsync(c => c.Id == itemId && c.DispatchId == id); - if (item == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); - - var now = DateTime.UtcNow; - item.IsCompleted = body.IsCompleted; - item.CompletedBy = body.IsCompleted ? vendor.CompanyName : null; - item.CompletedAt = body.IsCompleted ? now : null; - item.LastModificationTime = now; - - await _db.SaveChangesAsync(); - return Ok(new DataResponse + catch (KeyNotFoundException) { - Status = "Success", - Data = new { item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt } - }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/signoff")] - public async Task AddSignoff(int id, [FromBody] SignoffRequest body) + public async Task AddSignoff(int id, [FromBody] SignoffRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") + try { - return BadRequest(new Response { Status = "Error", Message = "Signoffs only allowed on In Progress or Completed dispatches" }); + var result = await _portalService.AddSignoffAsync(session, id, body?.SignoffType, body?.Name, body?.Signature, body?.SignatureMethod, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - if (string.IsNullOrWhiteSpace(body?.Name) || string.IsNullOrWhiteSpace(body?.Signature)) + catch (KeyNotFoundException) { - return BadRequest(new Response { Status = "Error", Message = "Name and signature are required" }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - - var signoffType = (body?.SignoffType ?? "vendor").Trim().ToLowerInvariant(); - if (signoffType != "vendor" && signoffType != "customer") + catch (InvalidOperationException ex) { - return BadRequest(new Response { Status = "Error", Message = "signoffType must be 'vendor' or 'customer'" }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } - - var existing = await _db.DispatchSignoffs - .FirstOrDefaultAsync(s => s.DispatchId == id && s.SignoffType == signoffType); - if (existing != null) - { - return BadRequest(new Response { Status = "Error", Message = $"A {signoffType} signoff already exists for this dispatch" }); - } - - var now = DateTime.UtcNow; - var signoff = new DispatchSignoff - { - DispatchId = id, - SignoffType = signoffType, - Name = body!.Name, - Signature = body.Signature, - SignatureMethod = body.SignatureMethod ?? "drawn", - SignedAt = now, - CreatedDate = now - }; - _db.DispatchSignoffs.Add(signoff); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", - OldValue = null, - NewValue = $"{(signoffType == "customer" ? "Customer" : "Vendor")}: {body.Name}", - Action = signoffType == "customer" ? "customer_signoff" : "vendor_signoff", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { signoff.Id, signoff.SignedAt } }); } [HttpPost("dispatches/{id:int}/comments")] - public async Task AddComment(int id, [FromBody] CommentRequest body) + public async Task AddComment(int id, [FromBody] CommentRequest body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (string.IsNullOrWhiteSpace(body?.CommentText)) + try { - return BadRequest(new Response { Status = "Error", Message = "Comment cannot be empty" }); + var result = await _portalService.AddCommentAsync(session, id, body?.CommentText, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - var now = DateTime.UtcNow; - var comment = new Comments + catch (KeyNotFoundException) { - DispatchId = id, - WorkerOrderId = dispatch.WorkOrderId, - Commenter = vendor.CompanyName, - CommentType = "vendor", - RecordType = "comment", - Commenttext = body!.CommentText, - CreatedDate = now - }; - _db.Comments.Add(comment); - await _db.SaveChangesAsync(); - - return Ok(new DataResponse + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) { - Status = "Success", - Data = new { comment.Id, comment.Commenttext, comment.Commenter, comment.CreatedDate } - }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } } [HttpPost("dispatches/{id:int}/uplift-request")] - public async Task RequestUplift(int id, [FromBody] UpliftRequestBody body) + public async Task RequestUplift(int id, [FromBody] UpliftRequestBody body, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + try { - return BadRequest(new Response { Status = "Error", Message = $"Cannot request uplift on a '{dispatch.Status}' dispatch" }); + var result = await _portalService.RequestUpliftAsync(session, id, body?.RequestedNTE ?? 0m, body?.Reason, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - - if (body == null || body.RequestedNTE <= 0) + catch (KeyNotFoundException) { - return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than zero" }); + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); } - - var current = dispatch.NTEAmount ?? 0m; - if (body.RequestedNTE <= current) + catch (InvalidOperationException ex) { - return BadRequest(new Response { Status = "Error", Message = "Requested NTE must be greater than the current NTE" }); + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } - - var pendingExists = await _db.DispatchUpliftRequests - .AnyAsync(u => u.DispatchId == id && u.Status == "Pending" && (u.IsDeleted == null || u.IsDeleted == false)); - if (pendingExists) - { - return BadRequest(new Response { Status = "Error", Message = "A pending uplift request already exists for this dispatch" }); - } - - var tier1Max = _config.GetValue("Approvals:UpliftTier1MaxUsd") ?? 2500m; - var delta = body.RequestedNTE - current; - var requiredTier = delta > tier1Max ? 2 : 1; - - var now = DateTime.UtcNow; - var req = new DispatchUpliftRequest - { - DispatchId = id, - CurrentNTE = current, - RequestedNTE = body.RequestedNTE, - VendorReason = string.IsNullOrWhiteSpace(body.Reason) ? null : body.Reason!.Trim(), - Status = "Pending", - RequiredTier = requiredTier, - RequestedByVendorName = vendor.CompanyName, - CreatedDate = now - }; - _db.DispatchUpliftRequests.Add(req); - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", - OldValue = $"${current:F2}", - NewValue = $"${body.RequestedNTE:F2}", - Action = "uplift_requested", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - - await NotifyDispatcherOfUpliftAsync(dispatch, req, vendor); - - return Ok(new DataResponse - { - Status = "Success", - Data = new { req.Id, req.Status, req.RequiredTier, req.CurrentNTE, req.RequestedNTE } - }); } [HttpPost("dispatches/{id:int}/uplift-request/{requestId:int}/cancel")] - public async Task CancelUpliftRequest(int id, int requestId) + public async Task CancelUpliftRequest(int id, int requestId, CancellationToken cancellationToken = default) { - var vendor = await ResolveVendorAsync(); - if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var session = await ResolveSessionAsync(cancellationToken); + if (session == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); - var dispatch = await LoadVendorDispatchAsync(id, vendor.Id); - if (dispatch == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); - - var req = await _db.DispatchUpliftRequests - .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == id); - if (req == null) return NotFound(new Response { Status = "Error", Message = "Uplift request not found" }); - - if (req.Status != "Pending") - { - return BadRequest(new Response { Status = "Error", Message = $"Cannot cancel a '{req.Status}' uplift request" }); - } - - var now = DateTime.UtcNow; - req.Status = "Cancelled"; - req.DecidedAt = now; - req.LastModificationTime = now; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", - OldValue = "Pending", - NewValue = "Cancelled", - Action = "uplift_cancelled", - CreatedAt = now - }); - - await _db.SaveChangesAsync(); - return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); - } - - private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor) - { try { - var dispatchAudit = await _db.WorkOrderAuditLogs - .Where(a => a.WorkOrderId == (dispatch.WorkOrderId ?? 0) && a.Action == "dispatch" && !string.IsNullOrEmpty(a.UserId)) - .OrderByDescending(a => a.CreatedAt) - .FirstOrDefaultAsync(); - if (dispatchAudit == null || string.IsNullOrWhiteSpace(dispatchAudit.UserId)) return; - - var dispatcher = await _db.Users.FindAsync(dispatchAudit.UserId); - if (dispatcher == null || string.IsNullOrWhiteSpace(dispatcher.Email)) return; - - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - var workOrderLink = dispatch.WorkOrderId.HasValue - ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" - : frontendBase; - - var vendorName = System.Net.WebUtility.HtmlEncode(vendor.CompanyName ?? "Vendor"); - var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); - var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); - var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; - - var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {vendor.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; - var body = $@" -

Vendor Uplift Request

-

Dispatch: {dispatchNum}

-

Vendor: {vendorName}

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

Vendor Reason

-

{reason}

- "; - - await _sendMessage.SendEMail(dispatcher.Email, subject, body); + var result = await _portalService.CancelUpliftRequestAsync(session, id, requestId, cancellationToken); + return Ok(new DataResponse { Status = "Success", Data = result }); } - catch + catch (KeyNotFoundException) { + return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } - private async Task ResolveVendorAsync() + private async Task ResolveSessionAsync(CancellationToken cancellationToken) { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; var token = values.ToString(); - return await _tokens.ResolveVendorAsync(token); - } - - private Task LoadVendorDispatchAsync(int id, int vendorId) - { - return _db.Dispatches - .Include(d => d.WorkOrder).ThenInclude(w => w!.Locations) - .FirstOrDefaultAsync(d => d.Id == id - && d.VendorId == vendorId - && (d.IsDeleted == null || d.IsDeleted == false)); - } - - private static bool IsAllowedVendorTransition(string? from, string? to) - { - if (from == "Acknowledged" && to == "In Progress") return true; - if (from == "In Progress" && to == "Completed") return true; - return false; + return await _portalService.ResolveSessionAsync(token, cancellationToken); } public class ChangeStatusRequest diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9c3b181..e8b5c02 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -1,23 +1,12 @@ using Api.SeaHavenIndustries.DTOs; using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using FluentValidation; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Components.Forms; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.Data.SqlClient; -using Microsoft.EntityFrameworkCore; -using Microsoft.VisualBasic; -using SendGrid.Helpers.Mail; -using System.IO; -using System.Net.Http.Headers; -using System.Security.Claims; -using static System.Runtime.InteropServices.JavaScript.JSType; -using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; -using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Interfaces; +using SeaHaven.DataServices.Models; +using System.Security.Claims; namespace Api.SeaHavenIndustries.Controllers { @@ -27,55 +16,17 @@ namespace Api.SeaHavenIndustries.Controllers [Route("api/workorders")] public class WorkOrderController : Controller { - private readonly UserManager _userManager; private readonly IWorkOrderService _workOrderService; - private readonly IWorkOrderDataService _workOrderDataService; - private readonly ICommentDataService _commentDataService; - private readonly IQuotesDataService _quotesDataService; - private readonly IUserDataService _userDataService; - private readonly IDispatchDataService _dispatchDataService; - private readonly IVendorDataService _vendorDataService; - private readonly ITaskListTemplateDataService _taskListTemplateDataService; - private readonly ApplicationDbContext _db; - private readonly IWebHostEnvironment _webHostEnvironment; - IHttpContextAccessor _httpContext; - private readonly Helper.SendMessage _sendMessage; - private readonly Helper.VendorPortalTokenService _vendorTokens; - private readonly IConfiguration _config; + private readonly IWorkOrderDispatchService _dispatchService; public WorkOrderController( - UserManager userManager, IWorkOrderService workOrderService, - IWorkOrderDataService workOrderDataService, - ICommentDataService commentDataService, - IQuotesDataService quotesDataService, - IUserDataService userDataService, - IDispatchDataService dispatchDataService, - IVendorDataService vendorDataService, - ITaskListTemplateDataService taskListTemplateDataService, - ApplicationDbContext db, - IWebHostEnvironment webHostEnvironment, - IHttpContextAccessor httpContext, - Helper.SendMessage sendMessage, - Helper.VendorPortalTokenService vendorTokens, - IConfiguration config) + IWorkOrderDispatchService dispatchService) { - _userManager = userManager; _workOrderService = workOrderService; - _workOrderDataService = workOrderDataService; - _commentDataService = commentDataService; - _quotesDataService = quotesDataService; - _userDataService = userDataService; - _dispatchDataService = dispatchDataService; - _vendorDataService = vendorDataService; - _taskListTemplateDataService = taskListTemplateDataService; - _db = db; - _webHostEnvironment = webHostEnvironment; - _httpContext = httpContext; - _sendMessage = sendMessage; - _vendorTokens = vendorTokens; - _config = config; + _dispatchService = dispatchService; } + [HttpPost] [Route("AddWorkorder")] public async Task Addworkorder([FromForm] Workorder_DTO model) @@ -83,76 +34,23 @@ namespace Api.SeaHavenIndustries.Controllers try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var createDto = new CreateWorkOrderDTO + var input = new CreateWorkOrderWithDetailsInput { WorkerOrderTitle = model.WorkerOrderTitle ?? "", Description = model.Description, - Status = WorkOrderStatus.Open, - Priority = model.Priority ?? WorkOrderPriority.Medium, + Priority = model.Priority?.ToString(), LocationId = model.LocationId, AssignTo = model.AssignTo, - DueDate = model.DueDate + DueDate = model.DueDate, + ContactIds = model.ContactIds, + CategoryIds = model.CategoryIds, + Attachments = model.Attachments, + BeforPhotoAttachment = model.BeforPhotoAttachment, + AfterPhotoAttachment = model.AfterPhotoAttachment, + SignOffAttachment = model.SignOffAttachment }; - var workOrderId = await _workOrderService.CreateWorkOrderAsync(createDto, userId!); - - if (model.ContactIds != null && model.ContactIds.Any()) - { - await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, model.ContactIds); - } - - if (model.CategoryIds != null && model.CategoryIds.Any()) - { - await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, model.CategoryIds); - } - - if (model.Attachments != null) - { - foreach (var attachment in model.Attachments) - { - var fileUrl = await SaveFileAsync(attachment); - await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments - { - WorkorderId = workOrderId, - Attachments = fileUrl - }); - } - } - - if (model.BeforPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.BeforPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.AfterPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.AfterPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.SignOffAttachment != null) - { - var fileUrl = await SaveFileAsync(model.SignOffAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); - if (workOrder != null) - { - workOrder.SignOffAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - + var workOrderId = await _workOrderService.CreateWorkOrderWithDetailsAsync(input, userId!); return Ok(new DataResponse { Message = "Work order created successfully", Status = "200", Data = workOrderId }); } catch (ValidationException vex) @@ -166,7 +64,6 @@ namespace Api.SeaHavenIndustries.Controllers } } - [HttpPost] [HttpPut("{id:int}")] [Route("EditWorkorder")] @@ -178,82 +75,29 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = "Work order ID is required" }); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - var updateDto = new UpdateWorkOrderDTO + var input = new UpdateWorkOrderWithDetailsInput { + Id = model.Id.Value, WorkerOrderTitle = model.WorkerOrderTitle, Description = model.Description, - Status = model.Status, - Priority = model.Priority, + Status = model.Status?.ToString(), + Priority = model.Priority?.ToString(), LocationId = model.LocationId, AssignTo = model.AssignTo, DueDate = model.DueDate, - CompletedDate = model.CompletedDate + CompletedDate = model.CompletedDate, + ContactIds = model.ContactIds, + CategoryIds = model.CategoryIds, + Attachments = model.Attachments, + BeforPhotoAttachment = model.BeforPhotoAttachment, + AfterPhotoAttachment = model.AfterPhotoAttachment, + SignOffAttachment = model.SignOffAttachment }; - var success = await _workOrderService.UpdateWorkOrderAsync(model.Id.Value, updateDto, userId!); + var success = await _workOrderService.UpdateWorkOrderWithDetailsAsync(input, userId!); if (!success) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - await _workOrderDataService.DeleteWorkOrderContactsAsync(model.Id.Value); - await _workOrderDataService.DeleteWorkOrderCategoriesAsync(model.Id.Value); - - if (model.ContactIds != null && model.ContactIds.Any()) - { - await _workOrderDataService.AddWorkOrderContactsAsync(model.Id.Value, model.ContactIds); - } - - if (model.CategoryIds != null && model.CategoryIds.Any()) - { - await _workOrderDataService.AddWorkOrderCategoriesAsync(model.Id.Value, model.CategoryIds); - } - - if (model.Attachments != null) - { - foreach (var attachment in model.Attachments) - { - var fileUrl = await SaveFileAsync(attachment); - await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments - { - WorkorderId = model.Id.Value, - Attachments = fileUrl - }); - } - } - - if (model.BeforPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.BeforPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.BeforPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.AfterPhotoAttachment != null) - { - var fileUrl = await SaveFileAsync(model.AfterPhotoAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.AfterPhotoAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - - if (model.SignOffAttachment != null) - { - var fileUrl = await SaveFileAsync(model.SignOffAttachment); - var workOrder = await _workOrderDataService.GetByIdAsync(model.Id.Value); - if (workOrder != null) - { - workOrder.SignOffAttachment = fileUrl; - await _workOrderDataService.UpdateAsync(workOrder); - } - } - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200" }); } catch (ValidationException vex) @@ -266,21 +110,12 @@ namespace Api.SeaHavenIndustries.Controllers return BadRequest(new Response { Status = "Error", Message = ex.Message }); } } - // ----------------------------------- - // GET: api/WorkOrder/GetWorkOrderList - // ----------------------------------- + [HttpGet("GetWorkOrderList")] public async Task GetWorkOrderList(string? search = "", int page = 1, int pageSize = 12, string? sortBy = "lastUpdated", string? sortDir = "desc", string? status = "", string? assignee = "", int? locationId = null) { - var (data, totalCount) = await _workOrderDataService.GetWorkOrderListPagedAsync( - page, - pageSize, - search, - status, - assignee, - locationId, - sortBy, - sortDir); + var (data, totalCount) = await _workOrderService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir); var viewModel = new Pagination_DTO { @@ -294,32 +129,22 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(viewModel); } - [HttpDelete] [Route("DeleteWorkorder")] public async Task Deleteworkorder([FromBody] EditWorkorder_DTO model) { int id = model.Id.HasValue ? model.Id.Value : 0; if (id == 0) - { return BadRequest("Id is required"); - } + try { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var success = await _workOrderService.DeleteWorkOrderAsync(id, userId!); + var success = await _workOrderService.DeleteWorkOrderCascadeAsync(id, userId!); if (!success) return BadRequest("Work order not found!"); - await _quotesDataService.UnlinkFromWorkOrderAsync(id); - - var comments = await _commentDataService.GetByWorkOrderIdAsync(id); - foreach (var comment in comments) - { - await _commentDataService.DeleteAsync(comment.Id); - } - return Ok(new DataResponse { Message = "Removed Successfully", Status = "200" }); } catch (Exception ex) @@ -330,106 +155,26 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet] [Route("GetFilteredWorkorder")] - public IActionResult GetFilteredWorkorder( - [FromQuery] string[] assignto, - [FromQuery] int[]? location, - [FromQuery] string[]? priority, - [FromQuery] string[]? status, - [FromQuery] string[]? duedate, - [FromQuery] string search = "", - [FromQuery] string sort = "Sort Order", - [FromQuery] string sortby = "Sort By", - int page = 1, - int pageSize = 10) + public async Task GetFilteredWorkorder( + [FromQuery] string[] assignto, + [FromQuery] int[]? location, + [FromQuery] string[]? priority, + [FromQuery] string[]? status, + [FromQuery] string[]? duedate, + [FromQuery] string search = "", + [FromQuery] string sort = "Sort Order", + [FromQuery] string sortby = "Sort By", + int page = 1, + int pageSize = 10) { try { - IQueryable query = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations); - - if (!string.IsNullOrEmpty(search)) - { - var s = search.ToLower(); - query = query.Where(w => - (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || - (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || - (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(s))); - } - - if (assignto != null && assignto.Length > 0) - { - if (assignto[0] != "" && assignto[0] != null) - query = query.Where(w => assignto.Contains(w.AssignTo)); - } - - if (location != null && location.Length > 0) - { - if (location[0] != 0) - query = query.Where(w => location.Contains(w.LocationId ?? 0)); - } - - if (priority != null && priority.Length > 0) - { - if (priority[0] != "" && priority[0] != null) - query = query.Where(w => priority.Contains(w.Priority)); - } - - if (status != null && status.Length > 0) - { - if (status[0] != "" && status[0] != null) - query = query.Where(w => status.Contains(w.Status)); - } - var data = query.ToList(); - - // Perform client-side filtering for due dates - data = data.Where(w => - duedate == null || duedate.Length == 0 || duedate[0] == null || - duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, DateTime.UtcNow.Date))).ToList(); - - // Apply sorting - if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") - { - switch (sortby) - { - case "Name": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.WorkerOrderTitle).ToList() : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); - break; - case "Due Date": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.DueDate).ToList() : data.OrderByDescending(w => w.DueDate).ToList(); - break; - case "Severity": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.Priority).ToList() : data.OrderByDescending(w => w.Priority).ToList(); - break; - default: - // Handle default case or throw an exception - break; - } - } - - var totalCount = data.Count(); - var pagedData = data - .Skip((page - 1) * pageSize) - .Take(pageSize) - .Select(s => new { s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, Name = s.Locations != null ? s.Locations.Name : "" }) - .OrderByDescending(d => d.Name) - .ToList(); - - var totalPages1 = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = pagedData, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages1 - }; - - return Ok(viewModel); + var result = await _workOrderService.GetFilteredWorkOrdersAsync( + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + return Ok(result); } - catch (Exception ex) + catch (Exception) { - // Handle exception and return a default result or an error response return StatusCode(500, "Internal Server Error"); } } @@ -438,201 +183,44 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetWorkorderById")] public async Task GetWorkorderById(int id) { - var exist = await _db.workOrders.Include(w => w.Locations).Include(w => w.Comments).Include(w => w.Quotes).Include(w => w.Dispatches).ThenInclude(d => d.Vendor).Where(w => w.Id == id).Select(s => new - { - s.Id, - s.InternalWONumber, - s.WorkerOrderNumber, - title = s.WorkerOrderTitle, - s.Description, - s.CreatedDate, - s.DueDate, - s.ScheduledDate, - s.CompletedDate, - 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, - s.Problem, - s.Trade, - s.SubTrade, - s.VendorNTE, - s.Source, - s.PO, - s.TT, - poc = s.WorkOrderContacts.Select(c => new { 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 : "" }), - category = s.WorkOrderCategories.Select(c => new { c.Id, name = c.Category != null ? c.Category.Name : "" }), - Attachments = s.workOrderAttachments.Select(a => new { a.Id, attachment = a.Attachments }), - s.BeforPhotoIssue, - s.BeforPhotoAttachment, - s.AfterPhotoIssue, - s.AfterPhotoAttachment, - s.SignOffName, - s.SignOffAttachment, - s.SignOffSignature, - Comments = s.Comments.Select(c => new { c.Id, c.CreatedDate, c.Commenttext, c.Commenter, c.RecordType, c.CommentType, c.Documents, userName = c.ApplicationUser != null ? c.ApplicationUser.FirstName : "" }), - quotes = s.Quotes.Select(q => new { code = q.QuotId, q.Id }), - auditLog = _db.WorkOrderAuditLogs - .Where(a => a.WorkOrderId == s.Id) - .OrderByDescending(a => a.CreatedAt) - .Select(a => new { - a.Id, - a.FieldName, - a.OldValue, - a.NewValue, - a.Action, - a.CreatedAt, - userName = a.User != null ? a.User.FirstName + " " + a.User.LastName : "" - }).ToList(), - dispatches = s.Dispatches.Select(d => new { - d.Id, - d.DispatchNumber, - d.PONumber, - d.NTEAmount, - d.Description, - d.Status, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.EmailSent, - vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", - vendorEmail = d.Vendor != null ? d.Vendor.Email : "", - vendorId = d.VendorId - }).ToList() - }).FirstOrDefaultAsync(); - if (exist == null) - { - + var detail = await _workOrderService.GetWorkOrderDetailAsync(id); + if (detail == null) return BadRequest(new Response { Status = "Error", Message = "ID not found!" }); - } - else - { - return Ok(exist); - } + return Ok(detail); } + [HttpGet] [Route("GetFilteredWorkorder2")] - public IActionResult GetFilteredWorkorder2([FromQuery] string[] assignto, [FromQuery] int[]? location, [FromQuery] string[]? priority, [FromQuery] string[]? status, [FromQuery] string[]? duedate, [FromQuery] string search = "", [FromQuery] string sort = "Sort Order", [FromQuery] string sortby = "Sort By", int page = 1, int pageSize = 10) + public async Task GetFilteredWorkorder2( + [FromQuery] string[] assignto, + [FromQuery] int[]? location, + [FromQuery] string[]? priority, + [FromQuery] string[]? status, + [FromQuery] string[]? duedate, + [FromQuery] string search = "", + [FromQuery] string sort = "Sort Order", + [FromQuery] string sortby = "Sort By", + int page = 1, + int pageSize = 10) { try { - var totalCount = _db.workOrders.Where(w => w.istemplate != true).Count(); - if (sort == "Sort Order" && sortby == "Sort By" && assignto.Count() == 0 && (duedate == null || duedate.Count() == 0) && location.Count() == 0 && (priority == null || priority.Count() == 0) && (status == null || status.Count() == 0) && search == "") + var result = await _workOrderService.GetFilteredWorkOrders2Async( + assignto, location, priority, status, duedate, search, sort, sortby, page, pageSize); + return Ok(result); + } + catch (Exception) + { + var totalCount = await _workOrderService.GetTotalWorkOrderCountAsync(); + return Ok(new Pagination_DTO { - var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).ToListAsync(); - return Ok(data1); - } - //if (sort == "Sort Order" && sortby == "Sort By" && assignto.Count() == 0 && (duedate == null || duedate.Count() == 0) && location.Count() == 0 && (priority == null || priority.Count() == 0) && (status == null || status.Count() == 0) && search == "") - //{ - // var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).Select(s => new {s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, s.Locations.Name }).OrderByDescending(d => d.Name).ToList().Skip((page - 1) * pageSize) - // .Take(pageSize); - - // // Calculate the number of pages - // var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - // // Create a view model containing the paged items and pagination metadata - // var viewModel1 = new Pagination_DTO - // { - // Data = data1, - // PageNumber = page, - // PageSize = pageSize, - // TotalCount = totalCount, - // TotalPages = totalPages - // }; - // return Ok(viewModel1); - //} - - var currentDate = DateTime.UtcNow.Date; - var data = _db.workOrders.Include(w => w.Locations) - .Where(w => w.istemplate != true && (((w.WorkerOrderTitle ?? "").ToLower().Contains(search.ToLower()) || (w.WorkerOrderNumber ?? "").ToLower().Contains(search.ToLower()) || (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(search.ToLower()))) && - (assignto.Count() == 0 || assignto.Contains(w.AssignTo)) && - (location.Count() == 0 || (w.LocationId.HasValue && location.Contains(w.LocationId.Value))) && - (priority.Count() == 0 || priority.Contains(w.Priority)) && - ((status.Count() == 0 || status.Contains(w.Status)) - ))) - .ToList(); - - // Perform client-side filtering for due dates - data = data.Where(w => - duedate.Count() == 0 || - duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, currentDate))).ToList(); - - // Apply sorting - if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") - { - switch (sortby) - { - case "Name": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.WorkerOrderTitle).ToList() : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); - break; - case "Due Date": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.DueDate).ToList() : data.OrderByDescending(w => w.DueDate).ToList(); - break; - case "Severity": - data = (sort.ToLower() == "ascending") ? data.OrderBy(w => w.Priority).ToList() : data.OrderByDescending(w => w.Priority).ToList(); - break; - default: - // Handle default case or throw an exception - break; - } - } - var newdata = data.Select(s => new { s.Id, s.WorkerOrderTitle, s.DueDate, s.Status, s.Priority, Name = s.Locations != null ? s.Locations.Name : "" }).OrderByDescending(d => d.Name).ToList().Skip((page - 1) * pageSize) - .Take(pageSize); - var totalPages1 = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = newdata, + Data = Array.Empty(), PageNumber = page, PageSize = pageSize, TotalCount = totalCount, - TotalPages = totalPages1 - }; - return Ok(viewModel); + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }); } - catch (Exception ex) - { - var totalCount = _db.workOrders.Where(w => w.istemplate != true).Count(); - var data1 = _db.workOrders.Where(w => w.istemplate != true).Include(w => w.Locations).Select(s => new - { - s.Id, - s.WorkerOrderTitle, - s.DueDate, - s.Status, - s.Priority, - Name = s.Locations != null ? s.Locations.Name : "" - }).OrderByDescending(d => d.Id).ToList().Skip((page - 1) * pageSize) - .Take(pageSize); - - // Calculate the number of pages - var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); - - // Create a view model containing the paged items and pagination metadata - var viewModel = new Pagination_DTO - { - Data = data1, - PageNumber = page, - PageSize = pageSize, - TotalCount = totalCount, - TotalPages = totalPages - }; - return Ok(viewModel); - } - } - bool CheckDueDate(string dd, DateTime dueDate, DateTime currentDate) - { - return dd switch - { - "Today" => dueDate == currentDate, - "Tomorrow" => dueDate == currentDate.AddDays(1), - "Next Week" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddDays(7), - "Next Month" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddMonths(1), - "Over Due" => dueDate < currentDate, - _ => false - }; } [HttpPost] @@ -641,30 +229,12 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var workOrder = await _workOrderDataService.GetByIdAsync(id); - if (workOrder == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _workOrderService.ChangeStatusAsync(id, status, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (workOrder.Status != status) - { - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = id, - UserId = userId, - FieldName = "Status", - OldValue = workOrder.Status ?? "", - NewValue = status, - Action = AuditAction.StatusChange.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - workOrder.Status = status; - await _workOrderDataService.UpdateAsync(workOrder); - - return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = WorkOrderDTO.MapToModel(workOrder) }); + return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = result }); } catch (Exception ex) { @@ -678,35 +248,12 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var workOrder = await _workOrderDataService.GetByIdAsync(id); - if (workOrder == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _workOrderService.ChangeAssignmentAsync(id, assignTo, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Work order not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (workOrder.AssignTo != assignTo) - { - var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null; - var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; - - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = id, - UserId = userId, - FieldName = "AssignedTo", - OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "", - NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned", - Action = AuditAction.AssignmentChange.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - workOrder.AssignTo = assignTo; - await _workOrderDataService.UpdateAsync(workOrder); - - var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; - var assignedName = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : ""; - return Ok(new { message = "Updated Successfully", assignedTo = assignedName }); + return Ok(result); } catch (Exception ex) { @@ -720,23 +267,16 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - - var comment = new Comments + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddCommentInput { - UserId = userId, - WorkerOrderId = model.WorkorderId, - Commenttext = model.Text, - CommentType = model.CommentType ?? "vendor" + WorkorderId = model.WorkorderId, + Text = model.Text, + CommentType = model.CommentType, + Document = model.Document }; - if (model.Document != null) - { - var fileUrl = await SaveFileAsync(model.Document); - comment.Documents = fileUrl; - } - - var saved = await _commentDataService.AddAsync(comment); + var saved = await _workOrderService.AddCommentAsync(input, userId!); return Ok(new DataResponse { Message = "Updated Successfully", Status = "200", Data = saved }); } catch (Exception ex) @@ -751,26 +291,16 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - var comment = new Comments + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddCommentInput { - UserId = userId, - WorkerOrderId = model.WorkorderId, - Commenttext = model.Text, - CommentType = model.CommentType ?? "internal", + WorkorderId = model.WorkorderId, + Text = model.Text, + CommentType = model.CommentType }; - var saved = await _commentDataService.AddAsync(comment); - var user = await _userDataService.GetByIdAsync(userId); - - return Ok(new - { - saved.Id, - saved.CreatedDate, - saved.Commenttext, - saved.CommentType, - userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" - }); + var result = await _workOrderService.AddCommentJsonAsync(input, userId!); + return Ok(result); } catch (Exception ex) { @@ -782,14 +312,7 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetComments")] public async Task GetComments() { - var comments = await _commentDataService.GetAllAsync(); - var data = comments.Select(s => new - { - s.Id, - s.Commenttext, - FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", - s.Documents - }).ToList(); + var data = await _workOrderService.GetCommentsAsync(); return Ok(data); } @@ -797,176 +320,48 @@ namespace Api.SeaHavenIndustries.Controllers [Route("GetCommentsByWorkorderId")] public async Task GetCommentsByWorkorderId(int woid) { - var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); - var data = comments.Select(s => new - { - s.Id, - s.Commenttext, - FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", - s.Documents - }).ToList(); + var data = await _workOrderService.GetCommentsByWorkorderIdAsync(woid); return Ok(data); } + [HttpGet] [Route("GetworkordersDD")] public async Task GetworkordersDD() { - var data = await _workOrderDataService.GetAllAsync(); - return Ok(data.Where(w => w.istemplate != true)); + var data = await _workOrderService.GetWorkordersDDAsync(); + return Ok(data); } + [HttpGet] [Route("Getworkorders")] public async Task Getworkorders() { - var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); - var data = workOrders.Select(s => new WorkorderFilterVM - { - Id = s.Id, - Priority = s.Priority, - Status = s.Status, - WorkerOrderNumber = s.WorkerOrderNumber, - WorkerOrderTitle = s.WorkerOrderTitle, - DueDate = s.DueDate, - AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", - LocationName = s.Locations != null ? s.Locations.Name : "", - LocationId = s.LocationId, - PO = s.PO, - TT = s.TT, - }).ToList(); + var data = await _workOrderService.GetWorkordersAsync(); return Ok(data); } + [HttpPost] [Route("DispatchToVendor")] - public async Task DispatchToVendor([FromBody] Dispatch_DTO model) + public async Task DispatchToVendor([FromBody] Dispatch_DTO model, CancellationToken cancellationToken) { try { - var allWorkOrders = await _workOrderDataService.GetAllWithDetailsAsync(); - var workOrders = allWorkOrders.Where(w => model.WorkOrderIds.Contains(w.Id)).ToList(); - - if (workOrders.Count == 0) - return BadRequest(new Response { Status = "Error", Message = "No work orders found" }); - - var primaryWO = workOrders.First(); - - var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); - int nextPO = int.TryParse(lastPO, out var po) ? po : 1; - - var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync(); - int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1; - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var currentUser = userId != null ? await _userDataService.GetByIdAsync(userId) : null; - var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; - var dispatches = new List(); - foreach (var vendorId in model.VendorIds) + var input = new DispatchToVendorInput { - var vendor = await _vendorDataService.GetByIdAsync(vendorId); - if (vendor == null) continue; + WorkOrderIds = model.WorkOrderIds, + VendorIds = model.VendorIds, + NTEAmount = model.NTEAmount, + Description = model.Description, + ScheduledDate = model.ScheduledDate, + TaskListTemplateId = model.TaskListTemplateId, + CustomChecklistItems = model.CustomChecklistItems, + UserId = userId + }; - var poNumber = $"VPO-{nextPO:D5}"; - nextPO++; - var dispatchNumber = $"DSP-{nextDSP:D5}"; - nextDSP++; - - var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com"; - - var dispatch = new Dispatch - { - WorkOrderId = primaryWO.Id, - VendorId = vendorId, - DispatchNumber = dispatchNumber, - PONumber = poNumber, - NTEAmount = model.NTEAmount, - Description = model.Description, - Status = "Sent", - ScheduledDate = model.ScheduledDate, - DispatchedAt = DateTime.UtcNow, - ReplyToAddress = replyTo, - DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() - }; - - var checklistItems = new List(); - if (model.TaskListTemplateId.HasValue) - { - var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(model.TaskListTemplateId.Value); - if (template?.Items != null) - { - checklistItems.AddRange(template.Items.OrderBy(i => i.SortOrder).Select((i, idx) => - new DispatchChecklistItem { ItemText = i.ItemText, SortOrder = idx })); - } - } - if (model.CustomChecklistItems != null) - { - var startIdx = checklistItems.Count; - checklistItems.AddRange(model.CustomChecklistItems.Select((text, idx) => - new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx })); - } - dispatch.ChecklistItems = checklistItems; - - var woListHtml = string.Join("", workOrders.Select(wo => - $"{System.Net.WebUtility.HtmlEncode(wo.InternalWONumber ?? "")}" + - $"{System.Net.WebUtility.HtmlEncode(wo.WorkerOrderTitle ?? "")}" + - $"{System.Net.WebUtility.HtmlEncode(wo.Priority ?? "")}" + - $"{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}")); - - var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? ""); - var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? ""); - var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}"; - var descriptionHtml = System.Net.WebUtility.HtmlEncode(model.Description ?? primaryWO.Description ?? ""); - var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); - - var vendorToken = await _vendorTokens.GetOrCreateActiveTokenAsync(vendorId); - var frontendBase = _config.GetValue("FrontendBaseUrl")?.TrimEnd('/') ?? ""; - var portalUrl = $"{frontendBase}/v/{vendorToken.Token}/dashboard"; - - var html = $@" -

Work Order Dispatch — {dispatchNumber}

-

Location: {locationName} {locationAddr}

-

Vendor PO: {poNumber}   NTE: ${model.NTEAmount?.ToString("F2") ?? "N/A"}

-

Work Orders

- - - - - - - - {woListHtml} -
WO #TitlePriorityDue Date
-

Description

-

{descriptionHtml}

-
- -

{senderNameHtml}
Sea Haven Industries

-

Reply to this email to communicate about this dispatch.

"; - - var emailSent = await _sendMessage.SendDispatchEmail(vendor.Email, subject, html, replyTo); - dispatch.EmailSent = emailSent; - - var savedDispatch = await _dispatchDataService.AddAsync(dispatch); - - foreach (var wo in workOrders) - { - await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog - { - WorkOrderId = wo.Id, - UserId = userId, - FieldName = "Dispatch", - OldValue = "", - NewValue = $"{vendor.CompanyName} ({dispatchNumber})", - Action = AuditAction.Dispatch.ToString(), - CreatedAt = DateTime.UtcNow - }); - } - - dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count }); - } - - return Ok(new { message = "Dispatched", dispatches }); + var result = await _dispatchService.DispatchToVendorAsync(input, cancellationToken); + return Ok(result); } catch (Exception ex) { @@ -977,97 +372,14 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("GetDispatches/{workOrderId}")] public async Task GetDispatches(int workOrderId) { - var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId); - var data = dispatches.OrderByDescending(d => d.DispatchedAt).Select(d => new - { - d.Id, - d.DispatchNumber, - d.PONumber, - d.NTEAmount, - d.Description, - d.Status, - d.ScheduledDate, - d.CompletedDate, - d.DispatchedAt, - d.EmailSent, - vendorName = d.Vendor != null ? d.Vendor.CompanyName : "", - vendorEmail = d.Vendor != null ? d.Vendor.Email : "", - vendorId = d.VendorId - }).ToList(); + var data = await _dispatchService.GetDispatchesAsync(workOrderId); return Ok(data); } [HttpGet("GetDispatch/{id}")] public async Task GetDispatchById(int id) { - var d = await _db.Dispatches - .Include(x => x.Vendor) - .Include(x => x.WorkOrder) - .Where(x => x.Id == id) - .Select(x => new - { - x.Id, - x.DispatchNumber, - x.PONumber, - x.NTEAmount, - x.Description, - x.Status, - x.ScheduledDate, - x.CompletedDate, - x.DispatchedAt, - x.EmailSent, - x.WorkOrderId, - x.AcknowledgedAt, - x.VerifiedBy, - x.VerifiedAt, - woNumber = x.WorkOrder != null ? x.WorkOrder.InternalWONumber : "", - woTitle = x.WorkOrder != null ? x.WorkOrder.WorkerOrderTitle : "", - vendorName = x.Vendor != null ? x.Vendor.CompanyName : "", - vendorEmail = x.Vendor != null ? x.Vendor.Email : "", - vendorId = x.VendorId, - workOrders = _db.DispatchWorkOrders - .Where(dwo => dwo.DispatchId == x.Id) - .Select(dwo => new { - dwo.WorkOrderId, - woNumber = dwo.WorkOrder != null ? dwo.WorkOrder.InternalWONumber : "", - woTitle = dwo.WorkOrder != null ? dwo.WorkOrder.WorkerOrderTitle : "" - }).ToList(), - checklistItems = _db.DispatchChecklistItems - .Where(ci => ci.DispatchId == x.Id) - .OrderBy(ci => ci.SortOrder) - .Select(ci => new { - ci.Id, - ci.ItemText, - ci.IsCompleted, - ci.CompletedBy, - ci.CompletedAt, - ci.SortOrder, - ci.WorkOrderId - }).ToList(), - signoffs = _db.DispatchSignoffs - .Where(s => s.DispatchId == x.Id) - .Select(s => new { - s.Id, - s.SignoffType, - s.Name, - s.Signature, - s.SignatureMethod, - s.SignedAt - }).ToList(), - comments = _db.Comments - .Where(c => c.DispatchId == x.Id) - .OrderBy(c => c.CreatedDate) - .Select(c => new - { - c.Id, - c.CreatedDate, - c.Commenttext, - c.Commenter, - c.CommentType, - userName = c.ApplicationUser != null ? c.ApplicationUser.FirstName + " " + c.ApplicationUser.LastName : "" - }).ToList() - }).FirstOrDefaultAsync(); - + var d = await _dispatchService.GetDispatchDetailAsync(id); if (d == null) return NotFound(new Response { Status = "Error", Message = "Dispatch not found" }); return Ok(d); @@ -1078,36 +390,22 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var dispatch = await _db.Dispatches.FindAsync(model.Id); - if (dispatch == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new UpdateDispatchInput + { + Id = model.Id, + Status = model.Status, + NTEAmount = model.NTEAmount, + ScheduledDate = model.ScheduledDate, + CompletedDate = model.CompletedDate, + Description = model.Description, + UserId = userId + }; + + var success = await _dispatchService.UpdateDispatchAsync(input); + if (!success) return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - - if (dispatch.Status != model.Status) - { - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber} Status", - OldValue = dispatch.Status ?? "", - NewValue = model.Status ?? "", - Action = "dispatch_status_change", - CreatedAt = DateTime.UtcNow - }); - } - - dispatch.Status = model.Status; - dispatch.NTEAmount = model.NTEAmount; - dispatch.ScheduledDate = model.ScheduledDate; - dispatch.CompletedDate = model.CompletedDate; - dispatch.Description = model.Description; - - if (model.Status == "Completed" && dispatch.CompletedDate == null) - dispatch.CompletedDate = DateTime.UtcNow; - - await _db.SaveChangesAsync(); return Ok(new { message = "Dispatch updated" }); } catch (Exception ex) @@ -1121,42 +419,20 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var dispatch = await _db.Dispatches.Include(d => d.Vendor).Include(d => d.WorkOrder) - .FirstOrDefaultAsync(d => d.Id == model.DispatchId); - if (dispatch == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var input = new AddDispatchCommentInput + { + DispatchId = model.DispatchId, + Text = model.Text, + SendEmail = model.SendEmail, + UserId = userId + }; + + var result = await _dispatchService.AddDispatchCommentAsync(input); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - var userId = _httpContext?.HttpContext?.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? ""; - var user = !string.IsNullOrEmpty(userId) ? await _db.Users.FindAsync(userId) : null; - var senderName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; - - var comment = new Comments - { - CreatedDate = DateTime.UtcNow, - UserId = userId, - WorkerOrderId = dispatch.WorkOrderId, - DispatchId = dispatch.Id, - Commenttext = model.Text, - CommentType = "dispatcher", - Commenter = !string.IsNullOrWhiteSpace(senderName) ? senderName : null, - }; - _db.Comments.Add(comment); - await _db.SaveChangesAsync(); - - if (model.SendEmail && dispatch.Vendor?.Email != null && dispatch.WorkOrder != null) - { - var subject = $"[WO-{dispatch.WorkOrder.InternalWONumber}-{dispatch.DispatchNumber}] {dispatch.WorkOrder.WorkerOrderTitle}"; - var html = $"

{model.Text}


{senderName}
Sea Haven Industries


Reply to this email to respond.

"; - await _sendMessage.SendDispatchEmail(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress); - } - return Ok(new - { - comment.Id, - comment.CreatedDate, - comment.Commenttext, - comment.CommentType, - userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" - }); + return Ok(result); } catch (Exception ex) { @@ -1164,67 +440,21 @@ namespace Api.SeaHavenIndustries.Controllers } } - 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; - } - [HttpPost("VerifyDispatch")] public async Task VerifyDispatch(int dispatchId) { try { - var dispatch = await _db.Dispatches.FindAsync(dispatchId); - if (dispatch == null) + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _dispatchService.VerifyDispatchAsync(dispatchId, userId!); + if (result == null) return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - var incompleteItems = await _db.DispatchChecklistItems - .Where(c => c.DispatchId == dispatchId && !c.IsCompleted) - .CountAsync(); - - var signoffCount = await _db.DispatchSignoffs - .Where(s => s.DispatchId == dispatchId) - .CountAsync(); - - var missing = new List(); - if (incompleteItems > 0) - missing.Add($"{incompleteItems} checklist item(s) incomplete"); - if (signoffCount < 2) - { - var existingTypes = await _db.DispatchSignoffs - .Where(s => s.DispatchId == dispatchId) - .Select(s => s.SignoffType) - .ToListAsync(); - if (!existingTypes.Contains("Customer")) missing.Add("Missing Customer sign-off"); - if (!existingTypes.Contains("Vendor")) missing.Add("Missing Vendor sign-off"); - } - - if (missing.Count > 0) - return BadRequest(new { status = "Error", message = "Cannot verify", missing }); - - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var user = userId != null ? await _db.Users.FindAsync(userId) : null; - var userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; - - dispatch.Status = "Verified"; - dispatch.VerifiedBy = userName; - dispatch.VerifiedAt = DateTime.UtcNow; - - _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog - { - WorkOrderId = dispatch.WorkOrderId ?? 0, - UserId = userId, - FieldName = $"Dispatch {dispatch.DispatchNumber}", - OldValue = dispatch.Status ?? "", - NewValue = "Verified", - Action = "dispatch_verified", - CreatedAt = DateTime.UtcNow - }); - - await _db.SaveChangesAsync(); - return Ok(new { message = "Dispatch verified", verifiedBy = userName, verifiedAt = dispatch.VerifiedAt }); + return Ok(result); + } + catch (DispatchVerificationException ex) + { + return BadRequest(new { status = "Error", message = "Cannot verify", missing = ex.Missing }); } catch (Exception ex) { @@ -1237,28 +467,24 @@ namespace Api.SeaHavenIndustries.Controllers { try { - var dispatch = await _db.Dispatches.FindAsync(model.DispatchId); - if (dispatch == null) - return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); - - var existing = await _db.DispatchSignoffs - .AnyAsync(s => s.DispatchId == model.DispatchId && s.SignoffType == model.SignoffType); - if (existing) - return BadRequest(new Response { Status = "Error", Message = $"{model.SignoffType} sign-off already exists" }); - - var signoff = new DispatchSignoff + var input = new AddDispatchSignoffInput { DispatchId = model.DispatchId, SignoffType = model.SignoffType, Name = model.Name, Signature = model.Signature, - SignatureMethod = model.SignatureMethod, - SignedAt = DateTime.UtcNow + SignatureMethod = model.SignatureMethod }; - _db.DispatchSignoffs.Add(signoff); - await _db.SaveChangesAsync(); - return Ok(new { signoff.Id, signoff.SignoffType, signoff.Name, signoff.SignedAt, signoff.SignatureMethod }); + var result = await _dispatchService.AddDispatchSignoffAsync(input); + if (result == null) + return BadRequest(new Response { Status = "Error", Message = "Dispatch not found" }); + + return Ok(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); } catch (Exception ex) { @@ -1269,80 +495,32 @@ namespace Api.SeaHavenIndustries.Controllers [HttpPost("UpdateChecklistItem")] public async Task UpdateChecklistItem([FromBody] ChecklistItemUpdate_DTO model) { - var item = await _dispatchDataService.GetChecklistItemByIdAsync(model.Id); - if (item == null) + var input = new ChecklistItemUpdateInput + { + Id = model.Id, + IsCompleted = model.IsCompleted, + CompletedBy = model.CompletedBy + }; + + var result = await _dispatchService.UpdateChecklistItemAsync(input); + if (result == null) return NotFound(new Response { Status = "Error", Message = "Checklist item not found" }); - item.IsCompleted = model.IsCompleted; - item.CompletedBy = model.IsCompleted ? model.CompletedBy : null; - item.CompletedAt = model.IsCompleted ? DateTime.UtcNow : null; - await _dispatchDataService.UpdateChecklistItemAsync(item); - return Ok(new { message = "Updated", item.Id, item.IsCompleted, item.CompletedBy, item.CompletedAt }); + return Ok(result); } [HttpPost("AddChecklistItem")] public async Task AddChecklistItem([FromBody] AddChecklistItem_DTO model) { - var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(model.DispatchId); - var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1; - - var item = new DispatchChecklistItem + var input = new AddChecklistItemInput { DispatchId = model.DispatchId, WorkOrderId = model.WorkOrderId, - ItemText = model.ItemText, - SortOrder = maxSort + 1, - IsCompleted = false + ItemText = model.ItemText }; - await _dispatchDataService.AddChecklistItemAsync(item); - return Ok(new { item.Id, item.ItemText, item.SortOrder, item.IsCompleted }); - } - private async Task GenerateInternalWONumber() - { - return await _workOrderDataService.GenerateNextInternalWONumberAsync(); - } - - private async Task SaveFileAsync(IFormFile file) - { - var fileName = ContentDispositionHeaderValue.Parse(file.ContentDisposition).FileName.Trim('"'); - var uniqueFileName = $"{Guid.NewGuid()}_{fileName}"; - var uploadPath = Path.Combine("Assets", "Documents"); - var fullPath = Path.Combine(_webHostEnvironment.WebRootPath, uploadPath, uniqueFileName); - - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - - using (var stream = System.IO.File.Create(fullPath)) - { - await file.CopyToAsync(stream); - } - - var request = _httpContext.HttpContext!.Request; - var domain = $"{request.Scheme}://{request.Host}"; - return $"{domain}/{uploadPath.Replace("\\", "/")}/{uniqueFileName}"; - } - - public class WorkorderFilterVM - { - public int? Id { get; set; } - public int? LocationId { get; set; } - public string? WorkerOrderNumber { get; set; } - public string? BillingAddress { get; set; } = "Amazon.com Services LLC 410 Terry Ave,North Seattle, Washington, 98109"; - - public string? WorkerOrderTitle { get; set; } - public string? LocationName { get; set; } - public string? AssignToUserName { get; set; } - public string? Priority { get; set; } - public string? Status { get; set; } - public DateTime? DueDate { get; set; } - public string FormattedDueDate - { - get { return DueDate.HasValue ? DueDate.Value.ToString("MM/dd/yyyy") : ""; } - } - public string? Dispatcher { get; set; } - public string? Scheduler { get; set; } - public string? PO { get; set; } - public string? TT { get; set; } + var result = await _dispatchService.AddChecklistItemAsync(input); + return Ok(result); } } } diff --git a/Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs b/Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs new file mode 100644 index 0000000..59dd4c6 --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/DropdownOption_DTO.cs @@ -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; + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs b/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs new file mode 100644 index 0000000..b59bb14 --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/SendGridEmailSender.cs @@ -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 SendEmailAsync(string emailTo, string subject, string body) + { + return _sendMessage.SendEMail(emailTo, subject, body); + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index fc5bf0d..08db640 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -1,11 +1,12 @@  +using SeaHaven.Services.Interfaces; using SendGrid; using SendGrid.Helpers.Mail; namespace Api.SeaHavenIndustries.Helper { - public class SendMessage + public class SendMessage : IEmailSender { private IConfiguration _configuration; //string keysapi = ""; @@ -58,6 +59,8 @@ namespace Api.SeaHavenIndustries.Helper return false; } } + public async Task SendEmailAsync(string emailTo, string subject, string htmlBody) + => await SendEMail(emailTo, subject, htmlBody); public async Task SendEMailAttachment(string emailTo, string subject, byte[] pdfBytes) { try diff --git a/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs b/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs deleted file mode 100644 index 1b9ddee..0000000 --- a/Api.SeaHavenIndustries/Helper/VendorPortalTokenService.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Security.Cryptography; -using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; - -namespace Api.SeaHavenIndustries.Helper -{ - public class VendorPortalTokenService - { - private readonly ApplicationDbContext _db; - private readonly int _lifetimeDays; - - public VendorPortalTokenService(ApplicationDbContext db, IConfiguration config) - { - _db = db; - _lifetimeDays = config.GetValue("VendorPortal:TokenLifetimeDays") ?? 365; - } - - public async Task GetOrCreateActiveTokenAsync(int vendorId) - { - var now = DateTime.UtcNow; - var existing = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId - && t.RevokedAt == null - && t.ExpiresAt > now - && (t.IsDeleted == null || t.IsDeleted == false)) - .OrderByDescending(t => t.IssuedAt) - .FirstOrDefaultAsync(); - - if (existing != null) return existing; - - var token = new VendorAccessToken - { - VendorId = vendorId, - Token = GenerateToken(), - IssuedAt = now, - ExpiresAt = now.AddDays(_lifetimeDays), - CreatedDate = now - }; - _db.VendorAccessTokens.Add(token); - await _db.SaveChangesAsync(); - return token; - } - - public async Task ResolveVendorAsync(string? token) - { - if (string.IsNullOrWhiteSpace(token)) return null; - var now = DateTime.UtcNow; - var record = await _db.VendorAccessTokens - .Include(t => t.Vendor) - .FirstOrDefaultAsync(t => t.Token == token - && t.RevokedAt == null - && t.ExpiresAt > now - && (t.IsDeleted == null || t.IsDeleted == false)); - - if (record?.Vendor == null) return null; - - record.LastUsedAt = now; - await _db.SaveChangesAsync(); - return record.Vendor; - } - - public async Task RotateAsync(int vendorId) - { - var now = DateTime.UtcNow; - var active = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId && t.RevokedAt == null) - .ToListAsync(); - foreach (var t in active) t.RevokedAt = now; - await _db.SaveChangesAsync(); - return await GetOrCreateActiveTokenAsync(vendorId); - } - - public async Task RevokeAllAsync(int vendorId) - { - var now = DateTime.UtcNow; - var active = await _db.VendorAccessTokens - .Where(t => t.VendorId == vendorId && t.RevokedAt == null) - .ToListAsync(); - foreach (var t in active) t.RevokedAt = now; - await _db.SaveChangesAsync(); - } - - private static string GenerateToken() - { - var bytes = RandomNumberGenerator.GetBytes(32); - return Convert.ToBase64String(bytes) - .Replace("+", "-") - .Replace("/", "_") - .TrimEnd('='); - } - } -} diff --git a/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs b/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs index e8d21c3..b7b7953 100644 --- a/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs +++ b/Api.SeaHavenIndustries/Helper/ZipCodeDistance.cs @@ -1,6 +1,8 @@ +using SeaHaven.Services.Interfaces; + namespace Api.SeaHavenIndustries.Helper { - public class ZipCodeDistance + public class ZipCodeDistance : IZipCodeDistance { private readonly Dictionary _coords = new(); private bool _loaded; diff --git a/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs b/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs new file mode 100644 index 0000000..a9223b5 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/DynamoSyncExternalSource.cs @@ -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> ScanWorkOrdersAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? 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> ScanCommentsAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? 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> ScanVendorRepliesAsync( + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + Dictionary? 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 ConvertItem(Dictionary item) + { + var result = new Dictionary(); + foreach (var kvp in item) + { + if (kvp.Value.S != null) + result[kvp.Key] = kvp.Value.S; + } + return result; + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs new file mode 100644 index 0000000..02c6278 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/PortAdapters.cs @@ -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 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 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 GetOrCreateActiveTokenAsync(int vendorId) + { + var token = await _tokenService.GetOrCreateActiveTokenAsync( + vendorId, CancellationToken.None); + return token.Token; + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..c711931 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -38,7 +38,8 @@ builder.Services.AddCors(option => option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod())); builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddSingleton(sp => { var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance(); @@ -47,9 +48,15 @@ builder.Services.AddSingleton(sp if (File.Exists(csvPath)) zipService.Load(csvPath); return zipService; }); +builder.Services.AddSingleton( + serviceProvider => serviceProvider.GetRequiredService()); builder.Services.AddDataServices(); -builder.Services.AddBusinessServices(); +builder.Services.AddBusinessServices(configuration); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddAuthentication(options => { @@ -67,7 +74,9 @@ builder.Services.AddAuthentication(options => ValidateAudience = true, ValidAudience = configuration["JWT:ValidAudience"], ValidIssuer = configuration["JWT:ValidIssuer"], - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration["JWT:Secret"])) + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes( + configuration["JWT:Secret"] + ?? throw new InvalidOperationException("JWT:Secret configuration is required"))) }; }); builder.Services.AddEndpointsApiExplorer(); diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md index ee1d809..ca27a73 100644 --- a/BACKEND_ARCHITECTURE.md +++ b/BACKEND_ARCHITECTURE.md @@ -1,564 +1,106 @@ -# Backend Architecture Guide +# Backend Architecture and Quality Rules -## 🏗️ Clean Architecture Overview +This repository uses a feature-oriented service boundary over Entity Framework Core: -The backend follows **Clean Architecture** with strict layer separation: - -``` -┌─────────────────────────────────────────────────────────────┐ -│ API LAYER (Controllers) │ -│ - HTTP Request/Response │ -│ - Authentication & Authorization │ -│ - Exception Handling │ -│ - Input Validation (basic) │ -└─────────────────┬───────────────────────────────────────────┘ - │ Calls - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ SERVICE LAYER (Business Logic) │ -│ - Business Rules │ -│ - FluentValidation (detailed validation) │ -│ - Data Transformation │ -│ - Orchestration │ -└─────────────────┬───────────────────────────────────────────┘ - │ Calls - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ DATA SERVICE LAYER (Persistence) │ -│ - Database Access │ -│ - Entity Operations (CRUD) │ -│ - Query Building │ -│ - Transaction Management │ -└─────────────────┬───────────────────────────────────────────┘ - │ Uses - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ DATA LAYER (EF Core) │ -│ - DbContext │ -│ - Entity Models │ -│ - Database Schema │ -└─────────────────────────────────────────────────────────────┘ +```text +HTTP controller -> I{Feature}Service -> I{Feature}DataService -> ApplicationDbContext ``` ---- - -## 📁 Project Structure - -``` -backend/shoc-backend/ -│ -├── Api.SeaHavenIndustries/ # 🌐 API Layer -│ ├── Controllers/ # HTTP endpoints -│ ├── DTOs/ # Data Transfer Objects (API contracts) -│ ├── Program.cs # App startup & DI registration -│ └── appsettings.json # Configuration -│ -├── SeaHaven.Services/ # 💼 Service Layer (Business Logic) -│ ├── Interfaces/ # Service contracts (IContactService, etc.) -│ ├── Implementation/ # Service implementations -│ ├── DTOs/ # Internal DTOs for service layer -│ └── Validators/ # FluentValidation validators -│ -├── SeaHaven.DataServices/ # 💾 Data Access Layer -│ ├── Interfaces/ # DataService contracts (IContactDataService, etc.) -│ ├── Implementation/ # DataService implementations -│ └── DTOs/ # Data layer DTOs -│ -└── Data.SeaHavenIndustries/ # 🗄️ Data Layer (EF Core) - ├── Models/ # Entity models (Contact, Employee, etc.) - ├── ApplicationDbContext.cs # EF Core DbContext - └── Migrations/ # Database migrations -``` - ---- - -## 🔄 Data Flow Example (Creating a Contact) - -### 1️⃣ **API Layer** (`ContactController.cs`) -```csharp -[HttpPost("Create")] -public async Task AddContacts(Contact_DTO dto) -{ - try - { - // Get authenticated user ID - var userId = _userManager.GetUserId(User); - if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - - // Convert API DTO → Service DTO - var serviceDto = dto.ToServiceCreateDTO(); - - // Call Service Layer - await _contactService.CreateContactAsync(serviceDto, userId); - - return Ok(new Response { Status = "Success", Message = "Contact created" }); - } - catch (ValidationException vex) - { - var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); - return BadRequest(new Response { Status = "Validation Error", Message = errors }); - } - catch (KeyNotFoundException) - { - return NotFound(new Response { Status = "Error", Message = "Resource not found" }); - } - catch (Exception ex) - { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); - } -} -``` - -**Responsibilities:** -- ✅ Authenticate user -- ✅ Extract user ID from claims -- ✅ Map API DTO to Service DTO -- ✅ Catch exceptions and return proper HTTP status codes -- ❌ NO business logic -- ❌ NO database access - ---- - -### 2️⃣ **Service Layer** (`ContactService.cs`) - -```csharp -public class ContactService : IContactService -{ - private readonly IContactDataService _dataService; - private readonly IValidator _createValidator; - - public ContactService( - IContactDataService dataService, - IValidator createValidator) - { - _dataService = dataService; - _createValidator = createValidator; - } - - public async Task CreateContactAsync(CreateContactDTO dto, string userId) - { - // STEP 1: Validate using FluentValidation - var validationResult = await _createValidator.ValidateAsync(dto); - if (!validationResult.IsValid) - { - throw new ValidationException(validationResult.Errors); - } - - // STEP 2: Business logic (if any) - // Example: Check for duplicates, apply business rules, etc. - - // STEP 3: Map Service DTO → Data DTO - var dataDto = new CreateContactDataDTO - { - FirstName = dto.FirstName, - LastName = dto.LastName, - Email = dto.Email, - Phone = dto.Phone - }; - - // STEP 4: Call Data Service Layer - var createdContact = await _dataService.CreateAsync(dataDto, userId); - - // STEP 5: Map Data DTO → Service DTO and return - return new ContactDTO - { - Id = createdContact.Id, - FirstName = createdContact.FirstName, - LastName = createdContact.LastName, - Email = createdContact.Email, - Phone = createdContact.Phone - }; - } -} -``` - -**Responsibilities:** -- ✅ Validate input using FluentValidation -- ✅ Apply business rules -- ✅ Orchestrate operations -- ✅ Transform data between layers -- ❌ NO HTTP concerns -- ❌ NO direct database access - ---- - -### 3️⃣ **Data Service Layer** (`ContactDataService.cs`) - -```csharp -public class ContactDataService : IContactDataService -{ - private readonly ApplicationDbContext _db; - - public ContactDataService(ApplicationDbContext db) - { - _db = db; - } - - public async Task 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 -{ - 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 CreateContactAsync(CreateContactDTO dto, string userId); - Task UpdateContactAsync(int id, UpdateContactDTO dto, string userId); - Task DeleteContactAsync(int id, string userId); - Task GetContactByIdAsync(int id); - Task> GetContactsPagedAsync(int page, int pageSize, string? search); -} -``` - -### Data Service Interface (`IContactDataService.cs`) -```csharp -public interface IContactDataService -{ - Task CreateAsync(CreateContactDataDTO dto, string userId); - Task UpdateAsync(int id, UpdateContactDataDTO dto, string userId); - Task DeleteAsync(int id, string userId); - Task GetByIdAsync(int id); - Task> 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 Create([FromBody] SomeDTO dto) -{ - try - { - // Get user ID - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); - - // Call service - await _service.CreateAsync(dto, userId); - - return Ok(new Response { Status = "Success", Message = "Created successfully" }); - } - catch (ValidationException vex) // ⚠️ CATCH THIS FIRST - { - var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); - return BadRequest(new Response { Status = "Validation Error", Message = errors }); - } - catch (KeyNotFoundException) // ⚠️ THEN THIS - { - return NotFound(new Response { Status = "Error", Message = "Not found" }); - } - catch (Exception ex) // ⚠️ GENERIC LAST - { - return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); - } -} -``` - -### HTTP Status Code Mapping -- **200 OK** → Success -- **400 Bad Request** → Validation errors -- **401 Unauthorized** → Not authenticated -- **404 Not Found** → Resource not found -- **500 Internal Server Error** → Unexpected errors - ---- - -## 🔐 Authentication & User ID - -### Getting User ID (Two Methods) - -**Method 1: Using Claims (Preferred)** -```csharp -var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); -if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); -``` - -**Method 2: Using UserManager (for Identity)** -```csharp -var userId = _userManager.GetUserId(User); -if (userId == null) - return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); -``` - -### Audit Fields -Every entity should track: -- `CreatedBy` (string - User ID GUID) -- `CreatedDate` (DateTime) -- `ModifiedBy` (string - User ID GUID) -- `ModifiedDate` (DateTime) - ---- - -## 📋 Naming Conventions - -### Controllers -- Named after entity + "Controller" -- Example: `ContactController`, `EmployeeController` - -### Services -- Interface: `I[Entity]Service` -- Implementation: `[Entity]Service` -- Example: `IContactService`, `ContactService` - -### Data Services -- Interface: `I[Entity]DataService` -- Implementation: `[Entity]DataService` -- Example: `IContactDataService`, `ContactDataService` - -### DTOs -- API Layer: `[Entity]_DTO`, `Edit[Entity]_DTO` -- Service Layer: `Create[Entity]DTO`, `Update[Entity]DTO`, `[Entity]DTO` -- Data Layer: `Create[Entity]DataDTO`, `[Entity]DataDTO` - ---- - -## ✅ Best Practices Checklist - -### Controllers -- [ ] Use dependency injection for services -- [ ] Get user ID from `User.FindFirstValue(ClaimTypes.NameIdentifier)` -- [ ] Return 401 if userId is null -- [ ] Catch `ValidationException` BEFORE generic `Exception` -- [ ] Return appropriate HTTP status codes -- [ ] NO business logic in controllers -- [ ] NO direct database access (`_db`) - -### Services -- [ ] Validate using FluentValidation -- [ ] Throw `ValidationException` for validation errors -- [ ] Throw `KeyNotFoundException` for not found -- [ ] Pass userId to DataService -- [ ] Transform DTOs between layers -- [ ] Implement business rules here - -### Data Services -- [ ] Direct database access only -- [ ] Set audit fields (CreatedBy, CreatedDate, ModifiedBy, ModifiedDate) -- [ ] Use async/await for all database operations -- [ ] NO validation logic -- [ ] NO business logic - ---- - -## 🎓 Quick Reference - -| Layer | Purpose | Can Access | Cannot Access | -|-------|---------|-----------|---------------| -| **Controller** | HTTP handling, auth | Service layer | Database directly | -| **Service** | Business logic, validation | DataService layer | Database directly | -| **DataService** | Database operations | DbContext, Entities | HTTP context | -| **Data** | Entity definitions | Nothing (POCOs) | Application logic | - ---- - -## 📚 Example: Full CRUD Implementation - -See these files for complete examples: -- **Contact**: `ContactController.cs`, `ContactService.cs`, `ContactDataService.cs` -- **Employee**: `EmployeeController.cs`, `EmployeeService.cs`, `EmployeeDataService.cs` -- **Asset**: `AssetController.cs`, `AssetService.cs`, `AssetDataService.cs` - -All follow the same Clean Architecture pattern! - ---- - -## 🔐 Configuration & Secrets - -No secrets are stored in source. The committed `appsettings*.json` files hold only -`${PLACEHOLDER}` tokens; real values must be supplied at runtime through one of the -standard .NET configuration sources (later sources win): - -1. `appsettings.json` / `appsettings.{Environment}.json` — committed, placeholders only. -2. **User Secrets** (local dev): `dotnet user-secrets set "Section:Key" "value"`. -3. **Environment variables** — use `__` (double underscore) as the section separator. - In AWS Elastic Beanstalk these are the environment properties. - -See `.env.example` in the repo root for the full list. Required values: - -| Setting | Env-var form | Used by | Notes | -|---|---|---|---| -| `ConnectionStrings:DefaultConnection` | `ConnectionStrings__DefaultConnection` | Both projects | SQL Server connection string | -| `SendGrid:ApiKey` | `SendGrid__ApiKey` | Both projects | Transactional email | -| `JWT:Secret` | `JWT__Secret` | `Api.SeaHavenIndustries` | JWT signing key | -| `GoogleMaps:ApiKey` | `GoogleMaps__ApiKey` | `SeaHavenIndustries` (Blazor) | Maps/Places; read in `App.razor` + `Home.razor` | - -### AWS credentials - -`UploadFileHp` (S3 uploads) uses the **AWS SDK default credential chain** — it no longer -hardcodes access keys. Credentials resolve from `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` -env vars, the shared profile/SSO, or (preferred in production) the EC2/ECS instance role. -The region comes from `AddDefaultAWSOptions` in `Program.cs`. - -> ⚠️ The secrets previously committed to this repo (DB password, SendGrid key, JWT secret, -> Google Maps keys, AWS access key) should be considered compromised and **rotated**. - +The interfaces are architectural seams, not a requirement to wrap every class or every +EF method. Add an interface when it separates HTTP, business, persistence, or external +I/O responsibilities and enables behavior-focused testing. + +## Layer responsibilities + +### API controllers + +- Parse HTTP input, authorize the caller, invoke a business-service interface, and map + the result to the established HTTP contract. +- Do not inject `ApplicationDbContext`, concrete service implementations, or concrete + data-service implementations. +- Do not contain persistence queries or business workflows. +- Do not expose exception messages to clients. Map known failures explicitly and let the + centralized exception boundary handle unexpected failures. + +### Business services + +- Own validation, business decisions, orchestration, and transaction intent. +- Depend on interfaces for persistence and external I/O. +- Use `IOptions` for typed configuration. Do not inject `IConfiguration`. +- Keep feature responsibilities cohesive. Split a service when it has independently + changing business reasons, not because it crossed an arbitrary line count. +- Preserve existing API and board-backed behavior unless the accepted requirement + explicitly changes it. + +### Data services + +- Own EF Core query shape, persistence, batching, and transaction implementation. +- Accept cancellation tokens on new I/O methods and pass them to EF Core. +- Use `AsNoTracking()` for read-only queries. +- Project only required columns for list/read models; include full graphs only when the + caller needs them. +- Page unbounded collections at the database. +- Avoid query-in-loop and save-in-loop patterns. Prefer set-based reads and batched writes + while preserving the workflow's failure and transaction semantics. +- Do not introduce a generic repository over EF Core. Feature data services should expose + intent-revealing operations. + +## Performance review + +For each changed hot path, document and test: + +- input size variables; +- CPU and memory complexity; +- database round trips; +- whether filtering, ordering, and paging execute in SQL; +- whether tracking is necessary; +- whether repeated work can be hoisted out of loops; +- the partial-failure and transaction behavior. + +Prefer dictionary or hash-set reconstruction when database results must follow caller +order. A query followed by dictionary reconstruction is normally `O(n)` CPU and memory; +repeated `First`/`Single` scans over the result are `O(n²)`. + +Do not optimize by weakening correctness. In particular, batching all writes into one +final save can change partial-success behavior, and adding a transaction can change +locking and retry semantics. Those are business decisions, not mechanical cleanup. + +## Configuration + +Configuration is bound once during composition in +`SeaHaven.Services/DependencyInjection/ServicesModule.cs`. Business services receive +typed options such as `FrontendOptions`, `JwtOptions`, `ApprovalsOptions`, and +`VendorPortalOptions`. + +Defaults must preserve current behavior. Startup validation should be introduced only +when every deployed environment is known to provide the required value. + +## Enforcement + +`ArchitectureTests` enforces mechanical dependency rules: + +- controllers cannot inject EF contexts or concrete service/data-service classes; +- business services cannot inject EF contexts or raw `IConfiguration`. + +The pull-request quality workflow runs those tests and verifies formatting for changed +C# files. The normal CI workflow builds the solution and runs the full test suite. + +Architecture tests are appropriate for dependency direction. Behavior belongs in tests +through public interfaces. Do not add tests merely to prove a linter or analyzer itself +works; test the product behavior the rule protects. + +## Review checklist + +- Controller depends only on service abstractions. +- Business logic is in a cohesive feature service. +- EF operations are behind a feature data-service abstraction. +- No raw `IConfiguration` in a business service. +- No unbounded load followed by in-memory filtering. +- No query/save loop where a set-based operation preserves semantics. +- Read-only EF queries use no tracking. +- New async I/O accepts and propagates cancellation where the public contract permits. +- Errors do not leak internal exception details. +- Tests cover behavior, authorization, data contracts, ordering, duplicates, missing + records, and relevant failure boundaries. +- Changed behavior has been checked against the authoritative ticket board. Missing board + access blocks readiness and merge claims. diff --git a/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs new file mode 100644 index 0000000..c2097f0 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorPortalReadModels.cs @@ -0,0 +1,101 @@ +namespace Data.SeaHavenIndustries +{ + public class VendorDispatchSummaryData + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? WorkOrderTitle { get; set; } + public string? InternalWONumber { get; set; } + public string? LocationName { get; set; } + public string? LocationCity { get; set; } + public string? LocationState { get; set; } + } + + public class PortalChecklistItemData + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class PortalSignoffData + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class PortalUpliftData + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByFirstName { get; set; } + public string? DecidedByLastName { get; set; } + } + + public class PortalCommentData + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public DateTime? CreatedDate { get; set; } + public string? UserId { get; set; } + public string? UserFirstName { get; set; } + public string? UserLastName { get; set; } + } + + public class UpliftListItemData + { + public int Id { get; set; } + public int DispatchId { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public int? WorkOrderId { get; set; } + public string? VendorCompanyName { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public int RequiredTier { get; set; } + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + } + + public class UpliftForDispatchData + { + public int Id { get; set; } + public int DispatchId { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByFirstName { get; set; } + public string? DecidedByLastName { get; set; } + } +} diff --git a/SeaHaven.DataServices/Dto/DispatchReadModels.cs b/SeaHaven.DataServices/Dto/DispatchReadModels.cs new file mode 100644 index 0000000..45dc210 --- /dev/null +++ b/SeaHaven.DataServices/Dto/DispatchReadModels.cs @@ -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 Items { get; set; } = Array.Empty(); + 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 WorkOrders { get; set; } = Array.Empty(); + public IReadOnlyList Checklist { get; set; } = Array.Empty(); + public IReadOnlyList Signoffs { get; set; } = Array.Empty(); + public IReadOnlyList Comments { get; set; } = Array.Empty(); + public IReadOnlyList UpliftRequests { get; set; } = Array.Empty(); + public IReadOnlyList AuditLog { get; set; } = Array.Empty(); + } +} diff --git a/SeaHaven.DataServices/Dto/SettingsReadModels.cs b/SeaHaven.DataServices/Dto/SettingsReadModels.cs new file mode 100644 index 0000000..99e4057 --- /dev/null +++ b/SeaHaven.DataServices/Dto/SettingsReadModels.cs @@ -0,0 +1,8 @@ +namespace SeaHaven.DataServices.Dto +{ + public class SettingsCategoryItemDTO + { + public int Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/SeaHaven.DataServices/Dto/UserReadModels.cs b/SeaHaven.DataServices/Dto/UserReadModels.cs new file mode 100644 index 0000000..c3cb153 --- /dev/null +++ b/SeaHaven.DataServices/Dto/UserReadModels.cs @@ -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; } + } +} diff --git a/SeaHaven.DataServices/Implementation/CalendarDataService.cs b/SeaHaven.DataServices/Implementation/CalendarDataService.cs new file mode 100644 index 0000000..105ecb0 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CalendarDataService.cs @@ -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 GetEventByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.Events + .AsNoTracking() + .Where(e => e.Id == id && e.IsDeleted != true) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetEventForEditAsync(int id, CancellationToken cancellationToken) + { + return await _context.Events.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task 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 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> 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); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/CommentDataService.cs b/SeaHaven.DataServices/Implementation/CommentDataService.cs index 72db1a1..705beaf 100644 --- a/SeaHaven.DataServices/Implementation/CommentDataService.cs +++ b/SeaHaven.DataServices/Implementation/CommentDataService.cs @@ -71,5 +71,32 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Comments.AnyAsync(c => c.Id == id); } + + public async Task StageAsync(Comments comment, CancellationToken cancellationToken) + { + await _context.Comments.AddAsync(comment, cancellationToken); + } + + public async Task> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from c in _context.Comments + where c.DispatchId == dispatchId + && c.CommentType != "internal" + && (c.IsDeleted == null || c.IsDeleted == false) + join u in _context.Users on c.UserId equals u.Id into users + from u in users.DefaultIfEmpty() + orderby c.CreatedDate + select new PortalCommentData + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + CommentType = c.CommentType, + CreatedDate = c.CreatedDate, + UserId = c.UserId, + UserFirstName = u != null ? u.FirstName : null, + UserLastName = u != null ? u.LastName : null + }).ToListAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/CommonDataService.cs b/SeaHaven.DataServices/Implementation/CommonDataService.cs new file mode 100644 index 0000000..b263155 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CommonDataService.cs @@ -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 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> GetRoleNamesAsync(CancellationToken cancellationToken) + { + return await _context.Roles + .AsNoTracking() + .Select(s => s.Name) + .ToListAsync(cancellationToken); + } + + public async Task> 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> 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> GetCategoriesAsync(CancellationToken cancellationToken) + { + return await _context.Categories + .AsNoTracking() + .Select(s => new Category + { + Id = s.Id, + Name = s.Name + }) + .ToListAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/DashboardDataService.cs b/SeaHaven.DataServices/Implementation/DashboardDataService.cs new file mode 100644 index 0000000..d1e29c3 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/DashboardDataService.cs @@ -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 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 + }; + } + } +} diff --git a/SeaHaven.DataServices/Implementation/DispatchDataService.cs b/SeaHaven.DataServices/Implementation/DispatchDataService.cs index 4a057f6..16d5747 100644 --- a/SeaHaven.DataServices/Implementation/DispatchDataService.cs +++ b/SeaHaven.DataServices/Implementation/DispatchDataService.cs @@ -1,6 +1,8 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -25,6 +27,14 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(d => d.Id == id); } + public async Task GetByIdWithVendorAndWorkOrderAsync(int id) + { + return await _context.Dispatches + .Include(dispatch => dispatch.Vendor) + .Include(dispatch => dispatch.WorkOrder) + .FirstOrDefaultAsync(dispatch => dispatch.Id == id); + } + public async Task> GetAllAsync() { return await _context.Dispatches.ToListAsync(); @@ -45,9 +55,9 @@ namespace SeaHaven.DataServices.Implementation } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( - int page, - int pageSize, - string? search = null, + int page, + int pageSize, + string? search = null, string? status = null) { var query = _context.Dispatches.AsQueryable(); @@ -216,5 +226,576 @@ namespace SeaHaven.DataServices.Implementation await _context.DispatchWorkOrders.AddAsync(dispatchWorkOrder); await _context.SaveChangesAsync(); } + + public async Task 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 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 + { + 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> GetVendorDispatchSummariesAsync( + int vendorId, string? status, CancellationToken cancellationToken) + { + var query = _context.Dispatches + .AsNoTracking() + .Where(dispatch => dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false)); + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(dispatch => dispatch.Status == status); + + return await query + .OrderByDescending(dispatch => dispatch.DispatchedAt ?? dispatch.CreatedDate) + .Select(dispatch => new VendorDispatchSummaryData + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + Status = dispatch.Status, + NTEAmount = dispatch.NTEAmount, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + AcknowledgedAt = dispatch.AcknowledgedAt, + WorkOrderTitle = dispatch.WorkOrder != null + ? dispatch.WorkOrder.WorkerOrderTitle + : null, + InternalWONumber = dispatch.WorkOrder != null + ? dispatch.WorkOrder.InternalWONumber + : null, + LocationName = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.Name + : null, + LocationCity = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.City + : null, + LocationState = dispatch.WorkOrder != null && dispatch.WorkOrder.Locations != null + ? dispatch.WorkOrder.Locations.State + : null + }) + .ToListAsync(cancellationToken); + } + + public async Task GetVendorDispatchDetailAsync( + int id, int vendorId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .Include(dispatch => dispatch.WorkOrder) + .ThenInclude(workOrder => workOrder!.Locations) + .AsNoTracking() + .FirstOrDefaultAsync(dispatch => dispatch.Id == id + && dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false), + cancellationToken); + } + + public async Task GetVendorDispatchForMutationAsync( + int id, int vendorId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .Include(dispatch => dispatch.WorkOrder) + .ThenInclude(workOrder => workOrder!.Locations) + .FirstOrDefaultAsync(dispatch => dispatch.Id == id + && dispatch.VendorId == vendorId + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false), + cancellationToken); + } + + public async Task> GetPortalChecklistAsync( + int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchChecklistItems + .AsNoTracking() + .Where(item => item.DispatchId == dispatchId + && (item.IsDeleted == null || item.IsDeleted == false)) + .OrderBy(item => item.SortOrder) + .Select(item => new PortalChecklistItemData + { + Id = item.Id, + ItemText = item.ItemText, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task> GetPortalSignoffsAsync( + int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchSignoffs + .AsNoTracking() + .Where(signoff => signoff.DispatchId == dispatchId + && (signoff.IsDeleted == null || signoff.IsDeleted == false)) + .Select(signoff => new PortalSignoffData + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + SignatureMethod = signoff.SignatureMethod, + SignedAt = signoff.SignedAt + }) + .ToListAsync(cancellationToken); + } + + public async Task GetChecklistItemForDispatchAsync( + int itemId, int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchChecklistItems + .FirstOrDefaultAsync( + item => item.Id == itemId && item.DispatchId == dispatchId, + cancellationToken); + } + + public async Task StageAuditLogAsync( + WorkOrderAuditLog log, CancellationToken cancellationToken) + { + await _context.WorkOrderAuditLogs.AddAsync(log, cancellationToken); + } + + public async Task GetDispatchDispatcherUserIdAsync( + int workOrderId, CancellationToken cancellationToken) + { + return await _context.WorkOrderAuditLogs + .AsNoTracking() + .Where(audit => audit.WorkOrderId == workOrderId + && audit.Action == "dispatch" + && audit.UserId != null + && audit.UserId != "") + .OrderByDescending(audit => audit.CreatedAt) + .Select(audit => audit.UserId) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task 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(); + } } } diff --git a/SeaHaven.DataServices/Implementation/DropdownOptionsDataService.cs b/SeaHaven.DataServices/Implementation/DropdownOptionsDataService.cs new file mode 100644 index 0000000..593dd8b --- /dev/null +++ b/SeaHaven.DataServices/Implementation/DropdownOptionsDataService.cs @@ -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> 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> 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 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 AddAsync(DropdownOption option, CancellationToken cancellationToken) + { + await _context.DropdownOptions.AddAsync(option, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return option; + } + + public async Task 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); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs index 4f34528..4eebe82 100644 --- a/SeaHaven.DataServices/Implementation/FollowUpDataService.cs +++ b/SeaHaven.DataServices/Implementation/FollowUpDataService.cs @@ -120,5 +120,138 @@ namespace SeaHaven.DataServices.Implementation { return await _context.FollowUps.CountAsync(); } + + public async Task> 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 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 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 GetByIdForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.FollowUps.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task 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 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 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 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; + } } } diff --git a/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs new file mode 100644 index 0000000..c1781a2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/ForgetPasswordDataService.cs @@ -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 CodeExistsAsync(string code, CancellationToken cancellationToken) + { + return await _context.ForgetPasswordCodes + .AsNoTracking() + .Where(u => u.Code == code) + .AnyAsync(cancellationToken); + } + + public async Task 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 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); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 65c46df..56ccf52 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -37,16 +37,16 @@ namespace SeaHaven.DataServices.Implementation } public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( - int page, - int pageSize, + int page, + int pageSize, string? search = null) { var query = _context.Locations.AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) { - query = query.Where(l => - l.Name!.Contains(search) || + query = query.Where(l => + l.Name!.Contains(search) || l.City!.Contains(search)); } @@ -95,6 +95,69 @@ namespace SeaHaven.DataServices.Implementation return await _context.Locations.CountAsync(); } + public async Task<(List 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 GetDetailByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.Locations + .AsNoTracking() + .FirstOrDefaultAsync(l => l.Id == id, cancellationToken); + } + + public async Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.Locations.FindAsync(new object[] { id }, cancellationToken); + } + + public async Task 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 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 Items, int TotalCount)> GetAddressbookPagedAsync( int page, int pageSize, diff --git a/SeaHaven.DataServices/Implementation/QuotesDataService.cs b/SeaHaven.DataServices/Implementation/QuotesDataService.cs index 44fc3f5..8dd5358 100644 --- a/SeaHaven.DataServices/Implementation/QuotesDataService.cs +++ b/SeaHaven.DataServices/Implementation/QuotesDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -71,5 +72,108 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + + public async Task<(IReadOnlyList 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 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> 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); + } + } } } diff --git a/SeaHaven.DataServices/Implementation/SettingsDataService.cs b/SeaHaven.DataServices/Implementation/SettingsDataService.cs new file mode 100644 index 0000000..f0a5feb --- /dev/null +++ b/SeaHaven.DataServices/Implementation/SettingsDataService.cs @@ -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 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 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 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 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 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> GetRoleNamesAsync(CancellationToken cancellationToken) + { + var roles = await _context.Roles.AsNoTracking().ToListAsync(cancellationToken); + return roles.Select(r => r.Name!).ToList(); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/SyncDataService.cs b/SeaHaven.DataServices/Implementation/SyncDataService.cs new file mode 100644 index 0000000..3ca083c --- /dev/null +++ b/SeaHaven.DataServices/Implementation/SyncDataService.cs @@ -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 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 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 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 GetLocationCountAsync(CancellationToken cancellationToken) + { + return await _context.Locations.CountAsync(cancellationToken); + } + + public async Task CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken) + { + return await _context.Comments + .AnyAsync(c => c.ExternalCommentId == externalId, cancellationToken); + } + + public async Task GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken) + { + return await _context.workOrders + .FirstOrDefaultAsync(w => w.InternalWONumber == internalNumber, cancellationToken); + } + + public async Task GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken) + { + return await _context.Dispatches + .FirstOrDefaultAsync(d => d.DispatchNumber == dispatchNumber && d.WorkOrderId == workOrderId, cancellationToken); + } + + public async Task> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken) + { + return await _context.workOrders + .Where(w => w.InternalWONumber == null || w.InternalWONumber == "") + .OrderBy(w => w.Id) + .ToListAsync(cancellationToken); + } + + public async Task> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken) + { + return await _context.Dispatches + .Where(d => d.DispatchNumber == null || d.DispatchNumber == "") + .OrderBy(d => d.Id) + .ToListAsync(cancellationToken); + } + + public async Task 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 GetLastDispatchNumberAsync(CancellationToken cancellationToken) + { + return await _context.Dispatches + .Where(d => d.DispatchNumber != null && d.DispatchNumber != "") + .OrderByDescending(d => d.DispatchNumber) + .Select(d => d.DispatchNumber) + .FirstOrDefaultAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs b/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs index f0ce1af..a5ec17d 100644 --- a/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs +++ b/SeaHaven.DataServices/Implementation/TaskListTemplateDataService.cs @@ -57,5 +57,58 @@ namespace SeaHaven.DataServices.Implementation { return await _context.TaskListTemplates.AnyAsync(t => t.Id == id); } + + public async Task> 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 GetTemplateWithItemsReadOnlyAsync(int id, CancellationToken cancellationToken) + { + return await _context.TaskListTemplates + .Include(t => t.Items) + .Where(t => t.Id == id) + .AsNoTracking() + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task GetTemplateWithItemsForUpdateAsync(int id, CancellationToken cancellationToken) + { + return await _context.TaskListTemplates + .Include(t => t.Items) + .FirstOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + public async Task AddTemplateAsync(TaskListTemplate template, CancellationToken cancellationToken) + { + await _context.TaskListTemplates.AddAsync(template, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return template; + } + + public async Task ReplaceTemplateItemsAsync(TaskListTemplate template, IEnumerable newItems, CancellationToken cancellationToken) + { + if (template.Items != null) + _context.TaskListTemplateItems.RemoveRange(template.Items); + + template.Items = newItems.ToList(); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task 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; + } } } diff --git a/SeaHaven.DataServices/Implementation/UpliftDataService.cs b/SeaHaven.DataServices/Implementation/UpliftDataService.cs new file mode 100644 index 0000000..f7000c2 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/UpliftDataService.cs @@ -0,0 +1,138 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class UpliftDataService : IUpliftDataService + { + private readonly ApplicationDbContext _context; + + public UpliftDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( + string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) + { + var query = from u in _context.DispatchUpliftRequests + join d in _context.Dispatches on u.DispatchId equals d.Id + join v in _context.Vendors on d.VendorId equals v.Id into vendors + from v in vendors.DefaultIfEmpty() + where (u.IsDeleted == null || u.IsDeleted == false) + select new { u, d, v }; + + if (!string.IsNullOrWhiteSpace(status)) + query = query.Where(x => x.u.Status == status); + if (tier.HasValue) + query = query.Where(x => x.u.RequiredTier == tier.Value); + + var total = await query.CountAsync(cancellationToken); + + var items = await query + .OrderByDescending(x => x.u.CreatedDate) + .Skip((Math.Max(page, 1) - 1) * pageSize) + .Take(pageSize) + .Select(x => new UpliftListItemData + { + Id = x.u.Id, + DispatchId = x.d.Id, + DispatchNumber = x.d.DispatchNumber, + PONumber = x.d.PONumber, + WorkOrderId = x.d.WorkOrderId, + VendorCompanyName = x.v != null ? x.v.CompanyName : x.u.RequestedByVendorName, + CurrentNTE = x.u.CurrentNTE, + RequestedNTE = x.u.RequestedNTE, + VendorReason = x.u.VendorReason, + RequiredTier = x.u.RequiredTier, + Status = x.u.Status, + CreatedDate = x.u.CreatedDate, + DecidedAt = x.u.DecidedAt, + DecisionNote = x.u.DecisionNote + }) + .ToListAsync(cancellationToken); + + return (total, items); + } + + public async Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from u in _context.DispatchUpliftRequests + where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) + join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs + from dec in decs.DefaultIfEmpty() + orderby u.CreatedDate descending + select new UpliftForDispatchData + { + Id = u.Id, + DispatchId = u.DispatchId, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + CreatedDate = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByFirstName = dec != null ? dec.FirstName : null, + DecidedByLastName = dec != null ? dec.LastName : null + }).ToListAsync(cancellationToken); + } + + public async Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken) + { + return await (from u in _context.DispatchUpliftRequests + where u.DispatchId == dispatchId && (u.IsDeleted == null || u.IsDeleted == false) + join dec in _context.Users on u.DecidedByUserId equals dec.Id into decs + from dec in decs.DefaultIfEmpty() + orderby u.CreatedDate descending + select new PortalUpliftData + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + CreatedDate = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByFirstName = dec != null ? dec.FirstName : null, + DecidedByLastName = dec != null ? dec.LastName : null + }).ToListAsync(cancellationToken); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.Id == id, cancellationToken); + } + + public async Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .FirstOrDefaultAsync(u => u.Id == requestId && u.DispatchId == dispatchId, cancellationToken); + } + + public async Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken) + { + return await _context.DispatchUpliftRequests + .AnyAsync(u => u.DispatchId == dispatchId + && u.Status == "Pending" + && (u.IsDeleted == null || u.IsDeleted == false), cancellationToken); + } + + public async Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken) + { + await _context.DispatchUpliftRequests.AddAsync(request, cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 365ceca..1e1c4bb 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -33,5 +34,122 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Users.AnyAsync(u => u.Id == id); } + + public async Task> 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 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 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 GetForEditAsync(string id, CancellationToken cancellationToken) + { + return await _context.Users + .Where(w => w.Id == id) + .FirstOrDefaultAsync(cancellationToken); + } + + public async Task 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 GetEmailByIdAsync( + string userId, CancellationToken cancellationToken) + { + return await _context.Users + .AsNoTracking() + .Where(user => user.Id == userId) + .Select(user => user.Email) + .FirstOrDefaultAsync(cancellationToken); + } } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 541c89f..d6767dc 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -29,11 +29,56 @@ namespace SeaHaven.DataServices.Implementation .FirstOrDefaultAsync(); } + public async Task> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken) + { + var orderedIds = ids.Distinct().ToList(); + if (orderedIds.Count == 0) + return Array.Empty(); + + 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(orderedIds.Count); + foreach (var id in orderedIds) + { + if (byId.TryGetValue(id, out var vendor)) + ordered.Add(vendor); + } + + return ordered; + } + public async Task> GetAllAsync() { return await _context.Vendors.ToListAsync(); } + public async Task> GetActiveVendorsAsync(CancellationToken cancellationToken) + { + return await _context.Vendors + .AsNoTracking() + .Where(v => v.IsActive) + .OrderBy(v => v.CompanyName) + .ToListAsync(cancellationToken); + } + + public async Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken) + { + var query = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + return await query + .OrderByDescending(v => v.Id) + .ToListAsync(cancellationToken); + } + public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -284,16 +329,47 @@ namespace SeaHaven.DataServices.Implementation return await _context.Vendors.AnyAsync(v => v.Id == id); } - public async Task GetActiveTokenAsync(int vendorId) + public async Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken) { + var now = DateTime.UtcNow; return await _context.VendorAccessTokens - .FirstOrDefaultAsync(t => t.VendorId == vendorId); + .AsNoTracking() + .Where(t => t.VendorId == vendorId + && t.RevokedAt == null + && t.ExpiresAt > now + && (t.IsDeleted == null || t.IsDeleted == false)) + .OrderByDescending(t => t.IssuedAt) + .FirstOrDefaultAsync(cancellationToken); } - public async Task GetTokenByStringAsync(string token) + public async Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + return await _context.VendorAccessTokens + .Include(t => t.Vendor) + .FirstOrDefaultAsync(t => t.Token == token + && t.RevokedAt == null + && t.ExpiresAt > now + && (t.IsDeleted == null || t.IsDeleted == false), cancellationToken); + } + + public async Task AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken) + { + await _context.VendorAccessTokens.AddAsync(token, cancellationToken); + await _context.SaveChangesAsync(cancellationToken); + return token; + } + + public async Task> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken) { return await _context.VendorAccessTokens - .FirstOrDefaultAsync(t => t.Token == token); + .Where(t => t.VendorId == vendorId && t.RevokedAt == null) + .ToListAsync(cancellationToken); + } + + public async Task SaveChangesAsync(CancellationToken cancellationToken) + { + await _context.SaveChangesAsync(cancellationToken); } public async Task CountAsync() diff --git a/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs new file mode 100644 index 0000000..c057337 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderAuditDataService.cs @@ -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); + } + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs index 9509840..a382d80 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderDataService.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Implementation { @@ -101,7 +102,7 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } - public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search = null, @@ -131,13 +132,12 @@ namespace SeaHaven.DataServices.Implementation if (!string.IsNullOrWhiteSpace(search)) { - var s = search.ToLower(); query = query.Where(w => - (w.InternalWONumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || - (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || - (w.Locations.Title ?? "").ToLower().Contains(s) || - (w.Locations.Name ?? "").ToLower().Contains(s) + (w.InternalWONumber != null && w.InternalWONumber.Contains(search)) || + (w.WorkerOrderNumber != null && w.WorkerOrderNumber.Contains(search)) || + (w.WorkerOrderTitle != null && w.WorkerOrderTitle.Contains(search)) || + (w.Locations != null && w.Locations.Title != null && w.Locations.Title.Contains(search)) || + (w.Locations != null && w.Locations.Name != null && w.Locations.Name.Contains(search)) ); } @@ -160,24 +160,24 @@ namespace SeaHaven.DataServices.Implementation var data = await ordered .Skip((page - 1) * pageSize) .Take(pageSize) - .Select(w => new + .Select(w => new WorkOrderListItemReadModel { - id = w.Id, - internalWONumber = w.InternalWONumber, - workOrderNumber = w.WorkerOrderNumber, - number = w.InternalWONumber ?? w.WorkerOrderNumber, - title = w.WorkerOrderTitle, - location = w.Locations != null ? w.Locations.Name : "", - locationName = w.Locations != null ? w.Locations.Name : "", - priority = w.Priority, - status = w.Status, - createdDate = w.CreatedDate, - dueDate = w.DueDate, - assignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - employeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", - lastUpdated = new[] { + Id = w.Id, + InternalWONumber = w.InternalWONumber, + WorkOrderNumber = w.WorkerOrderNumber, + Number = w.InternalWONumber ?? w.WorkerOrderNumber, + Title = w.WorkerOrderTitle, + Location = w.Locations != null ? w.Locations.Name : "", + LocationName = w.Locations != null ? w.Locations.Name : "", + Priority = w.Priority, + Status = w.Status, + CreatedDate = w.CreatedDate, + DueDate = w.DueDate, + AssignedTo = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", + EmployeeName = w.AssignToUser != null ? w.AssignToUser.FirstName + " " + w.AssignToUser.LastName : "", + LastUpdated = new[] { 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) }.Where(d => d.HasValue).Max() }) @@ -283,6 +283,15 @@ namespace SeaHaven.DataServices.Implementation await _context.SaveChangesAsync(); } + public async Task AddAuditLogsAsync(IReadOnlyCollection auditLogs) + { + if (auditLogs.Count == 0) + return; + + await _context.WorkOrderAuditLogs.AddRangeAsync(auditLogs); + await _context.SaveChangesAsync(); + } + public async Task DeleteWorkOrderContactsAsync(int workOrderId) { var contacts = await _context.WorkOrderContacts @@ -349,6 +358,11 @@ namespace SeaHaven.DataServices.Implementation return await _context.workOrders.CountAsync(w => w.Status == status); } + public async Task CountByPriorityAsync(string priority) + { + return await _context.workOrders.CountAsync(w => w.Priority == priority); + } + public async Task CountByLocationAsync(int locationId) { return await _context.workOrders.CountAsync(w => w.LocationId == locationId); @@ -363,5 +377,146 @@ namespace SeaHaven.DataServices.Implementation .AsSplitQuery() .ToListAsync(); } + + public async Task> GetByRequestedIdsWithDetailsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken) + { + var orderedIds = ids.Distinct().ToList(); + if (orderedIds.Count == 0) + return Array.Empty(); + + 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(orderedIds.Count); + foreach (var id in orderedIds) + { + if (byId.TryGetValue(id, out var workOrder)) + ordered.Add(workOrder); + } + + return ordered; + } + + public async Task 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> 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; + } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs new file mode 100644 index 0000000..22792d0 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderFieldLockDataService.cs @@ -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 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)); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ICalendarDataService.cs b/SeaHaven.DataServices/Interfaces/ICalendarDataService.cs new file mode 100644 index 0000000..7797bc2 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ICalendarDataService.cs @@ -0,0 +1,14 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ICalendarDataService + { + Task GetEventByIdAsync(int id, CancellationToken cancellationToken); + Task GetEventForEditAsync(int id, CancellationToken cancellationToken); + Task AddEventAsync(Events ev, CancellationToken cancellationToken); + Task UpdateEventAsync(Events ev, CancellationToken cancellationToken); + Task<(IEnumerable Items, int TotalCount)> GetEventListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs index e6bd08c..a4e87f7 100644 --- a/SeaHaven.DataServices/Interfaces/ICommentDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ICommentDataService.cs @@ -12,5 +12,7 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(Comments comment); Task DeleteAsync(int id); Task ExistsAsync(int id); + Task StageAsync(Comments comment, CancellationToken cancellationToken); + Task> GetVendorViewableForDispatchAsync(int dispatchId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/ICommonDataService.cs b/SeaHaven.DataServices/Interfaces/ICommonDataService.cs new file mode 100644 index 0000000..8d36d68 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ICommonDataService.cs @@ -0,0 +1,13 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ICommonDataService + { + Task<(IEnumerable Items, int TotalCount)> GetUsersPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken); + Task> GetRoleNamesAsync(CancellationToken cancellationToken); + Task> GetSiteNamesAsync(CancellationToken cancellationToken); + Task> GetContactsAsync(CancellationToken cancellationToken); + Task> GetCategoriesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs new file mode 100644 index 0000000..7acb41f --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IDashboardDataService.cs @@ -0,0 +1,15 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public interface IDashboardDataService + { + Task 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; } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs index 52473ba..efe7dcc 100644 --- a/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IDispatchDataService.cs @@ -1,4 +1,6 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -6,6 +8,7 @@ namespace SeaHaven.DataServices.Interfaces { Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); + Task GetByIdWithVendorAndWorkOrderAsync(int id); Task> GetAllAsync(); Task> GetByVendorIdAsync(int vendorId); Task> GetByWorkOrderIdAsync(int workOrderId); @@ -25,8 +28,29 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateChecklistItemAsync(DispatchChecklistItem item); Task> GetSignoffsAsync(int dispatchId); Task AddSignoffAsync(DispatchSignoff signoff); + Task StageSignoffAsync(DispatchSignoff signoff, CancellationToken cancellationToken); Task HasSignoffTypeAsync(int dispatchId, string signoffType); Task> GetDispatchWorkOrdersAsync(int dispatchId); Task AddDispatchWorkOrderAsync(DispatchWorkOrder dispatchWorkOrder); + + Task GetListAsync(DispatchListQuery query, CancellationToken cancellationToken); + Task GetDetailAsync(int id, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(int id); + Task> GetVendorDispatchSummariesAsync( + int vendorId, string? status, CancellationToken cancellationToken); + Task GetVendorDispatchDetailAsync( + int id, int vendorId, CancellationToken cancellationToken); + Task GetVendorDispatchForMutationAsync( + int id, int vendorId, CancellationToken cancellationToken); + Task> GetPortalChecklistAsync( + int dispatchId, CancellationToken cancellationToken); + Task> GetPortalSignoffsAsync( + int dispatchId, CancellationToken cancellationToken); + Task GetChecklistItemForDispatchAsync( + int itemId, int dispatchId, CancellationToken cancellationToken); + Task StageAuditLogAsync(WorkOrderAuditLog log, CancellationToken cancellationToken); + Task GetDispatchDispatcherUserIdAsync( + int workOrderId, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IDropdownOptionsDataService.cs b/SeaHaven.DataServices/Interfaces/IDropdownOptionsDataService.cs new file mode 100644 index 0000000..063dcae --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IDropdownOptionsDataService.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IDropdownOptionsDataService + { + Task> GetAllActiveAsync(string? category, CancellationToken cancellationToken); + Task> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken); + Task ExistsAsync(string category, string value, string? parentValue, CancellationToken cancellationToken); + Task AddAsync(DropdownOption option, CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task UpdateAsync(DropdownOption option, CancellationToken cancellationToken); + Task RemoveAsync(DropdownOption option, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs b/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs index c8b8df3..6a6c5de 100644 --- a/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IFollowUpDataService.cs @@ -15,5 +15,15 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task CountAsync(); + + Task> GetListPagedAsync(int page, int pageSize, string? search, string? status, CancellationToken cancellationToken); + Task GetListTotalAsync(string? search, string? status, CancellationToken cancellationToken); + Task GetDetailByIdAsync(int id, CancellationToken cancellationToken); + Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); + Task GetUserNameAsync(string? userId, CancellationToken cancellationToken); + Task AddAsync(FollowUps followUp, CancellationToken cancellationToken); + Task UpdateAsync(FollowUps followUp, CancellationToken cancellationToken); + Task SetStatusAsync(int id, string status, CancellationToken cancellationToken); + Task RemoveByIdAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs new file mode 100644 index 0000000..720e665 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IForgetPasswordDataService.cs @@ -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 CodeExistsAsync(string code, CancellationToken cancellationToken); + Task ExistsByEmailAndCodeAsync(string email, string code, CancellationToken cancellationToken); + Task GetByEmailAsync(string email, CancellationToken cancellationToken); + Task RemoveByEmailAsync(string email, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 2865e42..d3ee23e 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -20,5 +20,12 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task CountAsync(); + + Task<(List Items, int TotalCount)> GetListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task GetDetailByIdAsync(int id, CancellationToken cancellationToken); + Task GetByIdForUpdateAsync(int id, CancellationToken cancellationToken); + Task AddAsync(Locations location, CancellationToken cancellationToken); + Task UpdateAsync(Locations location, CancellationToken cancellationToken); + Task DeleteByIdAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs b/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs index 8357876..1f7a7da 100644 --- a/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IQuotesDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -12,5 +13,15 @@ namespace SeaHaven.DataServices.Interfaces Task DeleteAsync(int id); Task ExistsAsync(int id); Task UnlinkFromWorkOrderAsync(int workOrderId); + + Task<(IReadOnlyList Items, int TotalCount)> GetQuotesPagedAsync( + int page, int pageSize, string search, CancellationToken cancellationToken); + + Task GetQuoteDetailsByIdAsync(int id, CancellationToken cancellationToken); + + Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken); + + Task DeleteLineItemsByQuoteIdAsync(int quoteId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/ISettingsDataService.cs b/SeaHaven.DataServices/Interfaces/ISettingsDataService.cs new file mode 100644 index 0000000..78832a3 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ISettingsDataService.cs @@ -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 UpdateCategoryAsync(int id, string name, CancellationToken cancellationToken); + Task DeleteCategoryCascadeAsync(int id, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetCategoryListPagedAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetTemplatesPagedAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Users, int Total)> GetUsersPagedAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetRoleNamesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ISyncDataService.cs b/SeaHaven.DataServices/Interfaces/ISyncDataService.cs new file mode 100644 index 0000000..ee4d1be --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ISyncDataService.cs @@ -0,0 +1,25 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface ISyncDataService + { + Task GetLastInternalWONumberAsync(CancellationToken cancellationToken); + Task GetWorkOrderByExternalIdAsync(string externalId, CancellationToken cancellationToken); + void EnqueueWorkOrder(WorkOrder workOrder); + void EnqueueComment(Comments comment); + Task SaveChangesAsync(CancellationToken cancellationToken); + + Task ResolveLocationAsync(string? siteCode, string? building, string? address, CancellationToken cancellationToken); + Task GetLocationCountAsync(CancellationToken cancellationToken); + + Task CommentExistsByExternalIdAsync(string externalId, CancellationToken cancellationToken); + Task GetWorkOrderByInternalNumberAsync(string internalNumber, CancellationToken cancellationToken); + Task GetDispatchByNumberAndWorkOrderIdAsync(string dispatchNumber, int workOrderId, CancellationToken cancellationToken); + + Task> GetWorkOrdersMissingInternalNumberAsync(CancellationToken cancellationToken); + Task> GetDispatchesMissingNumberAsync(CancellationToken cancellationToken); + Task ExecuteBackfillCommentTypesAsync(CancellationToken cancellationToken); + Task GetLastDispatchNumberAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs b/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs index ac763d3..0c63709 100644 --- a/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ITaskListTemplateDataService.cs @@ -11,5 +11,13 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(TaskListTemplate template); Task DeleteAsync(int id); Task ExistsAsync(int id); + + // Cancelable query/command operations used by the TaskListTemplate service layer + Task> GetActiveTemplatesWithItemsAsync(CancellationToken cancellationToken); + Task GetTemplateWithItemsReadOnlyAsync(int id, CancellationToken cancellationToken); + Task GetTemplateWithItemsForUpdateAsync(int id, CancellationToken cancellationToken); + Task AddTemplateAsync(TaskListTemplate template, CancellationToken cancellationToken); + Task ReplaceTemplateItemsAsync(TaskListTemplate template, IEnumerable newItems, CancellationToken cancellationToken); + Task DeactivateTemplateAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs new file mode 100644 index 0000000..ee7ead2 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IUpliftDataService.cs @@ -0,0 +1,17 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IUpliftDataService + { + Task<(int TotalCount, IReadOnlyList Items)> GetPagedAsync( + string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); + Task> GetForDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task> GetForVendorDispatchAsync(int dispatchId, CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task GetByIdAndDispatchAsync(int requestId, int dispatchId, CancellationToken cancellationToken); + Task HasPendingAsync(int dispatchId, CancellationToken cancellationToken); + Task StageAsync(DispatchUpliftRequest request, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 6524cc4..8ca1655 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; namespace SeaHaven.DataServices.Interfaces { @@ -8,5 +9,14 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task GetByEmailAsync(string email); Task ExistsAsync(string id); + + Task> GetAllForListAsync(CancellationToken cancellationToken); + Task GetProfileAsync(string id, CancellationToken cancellationToken); + Task UpdateProfileAsync(string id, string? name, string? email, string? contact, CancellationToken cancellationToken); + Task GetForEditAsync(string id, CancellationToken cancellationToken); + Task GetByEmailNormalizedAsync(string email, CancellationToken cancellationToken); + Task UpdateUserAsync(ApplicationUser user, CancellationToken cancellationToken); + Task DeleteUserWithCascadeAsync(ApplicationUser user, CancellationToken cancellationToken); + Task GetEmailByIdAsync(string userId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index b0acd6d..3324b6e 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -6,7 +6,13 @@ namespace SeaHaven.DataServices.Interfaces { Task GetByIdAsync(int id); Task 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> GetByIdsOrderedAsync(IReadOnlyCollection ids, CancellationToken cancellationToken); Task> GetAllAsync(); + Task> GetActiveVendorsAsync(CancellationToken cancellationToken); + Task> GetVendorsForFacetsAsync(bool? isActive, CancellationToken cancellationToken); Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, @@ -20,8 +26,11 @@ namespace SeaHaven.DataServices.Interfaces Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); Task ExistsAsync(int id); - Task GetActiveTokenAsync(int vendorId); - Task GetTokenByStringAsync(string token); + Task GetActiveTokenForVendorAsync(int vendorId, CancellationToken cancellationToken); + Task GetTokenWithVendorAsync(string token, CancellationToken cancellationToken); + Task AddTokenAsync(VendorAccessToken token, CancellationToken cancellationToken); + Task> GetRevocableTokensAsync(int vendorId, CancellationToken cancellationToken); + Task SaveChangesAsync(CancellationToken cancellationToken); Task CountAsync(); Task GetCompanyByIdAsync(int id); diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs new file mode 100644 index 0000000..d1bcef4 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderAuditDataService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderAuditDataService + { + void EnqueueAuditLog(WorkOrderAuditLog auditLog); + + Task PersistAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs index 3ce62e5..3edbd12 100644 --- a/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDataService.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Models; namespace SeaHaven.DataServices.Interfaces { @@ -13,7 +14,7 @@ namespace SeaHaven.DataServices.Interfaces Task> GetByAssigneeAsync(string assigneeId); Task> GetByStatusAsync(string status); Task> GetByPriorityAsync(string priority); - + // Paged results Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, @@ -23,7 +24,7 @@ namespace SeaHaven.DataServices.Interfaces string? priority = null); // Paged list with detailed information (for GetWorkOrderList endpoint) - Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( int page, int pageSize, string? search = null, @@ -32,12 +33,12 @@ namespace SeaHaven.DataServices.Interfaces int? locationId = null, string? sortBy = "lastUpdated", string? sortDir = "desc"); - + // Command operations Task AddAsync(WorkOrder workOrder); Task UpdateAsync(WorkOrder workOrder); Task DeleteAsync(int id); - + // Related entities - Read Task> GetWorkOrderContactsAsync(int workOrderId); Task> GetWorkOrderCategoriesAsync(int workOrderId); @@ -50,6 +51,7 @@ namespace SeaHaven.DataServices.Interfaces Task AddWorkOrderCategoriesAsync(int workOrderId, IEnumerable categoryIds); Task AddWorkOrderAttachmentAsync(WorkOrderAttachments attachment); Task AddAuditLogAsync(WorkOrderAuditLog auditLog); + Task AddAuditLogsAsync(IReadOnlyCollection auditLogs); // Related entities - Delete Task DeleteWorkOrderContactsAsync(int workOrderId); @@ -66,7 +68,18 @@ namespace SeaHaven.DataServices.Interfaces // Statistics Task CountAsync(); Task CountByStatusAsync(string status); + Task CountByPriorityAsync(string priority); Task CountByLocationAsync(int locationId); Task> GetAllWithDetailsAsync(); + + // Batched lookup: server-filters the distinct requested ids, excludes templates, + // eagerly loads the location detail, deduplicates, and returns results in input order. + Task> GetByRequestedIdsWithDetailsAsync( + IReadOnlyCollection ids, + CancellationToken cancellationToken); + + // Complex read models + Task GetWorkOrderDetailAsync(int id); + Task> GetNonTemplateWorkOrdersWithLocationsAsync(); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs new file mode 100644 index 0000000..2f63cea --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderFieldLockDataService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderFieldLockDataService + { + Task IsLockedAsync(int workOrderId, string fieldName, CancellationToken cancellationToken); + + void EnqueueLock(int workOrderId, string fieldName, string? userId); + } +} diff --git a/SeaHaven.DataServices/Models/ReadModels.cs b/SeaHaven.DataServices/Models/ReadModels.cs new file mode 100644 index 0000000..71a5c76 --- /dev/null +++ b/SeaHaven.DataServices/Models/ReadModels.cs @@ -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 Poc { get; set; } = new(); + public List Category { get; set; } = new(); + public List 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 Comments { get; set; } = new(); + public List Quotes { get; set; } = new(); + public List AuditLog { get; set; } = new(); + public List 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 WorkOrders { get; set; } = new(); + public List ChecklistItems { get; set; } = new(); + public List Signoffs { get; set; } = new(); + public List 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 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 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; } + } +} diff --git a/SeaHaven.Services/Configuration/ServiceOptions.cs b/SeaHaven.Services/Configuration/ServiceOptions.cs new file mode 100644 index 0000000..dad10e0 --- /dev/null +++ b/SeaHaven.Services/Configuration/ServiceOptions.cs @@ -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(); + public string[] Tier2Roles { get; set; } = Array.Empty(); +} + +public sealed class VendorPortalOptions +{ + public const string SectionName = "VendorPortal"; + + public int? TokenLifetimeDays { get; set; } +} diff --git a/SeaHaven.Services/DTOs/CalendarDTOs.cs b/SeaHaven.Services/DTOs/CalendarDTOs.cs new file mode 100644 index 0000000..5e5b1c5 --- /dev/null +++ b/SeaHaven.Services/DTOs/CalendarDTOs.cs @@ -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 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; } + } +} diff --git a/SeaHaven.Services/DTOs/CommonDTOs.cs b/SeaHaven.Services/DTOs/CommonDTOs.cs new file mode 100644 index 0000000..639da7d --- /dev/null +++ b/SeaHaven.Services/DTOs/CommonDTOs.cs @@ -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 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; } + } +} diff --git a/SeaHaven.Services/DTOs/DashboardDTOs.cs b/SeaHaven.Services/DTOs/DashboardDTOs.cs new file mode 100644 index 0000000..6bac68c --- /dev/null +++ b/SeaHaven.Services/DTOs/DashboardDTOs.cs @@ -0,0 +1,10 @@ +namespace SeaHaven.Services.DTOs +{ + public class DashboardStatsDTO + { + public int Total { get; set; } + public int Open { get; set; } + public int NotDispatched { get; set; } + public int Completed { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/DispatchDTOs.cs b/SeaHaven.Services/DTOs/DispatchDTOs.cs index 49a4a53..1a68b6c 100644 --- a/SeaHaven.Services/DTOs/DispatchDTOs.cs +++ b/SeaHaven.Services/DTOs/DispatchDTOs.cs @@ -27,4 +27,158 @@ namespace SeaHaven.Services.DTOs public DateTime? CompletedDate { get; set; } public string? Status { get; set; } } + + public class DispatchListEntryDTO + { + 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 DispatchListResultDTO + { + public IReadOnlyList Items { get; set; } = Array.Empty(); + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + } + + public class DispatchDetailVendorResultDTO + { + 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 DispatchDetailLocationResultDTO + { + 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 DispatchDetailLinkedWorkOrderDTO + { + 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 DispatchDetailChecklistDTO + { + 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 DispatchDetailSignoffDTO + { + 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 DispatchDetailCommentDTO + { + 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 DispatchDetailUpliftDTO + { + 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 DispatchDetailAuditLogDTO + { + 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 DispatchDetailResultDTO + { + 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 DispatchDetailVendorResultDTO? Vendor { get; set; } + public DispatchDetailLocationResultDTO? PrimaryLocation { get; set; } + public IReadOnlyList WorkOrders { get; set; } = Array.Empty(); + public IReadOnlyList Checklist { get; set; } = Array.Empty(); + public IReadOnlyList Signoffs { get; set; } = Array.Empty(); + public IReadOnlyList Comments { get; set; } = Array.Empty(); + public IReadOnlyList UpliftRequests { get; set; } = Array.Empty(); + public IReadOnlyList AuditLog { get; set; } = Array.Empty(); + } } diff --git a/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs b/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs new file mode 100644 index 0000000..ede579e --- /dev/null +++ b/SeaHaven.Services/DTOs/DispatchServiceDTOs.cs @@ -0,0 +1,131 @@ +namespace SeaHaven.Services.DTOs +{ + public class DispatchVerificationException : Exception + { + public List Missing { get; } + public DispatchVerificationException(List missing) : base("Cannot verify") + { + Missing = missing; + } + } + + public class DispatchToVendorInput + { + public List WorkOrderIds { get; set; } = new(); + public List VendorIds { get; set; } = new(); + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? TaskListTemplateId { get; set; } + public List? CustomChecklistItems { get; set; } + public string? UserId { get; set; } + public string? SenderName { get; set; } + } + + public class DispatchResultItem + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? VendorName { get; set; } + public string? Status { get; set; } + public int WorkOrderCount { get; set; } + } + + public class DispatchToVendorResult + { + public string Message { get; set; } = ""; + public List Dispatches { get; set; } = new(); + } + + public class UpdateDispatchInput + { + public int Id { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Description { get; set; } + public string? UserId { get; set; } + } + + public class AddDispatchCommentInput + { + public int DispatchId { get; set; } + public string? Text { get; set; } + public bool SendEmail { get; set; } + public string? UserId { get; set; } + } + + public class AddDispatchCommentResult + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + } + + public class AddDispatchSignoffInput + { + public int DispatchId { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? Signature { get; set; } + public string? SignatureMethod { get; set; } + } + + public class AddDispatchSignoffResult + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public DateTime? SignedAt { get; set; } + public string? SignatureMethod { get; set; } + } + + public class VerifyDispatchResult + { + public string Message { get; set; } = ""; + public string? VerifiedBy { get; set; } + public DateTime? VerifiedAt { get; set; } + } + + public class VerifyDispatchError + { + public string Status { get; set; } = "Error"; + public string Message { get; set; } = ""; + public List Missing { get; set; } = new(); + } + + public class ChecklistItemUpdateInput + { + public int Id { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + } + + public class ChecklistItemUpdateResult + { + public string Message { get; set; } = ""; + public int Id { get; set; } + public bool? IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class AddChecklistItemInput + { + public int DispatchId { get; set; } + public int? WorkOrderId { get; set; } + public string? ItemText { get; set; } + } + + public class AddChecklistItemResult + { + public int Id { get; set; } + public string? ItemText { get; set; } + public int? SortOrder { get; set; } + public bool? IsCompleted { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/DropdownOptionDTOs.cs b/SeaHaven.Services/DTOs/DropdownOptionDTOs.cs new file mode 100644 index 0000000..754a7f7 --- /dev/null +++ b/SeaHaven.Services/DTOs/DropdownOptionDTOs.cs @@ -0,0 +1,12 @@ +namespace SeaHaven.Services.DTOs +{ + public class DropdownOptionDTO + { + 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; + } +} diff --git a/SeaHaven.Services/DTOs/FollowUpDTOs.cs b/SeaHaven.Services/DTOs/FollowUpDTOs.cs index 4200416..1575296 100644 --- a/SeaHaven.Services/DTOs/FollowUpDTOs.cs +++ b/SeaHaven.Services/DTOs/FollowUpDTOs.cs @@ -40,4 +40,69 @@ namespace SeaHaven.Services.DTOs public int? AccountId { get; set; } public int? LocationId { get; set; } } + + public class FollowUpCreateRequestDTO + { + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + } + + public class FollowUpUpdateRequestDTO + { + public int Id { get; set; } + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + } + + public class FollowUpListRowDTO + { + public int Id { get; set; } + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + public string AccountName { get; set; } = ""; + public string AssignedTo { get; set; } = ""; + public string LocationName { get; set; } = ""; + public string WorkOrderNumber { get; set; } = ""; + public string InternalWONumber { get; set; } = ""; + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public string? CreatedById { get; set; } + public string CreatedByName { get; set; } = ""; + } + + public class FollowUpDetailDTO + { + public int Id { get; set; } + public string? Reason { get; set; } + public DateTime ScheduleDate { get; set; } + public TimeSpan ScheduleStartTime { get; set; } + public int? EmployeeId { get; set; } + public int? WorkOrderId { get; set; } + public int? AccountId { get; set; } + public int? LocationId { get; set; } + public string AccountName { get; set; } = ""; + public string AssignedTo { get; set; } = ""; + public string LocationName { get; set; } = ""; + public string WorkOrderNumber { get; set; } = ""; + public string InternalWONumber { get; set; } = ""; + public string? Status { get; set; } + public DateTime? CreatedDate { get; set; } + public string? CreatedById { get; set; } + public string CreatedByName { get; set; } = ""; + } } diff --git a/SeaHaven.Services/DTOs/IdentityDTOs.cs b/SeaHaven.Services/DTOs/IdentityDTOs.cs new file mode 100644 index 0000000..94df6dc --- /dev/null +++ b/SeaHaven.Services/DTOs/IdentityDTOs.cs @@ -0,0 +1,68 @@ +namespace SeaHaven.Services.DTOs +{ + public class LoginResultDTO + { + public string Token { get; set; } = string.Empty; + public DateTime Expiration { get; set; } + public string? Email { get; set; } + public string? UserRole { get; set; } + public string? PhoneNumber { get; set; } + public string Fullname { get; set; } = string.Empty; + public string Id { get; set; } = string.Empty; + } + + public class UpdateProfileRequestDTO + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } + + public class UserProfileDTO + { + public string? FirstName { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } + + public class AddUserRequestDTO + { + public string? Name { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + public string? Role { get; set; } + } + + public class AddUserOutcomeDTO + { + public bool Success { get; set; } + public string? Error { get; set; } + } + + public class EditUserRequestDTO + { + public string? Id { get; set; } + public string? Name { get; set; } + public string? Email { get; set; } + public string? Role { get; set; } + } + + public class UserListRowDTO + { + public string? RoleName { get; set; } + public string? Email { get; set; } + public string? Name { get; set; } + public string Date { get; set; } = string.Empty; + public string? Status { get; set; } + public string? Id { get; set; } + } + + public class UserProfileRowDTO + { + public string? Id { get; set; } + public string? Name { get; set; } + public DateTime? AddedDate { get; set; } + public string? Email { get; set; } + public string? Contact { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 09e41b6..541ba14 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -36,4 +36,30 @@ namespace SeaHaven.Services.DTOs public string? Zipcode { get; set; } public int? AccountId { get; set; } } + + public class LocationCreateRequestDTO + { + public string? Name { get; set; } + public string? Title { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? ContactEmail { get; set; } + public string? Status { get; set; } + } + + public class LocationUpdateRequestDTO + { + public string? Name { get; set; } + public string? Title { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? ZipCode { get; set; } + public string? Phone { get; set; } + public string? ContactEmail { get; set; } + public string? Status { get; set; } + } } diff --git a/SeaHaven.Services/DTOs/QuoteDTOs.cs b/SeaHaven.Services/DTOs/QuoteDTOs.cs new file mode 100644 index 0000000..ef7364c --- /dev/null +++ b/SeaHaven.Services/DTOs/QuoteDTOs.cs @@ -0,0 +1,108 @@ +namespace SeaHaven.Services.DTOs +{ + public class CreateQuoteInput + { + public string? QuotId { get; set; } + public int? ContactId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? ProjectName { get; set; } + public DateTime? Date { get; set; } + public int? WorkorderId { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public List? LineItems { get; set; } + } + + public class UpdateQuoteInput + { + public int Id { get; set; } + public string? QuotId { get; set; } + public int? ContactId { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? ProjectName { get; set; } + public DateTime? Date { get; set; } + public int? WorkorderId { get; set; } + public string? SiteName { get; set; } + public string? SiteId { get; set; } + public string? BillingAddress { get; set; } + public List? LineItems { get; set; } + } + + public class QuoteLineItemInput + { + public int? LineitemNumber { get; set; } + public decimal? Lineitem { get; set; } + public string? Descriptions { get; set; } + } + + public class PagedQuotesResult + { + public IReadOnlyList Items { get; set; } = Array.Empty(); + public int TotalCount { get; set; } + } + + public class QuoteListItemDTO + { + public int Id { get; set; } + public string? QuoteId { get; set; } + public string? WorkOrder { get; set; } + public string? ProjectName { get; set; } + public string? 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 IReadOnlyList Lineitems { get; set; } = Array.Empty(); + } + + public class QuoteDetailDTO + { + 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 string? TotalAmount { get; set; } + public IReadOnlyList Lineitems { get; set; } = Array.Empty(); + } + + public class QuoteLineItemDTO + { + public string? Descriptions { get; set; } + public decimal? Lineitem { get; set; } + public int? LineitemNumber { get; set; } + } + + public class WorkOrderQuoteDetailDTO + { + 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; } + } + + public class QuoteReportingData + { + public int TotalCreatedWorkOrder { get; set; } + public int TotalCompleted { get; set; } + public int TotalSev1 { get; set; } + public int TotalSev2 { get; set; } + public int TotalSev3 { get; set; } + public int TotalSev4 { get; set; } + public int TotalSev5 { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/SettingsDTOs.cs b/SeaHaven.Services/DTOs/SettingsDTOs.cs new file mode 100644 index 0000000..f538dda --- /dev/null +++ b/SeaHaven.Services/DTOs/SettingsDTOs.cs @@ -0,0 +1,98 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class RoleDTO + { + public string? Name { get; set; } + public string? Description { get; set; } + } + + public class SettingsUserDTO + { + 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 SettingsCategoryDTO + { + public int Id { get; set; } + public string? Name { get; set; } + } + + public class SettingsTemplateDTO + { + public int Id { get; set; } + public bool? IsDeleted { get; set; } + public string? createdby { get; set; } + public string? DeleterUserId { get; set; } + public DateTime? DeletionTime { get; set; } + public DateTime? CreatedDate { get; set; } + public DateTime? LastModificationTime { get; set; } + public int? LastModifierUserId { get; set; } + + public string? InternalWONumber { get; set; } + public string? ExternalWorkOrderId { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Customer { get; set; } + public string? SiteCode { get; set; } + public string? Building { get; set; } + public string? Severity { get; set; } + public DateTime? DueDate { get; set; } + public string? AssignTo { get; set; } + public int? LocationId { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public string? LegacyStatus { get; set; } + public WorkOrderType? WorkOrderType { get; set; } + public string? Service { get; set; } + public int? PrimaryDispatchId { get; set; } + + public DateTime? ScheduledDate { get; set; } + public DateTime? ScheduledStart { get; set; } + public DateTime? ScheduledEnd { get; set; } + public DateOnly? TargetWeek { get; set; } + public bool? ScheduleWeekOnly { get; set; } + + public DateOnly? OriginalWeek { get; set; } + public DateOnly? OriginalDate { get; set; } + + public int RescheduleCount { get; set; } + public int CarriedOver { get; set; } + public OperationalFlags OperationalFlags { get; set; } + + public DocStatus? DocStatus { get; set; } + + public string? AvettaTask { get; set; } + public DateOnly? AssignDate { get; set; } + + public DateTime? DateReported { get; set; } + public string? SourceEmailS3Key { get; set; } + public string? Problem { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public decimal? VendorNTE { get; set; } + public DateTime? CompletedDate { get; set; } + public string? Source { get; set; } + public bool? istemplate { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public string? Attachments { get; set; } + 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 byte[]? RowVersion { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/SyncDTOs.cs b/SeaHaven.Services/DTOs/SyncDTOs.cs new file mode 100644 index 0000000..5973aaa --- /dev/null +++ b/SeaHaven.Services/DTOs/SyncDTOs.cs @@ -0,0 +1,28 @@ +namespace SeaHaven.Services.DTOs +{ + public class SyncWorkOrdersResult + { + public int Synced { get; set; } + public int Created { get; set; } + public int Updated { get; set; } + public int LocationsCreated { get; set; } + } + + public class SyncCommentsResult + { + public int Synced { get; set; } + public int Created { get; set; } + public int Skipped { get; set; } + } + + public class BackfillResult + { + public int Updated { get; set; } + } + + public class SyncAllResult + { + public SyncWorkOrdersResult? WorkOrders { get; set; } + public SyncCommentsResult? Comments { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/TaskListTemplateDTOs.cs b/SeaHaven.Services/DTOs/TaskListTemplateDTOs.cs new file mode 100644 index 0000000..9057525 --- /dev/null +++ b/SeaHaven.Services/DTOs/TaskListTemplateDTOs.cs @@ -0,0 +1,32 @@ +namespace SeaHaven.Services.DTOs +{ + public class TaskListTemplateDTO + { + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } + public List Items { get; set; } = new(); + } + + public class TaskListTemplateItemDTO + { + public int Id { get; set; } + public string? ItemText { get; set; } + public int SortOrder { get; set; } + } + + public class TaskListTemplateInput + { + public string? Name { get; set; } + public string? Description { get; set; } + public bool IsActive { get; set; } = true; + public List Items { get; set; } = new(); + } + + public class TaskListTemplateInputItem + { + public string? ItemText { get; set; } + public int SortOrder { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/UpliftDTOs.cs b/SeaHaven.Services/DTOs/UpliftDTOs.cs new file mode 100644 index 0000000..0dcc483 --- /dev/null +++ b/SeaHaven.Services/DTOs/UpliftDTOs.cs @@ -0,0 +1,66 @@ +namespace SeaHaven.Services.DTOs +{ + public class UpliftListItemDTO + { + public int Id { get; set; } + public int DispatchId { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public int? WorkOrderId { get; set; } + public string? VendorCompanyName { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public int RequiredTier { get; set; } + public string? Status { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public bool CanDecide { get; set; } + } + + public class UpliftListResultDTO + { + public int Total { get; set; } + public int Page { get; set; } + public int PageSize { get; set; } + public IEnumerable Items { get; set; } = Enumerable.Empty(); + } + + public class UpliftForDispatchDTO + { + public int Id { get; set; } + public int DispatchId { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public decimal Delta { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByName { get; set; } + public bool CanDecide { get; set; } + } + + public class UpliftApproveResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + } + + public class UpliftDenyResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } + + public class UpliftForbiddenException : Exception + { + public UpliftForbiddenException(string message) : base(message) { } + } +} diff --git a/SeaHaven.Services/DTOs/VendorPortalDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs new file mode 100644 index 0000000..8af1cfa --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorPortalDTOs.cs @@ -0,0 +1,65 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class VendorPortalTokenStateDTO + { + public required string Token { get; init; } + public DateTime IssuedAt { get; init; } + public DateTime ExpiresAt { get; init; } + public DateTime? LastUsedAt { get; init; } + } + + public class VendorPortalTokenDTO + { + public string Token { get; set; } = string.Empty; + public DateTime IssuedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? LastUsedAt { get; set; } + public string PortalUrl { get; set; } = string.Empty; + } + + public class VendorDropdownItemDTO + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? TradeSpecialties { get; set; } + public string? Address { get; set; } + public double? DistanceMiles { get; set; } + } + + public class VendorFacetCompanyDTO + { + public string? Name { get; set; } + public int? CompanyId { get; set; } + public string? CompanyPhone { get; set; } + public string? Email { get; set; } + public string? PreferredContact { get; set; } + public string? Address { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + public string? GoogleMapsUrl { get; set; } + } + + public class VendorFacetLocationDTO + { + public string? City { get; set; } + public string? State { get; set; } + public string? Label { get; set; } + } + + public class VendorJobBucketDTO + { + public string Id { get; set; } = string.Empty; + public string Label { get; set; } = string.Empty; + } + + public class VendorFacetsDTO + { + public IEnumerable Companies { get; set; } = Enumerable.Empty(); + public IEnumerable Trades { get; set; } = Enumerable.Empty(); + public IEnumerable Locations { get; set; } = Enumerable.Empty(); + public IEnumerable JobBuckets { get; set; } = Enumerable.Empty(); + } +} diff --git a/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs new file mode 100644 index 0000000..27cc8e7 --- /dev/null +++ b/SeaHaven.Services/DTOs/VendorPortalServiceDTOs.cs @@ -0,0 +1,164 @@ +namespace SeaHaven.Services.DTOs +{ + public class VendorPortalSession + { + public int Id { get; set; } + public string? CompanyName { get; set; } + public string? ContactName { get; set; } + public string? Email { get; set; } + public string? Phone { get; set; } + } + + public class VendorDispatchSummaryDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public string? WorkOrderTitle { get; set; } + public string? InternalWONumber { get; set; } + public string? LocationName { get; set; } + public string? LocationCity { get; set; } + public string? LocationState { get; set; } + } + + public class PortalChecklistItemDTO + { + public int Id { get; set; } + public string? ItemText { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class PortalSignoffDTO + { + public int Id { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignatureMethod { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class PortalUpliftDTO + { + public int Id { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + public string? VendorReason { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public string? RequestedByVendorName { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? DecidedAt { get; set; } + public string? DecisionNote { get; set; } + public string? DecidedByName { get; set; } + } + + public class PortalCommentDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public string? CommentType { get; set; } + public DateTime? CreatedDate { get; set; } + } + + public class PortalWorkOrderDTO + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Priority { get; set; } + public DateTime? DueDate { get; set; } + public string? Trade { get; set; } + public string? SubTrade { get; set; } + public string? Problem { get; set; } + } + + public class PortalLocationDTO + { + public string? Name { get; set; } + public string? Address1 { get; set; } + public string? City { get; set; } + public string? State { get; set; } + public string? Zip { get; set; } + } + + public class VendorDispatchDetailDTO + { + public int Id { get; set; } + public string? DispatchNumber { get; set; } + public string? PONumber { get; set; } + public string? Status { get; set; } + public decimal? NTEAmount { get; set; } + public string? Description { get; set; } + public DateTime? ScheduledDate { get; set; } + public DateTime? CompletedDate { get; set; } + public DateTime? DispatchedAt { get; set; } + public DateTime? AcknowledgedAt { get; set; } + public PortalWorkOrderDTO? WorkOrder { get; set; } + public PortalLocationDTO? Location { get; set; } + public IEnumerable Checklist { get; set; } = Enumerable.Empty(); + public IEnumerable Signoffs { get; set; } = Enumerable.Empty(); + public IEnumerable Comments { get; set; } = Enumerable.Empty(); + public IEnumerable UpliftRequests { get; set; } = Enumerable.Empty(); + } + + public class AcceptDispatchResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public DateTime? AcknowledgedAt { get; set; } + } + + public class ChangeStatusResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public DateTime? CompletedDate { get; set; } + } + + public class ChecklistItemResultDTO + { + public int Id { get; set; } + public bool IsCompleted { get; set; } + public string? CompletedBy { get; set; } + public DateTime? CompletedAt { get; set; } + } + + public class SignoffResultDTO + { + public int Id { get; set; } + public DateTime? SignedAt { get; set; } + } + + public class CommentResultDTO + { + public int Id { get; set; } + public string? Commenttext { get; set; } + public string? Commenter { get; set; } + public DateTime? CreatedDate { get; set; } + } + + public class UpliftRequestResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + public int RequiredTier { get; set; } + public decimal? CurrentNTE { get; set; } + public decimal RequestedNTE { get; set; } + } + + public class CancelUpliftResultDTO + { + public int Id { get; set; } + public string? Status { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs new file mode 100644 index 0000000..705306f --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderServiceDTOs.cs @@ -0,0 +1,115 @@ +using Microsoft.AspNetCore.Http; +using SeaHaven.DataServices.Models; + +namespace SeaHaven.Services.DTOs +{ + public class CreateWorkOrderWithDetailsInput + { + public string WorkerOrderTitle { get; set; } = ""; + public string? Description { get; set; } + public string Status { get; set; } = "Open"; + public string? Priority { get; set; } + public int? LocationId { get; set; } + public string? AssignTo { get; set; } + public DateTime? DueDate { get; set; } + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + public List? Attachments { get; set; } + public IFormFile? BeforPhotoAttachment { get; set; } + public IFormFile? AfterPhotoAttachment { get; set; } + public IFormFile? SignOffAttachment { get; set; } + } + + public class UpdateWorkOrderWithDetailsInput + { + public int Id { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Description { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public int? LocationId { get; set; } + public string? AssignTo { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? CompletedDate { get; set; } + public List? ContactIds { get; set; } + public List? CategoryIds { get; set; } + public List? Attachments { get; set; } + public IFormFile? BeforPhotoAttachment { get; set; } + public IFormFile? AfterPhotoAttachment { get; set; } + public IFormFile? SignOffAttachment { get; set; } + } + + public class AddCommentInput + { + public int WorkorderId { get; set; } + public string? Text { get; set; } + public string? CommentType { get; set; } + public IFormFile? Document { get; set; } + } + + public class CommentResult + { + public int Id { get; set; } + public DateTime? CreatedDate { get; set; } + public string? Commenttext { get; set; } + public string? CommentType { get; set; } + public string? UserName { get; set; } + public string? Documents { get; set; } + } + + public class ChangeStatusResult + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + } + + public class ChangeAssignmentResult + { + public string Message { get; set; } = ""; + public string AssignedTo { get; set; } = ""; + } + + public class WorkorderDDItem + { + public int Id { get; set; } + public string? InternalWONumber { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? WorkerOrderTitle { get; set; } + public string? Status { get; set; } + public string? Priority { get; set; } + public bool istemplate { get; set; } + } + + public class WorkorderFilterVM + { + public int? Id { get; set; } + public int? LocationId { get; set; } + public string? WorkerOrderNumber { get; set; } + public string? BillingAddress { get; set; } = "Amazon.com Services LLC 410 Terry Ave,North Seattle, Washington, 98109"; + public string? WorkerOrderTitle { get; set; } + public string? LocationName { get; set; } + public string? AssignToUserName { get; set; } + public string? Priority { get; set; } + public string? Status { get; set; } + public DateTime? DueDate { get; set; } + public string FormattedDueDate => DueDate.HasValue ? DueDate.Value.ToString("MM/dd/yyyy") : ""; + public string? Dispatcher { get; set; } + public string? Scheduler { get; set; } + public string? PO { get; set; } + public string? TT { get; set; } + } + + public class FilteredWorkOrderPageDTO + { + public IReadOnlyList Data { get; set; } = + Array.Empty(); + public int PageNumber { get; set; } + public int PageSize { get; set; } + public int TotalCount { get; set; } + public int TotalPages { get; set; } + } +} diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index 12673b4..d5fddc2 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -1,5 +1,7 @@ using FluentValidation; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using SeaHaven.Services.Configuration; using System.Reflection; namespace SeaHaven.Services.DependencyInjection @@ -8,8 +10,13 @@ namespace SeaHaven.Services.DependencyInjection { private static readonly List ExcludedTypes = new(); - public static IServiceCollection AddBusinessServices(this IServiceCollection services) + public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) { + services.Configure(configuration); + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + services.Configure(configuration.GetSection(ApprovalsOptions.SectionName)); + services.Configure(configuration.GetSection(VendorPortalOptions.SectionName)); + var assembly = Assembly.GetExecutingAssembly(); var allClasses = assembly.GetTypes() diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs new file mode 100644 index 0000000..74c6de8 --- /dev/null +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -0,0 +1,160 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace SeaHaven.Services.Implementation +{ + public class AuthenticationService : IAuthenticationService + { + private readonly UserManager _userManager; + private readonly JwtOptions _jwtOptions; + private readonly IUserDataService _userDataService; + private readonly IForgetPasswordDataService _forgetPasswordDataService; + private readonly IEmailSender _emailSender; + public AuthenticationService( + UserManager userManager, + IOptions jwtOptions, + IUserDataService userDataService, + IForgetPasswordDataService forgetPasswordDataService, + IEmailSender emailSender) + { + _userManager = userManager; + _jwtOptions = jwtOptions.Value; + _userDataService = userDataService; + _forgetPasswordDataService = forgetPasswordDataService; + _emailSender = emailSender; + } + + public async Task LoginAsync(string? username, string? password, CancellationToken cancellationToken) + { + var user = await _userManager.FindByNameAsync(username ?? ""); + if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? "")) + { + var userRoles = await _userManager.GetRolesAsync(user); + var authClaims = new List + { + 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 new LoginResultDTO + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + Expiration = token.ValidTo, + Email = user.Email, + UserRole = userRoles.FirstOrDefault(), + PhoneNumber = user.PhoneNumber, + Fullname = user.FirstName + " " + user.LastName, + Id = user.Id + }; + } + + return null; + } + + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) + return false; + + var result = await _userManager.ChangePasswordAsync(user, currentPassword ?? "", confirmPassword ?? ""); + return result.Succeeded; + } + + public async Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken) + { + var exists = await _userDataService.UpdateProfileAsync( + userId, + dto.Name, + dto.Email, + dto.Contact, + cancellationToken); + if (!exists) + return null; + + var updated = await _userDataService.GetProfileAsync(userId, cancellationToken); + if (updated == null) return null; + return new UserProfileDTO + { + FirstName = updated.FirstName, + Email = updated.Email, + Contact = updated.Contact + }; + } + + public async Task ForgetPasswordAsync(string email, CancellationToken cancellationToken) + { + var user = await _userDataService.GetByEmailNormalizedAsync(email, cancellationToken); + if (user == null) return false; + + var code = GenerateRandomNo(); + await _forgetPasswordDataService.ReplaceCodeAsync(user.Email ?? "", user.Id, code, cancellationToken); + var body = $"Your Password Reset Code is: " + code; + await _emailSender.SendEmailAsync(user.Email ?? email, "Forget Password Request.", body); + return true; + } + + public async Task VerifyCodeAsync(string code, CancellationToken cancellationToken) + { + return await _forgetPasswordDataService.CodeExistsAsync(code, cancellationToken); + } + + public async Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(password)) + return false; + + var matched = await _forgetPasswordDataService.ExistsByEmailAndCodeAsync(email, code, cancellationToken); + if (!matched) return false; + + var record = await _forgetPasswordDataService.GetByEmailAsync(email, cancellationToken); + if (record == null) + return false; + + var user = await _userManager.FindByIdAsync(record.UserId); + if (user == null) + return false; + + var token = await _userManager.GeneratePasswordResetTokenAsync(user); + var result = await _userManager.ResetPasswordAsync(user, token, password); + if (!result.Succeeded) + return false; + + await _forgetPasswordDataService.RemoveByEmailAsync(email, cancellationToken); + return true; + } + + private JwtSecurityToken GetToken(List authClaims) + { + var authSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOptions.Secret)); + var token = new JwtSecurityToken( + issuer: _jwtOptions.ValidIssuer, + audience: _jwtOptions.ValidAudience, + expires: DateTime.Now.AddDays(10), + claims: authClaims, + signingCredentials: new SigningCredentials(authSigningKey, SecurityAlgorithms.HmacSha256) + ); + return token; + } + + private static string GenerateRandomNo() + { + return RandomNumberGenerator.GetInt32(1_000_000).ToString("D6"); + } + } +} diff --git a/SeaHaven.Services/Implementation/CalendarService.cs b/SeaHaven.Services/Implementation/CalendarService.cs new file mode 100644 index 0000000..cece13d --- /dev/null +++ b/SeaHaven.Services/Implementation/CalendarService.cs @@ -0,0 +1,125 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class CalendarService : ICalendarService + { + private readonly ICalendarDataService _dataService; + + public CalendarService(ICalendarDataService dataService) + { + _dataService = dataService; + } + + public async Task CreateEventAsync(EditEventInput input, CancellationToken cancellationToken) + { + var model = new Events + { + Title = input.Title, + Description = input.Description, + Location = input.Location, + RecordID = input.RecordID, + RecordName = input.RecordName, + RecordTargetType = input.RecordTargetType, + ReminderMinutes = input.ReminderMinutes, + EventColor = input.EventColor, + StartDate = input.StartDate, + StartTime = input.StartTime, + EndDate = input.EndDate, + EndTime = input.EndTime, + AllDay = input.AllDay, + CreatedDate = DateTime.Now, + IsDeleted = false + }; + + await _dataService.AddEventAsync(model, cancellationToken); + } + + public async Task UpdateEventAsync(EditEventInput input, CancellationToken cancellationToken) + { + var model = await _dataService.GetEventForEditAsync(input.Id!.Value, cancellationToken); + if (model == null || model.IsDeleted == true) + return false; + + model.Title = input.Title; + model.Description = input.Description; + model.Location = input.Location; + model.RecordID = input.RecordID; + model.RecordName = input.RecordName; + model.RecordTargetType = input.RecordTargetType; + model.ReminderMinutes = input.ReminderMinutes; + model.EventColor = input.EventColor; + model.StartDate = input.StartDate; + model.StartTime = input.StartTime; + model.EndDate = input.EndDate; + model.EndTime = input.EndTime; + model.AllDay = input.AllDay; + model.LastModificationTime = DateTime.Now; + + await _dataService.UpdateEventAsync(model, cancellationToken); + return true; + } + + public async Task GetEventByIdAsync(int id, CancellationToken cancellationToken) + { + var model = await _dataService.GetEventByIdAsync(id, cancellationToken); + return model == null ? null : MapToDTO(model); + } + + public async Task DeleteEventAsync(int id, CancellationToken cancellationToken) + { + var model = await _dataService.GetEventForEditAsync(id, cancellationToken); + if (model == null) + return false; + + model.IsDeleted = true; + model.DeletionTime = DateTime.Now; + + await _dataService.UpdateEventAsync(model, cancellationToken); + return true; + } + + public async Task GetEventListAsync(string? search, int page, int pageSize, CancellationToken cancellationToken) + { + var (items, totalCount) = await _dataService.GetEventListPagedAsync(page, pageSize, search, cancellationToken); + + return new PagedEventsResult + { + Rows = items.Select(MapToDTO).ToList(), + TotalCount = totalCount + }; + } + + public async Task> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken) + { + var events = await _dataService.GetEventsForCalendarAsync(start, end, cancellationToken); + return events.Select(MapToDTO); + } + + private static EventDTO MapToDTO(Events model) + { + return new EventDTO + { + 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 + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/CommonService.cs b/SeaHaven.Services/Implementation/CommonService.cs new file mode 100644 index 0000000..4cb0aa2 --- /dev/null +++ b/SeaHaven.Services/Implementation/CommonService.cs @@ -0,0 +1,85 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class CommonService : ICommonService + { + private readonly ICommonDataService _dataService; + private readonly UserManager _userManager; + + public CommonService(ICommonDataService dataService, UserManager userManager) + { + _dataService = dataService; + _userManager = userManager; + } + + public async Task GetUsersPagedAsync(string? search, int page, int pageSize, CancellationToken cancellationToken) + { + var (paginatedUsers, totalCount) = await _dataService.GetUsersPagedAsync(page, pageSize, search ?? "", cancellationToken); + + var filteredUsers = new List(); + + foreach (var user in paginatedUsers) + { + var roles = await _userManager.GetRolesAsync(user); + if (!roles.Contains("Admin")) + { + filteredUsers.Add(new UserListItemDTO + { + RoleName = roles.Count() > 0 ? roles.First() : "", + Email = user.Email, + UserName = user.FirstName, + Date = user.CreatedDate?.Date.ToString(), + Status = user.UniqueName, + Id = user.Id + }); + } + } + + var totalPages = (int)Math.Ceiling(totalCount / (double)pageSize); + + return new PagedUsersResult + { + Users = filteredUsers, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + } + + public async Task> GetRolesAsync(CancellationToken cancellationToken) + { + var names = await _dataService.GetRoleNamesAsync(cancellationToken); + return names.Select(name => new RoleListItemDTO { Id = name, Name = name }); + } + + public async Task> GetSiteNamesAsync(CancellationToken cancellationToken) + { + var sites = await _dataService.GetSiteNamesAsync(cancellationToken); + return sites.Select(n => new SiteNameDTO { Id = n.Id, Name = n.Name, Title = n.Title }); + } + + public async Task> GetContactsAsync(CancellationToken cancellationToken) + { + var contacts = await _dataService.GetContactsAsync(cancellationToken); + return contacts.Select(n => new ContactListItemDTO + { + Id = n.Id, + Name = (n.FirstName + " " + n.LastName).Trim(), + Email = n.Email, + Phone = n.PhoneNumber + }); + } + + public async Task> GetCategoriesAsync(CancellationToken cancellationToken) + { + var categories = await _dataService.GetCategoriesAsync(cancellationToken); + return categories.Select(s => new CategoryListItemDTO { Id = s.Id, Name = s.Name }); + } + } +} diff --git a/SeaHaven.Services/Implementation/DashboardService.cs b/SeaHaven.Services/Implementation/DashboardService.cs new file mode 100644 index 0000000..0bec513 --- /dev/null +++ b/SeaHaven.Services/Implementation/DashboardService.cs @@ -0,0 +1,29 @@ +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class DashboardService : IDashboardService + { + private readonly IDashboardDataService _dataService; + + public DashboardService(IDashboardDataService dataService) + { + _dataService = dataService; + } + + public async Task GetStatsAsync(CancellationToken cancellationToken) + { + var counts = await _dataService.GetWorkOrderCountsAsync(cancellationToken); + + return new DashboardStatsDTO + { + Total = counts.Total, + Open = counts.Open, + NotDispatched = counts.NotDispatched, + Completed = counts.Completed + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/DispatchService.cs b/SeaHaven.Services/Implementation/DispatchService.cs index 2f2d200..680dbca 100644 --- a/SeaHaven.Services/Implementation/DispatchService.cs +++ b/SeaHaven.Services/Implementation/DispatchService.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using FluentValidation; +using SeaHaven.DataServices.Dto; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; @@ -141,6 +142,184 @@ namespace SeaHaven.Services.Implementation return await _dispatchDataService.CountAsync(); } + public async Task GetDispatchListAsync( + string? search, + string? status, + int? vendorId, + DateTime? dateFrom, + DateTime? dateTo, + string? sortBy, + string? sortDir, + int page, + int pageSize, + CancellationToken cancellationToken) + { + var query = new DispatchListQuery + { + Search = search, + Status = status, + VendorId = vendorId, + DateFrom = dateFrom, + DateTo = dateTo, + SortBy = sortBy, + SortDir = sortDir, + Page = page, + PageSize = pageSize + }; + + var result = await _dispatchDataService.GetListAsync(query, cancellationToken); + + return new DispatchListResultDTO + { + Items = result.Items.Select(MapListEntry).ToList(), + Total = result.Total, + Page = result.Page, + PageSize = result.PageSize + }; + } + + public async Task GetDispatchDetailAsync(int id, CancellationToken cancellationToken) + { + var detail = await _dispatchDataService.GetDetailAsync(id, cancellationToken); + return detail == null ? null : MapDetail(detail); + } + + private static DispatchListEntryDTO MapListEntry(DispatchListItemDTO d) + { + return new DispatchListEntryDTO + { + 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.VendorName, + WorkOrderId = d.WorkOrderId, + PrimaryWOInternalNumber = d.PrimaryWOInternalNumber, + PrimaryWOTitle = d.PrimaryWOTitle, + LocationName = d.LocationName, + WorkOrderCount = d.WorkOrderCount, + PendingUpliftCount = d.PendingUpliftCount + }; + } + + private static DispatchDetailResultDTO MapDetail(DispatchDetailDTO d) + { + return new DispatchDetailResultDTO + { + 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, + AcknowledgedAt = d.AcknowledgedAt, + VerifiedAt = d.VerifiedAt, + VerifiedBy = d.VerifiedBy, + EmailSent = d.EmailSent, + WorkOrderId = d.WorkOrderId, + Vendor = d.Vendor == null ? null : new DispatchDetailVendorResultDTO + { + Id = d.Vendor.Id, + CompanyName = d.Vendor.CompanyName, + ContactName = d.Vendor.ContactName, + Email = d.Vendor.Email, + Phone = d.Vendor.Phone, + Address = d.Vendor.Address, + City = d.Vendor.City, + State = d.Vendor.State, + Zip = d.Vendor.Zip, + TradeSpecialties = d.Vendor.TradeSpecialties + }, + PrimaryLocation = d.PrimaryLocation == null ? null : new DispatchDetailLocationResultDTO + { + Id = d.PrimaryLocation.Id, + Name = d.PrimaryLocation.Name, + Address1 = d.PrimaryLocation.Address1, + City = d.PrimaryLocation.City, + State = d.PrimaryLocation.State, + Zip = d.PrimaryLocation.Zip + }, + WorkOrders = d.WorkOrders.Select(w => new DispatchDetailLinkedWorkOrderDTO + { + WorkOrderId = w.WorkOrderId, + InternalWONumber = w.InternalWONumber, + Title = w.Title, + Priority = w.Priority, + Status = w.Status, + LocationName = w.LocationName, + Trade = w.Trade, + SubTrade = w.SubTrade, + Problem = w.Problem + }).ToList(), + Checklist = d.Checklist.Select(c => new DispatchDetailChecklistDTO + { + Id = c.Id, + ItemText = c.ItemText, + IsCompleted = c.IsCompleted, + CompletedBy = c.CompletedBy, + CompletedAt = c.CompletedAt, + SortOrder = c.SortOrder, + WorkOrderId = c.WorkOrderId + }).ToList(), + Signoffs = d.Signoffs.Select(s => new DispatchDetailSignoffDTO + { + Id = s.Id, + SignoffType = s.SignoffType, + Name = s.Name, + Signature = s.Signature, + SignatureMethod = s.SignatureMethod, + SignedAt = s.SignedAt + }).ToList(), + Comments = d.Comments.Select(c => new DispatchDetailCommentDTO + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = c.Commenter, + CommentType = c.CommentType, + RecordType = c.RecordType, + CreatedDate = c.CreatedDate, + UserId = c.UserId, + UserName = c.UserName + }).ToList(), + UpliftRequests = d.UpliftRequests.Select(u => new DispatchDetailUpliftDTO + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + Delta = u.Delta, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + RequestedAt = u.RequestedAt, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByName = u.DecidedByName + }).ToList(), + AuditLog = d.AuditLog.Select(a => new DispatchDetailAuditLogDTO + { + Id = a.Id, + Action = a.Action, + FieldName = a.FieldName, + OldValue = a.OldValue, + NewValue = a.NewValue, + CreatedAt = a.CreatedAt, + UserId = a.UserId, + UserName = a.UserName + }).ToList() + }; + } + private DispatchDTO MapToDTO(Dispatch dispatch) { return new DispatchDTO diff --git a/SeaHaven.Services/Implementation/DropdownOptionsService.cs b/SeaHaven.Services/Implementation/DropdownOptionsService.cs new file mode 100644 index 0000000..6963272 --- /dev/null +++ b/SeaHaven.Services/Implementation/DropdownOptionsService.cs @@ -0,0 +1,88 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class DropdownOptionsService : IDropdownOptionsService + { + private readonly IDropdownOptionsDataService _dataService; + + public DropdownOptionsService(IDropdownOptionsDataService dataService) + { + _dataService = dataService; + } + + public async Task> GetAllAsync(string? category, CancellationToken cancellationToken) + { + var data = await _dataService.GetAllActiveAsync(category, cancellationToken); + return data.Select(MapToDTO); + } + + public async Task> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken) + { + var data = await _dataService.GetByCategoryAsync(category, parent, cancellationToken); + return data.Select(MapToDTO); + } + + public async Task CreateAsync(DropdownOptionDTO option, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(option.Category) || string.IsNullOrWhiteSpace(option.Value)) + throw new InvalidOperationException("Category and Value are required"); + + if (await _dataService.ExistsAsync(option.Category, option.Value, option.ParentValue, cancellationToken)) + throw new InvalidOperationException("This option already exists"); + + var entity = new DropdownOption + { + Id = 0, + Category = option.Category, + Value = option.Value, + ParentValue = option.ParentValue, + SortOrder = option.SortOrder, + IsActive = true + }; + + var created = await _dataService.AddAsync(entity, cancellationToken); + return MapToDTO(created); + } + + public async Task UpdateAsync(int id, DropdownOptionDTO option, CancellationToken cancellationToken) + { + var item = await _dataService.GetByIdAsync(id, cancellationToken); + if (item == null) + throw new KeyNotFoundException("Option not found"); + + item.Value = option.Value; + item.ParentValue = option.ParentValue; + item.SortOrder = option.SortOrder; + item.IsActive = option.IsActive; + + await _dataService.UpdateAsync(item, cancellationToken); + return MapToDTO(item); + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + var item = await _dataService.GetByIdAsync(id, cancellationToken); + if (item == null) + throw new KeyNotFoundException("Option not found"); + + await _dataService.RemoveAsync(item, cancellationToken); + } + + private static DropdownOptionDTO MapToDTO(DropdownOption option) + { + return new DropdownOptionDTO + { + Id = option.Id, + Category = option.Category, + Value = option.Value, + ParentValue = option.ParentValue, + SortOrder = option.SortOrder, + IsActive = option.IsActive + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/FollowUpService.cs b/SeaHaven.Services/Implementation/FollowUpService.cs index e95c517..cfac5bf 100644 --- a/SeaHaven.Services/Implementation/FollowUpService.cs +++ b/SeaHaven.Services/Implementation/FollowUpService.cs @@ -54,7 +54,7 @@ namespace SeaHaven.Services.Implementation public async Task> GetFollowUpsPagedAsync(int page, int pageSize, string? search = null, string? status = null) { var (items, totalCount) = await _followUpDataService.GetPagedAsync(page, pageSize, search); - + var filteredItems = items.AsEnumerable(); if (!string.IsNullOrWhiteSpace(status)) { @@ -148,6 +148,131 @@ namespace SeaHaven.Services.Implementation return await _followUpDataService.CountAsync(); } + public async Task> GetFollowUpListAsync( + int page, + int pageSize, + string? search, + string? status, + CancellationToken cancellationToken) + { + status = string.IsNullOrWhiteSpace(status) ? "Open" : status; + + var items = await _followUpDataService.GetListPagedAsync(page, pageSize, search, status, cancellationToken); + var totalCount = await _followUpDataService.GetListTotalAsync(search, status, cancellationToken); + + return new PagedResult + { + Items = items.Select(MapToListRow).ToList(), + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task GetFollowUpDetailAsync(int id, CancellationToken cancellationToken) + { + var followUp = await _followUpDataService.GetDetailByIdAsync(id, cancellationToken); + if (followUp == null) + return null; + + var createdByName = await _followUpDataService.GetUserNameAsync(followUp.createdby, cancellationToken); + + return new FollowUpDetailDTO + { + Id = followUp.Id, + Reason = followUp.Reason, + ScheduleDate = followUp.ScheduleDate, + ScheduleStartTime = followUp.ScheduleStartTime, + EmployeeId = followUp.EmployeeId, + WorkOrderId = followUp.WorkOrderId, + AccountId = followUp.AccountId, + LocationId = followUp.LocationId, + AccountName = followUp.Account?.Name ?? "", + AssignedTo = followUp.Employee != null + ? $"{followUp.Employee.FirstName} {followUp.Employee.LastName}" + : "", + LocationName = followUp.Location?.Name ?? "", + WorkOrderNumber = "", + InternalWONumber = "", + Status = followUp.Status, + CreatedDate = followUp.CreatedDate, + CreatedById = followUp.createdby, + CreatedByName = createdByName + }; + } + + public async Task CreateOpenFollowUpAsync(FollowUpCreateRequestDTO dto, string userId, CancellationToken cancellationToken) + { + var followUp = new FollowUps + { + Reason = dto.Reason, + ScheduleDate = dto.ScheduleDate, + ScheduleStartTime = dto.ScheduleStartTime, + EmployeeId = dto.EmployeeId, + WorkOrderId = dto.WorkOrderId, + AccountId = dto.AccountId, + LocationId = dto.LocationId, + Status = "Open", + createdby = userId + }; + + var created = await _followUpDataService.AddAsync(followUp, cancellationToken); + return created.Id; + } + + public async Task UpdateFollowUpFieldsAsync(int id, FollowUpUpdateRequestDTO dto, CancellationToken cancellationToken) + { + var followUp = await _followUpDataService.GetByIdForUpdateAsync(id, cancellationToken); + if (followUp == null) + throw new KeyNotFoundException($"Follow-Up with ID {id} not found"); + + followUp.Reason = dto.Reason; + followUp.ScheduleDate = dto.ScheduleDate; + followUp.ScheduleStartTime = dto.ScheduleStartTime; + followUp.EmployeeId = dto.EmployeeId; + followUp.WorkOrderId = dto.WorkOrderId; + followUp.AccountId = dto.AccountId; + followUp.LocationId = dto.LocationId; + + await _followUpDataService.UpdateAsync(followUp, cancellationToken); + } + + public Task SetFollowUpStatusAsync(int id, string status, CancellationToken cancellationToken) + { + return _followUpDataService.SetStatusAsync(id, status, cancellationToken); + } + + public Task RemoveFollowUpAsync(int id, CancellationToken cancellationToken) + { + return _followUpDataService.RemoveByIdAsync(id, cancellationToken); + } + + private static FollowUpListRowDTO MapToListRow(FollowUps followUp) + { + return new FollowUpListRowDTO + { + Id = followUp.Id, + Reason = followUp.Reason, + ScheduleDate = followUp.ScheduleDate, + ScheduleStartTime = followUp.ScheduleStartTime, + EmployeeId = followUp.EmployeeId, + WorkOrderId = followUp.WorkOrderId, + AccountId = followUp.AccountId, + LocationId = followUp.LocationId, + AccountName = followUp.Account?.Name ?? "", + AssignedTo = followUp.Employee != null + ? $"{followUp.Employee.FirstName} {followUp.Employee.LastName}" + : "", + LocationName = followUp.Location?.Name ?? "", + WorkOrderNumber = "", + InternalWONumber = "", + Status = followUp.Status, + CreatedDate = followUp.CreatedDate, + CreatedById = null, + CreatedByName = "" + }; + } + private FollowUpDTO MapToDTO(FollowUps followUp) { return new FollowUpDTO diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index c625192..7edd2c4 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -139,6 +139,70 @@ namespace SeaHaven.Services.Implementation return await _locationDataService.CountAsync(); } + public async Task> GetLocationListPagedAsync( + int page, + int pageSize, + string? search, + CancellationToken cancellationToken) + { + var (items, totalCount) = await _locationDataService.GetListPagedAsync(page, pageSize, search, cancellationToken); + return new PagedResult + { + Items = items.Select(MapToDTO).ToList(), + TotalCount = totalCount, + Page = page, + PageSize = pageSize + }; + } + + public async Task GetLocationDetailAsync(int id, CancellationToken cancellationToken) + { + var location = await _locationDataService.GetDetailByIdAsync(id, cancellationToken); + return location == null ? null : MapToDTO(location); + } + + public async Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken) + { + var location = new Locations + { + Name = request.Name, + Title = request.Title, + Address1 = request.Address, + City = request.City, + State = request.State, + Zip = request.ZipCode, + PhoneNumber = request.Phone, + Email = request.ContactEmail, + Status = request.Status + }; + + await _locationDataService.AddAsync(location, cancellationToken); + } + + public async Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken) + { + var location = await _locationDataService.GetByIdForUpdateAsync(id, cancellationToken); + if (location == null) + throw new KeyNotFoundException($"Location with ID {id} not found"); + + location.Name = request.Name; + location.Title = request.Title; + location.Address1 = request.Address; + location.City = request.City; + location.State = request.State; + location.Zip = request.ZipCode; + location.PhoneNumber = request.Phone; + location.Email = request.ContactEmail; + location.Status = request.Status; + + await _locationDataService.UpdateAsync(location, cancellationToken); + } + + public Task DeleteLocationByIdAsync(int id, CancellationToken cancellationToken) + { + return _locationDataService.DeleteByIdAsync(id, cancellationToken); + } + // Private helper methods private LocationDTO MapToDTO(Locations location) { diff --git a/SeaHaven.Services/Implementation/QuotesService.cs b/SeaHaven.Services/Implementation/QuotesService.cs new file mode 100644 index 0000000..a82dd9c --- /dev/null +++ b/SeaHaven.Services/Implementation/QuotesService.cs @@ -0,0 +1,191 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class QuotesService : IQuotesService + { + private readonly IQuotesDataService _quotesData; + private readonly IWorkOrderDataService _workOrderData; + + public QuotesService(IQuotesDataService quotesData, IWorkOrderDataService workOrderData) + { + _quotesData = quotesData; + _workOrderData = workOrderData; + } + + public async Task CreateQuoteAsync(CreateQuoteInput input, CancellationToken cancellationToken) + { + var rnd = new Random(); + var id = rnd.Next(1, 99999); + + var quote = new Quotes + { + QuotId = id.ToString(), + ContactId = input.ContactId, + PO = input.PO, + TT = input.TT, + ProjectName = input.ProjectName, + Date = input.Date, + WorkorderId = input.WorkorderId, + SiteName = input.SiteName, + SiteId = input.SiteId, + BillingAddress = input.BillingAddress, + LineItems = new List() + }; + + if (input.LineItems != null && input.LineItems.Any()) + { + foreach (var li in input.LineItems) + { + quote.LineItems.Add(new QuotesLineItems + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }); + } + } + + await _quotesData.AddAsync(quote); + } + + public async Task UpdateQuoteAsync(UpdateQuoteInput input, CancellationToken cancellationToken) + { + var quote = await _quotesData.GetByIdAsync(input.Id); + if (quote == null) + return false; + + quote.QuotId = input.QuotId; + quote.ContactId = input.ContactId; + quote.PO = input.PO; + quote.TT = input.TT; + quote.ProjectName = input.ProjectName; + quote.Date = input.Date; + quote.WorkorderId = input.WorkorderId; + quote.SiteName = input.SiteName; + quote.SiteId = input.SiteId; + quote.BillingAddress = input.BillingAddress; + + await _quotesData.DeleteLineItemsByQuoteIdAsync(input.Id, cancellationToken); + + quote.LineItems ??= new List(); + + if (input.LineItems != null && input.LineItems.Any()) + { + foreach (var li in input.LineItems) + { + quote.LineItems.Add(new QuotesLineItems + { + LineitemNumber = li.LineitemNumber, + Lineitem = li.Lineitem, + Descriptions = li.Descriptions + }); + } + } + + await _quotesData.UpdateAsync(quote); + return true; + } + + public async Task DeleteQuoteAsync(int id, CancellationToken cancellationToken) + { + var quote = await _quotesData.GetByIdAsync(id); + if (quote == null) + return false; + + await _quotesData.DeleteLineItemsByQuoteIdAsync(id, cancellationToken); + await _quotesData.DeleteAsync(id); + return true; + } + + public async Task GetQuotesPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken) + { + var (items, totalCount) = await _quotesData.GetQuotesPagedAsync(page, pageSize, search, cancellationToken); + + return new PagedQuotesResult + { + Items = items.Select(item => new QuoteListItemDTO + { + Id = item.Id, + QuoteId = item.QuoteId, + WorkOrder = item.WorkOrder, + ProjectName = item.ProjectName, + TotalAmount = FormatAmount(item.TotalAmount), + SiteName = item.SiteName, + SiteId = item.SiteId, + PO = item.PO, + TT = item.TT, + BillingAddress = item.BillingAddress, + Status = item.Status, + ContactId = item.ContactId, + Date = item.Date, + Lineitems = item.Lineitems.Select(MapLineItem).ToList() + }).ToList(), + TotalCount = totalCount + }; + } + + public async Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken) + { + var items = await _quotesData.GetWorkOrderQuoteDetailsAsync(woId, cancellationToken); + return items.Select(item => new WorkOrderQuoteDetailDTO + { + Id = item.Id, + PO = item.PO, + TT = item.TT, + Title = item.Title, + Name = item.Name + }).ToList(); + } + + public async Task GetQuoteDetailsByIdAsync( + int id, CancellationToken cancellationToken) + { + var item = await _quotesData.GetQuoteDetailsByIdAsync(id, cancellationToken); + return item == null ? null : new QuoteDetailDTO + { + Id = item.Id, + QuotId = item.QuotId, + Date = item.Date, + Name = item.Name, + PO = item.PO, + TT = item.TT, + SiteName = item.SiteName, + ProjectName = item.ProjectName, + SiteId = item.SiteId, + BillingAddress = item.BillingAddress, + ShippingAddress = item.ShippingAddress, + TotalAmount = FormatAmount(item.TotalAmount), + Lineitems = item.Lineitems.Select(MapLineItem).ToList() + }; + } + + public async Task GetReportingAsync(CancellationToken cancellationToken) + { + return new QuoteReportingData + { + TotalCreatedWorkOrder = await _workOrderData.CountAsync(), + TotalCompleted = await _workOrderData.CountByStatusAsync("Done"), + TotalSev1 = await _workOrderData.CountByPriorityAsync("Sev 1"), + TotalSev2 = await _workOrderData.CountByPriorityAsync("Sev 2"), + TotalSev3 = await _workOrderData.CountByPriorityAsync("Sev 3"), + TotalSev4 = await _workOrderData.CountByPriorityAsync("Sev 4"), + TotalSev5 = await _workOrderData.CountByPriorityAsync("Sev 5") + }; + } + + private static QuoteLineItemDTO MapLineItem( + SeaHaven.DataServices.Models.QuoteLineItemReadModel item) => new() + { + Descriptions = item.Descriptions, + Lineitem = item.Lineitem, + LineitemNumber = item.LineitemNumber + }; + + private static string FormatAmount(decimal amount) => $"${amount}"; + } +} diff --git a/SeaHaven.Services/Implementation/SettingsService.cs b/SeaHaven.Services/Implementation/SettingsService.cs new file mode 100644 index 0000000..c513f88 --- /dev/null +++ b/SeaHaven.Services/Implementation/SettingsService.cs @@ -0,0 +1,166 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class SettingsService : ISettingsService + { + private readonly ISettingsDataService _dataService; + private readonly UserManager _userManager; + + public SettingsService(ISettingsDataService dataService, UserManager userManager) + { + _dataService = dataService; + _userManager = userManager; + } + + public async Task AddCategoryAsync(string? name, CancellationToken cancellationToken) + { + await _dataService.AddCategoryAsync(name ?? "", cancellationToken); + } + + public Task UpdateCategoryAsync(int? id, string? name, CancellationToken cancellationToken) + { + return _dataService.UpdateCategoryAsync(id ?? 0, name ?? "", cancellationToken); + } + + public Task DeleteCategoryAsync(int id, CancellationToken cancellationToken) + { + return _dataService.DeleteCategoryCascadeAsync(id, cancellationToken); + } + + public async Task<(IReadOnlyList Items, int Total)> GetCategoryListAsync(int page, int pageSize, CancellationToken cancellationToken) + { + var (items, total) = await _dataService.GetCategoryListPagedAsync(page, pageSize, cancellationToken); + return (items.Select(c => new SettingsCategoryDTO { Id = c.Id, Name = c.Name }).ToList(), total); + } + + public async Task<(IReadOnlyList Items, int Total)> GetTemplatesAsync(int page, int pageSize, CancellationToken cancellationToken) + { + var (items, total) = await _dataService.GetTemplatesPagedAsync(page, pageSize, cancellationToken); + return (items.Select(MapTemplate).ToList(), total); + } + + public async Task<(IReadOnlyList Users, int Total)> GetUsersAsync(string? search, int page, int pageSize, CancellationToken cancellationToken) + { + var (users, total) = await _dataService.GetUsersPagedAsync(search, page, pageSize, cancellationToken); + + var filtered = new List(); + foreach (var user in users) + { + var roles = await _userManager.GetRolesAsync(user); + if (!roles.Contains("Admin")) + { + filtered.Add(new SettingsUserDTO + { + RoleName = roles.Count() > 0 ? roles.First() : "", + Email = user.Email, + UserName = user.FirstName, + Date = user.CreatedDate?.Date.ToString(), + Status = user.UniqueName, + Id = user.Id + }); + } + } + + return (filtered, total); + } + + public async Task> GetRolesAsync(CancellationToken cancellationToken) + { + var names = await _dataService.GetRoleNamesAsync(cancellationToken); + var roles = new List(); + foreach (var name in names) + { + if (name == "Admin") + { + roles.Add(new RoleDTO { Name = name, Description = "Full Access" }); + } + else if (name == "Supervisor") + { + roles.Add(new RoleDTO { Name = name, Description = "Access to Team's Work Orders Only" }); + } + else if (name == "Manager") + { + roles.Add(new RoleDTO { Name = name, Description = "Full access accept quotes" }); + } + else if (name == "User") + { + roles.Add(new RoleDTO { Name = name, Description = "Access to Assigned Work Orders Only" }); + } + } + return roles; + } + + private static SettingsTemplateDTO MapTemplate(WorkOrder w) + { + return new SettingsTemplateDTO + { + Id = w.Id, + IsDeleted = w.IsDeleted, + createdby = w.createdby, + DeleterUserId = w.DeleterUserId, + DeletionTime = w.DeletionTime, + CreatedDate = w.CreatedDate, + LastModificationTime = w.LastModificationTime, + LastModifierUserId = w.LastModifierUserId, + InternalWONumber = w.InternalWONumber, + ExternalWorkOrderId = w.ExternalWorkOrderId, + WorkerOrderNumber = w.WorkerOrderNumber, + WorkerOrderTitle = w.WorkerOrderTitle, + Description = w.Description, + Customer = w.Customer, + SiteCode = w.SiteCode, + Building = w.Building, + Severity = w.Severity, + DueDate = w.DueDate, + AssignTo = w.AssignTo, + LocationId = w.LocationId, + LifecycleStatus = w.LifecycleStatus, + LegacyStatus = w.LegacyStatus, + WorkOrderType = w.WorkOrderType, + Service = w.Service, + PrimaryDispatchId = w.PrimaryDispatchId, + ScheduledDate = w.ScheduledDate, + ScheduledStart = w.ScheduledStart, + ScheduledEnd = w.ScheduledEnd, + TargetWeek = w.TargetWeek, + ScheduleWeekOnly = w.ScheduleWeekOnly, + OriginalWeek = w.OriginalWeek, + OriginalDate = w.OriginalDate, + RescheduleCount = w.RescheduleCount, + CarriedOver = w.CarriedOver, + OperationalFlags = w.OperationalFlags, + DocStatus = w.DocStatus, + AvettaTask = w.AvettaTask, + AssignDate = w.AssignDate, + DateReported = w.DateReported, + SourceEmailS3Key = w.SourceEmailS3Key, + Problem = w.Problem, + Trade = w.Trade, + SubTrade = w.SubTrade, + VendorNTE = w.VendorNTE, + CompletedDate = w.CompletedDate, + Source = w.Source, + istemplate = w.istemplate, + PO = w.PO, + TT = w.TT, + Priority = w.Priority, + Status = w.Status, + Attachments = w.Attachments, + BeforPhotoIssue = w.BeforPhotoIssue, + BeforPhotoAttachment = w.BeforPhotoAttachment, + AfterPhotoIssue = w.AfterPhotoIssue, + AfterPhotoAttachment = w.AfterPhotoAttachment, + SignOffName = w.SignOffName, + SignOffAttachment = w.SignOffAttachment, + SignOffSignature = w.SignOffSignature, + RowVersion = w.RowVersion + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/SyncService.cs b/SeaHaven.Services/Implementation/SyncService.cs new file mode 100644 index 0000000..f9268c6 --- /dev/null +++ b/SeaHaven.Services/Implementation/SyncService.cs @@ -0,0 +1,304 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class SyncService : ISyncService + { + private readonly ISyncDataService _data; + private readonly ISyncExternalSource _external; + + public SyncService(ISyncDataService data, ISyncExternalSource external) + { + _data = data; + _external = external; + } + + public async Task SyncWorkOrdersAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var updated = 0; + + var lastInternalWO = await _data.GetLastInternalWONumberAsync(cancellationToken); + int nextInternal = 10000001; + if (lastInternalWO != null && int.TryParse(lastInternalWO, out var parsed)) + nextInternal = parsed + 1; + + await foreach (var item in _external.ScanWorkOrdersAsync(cancellationToken)) + { + var externalId = Get(item, "work_order_id"); + if (string.IsNullOrEmpty(externalId)) continue; + + var existing = await _data.GetWorkOrderByExternalIdAsync(externalId, cancellationToken); + + var locationId = await _data.ResolveLocationAsync( + Get(item, "site_code"), + Get(item, "building"), + Get(item, "address"), + cancellationToken); + + if (existing == null) + { + var wo = new WorkOrder + { + InternalWONumber = (nextInternal++).ToString("D8"), + ExternalWorkOrderId = externalId, + WorkerOrderNumber = externalId, + WorkerOrderTitle = Get(item, "description"), + Description = Get(item, "description"), + Status = MapStatus(Get(item, "wo_status")), + Priority = MapSeverityToPriority(Get(item, "severity")), + Severity = Get(item, "severity"), + Customer = Get(item, "customer"), + SiteCode = Get(item, "site_code"), + Building = Get(item, "building"), + LocationId = locationId, + DueDate = ParseDate(Get(item, "due_date")), + DateReported = ParseDate(Get(item, "date_reported")), + ScheduledStart = ParseDate(Get(item, "scheduled_start")), + AssignTo = null, + SourceEmailS3Key = Get(item, "source_email_s3_key"), + CreatedDate = ParseDate(Get(item, "created_at")) ?? DateTime.UtcNow, + istemplate = false + }; + _data.EnqueueWorkOrder(wo); + created++; + } + else + { + existing.WorkerOrderTitle = Get(item, "description") ?? existing.WorkerOrderTitle; + existing.Description = Get(item, "description") ?? existing.Description; + existing.Status = MapStatus(Get(item, "wo_status")) ?? existing.Status; + existing.Priority = MapSeverityToPriority(Get(item, "severity")) ?? existing.Priority; + existing.Severity = Get(item, "severity") ?? existing.Severity; + existing.SiteCode = Get(item, "site_code") ?? existing.SiteCode; + existing.Building = Get(item, "building") ?? existing.Building; + existing.LocationId = locationId ?? existing.LocationId; + existing.DueDate = ParseDate(Get(item, "due_date")) ?? existing.DueDate; + existing.DateReported = ParseDate(Get(item, "date_reported")) ?? existing.DateReported; + existing.ScheduledStart = ParseDate(Get(item, "scheduled_start")) ?? existing.ScheduledStart; + existing.SourceEmailS3Key = Get(item, "source_email_s3_key") ?? existing.SourceEmailS3Key; + updated++; + } + + synced++; + + if (synced % 100 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + + return new SyncWorkOrdersResult + { + Synced = synced, + Created = created, + Updated = updated, + LocationsCreated = await _data.GetLocationCountAsync(cancellationToken) + }; + } + + public async Task SyncCommentsAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var skipped = 0; + + await foreach (var item in _external.ScanCommentsAsync(cancellationToken)) + { + var externalCommentId = Get(item, "comment_id"); + var externalWoId = Get(item, "work_order_id"); + + if (string.IsNullOrEmpty(externalCommentId) || string.IsNullOrEmpty(externalWoId)) + continue; + + var alreadyExists = await _data.CommentExistsByExternalIdAsync(externalCommentId, cancellationToken); + + if (alreadyExists) + { + skipped++; + synced++; + continue; + } + + var workOrder = await _data.GetWorkOrderByExternalIdAsync(externalWoId, cancellationToken); + + if (workOrder == null) + { + skipped++; + continue; + } + + var comment = new Comments + { + ExternalCommentId = externalCommentId, + WorkerOrderId = workOrder.Id, + Commenttext = Get(item, "text"), + Commenter = Get(item, "commenter"), + RecordType = Get(item, "record_type"), + CommentType = "customer", + CreatedDate = ParseDate(Get(item, "created_at")) ?? DateTime.UtcNow, + }; + + _data.EnqueueComment(comment); + created++; + synced++; + + if (synced % 100 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + + return new SyncCommentsResult { Synced = synced, Created = created, Skipped = skipped }; + } + + public async Task BackfillInternalWONumbersAsync(CancellationToken cancellationToken) + { + var wosMissing = await _data.GetWorkOrdersMissingInternalNumberAsync(cancellationToken); + + if (wosMissing.Count == 0) + return new BackfillResult { Updated = 0 }; + + var lastWO = await _data.GetLastInternalWONumberAsync(cancellationToken); + + 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 _data.SaveChangesAsync(cancellationToken); + return new BackfillResult { Updated = wosMissing.Count }; + } + + public async Task BackfillDispatchNumbersAsync(CancellationToken cancellationToken) + { + var missing = await _data.GetDispatchesMissingNumberAsync(cancellationToken); + if (missing.Count == 0) return new BackfillResult { Updated = 0 }; + + int next = 1; + var last = await _data.GetLastDispatchNumberAsync(cancellationToken); + 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 _data.SaveChangesAsync(cancellationToken); + return new BackfillResult { Updated = missing.Count }; + } + + public async Task SyncVendorRepliesAsync(CancellationToken cancellationToken) + { + var synced = 0; + var created = 0; + var skipped = 0; + + await foreach (var item in _external.ScanVendorRepliesAsync(cancellationToken)) + { + var replyId = Get(item, "reply_id"); + var woNumber = Get(item, "internal_wo_number"); + var dispatchNumber = Get(item, "dispatch_number"); + + if (string.IsNullOrEmpty(replyId) || string.IsNullOrEmpty(woNumber)) + continue; + + var alreadyExists = await _data.CommentExistsByExternalIdAsync(replyId, cancellationToken); + if (alreadyExists) { skipped++; synced++; continue; } + + var workOrder = await _data.GetWorkOrderByInternalNumberAsync(woNumber, cancellationToken); + if (workOrder == null) { skipped++; continue; } + + int? dispatchId = null; + if (!string.IsNullOrWhiteSpace(dispatchNumber)) + { + var dispatch = await _data.GetDispatchByNumberAndWorkOrderIdAsync(dispatchNumber, workOrder.Id, cancellationToken); + dispatchId = dispatch?.Id; + } + + var comment = new Comments + { + ExternalCommentId = replyId, + WorkerOrderId = workOrder.Id, + DispatchId = dispatchId, + Commenttext = Get(item, "reply_text"), + Commenter = Get(item, "sender_email"), + CommentType = "vendor", + RecordType = "vendor_reply", + CreatedDate = ParseDate(Get(item, "received_at")) ?? DateTime.UtcNow, + }; + + _data.EnqueueComment(comment); + created++; + synced++; + + if (synced % 50 == 0) + await _data.SaveChangesAsync(cancellationToken); + } + + await _data.SaveChangesAsync(cancellationToken); + return new SyncCommentsResult { Synced = synced, Created = created, Skipped = skipped }; + } + + public async Task BackfillCommentTypesAsync(CancellationToken cancellationToken) + { + var updated = await _data.ExecuteBackfillCommentTypesAsync(cancellationToken); + return new BackfillResult { Updated = updated }; + } + + public async Task SyncAllAsync(CancellationToken cancellationToken) + { + var woResult = await SyncWorkOrdersAsync(cancellationToken); + var commentResult = await SyncCommentsAsync(cancellationToken); + + return new SyncAllResult + { + WorkOrders = woResult, + Comments = commentResult + }; + } + + private static string? Get(Dictionary item, string key) + { + return item.TryGetValue(key, out var val) ? val : 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}"; + } + } +} diff --git a/SeaHaven.Services/Implementation/TaskListTemplateService.cs b/SeaHaven.Services/Implementation/TaskListTemplateService.cs new file mode 100644 index 0000000..294e4d7 --- /dev/null +++ b/SeaHaven.Services/Implementation/TaskListTemplateService.cs @@ -0,0 +1,92 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class TaskListTemplateService : ITaskListTemplateService + { + private readonly ITaskListTemplateDataService _dataService; + + public TaskListTemplateService(ITaskListTemplateDataService dataService) + { + _dataService = dataService; + } + + public async Task> GetAllAsync(CancellationToken cancellationToken) + { + var templates = await _dataService.GetActiveTemplatesWithItemsAsync(cancellationToken); + return templates.Select(MapToDTO); + } + + public async Task GetByIdAsync(int id, CancellationToken cancellationToken) + { + var template = await _dataService.GetTemplateWithItemsReadOnlyAsync(id, cancellationToken); + return template == null ? null : MapToDTO(template); + } + + public async Task CreateAsync(TaskListTemplateInput input, CancellationToken cancellationToken) + { + var template = new TaskListTemplate + { + Name = input.Name, + Description = input.Description, + IsActive = input.IsActive, + Items = input.Items.Select((item, idx) => new TaskListTemplateItem + { + ItemText = item.ItemText, + SortOrder = item.SortOrder > 0 ? item.SortOrder : idx + }).ToList() + }; + + var created = await _dataService.AddTemplateAsync(template, cancellationToken); + return created.Id; + } + + public async Task UpdateAsync(int id, TaskListTemplateInput input, CancellationToken cancellationToken) + { + var template = await _dataService.GetTemplateWithItemsForUpdateAsync(id, cancellationToken); + if (template == null) + return false; + + template.Name = input.Name; + template.Description = input.Description; + template.IsActive = input.IsActive; + + var newItems = input.Items.Select((item, idx) => new TaskListTemplateItem + { + TaskListTemplateId = id, + ItemText = item.ItemText, + SortOrder = item.SortOrder > 0 ? item.SortOrder : idx + }).ToList(); + + await _dataService.ReplaceTemplateItemsAsync(template, newItems, cancellationToken); + return true; + } + + public async Task DeleteAsync(int id, CancellationToken cancellationToken) + { + return await _dataService.DeactivateTemplateAsync(id, cancellationToken); + } + + private static TaskListTemplateDTO MapToDTO(TaskListTemplate template) + { + return new TaskListTemplateDTO + { + Id = template.Id, + Name = template.Name, + Description = template.Description, + IsActive = template.IsActive, + Items = (template.Items ?? new List()) + .OrderBy(i => i.SortOrder) + .Select(i => new TaskListTemplateItemDTO + { + Id = i.Id, + ItemText = i.ItemText, + SortOrder = i.SortOrder + }).ToList() + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/UpliftService.cs b/SeaHaven.Services/Implementation/UpliftService.cs new file mode 100644 index 0000000..051dd69 --- /dev/null +++ b/SeaHaven.Services/Implementation/UpliftService.cs @@ -0,0 +1,178 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; + +namespace SeaHaven.Services.Implementation +{ + public class UpliftService : IUpliftService + { + private readonly IUpliftDataService _upliftData; + private readonly IDispatchDataService _dispatchData; + private readonly ApprovalsOptions _approvalsOptions; + + public UpliftService(IUpliftDataService upliftData, IDispatchDataService dispatchData, IOptions approvalsOptions) + { + _upliftData = upliftData; + _dispatchData = dispatchData; + _approvalsOptions = approvalsOptions.Value; + } + + public async Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken) + { + var (total, items) = await _upliftData.GetPagedAsync(status, tier, page, pageSize, cancellationToken); + + var mapped = items.Select(r => new UpliftListItemDTO + { + Id = r.Id, + DispatchId = r.DispatchId, + DispatchNumber = r.DispatchNumber, + PONumber = r.PONumber, + WorkOrderId = r.WorkOrderId, + VendorCompanyName = r.VendorCompanyName, + CurrentNTE = r.CurrentNTE, + RequestedNTE = r.RequestedNTE, + Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + VendorReason = r.VendorReason, + RequiredTier = r.RequiredTier, + Status = r.Status, + RequestedAt = r.CreatedDate, + DecidedAt = r.DecidedAt, + DecisionNote = r.DecisionNote, + CanDecide = UserCanApprove(user, r.RequiredTier) + }).ToList(); + + return new UpliftListResultDTO + { + Total = total, + Page = page, + PageSize = pageSize, + Items = mapped + }; + } + + public async Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken) + { + var rows = await _upliftData.GetForDispatchAsync(dispatchId, cancellationToken); + + return rows.Select(r => new UpliftForDispatchDTO + { + Id = r.Id, + DispatchId = r.DispatchId, + CurrentNTE = r.CurrentNTE, + RequestedNTE = r.RequestedNTE, + Delta = r.RequestedNTE - (r.CurrentNTE ?? 0m), + VendorReason = r.VendorReason, + Status = r.Status, + RequiredTier = r.RequiredTier, + RequestedByVendorName = r.RequestedByVendorName, + RequestedAt = r.CreatedDate, + DecidedAt = r.DecidedAt, + DecisionNote = r.DecisionNote, + DecidedByName = string.Join(" ", new[] { r.DecidedByFirstName, r.DecidedByLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(), + CanDecide = UserCanApprove(user, r.RequiredTier) + }).ToList(); + } + + public async Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + { + var req = await _upliftData.GetByIdAsync(id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + if (req.Status != "Pending") throw new InvalidOperationException($"Cannot approve a '{req.Status}' request"); + if (!UserCanApprove(user, req.RequiredTier)) + throw new UpliftForbiddenException($"Approval requires a Tier {req.RequiredTier} role"); + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + throw new InvalidOperationException($"Cannot approve uplift on a '{dispatch.Status}' dispatch"); + + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var now = DateTime.UtcNow; + var oldNTE = dispatch.NTEAmount ?? 0m; + + dispatch.NTEAmount = req.RequestedNTE; + dispatch.LastModificationTime = now; + + req.Status = "Approved"; + req.DecidedAt = now; + req.DecidedByUserId = userId; + req.DecisionNote = string.IsNullOrWhiteSpace(note) ? null : note!.Trim(); + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch.DispatchNumber} NTE", + OldValue = $"${oldNTE:F2}", + NewValue = $"${req.RequestedNTE:F2}", + Action = "uplift_approved", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new UpliftApproveResultDTO { Id = req.Id, Status = req.Status, NTEAmount = dispatch.NTEAmount }; + } + + public async Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(note)) + throw new InvalidOperationException("A note is required when denying"); + + var req = await _upliftData.GetByIdAsync(id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + if (req.Status != "Pending") throw new InvalidOperationException($"Cannot deny a '{req.Status}' request"); + if (!UserCanApprove(user, req.RequiredTier)) + throw new UpliftForbiddenException($"Decision requires a Tier {req.RequiredTier} role"); + + var dispatch = await _dispatchData.GetByIdAsync(req.DispatchId); + + var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var now = DateTime.UtcNow; + + req.Status = "Denied"; + req.DecidedAt = now; + req.DecidedByUserId = userId; + req.DecisionNote = note!.Trim(); + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch?.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch?.DispatchNumber} Uplift", + OldValue = "Pending", + NewValue = "Denied", + Action = "uplift_denied", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new UpliftDenyResultDTO { Id = req.Id, Status = req.Status }; + } + + public bool CanApprove(ClaimsPrincipal user, int tier) => UserCanApprove(user, tier); + + private bool UserCanApprove(ClaimsPrincipal user, int requiredTier) + { + var roles = RolesForTier(requiredTier); + foreach (var r in roles) + { + if (!string.IsNullOrWhiteSpace(r) && user.IsInRole(r)) return true; + } + return false; + } + + private string[] RolesForTier(int requiredTier) => requiredTier switch + { + 1 => _approvalsOptions.Tier1Roles, + 2 => _approvalsOptions.Tier2Roles, + _ => Array.Empty() + }; + } +} diff --git a/SeaHaven.Services/Implementation/UserService.cs b/SeaHaven.Services/Implementation/UserService.cs new file mode 100644 index 0000000..454e619 --- /dev/null +++ b/SeaHaven.Services/Implementation/UserService.cs @@ -0,0 +1,201 @@ +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Cryptography; + +namespace SeaHaven.Services.Implementation +{ + public class UserService : IUserService + { + private readonly UserManager _userManager; + private readonly IUserDataService _userDataService; + private readonly IEmailSender _emailSender; + + public UserService( + UserManager userManager, + IUserDataService userDataService, + IEmailSender emailSender) + { + _userManager = userManager; + _userDataService = userDataService; + _emailSender = emailSender; + } + + public async Task> GetUsersAsync(CancellationToken cancellationToken) + { + var users = await _userDataService.GetAllForListAsync(cancellationToken); + return users.Select(s => new UserListRowDTO + { + RoleName = s.RoleName, + Email = s.Email, + Name = s.Name, + Date = s.CreatedDate != null ? s.CreatedDate.Value.Date.ToString("MMM dd yyyy") : "", + Status = s.Status, + Id = s.Id + }); + } + + public async Task AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken) + { + var model = new ApplicationUser + { + UserName = dto.Email, + FirstName = dto.Name, + Email = dto.Email, + }; + + var exist = await _userDataService.GetByIdAsync(model.Id); + if (exist == null) + { + model.EmailConfirmed = true; + model.UserName = model.Email; + model.CreatedDate = DateTime.Now; + model.UniqueName = "Active"; + model.PhoneNumber = dto.Role; + + var password = GenerateRandomPassword(); + + var result = await _userManager.CreateAsync(model, password); + if (result.Succeeded) + { + await _userManager.AddToRoleAsync(model, dto.Role ?? ""); + } + else + { + return new AddUserOutcomeDTO { Success = false, Error = result.Errors.FirstOrDefault()?.Description ?? "error" }; + } + 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:Login\n"; + string passwordInfo = $"Your password is: {password}\n\n"; + string body = $"{greeting}\n\n{loginLink}\n\n{passwordInfo}"; + await _emailSender.SendEmailAsync(model?.Email ?? "", subject, body); + return new AddUserOutcomeDTO { Success = true }; + } + else + { + var exist1 = await _userDataService.GetForEditAsync(model.Id, cancellationToken); + if (exist1 == null) + return new AddUserOutcomeDTO { Success = false, Error = "User not found" }; + + var existingRole = await _userManager.GetRolesAsync(exist1); + + if (existingRole != null && existingRole.Any() && existingRole.FirstOrDefault() != exist1.PhoneNumber) + { + await _userManager.RemoveFromRolesAsync(exist1, existingRole); + } + + exist1.FirstName = model.FirstName; + exist1.LastName = model.LastName; + exist1.Contact = model.Contact; + exist1.PhoneNumber = model.PhoneNumber; + + await _userDataService.UpdateUserAsync(exist1, cancellationToken); + + await _userManager.AddToRoleAsync(exist1, dto.Role ?? ""); + await _userManager.UpdateAsync(exist1); + return new AddUserOutcomeDTO { Success = true }; + } + } + + public async Task EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken) + { + var exist = await _userDataService.GetForEditAsync(dto.Id ?? "", cancellationToken); + if (exist == null) + return false; + + exist.FirstName = dto.Name; + if (string.IsNullOrWhiteSpace(dto.Email)) + throw new ArgumentException("Email is required.", nameof(dto)); + + exist.EmailConfirmed = true; + exist.UserName = dto.Email; + exist.Email = dto.Email; + exist.NormalizedEmail = dto.Email.ToUpperInvariant(); + exist.NormalizedUserName = dto.Email.ToUpperInvariant(); + exist.CreatedDate = DateTime.Now; + exist.UniqueName = "Active"; + exist.PhoneNumber = dto.Role; + + var existingRole = await _userManager.GetRolesAsync(exist); + if (existingRole != null && existingRole.Any()) + { + await _userManager.RemoveFromRolesAsync(exist, existingRole); + } + await _userManager.AddToRoleAsync(exist, dto.Role ?? ""); + + await _userDataService.UpdateUserAsync(exist, cancellationToken); + return true; + } + + public async Task DeleteUserAsync(string id, CancellationToken cancellationToken) + { + var data = await _userDataService.GetForEditAsync(id, cancellationToken); + if (data == null) + return false; + + await _userDataService.DeleteUserWithCascadeAsync(data, cancellationToken); + return true; + } + + public async Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken) + { + var exist = await _userDataService.GetForEditAsync(userId, cancellationToken); + if (exist != null) + { + exist.IsDeleted = true; + await _userDataService.UpdateUserAsync(exist, cancellationToken); + } + } + + public async Task> GetUserProfileAsync(string userId, CancellationToken cancellationToken) + { + var user = await _userDataService.GetProfileAsync(userId, cancellationToken); + if (user == null) + return Array.Empty(); + + return new List + { + new UserProfileRowDTO + { + Id = user.Id, + Name = user.FirstName, + AddedDate = user.CreatedDate, + Email = user.Email, + Contact = user.Contact + } + }; + } + + private static string GenerateRandomPassword(int length = 8) + { + const string validChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*()_-+=<>?"; + const string capitalLetters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const string digits = "1234567890"; + const string symbols = "!@#$%^&*()_-+=<>?"; + + var passwordChars = new char[length]; + + passwordChars[0] = capitalLetters[RandomNumberGenerator.GetInt32(capitalLetters.Length)]; + passwordChars[1] = digits[RandomNumberGenerator.GetInt32(digits.Length)]; + passwordChars[2] = symbols[RandomNumberGenerator.GetInt32(symbols.Length)]; + passwordChars[3] = "abcdefghijklmnopqrstuvwxyz"[RandomNumberGenerator.GetInt32(26)]; + + for (int i = 4; i < length; i++) + { + passwordChars[i] = validChars[RandomNumberGenerator.GetInt32(validChars.Length)]; + } + + for (int i = passwordChars.Length - 1; i > 0; i--) + { + int j = RandomNumberGenerator.GetInt32(i + 1); + (passwordChars[i], passwordChars[j]) = (passwordChars[j], passwordChars[i]); + } + + return new string(passwordChars); + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalService.cs b/SeaHaven.Services/Implementation/VendorPortalService.cs new file mode 100644 index 0000000..470eb98 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorPortalService.cs @@ -0,0 +1,548 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class VendorPortalService : IVendorPortalService + { + private readonly IVendorPortalTokenService _tokens; + private readonly IDispatchDataService _dispatchData; + private readonly IUpliftDataService _upliftData; + private readonly ICommentDataService _commentData; + private readonly IUserDataService _userData; + private readonly IEmailSender _emailSender; + private readonly FrontendOptions _frontendOptions; + private readonly ApprovalsOptions _approvalsOptions; + + public VendorPortalService( + IVendorPortalTokenService tokens, + IDispatchDataService dispatchData, + IUpliftDataService upliftData, + ICommentDataService commentData, + IUserDataService userData, + IEmailSender emailSender, + IOptions frontendOptions, + IOptions approvalsOptions) + { + _tokens = tokens; + _dispatchData = dispatchData; + _upliftData = upliftData; + _commentData = commentData; + _userData = userData; + _emailSender = emailSender; + _frontendOptions = frontendOptions.Value; + _approvalsOptions = approvalsOptions.Value; + } + + public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) + { + return await _tokens.ResolveSessionAsync(token, cancellationToken); + } + + public async Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken) + { + var summaries = await _dispatchData.GetVendorDispatchSummariesAsync(session.Id, status, cancellationToken); + + return summaries.Select(d => new VendorDispatchSummaryDTO + { + Id = d.Id, + DispatchNumber = d.DispatchNumber, + PONumber = d.PONumber, + Status = d.Status, + NTEAmount = d.NTEAmount, + ScheduledDate = d.ScheduledDate, + CompletedDate = d.CompletedDate, + DispatchedAt = d.DispatchedAt, + AcknowledgedAt = d.AcknowledgedAt, + WorkOrderTitle = d.WorkOrderTitle, + InternalWONumber = d.InternalWONumber, + LocationName = d.LocationName, + LocationCity = d.LocationCity, + LocationState = d.LocationState + }); + } + + public async Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchDetailAsync(id, session.Id, cancellationToken); + if (dispatch == null) return null; + + var checklist = await _dispatchData.GetPortalChecklistAsync(id, cancellationToken); + var signoffs = await _dispatchData.GetPortalSignoffsAsync(id, cancellationToken); + var rawUplifts = await _upliftData.GetForVendorDispatchAsync(id, cancellationToken); + var rawComments = await _commentData.GetVendorViewableForDispatchAsync(id, cancellationToken); + + var upliftRequests = rawUplifts.Select(u => new PortalUpliftDTO + { + Id = u.Id, + CurrentNTE = u.CurrentNTE, + RequestedNTE = u.RequestedNTE, + VendorReason = u.VendorReason, + Status = u.Status, + RequiredTier = u.RequiredTier, + RequestedByVendorName = u.RequestedByVendorName, + RequestedAt = u.CreatedDate, + DecidedAt = u.DecidedAt, + DecisionNote = u.DecisionNote, + DecidedByName = string.Join(" ", new[] { u.DecidedByFirstName, u.DecidedByLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim() + }).ToList(); + + var comments = rawComments.Select(c => + { + var hasShocUser = !string.IsNullOrWhiteSpace(c.UserId); + var userName = string.Join(" ", new[] { c.UserFirstName, c.UserLastName } + .Where(s => !string.IsNullOrWhiteSpace(s))).Trim(); + + string resolvedType; + string resolvedCommenter; + if (hasShocUser) + { + resolvedType = "dispatcher"; + resolvedCommenter = !string.IsNullOrWhiteSpace(userName) ? userName + : !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! + : "Dispatcher"; + } + else if (c.CommentType == "customer") + { + resolvedType = "customer"; + resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : "Customer"; + } + else + { + resolvedType = "vendor"; + resolvedCommenter = !string.IsNullOrWhiteSpace(c.Commenter) ? c.Commenter! : session.CompanyName ?? "Vendor"; + } + + return new PortalCommentDTO + { + Id = c.Id, + Commenttext = c.Commenttext, + Commenter = resolvedCommenter, + CommentType = resolvedType, + CreatedDate = c.CreatedDate + }; + }).ToList(); + + return new VendorDispatchDetailDTO + { + Id = dispatch.Id, + DispatchNumber = dispatch.DispatchNumber, + PONumber = dispatch.PONumber, + Status = dispatch.Status, + NTEAmount = dispatch.NTEAmount, + Description = dispatch.Description, + ScheduledDate = dispatch.ScheduledDate, + CompletedDate = dispatch.CompletedDate, + DispatchedAt = dispatch.DispatchedAt, + AcknowledgedAt = dispatch.AcknowledgedAt, + WorkOrder = dispatch.WorkOrder == null ? null : new PortalWorkOrderDTO + { + Id = dispatch.WorkOrder.Id, + InternalWONumber = dispatch.WorkOrder.InternalWONumber, + WorkerOrderTitle = dispatch.WorkOrder.WorkerOrderTitle, + Description = dispatch.WorkOrder.Description, + Priority = dispatch.WorkOrder.Priority, + DueDate = dispatch.WorkOrder.DueDate, + Trade = dispatch.WorkOrder.Trade, + SubTrade = dispatch.WorkOrder.SubTrade, + Problem = dispatch.WorkOrder.Problem + }, + Location = dispatch.WorkOrder?.Locations == null ? null : new PortalLocationDTO + { + Name = dispatch.WorkOrder.Locations.Name, + Address1 = dispatch.WorkOrder.Locations.Address1, + City = dispatch.WorkOrder.Locations.City, + State = dispatch.WorkOrder.Locations.State, + Zip = dispatch.WorkOrder.Locations.Zip + }, + Checklist = checklist.Select(c => new PortalChecklistItemDTO + { + Id = c.Id, + ItemText = c.ItemText, + IsCompleted = c.IsCompleted, + CompletedBy = c.CompletedBy, + CompletedAt = c.CompletedAt + }), + Signoffs = signoffs.Select(s => new PortalSignoffDTO + { + Id = s.Id, + SignoffType = s.SignoffType, + Name = s.Name, + SignatureMethod = s.SignatureMethod, + SignedAt = s.SignedAt + }), + Comments = comments, + UpliftRequests = upliftRequests + }; + } + + public async Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status != "Sent") + { + throw new InvalidOperationException($"Cannot accept a dispatch with status '{dispatch.Status}'"); + } + + var now = DateTime.UtcNow; + dispatch.Status = "Acknowledged"; + dispatch.AcknowledgedAt = now; + dispatch.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = "Sent", + NewValue = "Acknowledged", + Action = "vendor_accept", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new AcceptDispatchResultDTO { Id = dispatch.Id, Status = dispatch.Status, AcknowledgedAt = dispatch.AcknowledgedAt }; + } + + public async Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + var from = dispatch.Status; + if (!IsAllowedVendorTransition(from, to)) + { + throw new InvalidOperationException($"Transition from '{from}' to '{to}' is not allowed"); + } + + var now = DateTime.UtcNow; + dispatch.Status = to; + dispatch.LastModificationTime = now; + if (to == "Completed") dispatch.CompletedDate = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = from, + NewValue = to, + Action = "vendor_status_change", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new ChangeStatusResultDTO { Id = dispatch.Id, Status = dispatch.Status, CompletedDate = dispatch.CompletedDate }; + } + + public async Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException($"Cannot request cancel on a '{dispatch.Status}' dispatch"); + } + + var now = DateTime.UtcNow; + var reasonText = string.IsNullOrWhiteSpace(reason) ? "(no reason provided)" : reason!.Trim(); + + await _commentData.StageAsync(new Comments + { + DispatchId = id, + WorkerOrderId = dispatch.WorkOrderId, + Commenter = session.CompanyName, + CommentType = "vendor", + RecordType = "cancel_request", + Commenttext = $"Vendor requested cancellation: {reasonText}", + CreatedDate = now + }, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber}", + OldValue = dispatch.Status, + NewValue = "Cancel Requested", + Action = "vendor_request_cancel", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException("Dispatch is locked"); + } + + var item = await _dispatchData.GetChecklistItemForDispatchAsync(itemId, id, cancellationToken); + if (item == null) throw new KeyNotFoundException("Checklist item not found"); + + var now = DateTime.UtcNow; + item.IsCompleted = isCompleted; + item.CompletedBy = isCompleted ? session.CompanyName : null; + item.CompletedAt = isCompleted ? now : null; + item.LastModificationTime = now; + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new ChecklistItemResultDTO + { + Id = item.Id, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }; + } + + public async Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status != "In Progress" && dispatch.Status != "Completed") + { + throw new InvalidOperationException("Signoffs only allowed on In Progress or Completed dispatches"); + } + + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(signature)) + { + throw new InvalidOperationException("Name and signature are required"); + } + + var normalizedSignoffType = (signoffType ?? "vendor").Trim().ToLowerInvariant(); + if (normalizedSignoffType != "vendor" && normalizedSignoffType != "customer") + { + throw new InvalidOperationException("signoffType must be 'vendor' or 'customer'"); + } + + var existing = await _dispatchData.HasSignoffTypeAsync(id, normalizedSignoffType); + if (existing) + { + throw new InvalidOperationException($"A {normalizedSignoffType} signoff already exists for this dispatch"); + } + + var now = DateTime.UtcNow; + var signoff = new DispatchSignoff + { + DispatchId = id, + SignoffType = normalizedSignoffType, + Name = name, + Signature = signature, + SignatureMethod = signatureMethod ?? "drawn", + SignedAt = now, + CreatedDate = now + }; + await _dispatchData.StageSignoffAsync(signoff, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Signoff", + OldValue = null, + NewValue = $"{(normalizedSignoffType == "customer" ? "Customer" : "Vendor")}: {name}", + Action = normalizedSignoffType == "customer" ? "customer_signoff" : "vendor_signoff", + CreatedAt = now + }, cancellationToken); + + await _dispatchData.SaveChangesAsync(cancellationToken); + return new SignoffResultDTO { Id = signoff.Id, SignedAt = signoff.SignedAt }; + } + + public async Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (string.IsNullOrWhiteSpace(commentText)) + { + throw new InvalidOperationException("Comment cannot be empty"); + } + + var now = DateTime.UtcNow; + var comment = new Comments + { + DispatchId = id, + WorkerOrderId = dispatch.WorkOrderId, + Commenter = session.CompanyName, + CommentType = "vendor", + RecordType = "comment", + Commenttext = commentText, + CreatedDate = now + }; + await _commentData.StageAsync(comment, cancellationToken); + await _dispatchData.SaveChangesAsync(cancellationToken); + + return new CommentResultDTO + { + Id = comment.Id, + Commenttext = comment.Commenttext, + Commenter = comment.Commenter, + CreatedDate = comment.CreatedDate + }; + } + + public async Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + if (dispatch.Status == "Verified" || dispatch.Status == "Cancelled") + { + throw new InvalidOperationException($"Cannot request uplift on a '{dispatch.Status}' dispatch"); + } + + if (requestedNTE <= 0) + { + throw new InvalidOperationException("Requested NTE must be greater than zero"); + } + + var current = dispatch.NTEAmount ?? 0m; + if (requestedNTE <= current) + { + throw new InvalidOperationException("Requested NTE must be greater than the current NTE"); + } + + var pendingExists = await _upliftData.HasPendingAsync(id, cancellationToken); + if (pendingExists) + { + throw new InvalidOperationException("A pending uplift request already exists for this dispatch"); + } + + var tier1Max = _approvalsOptions.UpliftTier1MaxUsd ?? 2500m; + var delta = requestedNTE - current; + var requiredTier = delta > tier1Max ? 2 : 1; + + var now = DateTime.UtcNow; + var req = new DispatchUpliftRequest + { + DispatchId = id, + CurrentNTE = current, + RequestedNTE = requestedNTE, + VendorReason = string.IsNullOrWhiteSpace(reason) ? null : reason!.Trim(), + Status = "Pending", + RequiredTier = requiredTier, + RequestedByVendorName = session.CompanyName, + CreatedDate = now + }; + await _upliftData.StageAsync(req, cancellationToken); + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = $"${current:F2}", + NewValue = $"${requestedNTE:F2}", + Action = "uplift_requested", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + + await NotifyDispatcherOfUpliftAsync(dispatch, req, session, cancellationToken); + + return new UpliftRequestResultDTO + { + Id = req.Id, + Status = req.Status, + RequiredTier = req.RequiredTier, + CurrentNTE = req.CurrentNTE, + RequestedNTE = req.RequestedNTE + }; + } + + public async Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken) + { + var dispatch = await _dispatchData.GetVendorDispatchForMutationAsync(id, session.Id, cancellationToken); + if (dispatch == null) throw new KeyNotFoundException("Dispatch not found"); + + var req = await _upliftData.GetByIdAndDispatchAsync(requestId, id, cancellationToken); + if (req == null) throw new KeyNotFoundException("Uplift request not found"); + + if (req.Status != "Pending") + { + throw new InvalidOperationException($"Cannot cancel a '{req.Status}' uplift request"); + } + + var now = DateTime.UtcNow; + req.Status = "Cancelled"; + req.DecidedAt = now; + req.LastModificationTime = now; + + await _dispatchData.StageAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + FieldName = $"Dispatch {dispatch.DispatchNumber} Uplift", + OldValue = "Pending", + NewValue = "Cancelled", + Action = "uplift_cancelled", + CreatedAt = now + }, cancellationToken); + + await _upliftData.SaveChangesAsync(cancellationToken); + return new CancelUpliftResultDTO { Id = req.Id, Status = req.Status }; + } + + private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, VendorPortalSession session, CancellationToken cancellationToken) + { + try + { + var dispatcherUserId = await _dispatchData.GetDispatchDispatcherUserIdAsync(dispatch.WorkOrderId ?? 0, cancellationToken); + if (string.IsNullOrWhiteSpace(dispatcherUserId)) return; + + var dispatcherEmail = await _userData.GetEmailByIdAsync(dispatcherUserId, cancellationToken); + if (string.IsNullOrWhiteSpace(dispatcherEmail)) return; + + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; + var workOrderLink = dispatch.WorkOrderId.HasValue + ? $"{frontendBase}/workorders/{dispatch.WorkOrderId.Value}" + : frontendBase; + + var vendorName = System.Net.WebUtility.HtmlEncode(session.CompanyName ?? "Vendor"); + var reason = System.Net.WebUtility.HtmlEncode(req.VendorReason ?? "(no reason provided)"); + var dispatchNum = System.Net.WebUtility.HtmlEncode(dispatch.DispatchNumber ?? ""); + var tierText = req.RequiredTier == 2 ? "Tier 2 (Manager approval required)" : "Tier 1"; + + var subject = $"[Uplift Request] {dispatch.DispatchNumber} — {session.CompanyName} requests ${req.RequestedNTE:F2} (was ${req.CurrentNTE ?? 0m:F2})"; + var body = $@" +

Vendor Uplift Request

+

Dispatch: {dispatchNum}

+

Vendor: {vendorName}

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

Vendor Reason

+

{reason}

+ "; + + await _emailSender.SendEmailAsync(dispatcherEmail, subject, body); + } + catch + { + } + } + + private static bool IsAllowedVendorTransition(string? from, string? to) + { + if (from == "Acknowledged" && to == "In Progress") return true; + if (from == "In Progress" && to == "Completed") return true; + return false; + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorPortalTokenService.cs b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs new file mode 100644 index 0000000..377b333 --- /dev/null +++ b/SeaHaven.Services/Implementation/VendorPortalTokenService.cs @@ -0,0 +1,93 @@ +using Data.SeaHavenIndustries; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Cryptography; + +namespace SeaHaven.Services.Implementation +{ + public class VendorPortalTokenService : IVendorPortalTokenService + { + private readonly IVendorDataService _vendorDataService; + private readonly int _lifetimeDays; + + public VendorPortalTokenService(IVendorDataService vendorDataService, IOptions options) + { + _vendorDataService = vendorDataService; + _lifetimeDays = options.Value.TokenLifetimeDays ?? 365; + } + + public async Task GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var existing = await _vendorDataService.GetActiveTokenForVendorAsync(vendorId, cancellationToken); + if (existing != null) return MapToken(existing); + + var token = new VendorAccessToken + { + VendorId = vendorId, + Token = GenerateToken(), + IssuedAt = now, + ExpiresAt = now.AddDays(_lifetimeDays), + CreatedDate = now + }; + return MapToken(await _vendorDataService.AddTokenAsync(token, cancellationToken)); + } + + public async Task ResolveSessionAsync(string? token, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(token)) return null; + var now = DateTime.UtcNow; + var record = await _vendorDataService.GetTokenWithVendorAsync(token, cancellationToken); + + if (record?.Vendor == null) return null; + + record.LastUsedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + return new VendorPortalSession + { + Id = record.Vendor.Id, + CompanyName = record.Vendor.CompanyName, + ContactName = record.Vendor.ContactName, + Email = record.Vendor.Email, + Phone = record.Vendor.Phone + }; + } + + public async Task RotateAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken); + foreach (var t in active) t.RevokedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + return await GetOrCreateActiveTokenAsync(vendorId, cancellationToken); + } + + public async Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + var active = await _vendorDataService.GetRevocableTokensAsync(vendorId, cancellationToken); + foreach (var t in active) t.RevokedAt = now; + await _vendorDataService.SaveChangesAsync(cancellationToken); + } + + private static string GenerateToken() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Convert.ToBase64String(bytes) + .Replace("+", "-") + .Replace("/", "_") + .TrimEnd('='); + } + + private static VendorPortalTokenStateDTO MapToken(VendorAccessToken token) => new() + { + Token = token.Token, + IssuedAt = token.IssuedAt, + ExpiresAt = token.ExpiresAt, + LastUsedAt = token.LastUsedAt + }; + } +} diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 009a9e4..117b0da 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -2,7 +2,9 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using FluentValidation.Results; +using Microsoft.Extensions.Options; using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; using SeaHaven.Services.DTOs; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; @@ -16,17 +18,26 @@ namespace SeaHaven.Services.Implementation { "completed", "cancelled", "canceled" }; private readonly IVendorDataService _vendorDataService; + private readonly IVendorPortalTokenService _vendorTokenService; + private readonly IZipCodeDistance _zipDistance; + private readonly FrontendOptions _frontendOptions; private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; public VendorService( IVendorDataService vendorDataService, + IVendorPortalTokenService vendorTokenService, + IZipCodeDistance zipDistance, + IOptions frontendOptions, ICreateVendorValidation createValidator, IUpdateVendorValidation updateValidator, IWorkOrderVendorUpdateValidation workOrderUpdateValidator) { _vendorDataService = vendorDataService; + _vendorTokenService = vendorTokenService; + _zipDistance = zipDistance; + _frontendOptions = frontendOptions.Value; _createValidator = createValidator; _updateValidator = updateValidator; _workOrderUpdateValidator = workOrderUpdateValidator; @@ -519,5 +530,136 @@ namespace SeaHaven.Services.Implementation .Distinct() .Count(); } + + public async Task> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken) + { + var vendors = await _vendorDataService.GetActiveVendorsAsync(cancellationToken); + + var result = vendors.Select(v => + { + var distance = _zipDistance.GetDistanceMiles(siteZip, v.Zip); + var addr = new[] { v.Address, v.City, v.State, v.Zip } + .Where(s => !string.IsNullOrWhiteSpace(s)); + return new VendorDropdownItemDTO + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + PreferredContact = v.PreferredContact, + TradeSpecialties = v.TradeSpecialties, + Address = string.Join(", ", addr), + DistanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null + }; + }).ToList(); + + if (!string.IsNullOrWhiteSpace(trade)) + { + var tradeMatched = result.Where(v => (v.TradeSpecialties ?? "").Contains(trade)).ToList(); + var rest = result.Where(v => !(v.TradeSpecialties ?? "").Contains(trade)).ToList(); + result = tradeMatched.Concat(rest).ToList(); + } + + if (!string.IsNullOrWhiteSpace(siteZip)) + { + result = result.OrderBy(v => v.DistanceMiles ?? 99999).ToList(); + } + + return result; + } + + public async Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken) + { + var vendors = await _vendorDataService.GetVendorsForFacetsAsync(isActive, cancellationToken); + + var companies = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.CompanyName)) + .GroupBy(v => v.CompanyName!.Trim(), StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .OrderBy(v => v.CompanyName) + .Select(v => new VendorFacetCompanyDTO + { + Name = v.CompanyName, + CompanyId = v.CompanyId, + CompanyPhone = v.CompanyPhone, + Email = v.Email, + PreferredContact = v.PreferredContact, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + GoogleMapsUrl = v.GoogleMapsUrl + }); + + var trades = vendors + .SelectMany(v => (v.TradeSpecialties ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value); + + var locations = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) + .Select(v => new VendorFacetLocationDTO + { + City = v.City, + State = v.State, + Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) + }) + .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) + .OrderBy(location => location.Label); + + return new VendorFacetsDTO + { + Companies = companies, + Trades = trades, + Locations = locations, + JobBuckets = new[] + { + new VendorJobBucketDTO { Id = "under-50", Label = "Under 50" }, + new VendorJobBucketDTO { Id = "50-99", Label = "50–99" }, + new VendorJobBucketDTO { Id = "100-149", Label = "100–149" }, + new VendorJobBucketDTO { Id = "150-plus", Label = "150+" } + } + }; + } + + public async Task GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return null; + + var token = await _vendorTokenService.GetOrCreateActiveTokenAsync(vendorId, cancellationToken); + return BuildPortalTokenResponse(token); + } + + public async Task RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return null; + + var token = await _vendorTokenService.RotateAsync(vendorId, cancellationToken); + return BuildPortalTokenResponse(token); + } + + public async Task RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + return false; + + await _vendorTokenService.RevokeAllAsync(vendorId, cancellationToken); + return true; + } + + private VendorPortalTokenDTO BuildPortalTokenResponse(VendorPortalTokenStateDTO token) + { + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; + return new VendorPortalTokenDTO + { + Token = token.Token, + IssuedAt = token.IssuedAt, + ExpiresAt = token.ExpiresAt, + LastUsedAt = token.LastUsedAt, + PortalUrl = $"{frontendBase}/v/{token.Token}/dashboard" + }; + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 19df98b..cfb3476 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -1,6 +1,6 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Constants; using SeaHaven.Services.Interfaces; @@ -8,12 +8,12 @@ namespace SeaHaven.Services.Implementation { public class WorkOrderAuditService : IWorkOrderAuditService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderAuditDataService _auditData; private readonly IWorkOrderFieldLockService _fieldLocks; - public WorkOrderAuditService(ApplicationDbContext context, IWorkOrderFieldLockService fieldLocks) + public WorkOrderAuditService(IWorkOrderAuditDataService auditData, IWorkOrderFieldLockService fieldLocks) { - _context = context; + _auditData = auditData; _fieldLocks = fieldLocks; } @@ -41,7 +41,7 @@ namespace SeaHaven.Services.Implementation int? dispatchId = null, string? correlationId = null) { - _context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + _auditData.EnqueueAuditLog(new WorkOrderAuditLog { WorkOrderId = workOrderId, UserId = actorId, @@ -64,29 +64,7 @@ namespace SeaHaven.Services.Implementation await _fieldLocks.LockFieldAsync(workOrderId, fieldName, actorId); } - await SaveChangesIdempotentAsync(); - } - - private async Task SaveChangesIdempotentAsync() - { - try - { - await _context.SaveChangesAsync(); - } - 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(); - } - } - - private static bool IsUniqueConstraintViolation(DbUpdateException ex) - { - var message = ex.InnerException?.Message ?? ex.Message; - return message.Contains("duplicate", StringComparison.OrdinalIgnoreCase) - || message.Contains("UNIQUE", StringComparison.OrdinalIgnoreCase); + await _auditData.PersistAsync(CancellationToken.None); } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs new file mode 100644 index 0000000..c0e51a7 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDispatchService.cs @@ -0,0 +1,433 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderDispatchService : IWorkOrderDispatchService + { + private readonly IWorkOrderDataService _workOrderDataService; + private readonly IDispatchDataService _dispatchDataService; + private readonly IVendorDataService _vendorDataService; + private readonly IUserDataService _userDataService; + private readonly ITaskListTemplateDataService _taskListTemplateDataService; + private readonly ICommentDataService _commentDataService; + private readonly IDispatchEmailPort _emailPort; + private readonly IVendorTokenPort _vendorTokenPort; + private readonly FrontendOptions _frontendOptions; + + public WorkOrderDispatchService( + IWorkOrderDataService workOrderDataService, + IDispatchDataService dispatchDataService, + IVendorDataService vendorDataService, + IUserDataService userDataService, + ITaskListTemplateDataService taskListTemplateDataService, + ICommentDataService commentDataService, + IDispatchEmailPort emailPort, + IVendorTokenPort vendorTokenPort, + IOptions frontendOptions) + { + _workOrderDataService = workOrderDataService; + _dispatchDataService = dispatchDataService; + _vendorDataService = vendorDataService; + _userDataService = userDataService; + _taskListTemplateDataService = taskListTemplateDataService; + _commentDataService = commentDataService; + _emailPort = emailPort; + _vendorTokenPort = vendorTokenPort; + _frontendOptions = frontendOptions.Value; + } + + public async Task DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken) + { + var workOrders = await _workOrderDataService.GetByRequestedIdsWithDetailsAsync( + input.WorkOrderIds, + cancellationToken); + + if (workOrders.Count == 0) + throw new InvalidOperationException("No work orders found"); + + var primaryWO = workOrders[0]; + + var lastPO = await _dispatchDataService.GenerateNextPONumberAsync(); + int nextPO = int.TryParse(lastPO, out var po) ? po : 1; + + var lastDSP = await _dispatchDataService.GenerateNextDispatchNumberAsync(); + int nextDSP = lastDSP.StartsWith("DSP-") && int.TryParse(lastDSP.Substring(4), out var dspNum) ? dspNum : 1; + + var currentUser = input.UserId != null ? await _userDataService.GetByIdAsync(input.UserId) : null; + var senderName = currentUser != null ? (currentUser.FirstName + " " + currentUser.LastName).Trim() : ""; + + var templateItemTexts = await LoadTemplateItemTextsAsync(input.TaskListTemplateId); + + var distinctVendorIds = input.VendorIds.Distinct().ToList(); + var vendors = await _vendorDataService.GetByIdsOrderedAsync(distinctVendorIds, cancellationToken); + + var dispatches = new List(); + + foreach (var vendor in vendors) + { + var poNumber = $"VPO-{nextPO:D5}"; + nextPO++; + var dispatchNumber = $"DSP-{nextDSP:D5}"; + nextDSP++; + + var replyTo = $"wo-{primaryWO.InternalWONumber}-{dispatchNumber}@int.seahaven.com"; + + var dispatch = new Dispatch + { + WorkOrderId = primaryWO.Id, + VendorId = vendor.Id, + DispatchNumber = dispatchNumber, + PONumber = poNumber, + NTEAmount = input.NTEAmount, + Description = input.Description, + Status = "Sent", + ScheduledDate = input.ScheduledDate, + DispatchedAt = DateTime.UtcNow, + ReplyToAddress = replyTo, + DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() + }; + + var checklistItems = BuildChecklistItems(templateItemTexts, input.CustomChecklistItems); + dispatch.ChecklistItems = checklistItems; + + var woListHtml = string.Join("", workOrders.Select(wo => + $"{System.Net.WebUtility.HtmlEncode(wo.InternalWONumber ?? "")}" + + $"{System.Net.WebUtility.HtmlEncode(wo.WorkerOrderTitle ?? "")}" + + $"{System.Net.WebUtility.HtmlEncode(wo.Priority ?? "")}" + + $"{wo.DueDate?.ToString("yyyy-MM-dd") ?? "N/A"}")); + + var locationName = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Name ?? ""); + var locationAddr = System.Net.WebUtility.HtmlEncode(primaryWO.Locations?.Address1 ?? ""); + var subject = $"[{dispatchNumber}] {(workOrders.Count > 1 ? $"{workOrders.Count} Work Orders" : primaryWO.WorkerOrderTitle)} at {primaryWO.Locations?.Name ?? ""}"; + var descriptionHtml = System.Net.WebUtility.HtmlEncode(input.Description ?? primaryWO.Description ?? ""); + var senderNameHtml = System.Net.WebUtility.HtmlEncode(senderName); + + var vendorToken = await _vendorTokenPort.GetOrCreateActiveTokenAsync(vendor.Id); + var frontendBase = _frontendOptions.FrontendBaseUrl?.TrimEnd('/') ?? ""; + var portalUrl = $"{frontendBase}/v/{vendorToken}/dashboard"; + + var html = $@" +

Work Order Dispatch — {dispatchNumber}

+

Location: {locationName} {locationAddr}

+

Vendor PO: {poNumber}   NTE: ${input.NTEAmount?.ToString("F2") ?? "N/A"}

+

Work Orders

+ + + + + + + + {woListHtml} +
WO #TitlePriorityDue Date
+

Description

+

{descriptionHtml}

+
+ +

{senderNameHtml}
Sea Haven Industries

+

Reply to this email to communicate about this dispatch.

"; + + var emailSent = await _emailPort.SendDispatchEmailAsync(vendor.Email!, subject, html, replyTo); + dispatch.EmailSent = emailSent; + + var savedDispatch = await _dispatchDataService.AddAsync(dispatch); + + var auditLogs = workOrders.Select(wo => new WorkOrderAuditLog + { + WorkOrderId = wo.Id, + UserId = input.UserId, + FieldName = "Dispatch", + OldValue = "", + NewValue = $"{vendor.CompanyName} ({dispatchNumber})", + Action = AuditAction.Dispatch.ToString(), + CreatedAt = DateTime.UtcNow + }).ToList(); + await _workOrderDataService.AddAuditLogsAsync(auditLogs); + + dispatches.Add(new DispatchResultItem + { + Id = savedDispatch.Id, + DispatchNumber = dispatchNumber, + PONumber = poNumber, + VendorName = vendor.CompanyName, + Status = savedDispatch.Status, + WorkOrderCount = workOrders.Count + }); + } + + return new DispatchToVendorResult { Message = "Dispatched", Dispatches = dispatches }; + } + + private async Task?> LoadTemplateItemTextsAsync(int? taskListTemplateId) + { + if (!taskListTemplateId.HasValue) + return null; + + var template = await _taskListTemplateDataService.GetByIdWithItemsAsync(taskListTemplateId.Value); + if (template?.Items == null) + return null; + + return template.Items + .OrderBy(i => i.SortOrder) + .Select(i => i.ItemText) + .ToList(); + } + + private static List BuildChecklistItems(List? templateItemTexts, List? customChecklistItems) + { + var checklistItems = new List(); + + if (templateItemTexts != null) + { + checklistItems.AddRange(templateItemTexts.Select((text, idx) => + new DispatchChecklistItem { ItemText = text, SortOrder = idx })); + } + + if (customChecklistItems != null) + { + var startIdx = checklistItems.Count; + checklistItems.AddRange(customChecklistItems.Select((text, idx) => + new DispatchChecklistItem { ItemText = text, SortOrder = startIdx + idx })); + } + + return checklistItems; + } + + public async Task> GetDispatchesAsync(int workOrderId) + { + var dispatches = await _dispatchDataService.GetByWorkOrderIdAsync(workOrderId); + return dispatches.OrderByDescending(d => d.DispatchedAt).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(); + } + + public async Task GetDispatchDetailAsync(int id) + { + return await _dispatchDataService.GetDispatchDetailAsync(id); + } + + public async Task UpdateDispatchAsync(UpdateDispatchInput input) + { + var dispatch = await _dispatchDataService.GetByIdAsync(input.Id); + if (dispatch == null) + return false; + + if (dispatch.Status != input.Status) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = input.UserId, + FieldName = $"Dispatch {dispatch.DispatchNumber} Status", + OldValue = dispatch.Status ?? "", + NewValue = input.Status ?? "", + Action = "dispatch_status_change", + CreatedAt = DateTime.UtcNow + }); + } + + dispatch.Status = input.Status; + dispatch.NTEAmount = input.NTEAmount; + dispatch.ScheduledDate = input.ScheduledDate; + dispatch.CompletedDate = input.CompletedDate; + dispatch.Description = input.Description; + + if (input.Status == "Completed" && dispatch.CompletedDate == null) + dispatch.CompletedDate = DateTime.UtcNow; + + await _dispatchDataService.UpdateAsync(dispatch); + return true; + } + + public async Task AddDispatchCommentAsync(AddDispatchCommentInput input) + { + var dispatch = await _dispatchDataService.GetByIdWithVendorAndWorkOrderAsync(input.DispatchId); + if (dispatch == null) + return null; + + var user = !string.IsNullOrEmpty(input.UserId) ? await _userDataService.GetByIdAsync(input.UserId) : null; + var senderName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; + + var comment = new Comments + { + CreatedDate = DateTime.UtcNow, + UserId = input.UserId, + WorkerOrderId = dispatch.WorkOrderId, + DispatchId = dispatch.Id, + Commenttext = input.Text, + CommentType = "dispatcher", + Commenter = !string.IsNullOrWhiteSpace(senderName) ? senderName : null, + }; + + await _commentDataService.AddAsync(comment); + + if (input.SendEmail && dispatch.Vendor?.Email != null && dispatch.WorkOrder != null) + { + var subject = $"[WO-{dispatch.WorkOrder.InternalWONumber}-{dispatch.DispatchNumber}] {dispatch.WorkOrder.WorkerOrderTitle}"; + var html = $"

{input.Text}


{senderName}
Sea Haven Industries


Reply to this email to respond.

"; + await _emailPort.SendDispatchEmailAsync(dispatch.Vendor.Email, subject, html, dispatch.ReplyToAddress); + } + + return new AddDispatchCommentResult + { + Id = comment.Id, + CreatedDate = comment.CreatedDate, + Commenttext = comment.Commenttext, + CommentType = comment.CommentType, + UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" + }; + } + + public async Task VerifyDispatchAsync(int dispatchId, string userId) + { + var dispatch = await _dispatchDataService.GetByIdAsync(dispatchId); + if (dispatch == null) + return null; + + var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(dispatchId); + var incompleteItems = checklistItems.Count(c => !c.IsCompleted); + + var signoffs = await _dispatchDataService.GetSignoffsAsync(dispatchId); + var signoffList = signoffs.ToList(); + var signoffCount = signoffList.Count; + var existingTypes = signoffList.Select(s => s.SignoffType).ToList(); + + var missing = new List(); + if (incompleteItems > 0) + missing.Add($"{incompleteItems} checklist item(s) incomplete"); + if (signoffCount < 2) + { + if (!existingTypes.Contains("Customer")) missing.Add("Missing Customer sign-off"); + if (!existingTypes.Contains("Vendor")) missing.Add("Missing Vendor sign-off"); + } + + if (missing.Count > 0) + throw new DispatchVerificationException(missing); + + var user = userId != null ? await _userDataService.GetByIdAsync(userId) : null; + var userName = user != null ? (user.FirstName + " " + user.LastName).Trim() : ""; + + dispatch.Status = "Verified"; + dispatch.VerifiedBy = userName; + dispatch.VerifiedAt = DateTime.UtcNow; + + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = $"Dispatch {dispatch.DispatchNumber}", + OldValue = dispatch.Status ?? "", + NewValue = "Verified", + Action = "dispatch_verified", + CreatedAt = DateTime.UtcNow + }); + + await _dispatchDataService.UpdateAsync(dispatch); + + return new VerifyDispatchResult + { + Message = "Dispatch verified", + VerifiedBy = userName, + VerifiedAt = dispatch.VerifiedAt + }; + } + + public async Task AddDispatchSignoffAsync(AddDispatchSignoffInput input) + { + var dispatch = await _dispatchDataService.GetByIdAsync(input.DispatchId); + if (dispatch == null) + return null; + + var existing = await _dispatchDataService.HasSignoffTypeAsync(input.DispatchId, input.SignoffType!); + if (existing) + throw new InvalidOperationException($"{input.SignoffType} sign-off already exists"); + + var signoff = new DispatchSignoff + { + DispatchId = input.DispatchId, + SignoffType = input.SignoffType, + Name = input.Name, + Signature = input.Signature, + SignatureMethod = input.SignatureMethod, + SignedAt = DateTime.UtcNow + }; + await _dispatchDataService.AddSignoffAsync(signoff); + + return new AddDispatchSignoffResult + { + Id = signoff.Id, + SignoffType = signoff.SignoffType, + Name = signoff.Name, + SignedAt = signoff.SignedAt, + SignatureMethod = signoff.SignatureMethod + }; + } + + public async Task UpdateChecklistItemAsync(ChecklistItemUpdateInput input) + { + var item = await _dispatchDataService.GetChecklistItemByIdAsync(input.Id); + if (item == null) + return null; + + item.IsCompleted = input.IsCompleted; + item.CompletedBy = input.IsCompleted ? input.CompletedBy : null; + item.CompletedAt = input.IsCompleted ? DateTime.UtcNow : null; + await _dispatchDataService.UpdateChecklistItemAsync(item); + + return new ChecklistItemUpdateResult + { + Message = "Updated", + Id = item.Id, + IsCompleted = item.IsCompleted, + CompletedBy = item.CompletedBy, + CompletedAt = item.CompletedAt + }; + } + + public async Task AddChecklistItemAsync(AddChecklistItemInput input) + { + var checklistItems = await _dispatchDataService.GetChecklistItemsAsync(input.DispatchId); + var maxSort = checklistItems.Any() ? checklistItems.Max(c => c.SortOrder) : -1; + + var item = new DispatchChecklistItem + { + DispatchId = input.DispatchId, + WorkOrderId = input.WorkOrderId, + ItemText = input.ItemText, + SortOrder = maxSort + 1, + IsCompleted = false + }; + await _dispatchDataService.AddChecklistItemAsync(item); + + return new AddChecklistItemResult + { + Id = item.Id, + ItemText = item.ItemText, + SortOrder = item.SortOrder, + IsCompleted = item.IsCompleted + }; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs index 442fbb3..5bb006c 100644 --- a/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderFieldLockService.cs @@ -1,47 +1,24 @@ -using Data.SeaHavenIndustries; -using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation { public class WorkOrderFieldLockService : IWorkOrderFieldLockService { - private readonly ApplicationDbContext _context; + private readonly IWorkOrderFieldLockDataService _dataService; - public WorkOrderFieldLockService(ApplicationDbContext context) + public WorkOrderFieldLockService(IWorkOrderFieldLockDataService dataService) { - _context = context; + _dataService = dataService; } - public async Task IsLockedAsync(int workOrderId, string fieldName) + public Task IsLockedAsync(int workOrderId, string fieldName) + => _dataService.IsLockedAsync(workOrderId, fieldName, CancellationToken.None); + + public Task LockFieldAsync(int workOrderId, string fieldName, string? userId) { - if (IsTracked(workOrderId, fieldName)) - return true; - - return await _context.WorkOrderFieldLocks - .AnyAsync(l => l.WorkOrderId == workOrderId && l.FieldName == fieldName); + _dataService.EnqueueLock(workOrderId, fieldName, userId); + return Task.CompletedTask; } - - public async Task LockFieldAsync(int workOrderId, string fieldName, string? userId) - { - if (IsTracked(workOrderId, fieldName)) - return; - - if (await IsLockedAsync(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)); } } diff --git a/SeaHaven.Services/Implementation/WorkOrderService.cs b/SeaHaven.Services/Implementation/WorkOrderService.cs index 19fefc3..88632dc 100644 --- a/SeaHaven.Services/Implementation/WorkOrderService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderService.cs @@ -2,6 +2,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; using SeaHaven.DataServices.Interfaces; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; @@ -11,15 +12,27 @@ namespace SeaHaven.Services.Implementation public class WorkOrderService : IWorkOrderService { private readonly IWorkOrderDataService _workOrderDataService; + private readonly ICommentDataService _commentDataService; + private readonly IUserDataService _userDataService; + private readonly IQuotesDataService _quotesDataService; + private readonly IFileStoragePort _fileStorage; private readonly ICreateWorkOrderValidation _createValidator; private readonly IUpdateWorkOrderValidation _updateValidator; public WorkOrderService( IWorkOrderDataService workOrderDataService, + ICommentDataService commentDataService, + IUserDataService userDataService, + IQuotesDataService quotesDataService, + IFileStoragePort fileStorage, ICreateWorkOrderValidation createValidator, IUpdateWorkOrderValidation updateValidator) { _workOrderDataService = workOrderDataService; + _commentDataService = commentDataService; + _userDataService = userDataService; + _quotesDataService = quotesDataService; + _fileStorage = fileStorage; _createValidator = createValidator; _updateValidator = updateValidator; } @@ -53,13 +66,12 @@ namespace SeaHaven.Services.Implementation PageSize = pageSize }; } + public async Task CreateWorkOrderAsync(CreateWorkOrderDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); if (!validationResult.IsValid) - { throw new ValidationException(validationResult.Errors); - } var workOrder = CreateWorkOrderDTO.MapToEntity(dto); workOrder.createdby = userId; @@ -67,9 +79,7 @@ namespace SeaHaven.Services.Implementation workOrder.InternalWONumber = await _workOrderDataService.GenerateNextInternalWONumberAsync(); var saved = await _workOrderDataService.AddAsync(workOrder); - await LogAuditAsync(saved.Id, userId, AuditAction.Create, "Work order created"); - return saved.Id; } @@ -77,9 +87,7 @@ namespace SeaHaven.Services.Implementation { var validationResult = await _updateValidator.ValidateAsync(dto); if (!validationResult.IsValid) - { throw new ValidationException(validationResult.Errors); - } var existingWorkOrder = await _workOrderDataService.GetByIdAsync(id); if (existingWorkOrder == null) @@ -92,9 +100,7 @@ namespace SeaHaven.Services.Implementation await _workOrderDataService.UpdateAsync(existingWorkOrder); if (changes.Any()) - { await LogAuditAsync(id, userId, AuditAction.Edit, string.Join(", ", changes)); - } return true; } @@ -108,61 +114,502 @@ namespace SeaHaven.Services.Implementation await _workOrderDataService.DeleteWorkOrderContactsAsync(id); await _workOrderDataService.DeleteWorkOrderCategoriesAsync(id); await _workOrderDataService.DeleteAsync(id); - await LogAuditAsync(id, userId, AuditAction.Delete, "Work order deleted"); + return true; + } + + public async Task WorkOrderExistsAsync(int id) => await _workOrderDataService.ExistsAsync(id); + public async Task GenerateNextInternalWONumberAsync() => await _workOrderDataService.GenerateNextInternalWONumberAsync(); + public async Task GetTotalWorkOrderCountAsync() => await _workOrderDataService.CountAsync(); + + public async Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + int page, int pageSize, string? search, string? status, string? assignee, + int? locationId, string? sortBy, string? sortDir) + { + return await _workOrderDataService.GetWorkOrderListPagedAsync( + page, pageSize, search, status, assignee, locationId, sortBy, sortDir); + } + + public async Task GetWorkOrderDetailAsync(int id) + { + return await _workOrderDataService.GetWorkOrderDetailAsync(id); + } + + public async Task GetFilteredWorkOrdersAsync( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, + string sort, string sortby, int page, int pageSize) + { + var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); + + var data = allWorkOrders.AsEnumerable(); + + if (!string.IsNullOrEmpty(search)) + { + var s = search.ToLower(); + data = data.Where(w => + (w.WorkerOrderTitle ?? "").ToLower().Contains(s) || + (w.WorkerOrderNumber ?? "").ToLower().Contains(s) || + (w.Locations != null && (w.Locations.Title ?? "").ToLower().Contains(s))); + } + + if (assignto != null && assignto.Length > 0 && assignto[0] != "" && assignto[0] != null) + data = data.Where(w => assignto.Contains(w.AssignTo)); + + if (location != null && location.Length > 0 && location[0] != 0) + data = data.Where(w => location.Contains(w.LocationId ?? 0)); + + if (priority != null && priority.Length > 0 && priority[0] != "" && priority[0] != null) + data = data.Where(w => priority.Contains(w.Priority)); + + if (status != null && status.Length > 0 && status[0] != "" && status[0] != null) + data = data.Where(w => status.Contains(w.Status)); + + var dataList = data.ToList(); + + dataList = dataList.Where(w => + duedate == null || duedate.Length == 0 || duedate[0] == null || + duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, DateTime.UtcNow.Date))).ToList(); + + if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") + { + switch (sortby) + { + case "Name": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.WorkerOrderTitle).ToList() + : dataList.OrderByDescending(w => w.WorkerOrderTitle).ToList(); + break; + case "Due Date": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.DueDate).ToList() + : dataList.OrderByDescending(w => w.DueDate).ToList(); + break; + case "Severity": + dataList = sort.ToLower() == "ascending" + ? dataList.OrderBy(w => w.Priority).ToList() + : dataList.OrderByDescending(w => w.Priority).ToList(); + break; + } + } + + var totalCount = dataList.Count; + var pagedData = dataList + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(s => new FilteredWorkOrderReadModel + { + Id = s.Id, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + Status = s.Status, + Priority = s.Priority, + Name = s.Locations != null ? s.Locations.Name : "" + }) + .OrderByDescending(d => d.Name) + .ToList(); + + return new FilteredWorkOrderPageDTO + { + Data = pagedData, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + } + + public async Task GetFilteredWorkOrders2Async( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, + string sort, string sortby, int page, int pageSize) + { + location ??= Array.Empty(); + priority ??= Array.Empty(); + status ??= Array.Empty(); + duedate ??= Array.Empty(); + search ??= string.Empty; + + var allWorkOrders = await _workOrderDataService.GetNonTemplateWorkOrdersWithLocationsAsync(); + var totalCount = allWorkOrders.Count; + + var currentDate = DateTime.UtcNow.Date; + var data = allWorkOrders.Where(w => + (((w.WorkerOrderTitle ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) || + (w.WorkerOrderNumber ?? "").Contains(search, StringComparison.OrdinalIgnoreCase) || + (w.Locations != null && + (w.Locations.Title ?? "").Contains(search, StringComparison.OrdinalIgnoreCase))) && + (assignto.Length == 0 || assignto.Contains(w.AssignTo)) && + (location.Length == 0 || (w.LocationId.HasValue && location.Contains(w.LocationId.Value))) && + (priority.Length == 0 || priority.Contains(w.Priority)) && + (status.Length == 0 || status.Contains(w.Status)))).ToList(); + + data = data.Where(w => + duedate.Length == 0 || + duedate.Any(dd => w.DueDate.HasValue && CheckDueDate(dd, w.DueDate.Value.Date, currentDate))).ToList(); + + if (!string.IsNullOrEmpty(sort) && !string.IsNullOrEmpty(sortby) && sortby != "") + { + switch (sortby) + { + case "Name": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.WorkerOrderTitle).ToList() + : data.OrderByDescending(w => w.WorkerOrderTitle).ToList(); + break; + case "Due Date": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.DueDate).ToList() + : data.OrderByDescending(w => w.DueDate).ToList(); + break; + case "Severity": + data = sort.ToLower() == "ascending" + ? data.OrderBy(w => w.Priority).ToList() + : data.OrderByDescending(w => w.Priority).ToList(); + break; + } + } + + var newdata = data + .Select(s => new FilteredWorkOrderReadModel + { + Id = s.Id, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + Status = s.Status, + Priority = s.Priority, + Name = s.Locations != null ? s.Locations.Name : "" + }) + .OrderByDescending(d => d.Name) + .ToList() + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToList(); + + return new FilteredWorkOrderPageDTO + { + Data = newdata, + PageNumber = page, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize) + }; + } + + public async Task ChangeStatusAsync(int id, string status, string userId) + { + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return null; + + if (workOrder.Status != status) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = id, + UserId = userId, + FieldName = "Status", + OldValue = workOrder.Status ?? "", + NewValue = status, + Action = AuditAction.StatusChange.ToString(), + CreatedAt = DateTime.UtcNow + }); + } + + workOrder.Status = status; + await _workOrderDataService.UpdateAsync(workOrder); + + return WorkOrderDTO.MapToModel(workOrder); + } + + public async Task ChangeAssignmentAsync(int id, string? assignTo, string userId) + { + var workOrder = await _workOrderDataService.GetByIdAsync(id); + if (workOrder == null) return null; + + if (workOrder.AssignTo != assignTo) + { + var oldUser = workOrder.AssignTo != null ? await _userDataService.GetByIdAsync(workOrder.AssignTo) : null; + var newUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; + + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = id, + UserId = userId, + FieldName = "AssignedTo", + OldValue = oldUser != null ? (oldUser.FirstName + " " + oldUser.LastName).Trim() : "", + NewValue = newUser != null ? (newUser.FirstName + " " + newUser.LastName).Trim() : "Unassigned", + Action = AuditAction.AssignmentChange.ToString(), + CreatedAt = DateTime.UtcNow + }); + } + + workOrder.AssignTo = assignTo; + await _workOrderDataService.UpdateAsync(workOrder); + + var assignedUser = assignTo != null ? await _userDataService.GetByIdAsync(assignTo) : null; + return new ChangeAssignmentResult + { + Message = "Updated Successfully", + AssignedTo = assignedUser != null ? (assignedUser.FirstName + " " + assignedUser.LastName).Trim() : "" + }; + } + + public async Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId) + { + var createDto = new CreateWorkOrderDTO + { + WorkerOrderTitle = input.WorkerOrderTitle, + Description = input.Description, + Status = WorkOrderStatus.Open, + Priority = Enum.TryParse(input.Priority, out var p) ? p : WorkOrderPriority.Medium, + LocationId = input.LocationId, + AssignTo = input.AssignTo, + DueDate = input.DueDate + }; + + var workOrderId = await CreateWorkOrderAsync(createDto, userId); + + if (input.ContactIds != null && input.ContactIds.Any()) + await _workOrderDataService.AddWorkOrderContactsAsync(workOrderId, input.ContactIds); + + if (input.CategoryIds != null && input.CategoryIds.Any()) + await _workOrderDataService.AddWorkOrderCategoriesAsync(workOrderId, input.CategoryIds); + + await SaveAttachmentsAndPhotos(workOrderId, input.Attachments, input.BeforPhotoAttachment, + input.AfterPhotoAttachment, input.SignOffAttachment); + + return workOrderId; + } + + public async Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId) + { + var updateDto = new UpdateWorkOrderDTO + { + WorkerOrderTitle = input.WorkerOrderTitle, + Description = input.Description, + Status = Enum.TryParse(input.Status, out var st) ? st : null, + Priority = Enum.TryParse(input.Priority, out var pr) ? pr : null, + LocationId = input.LocationId, + AssignTo = input.AssignTo, + DueDate = input.DueDate, + CompletedDate = input.CompletedDate + }; + + var success = await UpdateWorkOrderAsync(input.Id, updateDto, userId); + if (!success) return false; + + await _workOrderDataService.DeleteWorkOrderContactsAsync(input.Id); + await _workOrderDataService.DeleteWorkOrderCategoriesAsync(input.Id); + + if (input.ContactIds != null && input.ContactIds.Any()) + await _workOrderDataService.AddWorkOrderContactsAsync(input.Id, input.ContactIds); + + if (input.CategoryIds != null && input.CategoryIds.Any()) + await _workOrderDataService.AddWorkOrderCategoriesAsync(input.Id, input.CategoryIds); + + await SaveAttachmentsAndPhotos(input.Id, input.Attachments, input.BeforPhotoAttachment, + input.AfterPhotoAttachment, input.SignOffAttachment); return true; } - public async Task WorkOrderExistsAsync(int id) + public async Task DeleteWorkOrderCascadeAsync(int id, string userId) { - return await _workOrderDataService.ExistsAsync(id); + var success = await DeleteWorkOrderAsync(id, userId); + if (!success) return false; + + await _quotesDataService.UnlinkFromWorkOrderAsync(id); + + var comments = await _commentDataService.GetByWorkOrderIdAsync(id); + foreach (var comment in comments) + await _commentDataService.DeleteAsync(comment.Id); + + return true; } - public async Task GenerateNextInternalWONumberAsync() + public async Task AddCommentAsync(AddCommentInput input, string userId) { - return await _workOrderDataService.GenerateNextInternalWONumberAsync(); + var comment = new Comments + { + UserId = userId, + WorkerOrderId = input.WorkorderId, + Commenttext = input.Text, + CommentType = input.CommentType ?? "vendor" + }; + + if (input.Document != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(input.Document); + comment.Documents = fileUrl; + } + + return await _commentDataService.AddAsync(comment); } - public async Task GetTotalWorkOrderCountAsync() + public async Task AddCommentJsonAsync(AddCommentInput input, string userId) { - return await _workOrderDataService.CountAsync(); + var comment = new Comments + { + UserId = userId, + WorkerOrderId = input.WorkorderId, + Commenttext = input.Text, + CommentType = input.CommentType ?? "internal" + }; + + var saved = await _commentDataService.AddAsync(comment); + var user = await _userDataService.GetByIdAsync(userId); + + return new CommentResult + { + Id = saved.Id, + CreatedDate = saved.CreatedDate, + Commenttext = saved.Commenttext, + CommentType = saved.CommentType, + UserName = user != null ? (user.FirstName + " " + user.LastName).Trim() : "" + }; + } + + public async Task> GetCommentsAsync() + { + var comments = await _commentDataService.GetAllAsync(); + return comments.Select(s => new CommentListItemReadModel + { + Id = s.Id, + Commenttext = s.Commenttext, + FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", + Documents = s.Documents + }).ToList(); + } + + public async Task> GetCommentsByWorkorderIdAsync(int woid) + { + var comments = await _commentDataService.GetByWorkOrderIdAsync(woid); + return comments.Select(s => new CommentListItemReadModel + { + Id = s.Id, + Commenttext = s.Commenttext, + FirstName = s.ApplicationUser != null ? s.ApplicationUser.FirstName : "", + Documents = s.Documents + }).ToList(); + } + + public async Task> GetWorkordersDDAsync() + { + var data = await _workOrderDataService.GetAllAsync(); + return data.Where(w => w.istemplate != true).ToList(); + } + + public async Task> GetWorkordersAsync() + { + var workOrders = await _workOrderDataService.GetAllWithDetailsAsync(); + return workOrders.Select(s => new WorkorderFilterVM + { + Id = s.Id, + Priority = s.Priority, + Status = s.Status, + WorkerOrderNumber = s.WorkerOrderNumber, + WorkerOrderTitle = s.WorkerOrderTitle, + DueDate = s.DueDate, + AssignToUserName = s.AssignToUser != null ? s.AssignToUser.FirstName : "", + LocationName = s.Locations != null ? s.Locations.Name : "", + LocationId = s.LocationId, + PO = s.PO, + TT = s.TT + }).ToList(); + } + + private async Task SaveAttachmentsAndPhotos(int workOrderId, + List? attachments, + Microsoft.AspNetCore.Http.IFormFile? beforPhoto, + Microsoft.AspNetCore.Http.IFormFile? afterPhoto, + Microsoft.AspNetCore.Http.IFormFile? signOffPhoto) + { + if (attachments != null) + { + foreach (var attachment in attachments) + { + var fileUrl = await _fileStorage.SaveFileAsync(attachment); + await _workOrderDataService.AddWorkOrderAttachmentAsync(new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl + }); + } + } + + if (beforPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(beforPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.BeforPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + + if (afterPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(afterPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.AfterPhotoAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + + if (signOffPhoto != null) + { + var fileUrl = await _fileStorage.SaveFileAsync(signOffPhoto); + var workOrder = await _workOrderDataService.GetByIdAsync(workOrderId); + if (workOrder != null) + { + workOrder.SignOffAttachment = fileUrl; + await _workOrderDataService.UpdateAsync(workOrder); + } + } + } + + private static bool CheckDueDate(string dd, DateTime dueDate, DateTime currentDate) + { + return dd switch + { + "Today" => dueDate == currentDate, + "Tomorrow" => dueDate == currentDate.AddDays(1), + "Next Week" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddDays(7), + "Next Month" => dueDate >= currentDate.AddDays(1) && dueDate <= currentDate.AddMonths(1), + "Over Due" => dueDate < currentDate, + _ => false + }; } private List TrackChanges(WorkOrder existing, UpdateWorkOrderDTO dto) { var changes = new List(); - if (dto.WorkerOrderTitle != null && dto.WorkerOrderTitle != existing.WorkerOrderTitle) changes.Add($"Title: {existing.WorkerOrderTitle} → {dto.WorkerOrderTitle}"); - if (dto.Status.HasValue && dto.Status.ToString() != existing.Status) changes.Add($"Status: {existing.Status} → {dto.Status}"); - if (dto.Priority.HasValue && dto.Priority.ToString() != existing.Priority) changes.Add($"Priority: {existing.Priority} → {dto.Priority}"); - if (dto.AssignTo != null && dto.AssignTo != existing.AssignTo) changes.Add($"Assigned To: {existing.AssignTo} → {dto.AssignTo}"); - if (dto.DueDate.HasValue && dto.DueDate != existing.DueDate) changes.Add($"Due Date: {existing.DueDate:yyyy-MM-dd} → {dto.DueDate:yyyy-MM-dd}"); - return changes; } private async Task LogAuditAsync(int workOrderId, string userId, AuditAction action, string description) { - var auditLog = new WorkOrderAuditLog + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog { WorkOrderId = workOrderId, Action = action.ToString(), FieldName = description, UserId = userId, CreatedAt = DateTime.UtcNow - }; - - await _workOrderDataService.AddAuditLogAsync(auditLog); + }); } } -} \ No newline at end of file +} diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs new file mode 100644 index 0000000..426eb5f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IAuthenticationService + { + Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); + Task ChangePasswordAsync(string userId, string? currentPassword, string? confirmPassword, CancellationToken cancellationToken); + Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); + Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); + Task VerifyCodeAsync(string code, CancellationToken cancellationToken); + Task ResetPasswordAsync(string email, string? code, string? password, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ICalendarService.cs b/SeaHaven.Services/Interfaces/ICalendarService.cs new file mode 100644 index 0000000..3bc1cbc --- /dev/null +++ b/SeaHaven.Services/Interfaces/ICalendarService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ICalendarService + { + Task CreateEventAsync(EditEventInput input, CancellationToken cancellationToken); + Task UpdateEventAsync(EditEventInput input, CancellationToken cancellationToken); + Task GetEventByIdAsync(int id, CancellationToken cancellationToken); + Task DeleteEventAsync(int id, CancellationToken cancellationToken); + Task GetEventListAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetEventsForCalendarAsync(DateTime? start, DateTime? end, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ICommonService.cs b/SeaHaven.Services/Interfaces/ICommonService.cs new file mode 100644 index 0000000..4b27e9a --- /dev/null +++ b/SeaHaven.Services/Interfaces/ICommonService.cs @@ -0,0 +1,13 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ICommonService + { + Task GetUsersPagedAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetRolesAsync(CancellationToken cancellationToken); + Task> GetSiteNamesAsync(CancellationToken cancellationToken); + Task> GetContactsAsync(CancellationToken cancellationToken); + Task> GetCategoriesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IDashboardService.cs b/SeaHaven.Services/Interfaces/IDashboardService.cs new file mode 100644 index 0000000..bfc2e2d --- /dev/null +++ b/SeaHaven.Services/Interfaces/IDashboardService.cs @@ -0,0 +1,9 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IDashboardService + { + Task GetStatsAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IDispatchService.cs b/SeaHaven.Services/Interfaces/IDispatchService.cs index a650389..1f1da0a 100644 --- a/SeaHaven.Services/Interfaces/IDispatchService.cs +++ b/SeaHaven.Services/Interfaces/IDispatchService.cs @@ -15,5 +15,8 @@ namespace SeaHaven.Services.Interfaces Task DispatchExistsAsync(int id); Task GenerateNextDispatchNumberAsync(); Task GetTotalDispatchCountAsync(); + + Task GetDispatchListAsync(string? search, string? status, int? vendorId, DateTime? dateFrom, DateTime? dateTo, string? sortBy, string? sortDir, int page, int pageSize, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IDropdownOptionsService.cs b/SeaHaven.Services/Interfaces/IDropdownOptionsService.cs new file mode 100644 index 0000000..3eb291f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IDropdownOptionsService.cs @@ -0,0 +1,13 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IDropdownOptionsService + { + Task> GetAllAsync(string? category, CancellationToken cancellationToken); + Task> GetByCategoryAsync(string category, string? parent, CancellationToken cancellationToken); + Task CreateAsync(DropdownOptionDTO option, CancellationToken cancellationToken); + Task UpdateAsync(int id, DropdownOptionDTO option, CancellationToken cancellationToken); + Task DeleteAsync(int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IEmailSender.cs b/SeaHaven.Services/Interfaces/IEmailSender.cs new file mode 100644 index 0000000..3d2f68f --- /dev/null +++ b/SeaHaven.Services/Interfaces/IEmailSender.cs @@ -0,0 +1,11 @@ +namespace SeaHaven.Services.Interfaces +{ + /// + /// Outbound email port. Implemented in the API layer so the service layer + /// can orchestrate email without depending on the transport (SendGrid). + /// + public interface IEmailSender + { + Task SendEmailAsync(string emailTo, string subject, string body); + } +} diff --git a/SeaHaven.Services/Interfaces/IFollowUpService.cs b/SeaHaven.Services/Interfaces/IFollowUpService.cs index ae0b3dd..0b2fd94 100644 --- a/SeaHaven.Services/Interfaces/IFollowUpService.cs +++ b/SeaHaven.Services/Interfaces/IFollowUpService.cs @@ -14,5 +14,12 @@ namespace SeaHaven.Services.Interfaces Task DeleteFollowUpAsync(int id, string userId); Task FollowUpExistsAsync(int id); Task GetTotalFollowUpCountAsync(); + + Task> GetFollowUpListAsync(int page, int pageSize, string? search, string? status, CancellationToken cancellationToken); + Task GetFollowUpDetailAsync(int id, CancellationToken cancellationToken); + Task CreateOpenFollowUpAsync(FollowUpCreateRequestDTO dto, string userId, CancellationToken cancellationToken); + Task UpdateFollowUpFieldsAsync(int id, FollowUpUpdateRequestDTO dto, CancellationToken cancellationToken); + Task SetFollowUpStatusAsync(int id, string status, CancellationToken cancellationToken); + Task RemoveFollowUpAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 7bf9d4e..95db466 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -14,5 +14,11 @@ namespace SeaHaven.Services.Interfaces Task DeleteLocationAsync(int id, string userId); Task LocationExistsAsync(int id); Task GetTotalLocationCountAsync(); + + Task> GetLocationListPagedAsync(int page, int pageSize, string? search, CancellationToken cancellationToken); + Task GetLocationDetailAsync(int id, CancellationToken cancellationToken); + Task CreateLocationFromRequestAsync(LocationCreateRequestDTO request, CancellationToken cancellationToken); + Task UpdateLocationFromRequestAsync(int id, LocationUpdateRequestDTO request, CancellationToken cancellationToken); + Task DeleteLocationByIdAsync(int id, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IQuotesService.cs b/SeaHaven.Services/Interfaces/IQuotesService.cs new file mode 100644 index 0000000..13c2074 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IQuotesService.cs @@ -0,0 +1,16 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IQuotesService + { + Task CreateQuoteAsync(CreateQuoteInput input, CancellationToken cancellationToken); + Task UpdateQuoteAsync(UpdateQuoteInput input, CancellationToken cancellationToken); + Task DeleteQuoteAsync(int id, CancellationToken cancellationToken); + Task GetQuotesPagedAsync(int page, int pageSize, string search, CancellationToken cancellationToken); + Task> GetWorkOrderQuoteDetailsAsync( + int woId, CancellationToken cancellationToken); + Task GetQuoteDetailsByIdAsync(int id, CancellationToken cancellationToken); + Task GetReportingAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IServicePorts.cs b/SeaHaven.Services/Interfaces/IServicePorts.cs new file mode 100644 index 0000000..8050cc5 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IServicePorts.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; + +namespace SeaHaven.Services.Interfaces +{ + public interface IFileStoragePort + { + Task SaveFileAsync(IFormFile file); + } + + public interface IDispatchEmailPort + { + Task SendDispatchEmailAsync(string emailTo, string subject, string htmlBody, string? replyTo); + } + + public interface IVendorTokenPort + { + Task GetOrCreateActiveTokenAsync(int vendorId); + } +} diff --git a/SeaHaven.Services/Interfaces/ISettingsService.cs b/SeaHaven.Services/Interfaces/ISettingsService.cs new file mode 100644 index 0000000..6b920b8 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISettingsService.cs @@ -0,0 +1,15 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ISettingsService + { + Task AddCategoryAsync(string? name, CancellationToken cancellationToken); + Task UpdateCategoryAsync(int? id, string? name, CancellationToken cancellationToken); + Task DeleteCategoryAsync(int id, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetCategoryListAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Items, int Total)> GetTemplatesAsync(int page, int pageSize, CancellationToken cancellationToken); + Task<(IReadOnlyList Users, int Total)> GetUsersAsync(string? search, int page, int pageSize, CancellationToken cancellationToken); + Task> GetRolesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ISyncExternalSource.cs b/SeaHaven.Services/Interfaces/ISyncExternalSource.cs new file mode 100644 index 0000000..849927b --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISyncExternalSource.cs @@ -0,0 +1,9 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface ISyncExternalSource + { + IAsyncEnumerable> ScanWorkOrdersAsync(CancellationToken cancellationToken); + IAsyncEnumerable> ScanCommentsAsync(CancellationToken cancellationToken); + IAsyncEnumerable> ScanVendorRepliesAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ISyncService.cs b/SeaHaven.Services/Interfaces/ISyncService.cs new file mode 100644 index 0000000..b8f0759 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ISyncService.cs @@ -0,0 +1,15 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ISyncService + { + Task SyncWorkOrdersAsync(CancellationToken cancellationToken); + Task SyncCommentsAsync(CancellationToken cancellationToken); + Task BackfillInternalWONumbersAsync(CancellationToken cancellationToken); + Task BackfillDispatchNumbersAsync(CancellationToken cancellationToken); + Task SyncVendorRepliesAsync(CancellationToken cancellationToken); + Task BackfillCommentTypesAsync(CancellationToken cancellationToken); + Task SyncAllAsync(CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/ITaskListTemplateService.cs b/SeaHaven.Services/Interfaces/ITaskListTemplateService.cs new file mode 100644 index 0000000..596aa2f --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITaskListTemplateService.cs @@ -0,0 +1,13 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface ITaskListTemplateService + { + Task> GetAllAsync(CancellationToken cancellationToken); + Task GetByIdAsync(int id, CancellationToken cancellationToken); + Task CreateAsync(TaskListTemplateInput input, CancellationToken cancellationToken); + Task UpdateAsync(int id, TaskListTemplateInput input, CancellationToken cancellationToken); + Task DeleteAsync(int id, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IUpliftService.cs b/SeaHaven.Services/Interfaces/IUpliftService.cs new file mode 100644 index 0000000..3fb0b53 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IUpliftService.cs @@ -0,0 +1,14 @@ +using System.Security.Claims; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IUpliftService + { + Task ListAsync(ClaimsPrincipal user, string? status, int? tier, int page, int pageSize, CancellationToken cancellationToken); + Task> ListForDispatchAsync(ClaimsPrincipal user, int dispatchId, CancellationToken cancellationToken); + Task ApproveAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + Task DenyAsync(ClaimsPrincipal user, int id, string? note, CancellationToken cancellationToken); + bool CanApprove(ClaimsPrincipal user, int tier); + } +} diff --git a/SeaHaven.Services/Interfaces/IUserService.cs b/SeaHaven.Services/Interfaces/IUserService.cs new file mode 100644 index 0000000..9f47410 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IUserService.cs @@ -0,0 +1,14 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IUserService + { + Task> GetUsersAsync(CancellationToken cancellationToken); + Task AddUserAsync(AddUserRequestDTO dto, CancellationToken cancellationToken); + Task EditUserAsync(EditUserRequestDTO dto, CancellationToken cancellationToken); + Task DeleteUserAsync(string id, CancellationToken cancellationToken); + Task DeleteCurrentUserAsync(string userId, CancellationToken cancellationToken); + Task> GetUserProfileAsync(string userId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorPortalService.cs b/SeaHaven.Services/Interfaces/IVendorPortalService.cs new file mode 100644 index 0000000..1ba36c9 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorPortalService.cs @@ -0,0 +1,19 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorPortalService + { + Task ResolveSessionAsync(string? token, CancellationToken cancellationToken); + Task> ListDispatchesAsync(VendorPortalSession session, string? status, CancellationToken cancellationToken); + Task GetDispatchDetailAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); + Task AcceptDispatchAsync(VendorPortalSession session, int id, CancellationToken cancellationToken); + Task ChangeStatusAsync(VendorPortalSession session, int id, string? to, CancellationToken cancellationToken); + Task RequestCancelAsync(VendorPortalSession session, int id, string? reason, CancellationToken cancellationToken); + Task UpdateChecklistItemAsync(VendorPortalSession session, int id, int itemId, bool isCompleted, CancellationToken cancellationToken); + Task AddSignoffAsync(VendorPortalSession session, int id, string? signoffType, string? name, string? signature, string? signatureMethod, CancellationToken cancellationToken); + Task AddCommentAsync(VendorPortalSession session, int id, string? commentText, CancellationToken cancellationToken); + Task RequestUpliftAsync(VendorPortalSession session, int id, decimal requestedNTE, string? reason, CancellationToken cancellationToken); + Task CancelUpliftRequestAsync(VendorPortalSession session, int id, int requestId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs b/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs new file mode 100644 index 0000000..50a4e53 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IVendorPortalTokenService.cs @@ -0,0 +1,12 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IVendorPortalTokenService + { + Task GetOrCreateActiveTokenAsync(int vendorId, CancellationToken cancellationToken); + Task ResolveSessionAsync(string? token, CancellationToken cancellationToken); + Task RotateAsync(int vendorId, CancellationToken cancellationToken); + Task RevokeAllAsync(int vendorId, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 2ab4c67..18dd029 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -23,5 +23,10 @@ namespace SeaHaven.Services.Interfaces Task GetTotalVendorCountAsync(); Task GetDeactivationImpactAsync(int vendorId); Task UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId); + Task> GetDropdownAsync(string? trade, string? siteZip, CancellationToken cancellationToken); + Task GetFacetsAsync(bool? isActive, CancellationToken cancellationToken); + Task GetPortalTokenAsync(int vendorId, CancellationToken cancellationToken); + Task RotatePortalTokenAsync(int vendorId, CancellationToken cancellationToken); + Task RevokePortalTokenAsync(int vendorId, CancellationToken cancellationToken); } } diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs new file mode 100644 index 0000000..f5d0ae3 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderDispatchService.cs @@ -0,0 +1,18 @@ +using SeaHaven.DataServices.Models; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderDispatchService + { + Task DispatchToVendorAsync(DispatchToVendorInput input, CancellationToken cancellationToken); + Task> GetDispatchesAsync(int workOrderId); + Task GetDispatchDetailAsync(int id); + Task UpdateDispatchAsync(UpdateDispatchInput input); + Task AddDispatchCommentAsync(AddDispatchCommentInput input); + Task VerifyDispatchAsync(int dispatchId, string userId); + Task AddDispatchSignoffAsync(AddDispatchSignoffInput input); + Task UpdateChecklistItemAsync(ChecklistItemUpdateInput input); + Task AddChecklistItemAsync(AddChecklistItemInput input); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderService.cs b/SeaHaven.Services/Interfaces/IWorkOrderService.cs index 913adcf..3c60e46 100644 --- a/SeaHaven.Services/Interfaces/IWorkOrderService.cs +++ b/SeaHaven.Services/Interfaces/IWorkOrderService.cs @@ -1,3 +1,7 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Http; +using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces @@ -14,5 +18,27 @@ namespace SeaHaven.Services.Interfaces Task WorkOrderExistsAsync(int id); Task GenerateNextInternalWONumberAsync(); Task GetTotalWorkOrderCountAsync(); + + Task<(IEnumerable Items, int TotalCount)> GetWorkOrderListPagedAsync( + int page, int pageSize, string? search, string? status, string? assignee, + int? locationId, string? sortBy, string? sortDir); + Task GetWorkOrderDetailAsync(int id); + Task GetFilteredWorkOrdersAsync( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); + Task GetFilteredWorkOrders2Async( + string[] assignto, int[]? location, string[]? priority, + string[]? status, string[]? duedate, string search, string sort, string sortby, int page, int pageSize); + Task ChangeStatusAsync(int id, string status, string userId); + Task ChangeAssignmentAsync(int id, string? assignTo, string userId); + Task CreateWorkOrderWithDetailsAsync(CreateWorkOrderWithDetailsInput input, string userId); + Task UpdateWorkOrderWithDetailsAsync(UpdateWorkOrderWithDetailsInput input, string userId); + Task DeleteWorkOrderCascadeAsync(int id, string userId); + Task AddCommentAsync(AddCommentInput input, string userId); + Task AddCommentJsonAsync(AddCommentInput input, string userId); + Task> GetCommentsAsync(); + Task> GetCommentsByWorkorderIdAsync(int woid); + Task> GetWorkordersDDAsync(); + Task> GetWorkordersAsync(); } } diff --git a/SeaHaven.Services/Interfaces/IZipCodeDistance.cs b/SeaHaven.Services/Interfaces/IZipCodeDistance.cs new file mode 100644 index 0000000..d69ab50 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IZipCodeDistance.cs @@ -0,0 +1,7 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface IZipCodeDistance + { + double? GetDistanceMiles(string? zip1, string? zip2); + } +} diff --git a/SeaHaven.Services/SeaHaven.Services.csproj b/SeaHaven.Services/SeaHaven.Services.csproj index 281c47c..993548e 100644 --- a/SeaHaven.Services/SeaHaven.Services.csproj +++ b/SeaHaven.Services/SeaHaven.Services.csproj @@ -16,4 +16,8 @@ + + + + diff --git a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs index e9831d7..1271cdb 100644 --- a/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs +++ b/SeaHavenIndustries.Tests/SyncFieldMergePolicyTests.cs @@ -1,6 +1,7 @@ using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -17,6 +18,12 @@ public class SyncFieldMergePolicyTests return new ApplicationDbContext(options); } + private static WorkOrderFieldLockService CreateLocks(ApplicationDbContext context) + => new(new WorkOrderFieldLockDataService(context)); + + private static WorkOrderAuditService CreateAudit(ApplicationDbContext context, WorkOrderFieldLockService locks) + => new(new WorkOrderAuditDataService(context), locks); + [Fact] public async Task TryApply_WhenFieldLocked_RejectsAndAudits() { @@ -31,8 +38,8 @@ public class SyncFieldMergePolicyTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -61,8 +68,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -86,8 +93,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -113,8 +120,8 @@ public class SyncFieldMergePolicyTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var locks = CreateLocks(context); + var audit = CreateAudit(context, locks); var policy = new SyncFieldMergePolicy(locks); var syncContext = new WorkOrderSyncContext @@ -141,7 +148,7 @@ public class SyncFieldMergePolicyTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); + var locks = CreateLocks(context); var policy = new SyncFieldMergePolicy(locks); var canOverwrite = await policy.CanSyncOverwriteAsync(20, WorkOrderFieldNames.AssignTo); diff --git a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs index f7df5ed..7cc862c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderAuditServiceTests.cs @@ -1,6 +1,6 @@ using Data.SeaHavenIndustries; -using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; using SeaHaven.Services.Interfaces; @@ -17,6 +17,15 @@ public class WorkOrderAuditServiceTests return new ApplicationDbContext(options); } + private static (WorkOrderAuditService audit, ApplicationDbContext context) CreateAudit(ApplicationDbContext context) + { + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); + var auditData = new WorkOrderAuditDataService(context); + var audit = new WorkOrderAuditService(auditData, locks); + return (audit, context); + } + [Fact] public async Task LogStatusChanged_CreatesSingleAuditWithManualEventType() { @@ -24,8 +33,7 @@ public class WorkOrderAuditServiceTests context.workOrders.Add(new WorkOrder { Id = 1, Status = "Open" }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var (audit, _) = CreateAudit(context); await audit.LogStatusChangedAsync(1, "Open", "In Progress", "user-1"); @@ -44,8 +52,7 @@ public class WorkOrderAuditServiceTests context.workOrders.Add(new WorkOrder { Id = 2, Status = "Open" }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); - var audit = new WorkOrderAuditService(context, locks); + var (audit, _) = CreateAudit(context); await audit.LogStatusChangedAsync(2, "Open", "Scheduled", "user-1"); diff --git a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs index b598a4d..d9a6a39 100644 --- a/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderFieldLockServiceTests.cs @@ -1,5 +1,6 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; using SeaHaven.Services.Constants; using SeaHaven.Services.Implementation; @@ -19,7 +20,8 @@ public class WorkOrderFieldLockServiceTests public async Task LockFieldAsync_DuplicateCalls_EnqueuesSingleLock() { await using var context = CreateContext(); - var locks = new WorkOrderFieldLockService(context); + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-1"); await locks.LockFieldAsync(1, WorkOrderFieldNames.Description, "user-2"); @@ -43,7 +45,8 @@ public class WorkOrderFieldLockServiceTests }); await context.SaveChangesAsync(); - var locks = new WorkOrderFieldLockService(context); + var lockData = new WorkOrderFieldLockDataService(context); + var locks = new WorkOrderFieldLockService(lockData); await locks.LockFieldAsync(2, WorkOrderFieldNames.AssignTo, "user-2"); await context.SaveChangesAsync();