From cc8825ace08fa978e9e59bfad90ab72fffad4145 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 10 Jul 2026 11:20:14 -0300 Subject: [PATCH 1/8] fix(migrations): add Phase1 BoardIndexes Designer metadata for EF discovery --- ...0701120000_Phase1_BoardIndexes.Designer.cs | 2996 +++++++++++++++++ .../20260701120000_Phase1_BoardIndexes.cs | 2 +- 2 files changed, 2997 insertions(+), 1 deletion(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs new file mode 100644 index 0000000..ba49c97 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.Designer.cs @@ -0,0 +1,2996 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260701120000_Phase1_BoardIndexes")] + partial class Phase1_BoardIndexes + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("WorkOrderType"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs index 23968a8..744a9fa 100644 --- a/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs +++ b/Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Data.SeaHavenIndustries.Migrations { /// - public class Phase1_BoardIndexes : Migration + public partial class Phase1_BoardIndexes : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) From f3d2a5cfe738cf677a3f1af2b3c90beddef2f7ca Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Fri, 17 Jul 2026 14:39:47 -0300 Subject: [PATCH 2/8] fix(work-orders): address PR13 phase-1 review feedback --- .../Controllers/WorkOrderController.cs | 3 +- .../Filters/ArgumentExceptionFilter.cs | 21 +++ Api.SeaHavenIndustries/Program.cs | 1 + .../Enums/LifecycleStatusSets.cs | 14 ++ .../WorkOrderBoardDataService.cs | 3 +- docs/work-orders/phase-1/README.md | 159 ++++++++++++++++++ 6 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs create mode 100644 Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs create mode 100644 docs/work-orders/phase-1/README.md diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 1989d84..f72501c 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Components.Forms; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.VisualBasic; @@ -83,7 +84,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("board")] public async Task GetBoard( - [FromQuery] DateOnly weekStart, + [FromQuery, BindRequired] DateOnly weekStart, [FromQuery] DateOnly? weekEnd = null, [FromQuery] List? dispatchers = null, [FromQuery] bool myWorkOrders = false, diff --git a/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs new file mode 100644 index 0000000..0bfeabb --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/ArgumentExceptionFilter.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Api.SeaHavenIndustries.Filters +{ + public class ArgumentExceptionFilter : IExceptionFilter + { + public void OnException(ExceptionContext context) + { + if (context.Exception is not ArgumentException ex) return; + + context.Result = new BadRequestObjectResult(new + { + status = "Error", + message = ex.Message, + code = 400 + }); + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..55d5a4d 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -25,6 +25,7 @@ builder.Services.AddIdentity(options => builder.Services.AddControllers(options => { options.Filters.Add(); + options.Filters.Add(); }).AddJsonOptions(options => { options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value diff --git a/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs new file mode 100644 index 0000000..d4a93bc --- /dev/null +++ b/Data.SeaHavenIndustries/Enums/LifecycleStatusSets.cs @@ -0,0 +1,14 @@ +namespace Data.SeaHavenIndustries.Enums +{ + /// + /// Shared terminal lifecycle statuses for EF-translatable filters and board rules. + /// + public static class LifecycleStatusSets + { + public static readonly LifecycleStatus?[] Terminal = + { + LifecycleStatus.Completed, + LifecycleStatus.Canceled + }; + } +} diff --git a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs index 8fd0422..be23363 100644 --- a/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs +++ b/SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs @@ -33,8 +33,7 @@ namespace SeaHaven.DataServices.Implementation var unscheduledBase = baseQuery.Where(w => w.ScheduledDate == null - && w.LifecycleStatus != LifecycleStatus.Completed - && w.LifecycleStatus != LifecycleStatus.Canceled); + && !LifecycleStatusSets.Terminal.Contains(w.LifecycleStatus)); var scheduledTotal = await scheduledBase.CountAsync(); diff --git a/docs/work-orders/phase-1/README.md b/docs/work-orders/phase-1/README.md new file mode 100644 index 0000000..660bfb3 --- /dev/null +++ b/docs/work-orders/phase-1/README.md @@ -0,0 +1,159 @@ +# Fase 1 — Weekly Board (leitura) + +**Programa:** Work Orders Board +**Objetivo:** Endpoint window-based para carregar a visão semanal do SHOC (Mon–Fri + Unscheduled) com projeção das 14 colunas operacionais. + +--- + +## Endpoints + +### `GET /api/workorders/board` + +Retorna WOs agendadas na semana + seção **Unscheduled** em uma única chamada. + +**Autenticação:** Bearer JWT (`[Authorize]`) + +**Query params:** + +| Param | Obrigatório | Descrição | +|-------|-------------|-----------| +| `weekStart` | Sim (`BindRequired`) | Segunda-feira da semana (`YYYY-MM-DD`). Omitir retorna **400**. | +| `weekEnd` | Não | Default: `weekStart + 4 dias` (sexta). Janela pode ter até 7 dias. | +| `dispatchers` | Não | Lista de GUIDs; use `__unassigned__` para não atribuídos. **Ignorado** quando `myWorkOrders=true`. | +| `myWorkOrders` | Não | `true` → filtra `AssignTo == usuário logado` e tem **precedência** sobre `dispatchers` (não é AND). | +| `types` | Não | Valores enum `WorkOrderType` (`PM`, `PO`, `Emergency`, etc.) | +| `search` | Não | Busca contextual (site, WO#, location, dispatcher, trade, vendor, status) | + +**Exemplo:** + +```bash +curl -H "Authorization: Bearer " \ + "https://localhost:5001/api/workorders/board?weekStart=2026-06-22&myWorkOrders=true" +``` + +**Response:** + +```json +{ + "weekStart": "2026-06-22", + "weekEnd": "2026-06-26", + "counts": { "returned": 42, "total": 58 }, + "unscheduled": [ /* WorkOrderBoardRowDto[] */ ], + "scheduled": [ /* WorkOrderBoardRowDto[] — agrupar por dayGroup no FE */ ] +} +``` + +- `counts.total` — WOs agendadas na semana (filtros dispatcher/tipo, **sem** search) +- `counts.returned` — WOs agendadas após aplicar search +- `dayGroup` — `monday`…`friday` para dias úteis; **`null` no sábado/domingo** (a janela pode incluir fim de semana; o FE deve tratar `dayGroup` nulo) +- Referência de dia / past-due: **UTC** nesta fase (timezone de negócio fica para fase posterior) + +### `GET /api/workorders/lookups/dispatchers` + +Lista opções para o filtro/dropdown de assignee do board. + +**Escopo atual (placeholder):** todos os users com `IsDeleted != true` — ainda **sem** filtro por role. Quando roles de assignee estiverem definidos, este lookup será restrito. + +```json +[ + { "id": "guid", "name": "Jane Doe", "initials": "JD", "color": "#4A90D9" } +] +``` + +--- + +## Matriz coluna → campo API + +| Coluna board | Campos response | +|--------------|-----------------| +| WO# | `woNumber`, `rescheduleCount`, `carriedOver` | +| Type | `workOrderType`, `isPastDue` | +| Site | `siteCode`, `locationName`, `pocName`, `pocPhone`, `pocNotes` | +| Status | `lifecycleStatus`, `lifecycleStatusLabel`, `legacyStatus` | +| Assignee | `dispatcherId`, `dispatcherName`, `initials`, `color` | +| Due | `dueDate` | +| Scheduled | `scheduledDate`, `targetWeek`, `scheduleWeekOnly`, `dayGroup` | +| Vendor | `vendorId`, `vendorName`, `techName`, `techPhone` | +| Appt | `apptDate`, `apptTime` | +| Past Due | `isPastDue` (derivado on-read) | +| Carried | `carriedOver` | +| Reschedule | `rescheduleCount` | +| Doc | `docStatus` | +| Actions | FE only | + +--- + +## Regras de derivação + +### isPastDue + +``` +ScheduledDate < UTC hoje +AND LifecycleStatus NOT IN (Complete, Canceled, Closed) +``` + +Implementação: `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` + +### Vendor / Appt + +Projeção read-only via `WorkOrder.PrimaryDispatchId` → `Dispatch` → `Vendor` ([ADR](../phase-0/adr-vendor-source-of-truth.md)). + +### Janela semanal + +WO entra em **scheduled** se: + +- `ScheduledDate` entre `weekStart` e `weekEnd`, **ou** +- `ScheduleWeekOnly == true` e `TargetWeek == weekStart` + +**Unscheduled:** `ScheduledDate == null` e status não terminal (`LifecycleStatusSets.Terminal`). + +--- + +## Código entregue + +| Camada | Arquivo | +|--------|---------| +| DTOs | `SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs` | +| Derived fields | `SeaHaven.Services/Helpers/WorkOrderDerivedFields.cs` | +| Data | `SeaHaven.DataServices/Implementation/WorkOrderBoardDataService.cs` | +| Service | `SeaHaven.Services/Implementation/WorkOrderBoardService.cs` | +| API | `Api.SeaHavenIndustries/Controllers/WorkOrderController.cs` (`board`, `lookups/dispatchers`) | +| Migration | `Data.SeaHavenIndustries/Migrations/20260701120000_Phase1_BoardIndexes.cs` | +| Testes | `SeaHavenIndustries.Tests/WorkOrderDerivedFieldsTests.cs`, `WorkOrderBoardServiceTests.cs` | + +--- + +## Gates de aceite + +### Desenvolvimento (concluído) + +- [x] Endpoint `GET board` — semana + Unscheduled +- [x] Projeção 12/14 colunas de dados +- [x] `isPastDue` derivado on-read +- [x] Vendor via dispatch primário +- [x] Filtros dispatcher / My WOs / tipo / search +- [x] Contador X of Y +- [x] Índices Tier S +- [x] 14 testes unitários board (+ 12 Fase 0 = 26 total) + +### Staging / produção (pendente) + +- [ ] Sign-off CTO/PO (herda gates Fase 0) +- [ ] Dry-run migration Phase0 + Phase1 em staging +- [ ] Tier volume confirmado em staging/prod +- [ ] Smoke checklist Blazor / Sync / Portal +- [ ] Gate Vendor Portal regression (endpoints dispatch inalterados) +- [ ] Latência aceitável com volume real (Tier S: < 25k WOs) +- [ ] Feature flag SHOC board em staging + +--- + +## Coexistência + +Endpoints legados (`GetWorkOrderList`, `GetWorkorderById`, etc.) **não foram alterados**. O board usa contrato novo em rotas separadas. + +--- + +## Próximo passo + +**Fase 2** — Inline edit + concorrência (dual RowVersion, audit por campo, PATCH granular). From 75969a1e3da8de1fd6a8f5209164c38435be2424 Mon Sep 17 00:00:00 2001 From: Arthur Bassi Date: Tue, 21 Jul 2026 09:33:24 -0300 Subject: [PATCH 3/8] docs(work-orders): clarify board filter precedence and dispatcher lookup Document myWorkOrders overriding dispatchers, weekend dayGroup null contract, and that dispatcher lookups currently return all users. Map service ArgumentException to 400 for consistency. --- .../Controllers/WorkOrderController.cs | 39 +++++++++++++------ .../Helpers/WorkOrderBoardQueryFilters.cs | 5 +++ 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index f72501c..f3d5ca9 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -82,6 +82,12 @@ namespace Api.SeaHavenIndustries.Controllers _workOrderBoardService = workOrderBoardService; } + /// + /// Weekly board payload. + /// When is true, the authenticated user filter wins and + /// is ignored (not AND-combined). + /// Weekend WOs in a Mon–Sun window may have dayGroup = null; FE should handle that. + /// [HttpGet("board")] public async Task GetBoard( [FromQuery, BindRequired] DateOnly weekStart, @@ -96,21 +102,32 @@ namespace Api.SeaHavenIndustries.Controllers if (weekValidationError != null) return BadRequest(weekValidationError); - var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var query = new WorkOrderBoardQueryDto + try { - WeekStart = weekStart, - WeekEnd = weekEnd, - Dispatchers = dispatchers, - MyWorkOrders = myWorkOrders, - Types = types, - Search = search - }; + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var query = new WorkOrderBoardQueryDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Dispatchers = dispatchers, + MyWorkOrders = myWorkOrders, + Types = types, + Search = search + }; - var result = await _workOrderBoardService.GetBoardAsync(query, userId); - return Ok(result); + var result = await _workOrderBoardService.GetBoardAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(ex.Message); + } } + /// + /// Dispatcher dropdown options. Currently returns all non-deleted users + /// (role-filtered assignee list deferred until roles settle). + /// [HttpGet("lookups/dispatchers")] public async Task GetDispatcherLookups() { diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index c57fc5f..d13b4d3 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -8,6 +8,11 @@ namespace SeaHaven.DataServices.Helpers public static IQueryable ApplyBaseScope(IQueryable query) => query.Where(w => w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + /// + /// Applies assignee filters. When is true and + /// is set, that takes precedence and + /// is ignored. + /// public static IQueryable ApplyDispatcherFilter( IQueryable query, IReadOnlyList? dispatchers, From 1162c68596ac5f276b69604734e499dfc8a29c85 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 12:55:47 -0300 Subject: [PATCH 4/8] feat(vendors): add directory filters and details API (#25) * feat(vendors): add directory filters and details API * fix(vendors): preserve omitted status * fix(vendors): align facet filtering * fix(vendors): address directory review findings --- .../VendorControllerTests.cs | 169 +++++++++++ .../VendorDataServiceTests.cs | 269 ++++++++++++++++++ .../VendorServiceTests.cs | 213 ++++++++++++++ .../Controllers/VendorController.cs | 153 +++++++++- Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs | 5 +- ...0260720220000_AddVendorDirectoryDetails.cs | 41 +++ .../ApplicationDbContextModelSnapshot.cs | 9 + Data.SeaHavenIndustries/Models/Vendor.cs | 7 + .../Implementation/VendorDataService.cs | 234 +++++++++++++-- .../Interfaces/IVendorDataService.cs | 10 +- .../Properties/AssemblyInfo.cs | 3 + SeaHaven.Services/DTOs/VendorDTOs.cs | 19 ++ .../Implementation/VendorService.cs | 70 ++++- .../Interfaces/IVendorService.cs | 10 +- .../Validation/VendorValidation.cs | 48 ++++ 15 files changed, 1218 insertions(+), 42 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/VendorControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorServiceTests.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs create mode 100644 SeaHaven.DataServices/Properties/AssemblyInfo.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs new file mode 100644 index 0000000..2440734 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorControllerTests.cs @@ -0,0 +1,169 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +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; + +public class VendorControllerTests +{ + private static VendorController NewController(Mock service) + { + var controller = new VendorController( + service.Object, + db: null!, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, "42") + }, "Test")) + } + }; + + return controller; + } + + [Fact] + public async Task Update_WithoutIsActive_PassesNullToServiceToPreserveStatus() + { + var service = new Mock(); + service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new EditVendor_DTO + { + Id = 8, + CompanyName = "Updated Name" + }; + + var result = await controller.Update(8, model); + + result.Should().BeOfType(); + service.Verify(x => x.UpdateVendorAsync( + 8, + It.Is(dto => dto.IsActive == null), + "42"), Times.Once); + } + + [Fact] + public async Task Update_WithExplicitIsActive_ForwardsValueUnchanged() + { + var service = new Mock(); + service.Setup(x => x.UpdateVendorAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new EditVendor_DTO + { + Id = 8, + CompanyName = "Updated Name", + IsActive = false + }; + + await controller.Update(8, model); + + service.Verify(x => x.UpdateVendorAsync( + 8, + It.Is(dto => dto.IsActive == false), + "42"), Times.Once); + } + + [Fact] + public async Task Create_WithoutIsActive_DefaultsToActive() + { + var service = new Mock(); + service.Setup(x => x.CreateVendorAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new VendorDTO()); + + var controller = NewController(service); + + var model = new Vendor_DTO + { + CompanyName = "New Co" + }; + + await controller.Create(model); + + service.Verify(x => x.CreateVendorAsync( + It.Is(dto => dto.IsActive == true), + "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) + { + 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 controller = new VendorController( + Mock.Of(), + context, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + var result = await controller.GetFacets(isActive); + var payload = JsonSerializer.Serialize( + result.Should().BeOfType().Subject.Value); + + payload.Should().Contain(expected); + payload.Should().NotContain(excluded); + } + + [Fact] + public async Task GetFacets_WithoutStatus_PreservesGlobalFacetLookup() + { + 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 controller = new VendorController( + Mock.Of(), + context, + zipDistance: null!, + vendorTokens: null!, + config: null!); + + var result = await controller.GetFacets(); + var payload = JsonSerializer.Serialize( + result.Should().BeOfType().Subject.Value); + + payload.Should().Contain("Active Co"); + payload.Should().Contain("Inactive Co"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs new file mode 100644 index 0000000..31c4940 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -0,0 +1,269 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorDataServiceTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static ApplicationDbContext NewSqlServerContext() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=(local);Database=TranslationOnly;Trusted_Connection=True;TrustServerCertificate=True") + .Options; + return new ApplicationDbContext(options); + } + + private static Vendor VendorWithJobs( + string company, + string city, + string state, + string trades, + int jobCount, + bool isActive = true) + { + return new Vendor + { + CompanyName = company, + ContactName = $"{company} Technician", + City = city, + State = state, + TradeSpecialties = trades, + IsActive = isActive, + IsDeleted = false, + Dispatches = Enumerable.Range(1, jobCount) + .Select(workOrderId => new Dispatch { WorkOrderId = workOrderId }) + .ToList() + }; + } + + private static async Task SeedBoundaryVendors(ApplicationDbContext context) + { + context.Vendors.AddRange( + VendorWithJobs("Under Co", "Chicago", "IL", "Plumbing, HVAC", 49), + VendorWithJobs("Fifty Co", "Chicago", "IL", "Electrical", 50), + VendorWithJobs("Hundred Co", "Dallas", "TX", "Plumbing", 100), + VendorWithJobs("One Fifty Co", "Dallas", "TX", "Roofing", 150)); + await context.SaveChangesAsync(); + } + + [Theory] + [InlineData("under-50", "Under Co")] + [InlineData("50-99", "Fifty Co")] + [InlineData("100-149", "Hundred Co")] + [InlineData("150-plus", "One Fifty Co")] + public async Task GetPagedAsync_AppliesExactJobBucketBoundaries( + string bucket, + string expectedCompany) + { + await using var context = NewContext(); + await SeedBoundaryVendors(context); + var service = new VendorDataService(context); + + var (items, totalCount) = await service.GetPagedAsync( + 1, + 12, + isActive: true, + jobBuckets: new[] { bucket }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be(expectedCompany); + } + + [Fact] + public async Task GetPagedAsync_UsesOrWithinFacetAndAndAcrossFacets() + { + await using var context = NewContext(); + await SeedBoundaryVendors(context); + var service = new VendorDataService(context); + + var (items, totalCount) = await service.GetPagedAsync( + 1, + 12, + isActive: true, + companies: new[] { "Under Co", "Hundred Co" }, + trades: new[] { "HVAC", "Plumbing" }, + locations: new[] { "Chicago, IL" }, + jobBuckets: new[] { "under-50", "100-149" }); + + totalCount.Should().Be(1); + items.Single().CompanyName.Should().Be("Under Co"); + } + + [Fact] + public async Task GetPagedAsync_StatusAndCountAreIndependentOfInactiveHistory() + { + await using var context = NewContext(); + context.Vendors.AddRange( + VendorWithJobs("Active Co", "Chicago", "IL", "Plumbing", 1), + VendorWithJobs("Inactive Co", "Chicago", "IL", "Plumbing", 1, false)); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var active = await service.GetPagedAsync(1, 1, isActive: true); + var inactive = await service.GetPagedAsync(1, 1, isActive: false); + + active.TotalCount.Should().Be(1); + inactive.TotalCount.Should().Be(1); + active.Items.Single().CompanyName.Should().Be("Active Co"); + inactive.Items.Single().CompanyName.Should().Be("Inactive Co"); + } + + [Fact] + public async Task GetPagedAsync_LocationFacetMatchesCityStateAndFullLabels() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "CityOnly", City = "Austin", State = null, TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "StateOnly", City = null, State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "FullLoc", City = "Austin", State = "TX", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var cityOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin" }); + cityOnly.TotalCount.Should().Be(1); + cityOnly.Items.Single().CompanyName.Should().Be("CityOnly"); + + var stateOnly = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "TX" }); + stateOnly.TotalCount.Should().Be(1); + stateOnly.Items.Single().CompanyName.Should().Be("StateOnly"); + + var full = await service.GetPagedAsync(1, 12, isActive: true, locations: new[] { "Austin, TX" }); + full.TotalCount.Should().Be(1); + full.Items.Single().CompanyName.Should().Be("FullLoc"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesExactTokensNotSubstrings() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "HvacOnly", City = "Chicago", State = "IL", TradeSpecialties = "HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "HvacControls", City = "Chicago", State = "IL", TradeSpecialties = "HVAC Controls", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "PlumbingCo", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var hvac = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC" }); + hvac.TotalCount.Should().Be(1); + hvac.Items.Single().CompanyName.Should().Be("HvacOnly"); + + var hvacControls = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "HVAC Controls" }); + hvacControls.TotalCount.Should().Be(1); + hvacControls.Items.Single().CompanyName.Should().Be("HvacControls"); + + var plumb = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumb" }); + plumb.TotalCount.Should().Be(0); + plumb.Items.Should().BeEmpty(); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(1); + plumbing.Items.Single().CompanyName.Should().Be("PlumbingCo"); + } + + [Fact] + public async Task GetPagedAsync_TradeFacetMatchesTokenInCommaDelimitedList() + { + await using var context = NewContext(); + context.Vendors.AddRange( + new Vendor { CompanyName = "Multi", City = "Chicago", State = "IL", TradeSpecialties = "Plumbing, HVAC", IsActive = true, IsDeleted = false }, + new Vendor { CompanyName = "Middle", City = "Chicago", State = "IL", TradeSpecialties = "Electrical, Plumbing, Roofing", IsActive = true, IsDeleted = false }); + await context.SaveChangesAsync(); + var service = new VendorDataService(context); + + var plumbing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Plumbing" }); + plumbing.TotalCount.Should().Be(2); + plumbing.Items.Select(v => v.CompanyName).Should().BeEquivalentTo("Multi", "Middle"); + + var roofing = await service.GetPagedAsync(1, 12, isActive: true, trades: new[] { "Roofing" }); + roofing.TotalCount.Should().Be(1); + roofing.Items.Single().CompanyName.Should().Be("Middle"); + } + + [Fact] + public async Task GetPagedAsync_CountsDistinctDirectAndLinkedWorkOrders() + { + await using var context = NewContext(); + var vendor = new Vendor + { + CompanyName = "History Co", + IsActive = true, + IsDeleted = false, + Dispatches = new List + { + new() + { + WorkOrderId = 100, + DispatchWorkOrders = new List + { + new() { WorkOrderId = 100 }, + new() { WorkOrderId = 101 } + } + }, + new() { WorkOrderId = 101 } + } + }; + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var result = await service.GetPagedAsync(1, 12, isActive: true); + + result.Items.Single().TotalJobs.Should().Be(2); + } + + [Fact] + public async Task GetByIdAsync_ReturnsTrackedVendorWithoutLoadingDispatchGraph() + { + await using var context = NewContext(); + var vendor = VendorWithJobs("Lean Co", "Chicago", "IL", "Plumbing", 2); + context.Vendors.Add(vendor); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var service = new VendorDataService(context); + var result = await service.GetByIdAsync(vendor.Id); + + result.Should().NotBeNull(); + context.Entry(result!).State.Should().Be(EntityState.Unchanged); + context.Entry(result!).Collection(nameof(Vendor.Dispatches)).IsLoaded.Should().BeFalse(); + result!.Dispatches.Should().BeNull(); + } + + [Theory] + [InlineData("trade")] + [InlineData("location")] + [InlineData("job-bucket")] + public void BuildDirectoryQuery_TranslatesRepresentativeFiltersForSqlServer(string filter) + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var query = filter switch + { + "trade" => service.BuildDirectoryQuery(trades: new[] { "Plumbing" }), + "location" => service.BuildDirectoryQuery(locations: new[] { "Chicago, IL" }), + _ => service.BuildDirectoryQuery(jobBuckets: new[] { "50-99" }) + }; + + var sql = query.ToQueryString(); + + sql.Should().Contain("SELECT"); + if (filter == "trade") + sql.Should().Contain("TradeSpecialties"); + if (filter == "location") + sql.Should().Contain("City"); + if (filter == "job-bucket") + sql.Should().Contain("UNION"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs new file mode 100644 index 0000000..1067869 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -0,0 +1,213 @@ +using Data.SeaHavenIndustries; +using FluentAssertions; +using Moq; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorServiceTests +{ + private static VendorService NewService(Mock data) => + new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation()); + + [Fact] + public async Task CreateVendor_PersistsCompleteProfile() + { + var data = new Mock(); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor vendor) => { saved = vendor; vendor.Id = 7; return vendor; }); + + var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Gateway Plumbing", + ContactName = "Jordan Lee", + Email = "dispatch@gateway.example", + Phone = "555-0100", + CompanyPhone = "555-0199", + Address = "10 Lake St", + City = "Chicago", + State = "IL", + Zipcode = "60601", + TradeSpecialties = "Plumbing, Backflow", + GoogleMapsUrl = "https://maps.google.com/example", + Notes = "After-hours dispatch available", + IsActive = true + }, "42"); + + saved.Should().NotBeNull(); + saved!.ContactName.Should().Be("Jordan Lee"); + saved.TradeSpecialties.Should().Be("Plumbing, Backflow"); + saved.CompanyPhone.Should().Be("555-0199"); + saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example"); + saved.Notes.Should().Be("After-hours dispatch available"); + saved.City.Should().Be("Chicago"); + result.IsActive.Should().BeTrue(); + result.ContactName.Should().Be("Jordan Lee"); + } + + [Fact] + public async Task UpdateVendor_UpdatesProfileAndActiveState() + { + var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO + { + Name = "New", + ContactName = "Casey", + TradeSpecialties = "HVAC", + IsActive = false + }, "42"); + + existing.CompanyName.Should().Be("New"); + existing.ContactName.Should().Be("Casey"); + existing.TradeSpecialties.Should().Be("HVAC"); + existing.IsActive.Should().BeFalse(); + existing.LastModifierUserId.Should().Be(42); + result.IsActive.Should().BeFalse(); + } + + [Fact] + public async Task DeleteVendor_DeactivatesWithoutDeletingHistory() + { + var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).DeleteVendorAsync(9, "42"); + + existing.IsActive.Should().BeFalse(); + existing.IsDeleted.Should().NotBeTrue(); + existing.DeletionTime.Should().BeNull(); + existing.LastModificationTime.Should().NotBeNull(); + } + + [Fact] + public async Task GetVendorsPaged_RequestsSelectedStatusAndMapsDirectoryFields() + { + var data = new Mock(); + data.Setup(x => x.GetPagedAsync( + 1, + 12, + "chicago", + false, + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>(), + It.IsAny?>())) + .ReturnsAsync((new[] + { + new Vendor + { + Id = 10, + CompanyName = "Inactive Vendor", + ContactName = "Taylor", + City = "Chicago", + State = "IL", + TradeSpecialties = "Electrical", + IsActive = false + } + }.AsEnumerable(), 1)); + + var result = await NewService(data).GetVendorsPagedAsync(1, 12, "chicago", false); + + result.TotalCount.Should().Be(1); + result.Items.Single().Should().BeEquivalentTo(new + { + Id = 10, + Name = "Inactive Vendor", + ContactName = "Taylor", + City = "Chicago", + State = "IL", + TradeSpecialties = "Electrical", + IsActive = false + }, options => options.ExcludingMissingMembers()); + } + + [Fact] + public async Task GetVendorById_CountsDistinctHistoricalWorkOrders() + { + var vendor = new Vendor + { + Id = 11, + CompanyName = "History Vendor", + Dispatches = new List + { + new() + { + WorkOrderId = 100, + DispatchWorkOrders = new List + { + new() { WorkOrderId = 100 }, + new() { WorkOrderId = 101 } + } + }, + new() { WorkOrderId = 101 } + } + }; + var data = new Mock(); + data.Setup(x => x.GetByIdWithDetailsAsync(11)).ReturnsAsync(vendor); + + var result = await NewService(data).GetVendorByIdAsync(11); + + result.Should().NotBeNull(); + result!.TotalJobs.Should().Be(2); + } + + [Fact] + public async Task GetVendorsPaged_ForwardsFacetFilters() + { + var companies = new[] { "Gateway Plumbing", "Roto-Rooter Dallas" }; + var trades = new[] { "Backflow Preventers" }; + var locations = new[] { "Dallas, TX" }; + var jobBuckets = new[] { "100-149" }; + var data = new Mock(); + data.Setup(x => x.GetPagedAsync( + 1, + 25, + "gateway", + true, + companies, + trades, + locations, + jobBuckets)) + .ReturnsAsync((Enumerable.Empty(), 0)); + + await NewService(data).GetVendorsPagedAsync( + 1, + 25, + "gateway", + true, + companies, + trades, + locations, + jobBuckets); + + data.VerifyAll(); + } + + [Fact] + public void CreateVendor_RequiresTechnicianNameAndHttpsMapUrl() + { + var validator = new CreateVendorValidation(); + + var result = validator.Validate(new CreateVendorDTO + { + Name = "Gateway Plumbing", + ContactName = "", + GoogleMapsUrl = "http://maps.google.com/gateway" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); + result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..ecc4ccd 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -32,16 +32,43 @@ namespace Api.SeaHavenIndustries.Controllers } [HttpGet("GetVendorList")] - public async Task GetVendorList(string? search = "", int page = 1, int pageSize = 10) + public async Task GetVendorList( + string? search = "", + int page = 1, + int pageSize = 10, + bool? isActive = true, + [FromQuery] string[]? companies = null, + [FromQuery] string[]? trades = null, + [FromQuery] string[]? locations = null, + [FromQuery] string[]? jobBuckets = null) { - var pagedResult = await _vendorService.GetVendorsPagedAsync(page, pageSize, search); + var pagedResult = await _vendorService.GetVendorsPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets); var data = pagedResult.Items.Select(v => new { v.Id, CompanyName = v.Name, + v.ContactName, v.Email, - v.Phone + v.Phone, + v.CompanyPhone, + v.Address, + v.City, + v.State, + Zip = v.Zipcode, + v.TradeSpecialties, + v.GoogleMapsUrl, + v.Notes, + v.IsActive, + v.TotalJobs }); return Ok(new Pagination_DTO @@ -56,17 +83,35 @@ namespace Api.SeaHavenIndustries.Controllers [HttpGet("{id}")] [HttpGet("GetById")] - public async Task GetVendorById([FromQuery] int? id) + public async Task GetVendorById([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId) { - if (id == null) + var vendorId = id ?? queryId; + if (vendorId == null) return BadRequest(new Response { Status = "Error", Message = "Id is required" }); - var vendor = await _vendorService.GetVendorByIdAsync(id.Value); + var vendor = await _vendorService.GetVendorByIdAsync(vendorId.Value); if (vendor == null) return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); - return Ok(vendor); + return Ok(new + { + vendor.Id, + CompanyName = vendor.Name, + vendor.ContactName, + vendor.Email, + vendor.Phone, + vendor.CompanyPhone, + vendor.Address, + vendor.City, + vendor.State, + Zip = vendor.Zipcode, + vendor.TradeSpecialties, + vendor.GoogleMapsUrl, + vendor.Notes, + vendor.IsActive, + vendor.TotalJobs + }); } [HttpPost] @@ -77,7 +122,19 @@ namespace Api.SeaHavenIndustries.Controllers { var createDto = new CreateVendorDTO { - Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required") + Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"), + ContactName = model.ContactName, + Email = model.Email, + Phone = model.Phone, + CompanyPhone = model.CompanyPhone, + Address = model.Address, + City = model.City, + State = model.State, + Zipcode = model.Zip, + TradeSpecialties = model.TradeSpecialties, + GoogleMapsUrl = model.GoogleMapsUrl, + Notes = model.Notes, + IsActive = model.IsActive ?? true }; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -111,7 +168,19 @@ namespace Api.SeaHavenIndustries.Controllers var updateDto = new UpdateVendorDTO { - Name = model.CompanyName + Name = model.CompanyName, + ContactName = model.ContactName, + Email = model.Email, + Phone = model.Phone, + CompanyPhone = model.CompanyPhone, + Address = model.Address, + City = model.City, + State = model.State, + Zipcode = model.Zip, + TradeSpecialties = model.TradeSpecialties, + GoogleMapsUrl = model.GoogleMapsUrl, + Notes = model.Notes, + IsActive = model.IsActive }; var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -126,7 +195,7 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } - catch (KeyNotFoundException) + catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); } @@ -138,7 +207,7 @@ namespace Api.SeaHavenIndustries.Controllers [HttpDelete("{id}")] [HttpPost("Delete")] - public async Task Delete([FromRoute] int? id, [FromQuery] int? queryId = null) + public async Task Delete([FromRoute] int? id, [FromQuery(Name = "id")] int? queryId = null) { try { @@ -152,9 +221,9 @@ namespace Api.SeaHavenIndustries.Controllers return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); await _vendorService.DeleteVendorAsync(vendorId, userId); - return Ok(new DataResponse { Message = "Vendor Deleted", Status = "200" }); + return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" }); } - catch (KeyNotFoundException) + catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); } @@ -212,6 +281,64 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(result); } + [HttpGet("facets")] + public async Task GetFacets([FromQuery] bool? isActive = null) + { + 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.CompanyPhone, + v.Email, + v.Address, + v.City, + v.State, + Zip = v.Zip, + v.GoogleMapsUrl + }); + + var trades = vendors + .SelectMany(v => (v.TradeSpecialties ?? "") + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(value => value); + + var locations = vendors + .Where(v => !string.IsNullOrWhiteSpace(v.City) || !string.IsNullOrWhiteSpace(v.State)) + .Select(v => new { v.City, v.State, Label = string.Join(", ", new[] { v.City, v.State }.Where(value => !string.IsNullOrWhiteSpace(value))) }) + .DistinctBy(location => location.Label, StringComparer.OrdinalIgnoreCase) + .OrderBy(location => location.Label); + + return Ok(new + { + Companies = companies, + Trades = trades, + Locations = locations, + JobBuckets = new[] + { + new { Id = "under-50", Label = "Under 50" }, + new { Id = "50-99", Label = "50–99" }, + new { Id = "100-149", Label = "100–149" }, + new { Id = "150-plus", Label = "150+" } + } + }); + } + [HttpGet("{id:int}/portal-token")] public async Task GetPortalToken(int id) { diff --git a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs index f283379..d2a4717 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -6,12 +6,15 @@ namespace Api.SeaHavenIndustries.DTOs public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { 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 bool IsActive { get; set; } = true; + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool? IsActive { get; set; } } public class EditVendor_DTO : Vendor_DTO diff --git a/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs b/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs new file mode 100644 index 0000000..ad4b3b1 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260720220000_AddVendorDirectoryDetails.cs @@ -0,0 +1,41 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260720220000_AddVendorDirectoryDetails")] + public partial class AddVendorDirectoryDetails : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompanyPhone", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "GoogleMapsUrl", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Notes", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "CompanyPhone", table: "Vendors"); + migrationBuilder.DropColumn(name: "GoogleMapsUrl", table: "Vendors"); + migrationBuilder.DropColumn(name: "Notes", table: "Vendors"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 43e63e3..190a690 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1733,6 +1733,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("CompanyName") .HasColumnType("nvarchar(max)"); + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + b.Property("ContactName") .HasColumnType("nvarchar(max)"); @@ -1748,6 +1751,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Email") .HasColumnType("nvarchar(max)"); + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + b.Property("IsActive") .HasColumnType("bit"); @@ -1760,6 +1766,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("LastModifierUserId") .HasColumnType("int"); + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + b.Property("Phone") .HasColumnType("nvarchar(max)"); diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index d6f623a..d1d48b7 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations.Schema; + namespace Data.SeaHavenIndustries { public class Vendor : FullAuditEntity @@ -6,12 +8,17 @@ namespace Data.SeaHavenIndustries public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { 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 string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } public bool IsActive { get; set; } = true; + [NotMapped] + public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 6eb2eeb..273dec0 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using System.Reflection; using SeaHaven.DataServices.Interfaces; namespace SeaHaven.DataServices.Implementation @@ -15,13 +17,16 @@ namespace SeaHaven.DataServices.Implementation public async Task GetByIdAsync(int id) { - return await _context.Vendors.FindAsync(id); + return await _context.Vendors.FirstOrDefaultAsync(v => v.Id == id); } public async Task GetByIdWithDetailsAsync(int id) { - return await _context.Vendors - .FirstOrDefaultAsync(v => v.Id == id); + return await ProjectWithTotalJobs( + _context.Vendors + .AsNoTracking() + .Where(v => v.Id == id)) + .FirstOrDefaultAsync(); } public async Task> GetAllAsync() @@ -32,24 +37,23 @@ namespace SeaHaven.DataServices.Implementation public async Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( int page, int pageSize, - string? search = null) + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) { - search ??= ""; + var projectedQuery = BuildDirectoryQuery( + search, + isActive, + companies, + trades, + locations, + jobBuckets); - var query = _context.Vendors.Where(v => v.IsActive); - - if (!string.IsNullOrWhiteSpace(search)) - { - var s = search.ToLower(); - query = query.Where(v => - (v.CompanyName ?? "").ToLower().Contains(s) || - (v.ContactName ?? "").ToLower().Contains(s) || - (v.Email ?? "").ToLower().Contains(s) || - (v.TradeSpecialties ?? "").ToLower().Contains(s)); - } - - var totalCount = await query.CountAsync(); - var items = await query + var totalCount = await projectedQuery.CountAsync(); + var items = await projectedQuery .OrderByDescending(v => v.Id) .Skip((page - 1) * pageSize) .Take(pageSize) @@ -58,6 +62,198 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + internal IQueryable BuildDirectoryQuery( + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) + { + var query = _context.Vendors + .AsNoTracking() + .Where(v => v.IsDeleted == null || v.IsDeleted == false); + + if (isActive.HasValue) + query = query.Where(v => v.IsActive == isActive.Value); + + if (!string.IsNullOrWhiteSpace(search)) + { + var normalizedSearch = search.ToLower(); + query = query.Where(v => + (v.CompanyName ?? "").ToLower().Contains(normalizedSearch) || + (v.ContactName ?? "").ToLower().Contains(normalizedSearch) || + (v.Email ?? "").ToLower().Contains(normalizedSearch) || + (v.TradeSpecialties ?? "").ToLower().Contains(normalizedSearch) || + (v.City ?? "").ToLower().Contains(normalizedSearch) || + (v.State ?? "").ToLower().Contains(normalizedSearch) || + (v.Zip ?? "").ToLower().Contains(normalizedSearch)); + } + + var selectedCompanies = companies? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedCompanies.Length > 0) + query = query.Where(v => + selectedCompanies.Contains((v.CompanyName ?? "").ToLower())); + + var selectedTrades = trades? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedTrades.Length > 0) + query = query.Where(BuildTradePredicate(selectedTrades)); + + var selectedLocations = locations? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedLocations.Length > 0) + query = query.Where(BuildLocationPredicate(selectedLocations)); + + var result = ProjectWithTotalJobs(query); + var selectedJobBuckets = jobBuckets? + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim().ToLower()) + .Distinct() + .ToArray() ?? Array.Empty(); + if (selectedJobBuckets.Length == 0) + return result; + + var under50 = selectedJobBuckets.Contains("under-50"); + var from50To99 = selectedJobBuckets.Contains("50-99"); + var from100To149 = selectedJobBuckets.Contains("100-149"); + var from150 = selectedJobBuckets.Contains("150-plus"); + + return result.Where(v => + (under50 && v.TotalJobs < 50) || + (from50To99 && v.TotalJobs >= 50 && v.TotalJobs <= 99) || + (from100To149 && v.TotalJobs >= 100 && v.TotalJobs <= 149) || + (from150 && v.TotalJobs >= 150)); + } + + private IQueryable ProjectWithTotalJobs(IQueryable query) => + query.Select(v => new Vendor + { + Id = v.Id, + CompanyName = v.CompanyName, + ContactName = v.ContactName, + Email = v.Email, + Phone = v.Phone, + CompanyPhone = v.CompanyPhone, + Address = v.Address, + City = v.City, + State = v.State, + Zip = v.Zip, + TradeSpecialties = v.TradeSpecialties, + GoogleMapsUrl = v.GoogleMapsUrl, + Notes = v.Notes, + IsActive = v.IsActive, + IsDeleted = v.IsDeleted, + TotalJobs = _context.Dispatches + .Where(dispatch => + dispatch.VendorId == v.Id && + dispatch.WorkOrderId.HasValue) + .Select(dispatch => dispatch.WorkOrderId!.Value) + .Union( + _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == v.Id) + .Select(link => link.WorkOrderId)) + .Count() + }); + + private static readonly MethodInfo StringConcatMethod = + typeof(string).GetMethod(nameof(string.Concat), new[] { typeof(string), typeof(string) })!; + + private static Expression> BuildTradePredicate( + IReadOnlyCollection selectedTrades) + { + var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var specialties = Expression.Coalesce( + Expression.Property(vendor, nameof(Vendor.TradeSpecialties)), + Expression.Constant(string.Empty)); + var lowered = Expression.Call(specialties, nameof(string.ToLower), Type.EmptyTypes); + + var delimiters = new[] { ", ", "," }; + Expression? body = null; + + foreach (var trade in selectedTrades) + { + var token = Expression.Constant(trade); + + body = OrElse(body, Expression.Equal(lowered, token)); + + foreach (var delimiter in delimiters) + { + var delim = Expression.Constant(delimiter); + var tokenWithTrailing = Concat(token, delim); + var leadingToken = Concat(delim, token); + var wrappedToken = Concat(leadingToken, delim); + + body = OrElse(body, StartsWith(lowered, tokenWithTrailing)); + body = OrElse(body, EndsWith(lowered, leadingToken)); + body = OrElse(body, Contains(lowered, wrappedToken)); + } + } + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + + private static Expression> BuildLocationPredicate( + IReadOnlyCollection selectedLocations) + { + var vendor = Expression.Parameter(typeof(Vendor), "vendor"); + var label = BuildLocationLabelBody(vendor); + var lowered = Expression.Call(label, nameof(string.ToLower), Type.EmptyTypes); + + Expression? body = null; + foreach (var location in selectedLocations) + body = OrElse(body, Expression.Equal(lowered, Expression.Constant(location))); + + return Expression.Lambda>(body ?? Expression.Constant(false), vendor); + } + + private static Expression BuildLocationLabelBody(ParameterExpression vendor) + { + var city = Expression.Property(vendor, nameof(Vendor.City)); + var state = Expression.Property(vendor, nameof(Vendor.State)); + var cityBlank = IsBlank(city); + var stateBlank = IsBlank(state); + + var cityCommaState = Concat(Concat(city, Expression.Constant(", ")), state); + + var onlyState = Expression.Condition(stateBlank, Expression.Constant(string.Empty), state); + var cityOrBoth = Expression.Condition(stateBlank, city, cityCommaState); + return Expression.Condition(cityBlank, onlyState, cityOrBoth); + } + + private static Expression IsBlank(Expression value) + { + var isNull = Expression.Equal(value, Expression.Constant(null, value.Type)); + var trimmed = Expression.Call(value, nameof(string.Trim), Type.EmptyTypes); + var isEmpty = Expression.Equal(trimmed, Expression.Constant(string.Empty)); + return Expression.Condition(isNull, Expression.Constant(true), isEmpty); + } + + private static Expression Concat(Expression left, Expression right) => + Expression.Call(StringConcatMethod, left, right); + + private static Expression StartsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.StartsWith), Type.EmptyTypes, value); + + private static Expression EndsWith(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.EndsWith), Type.EmptyTypes, value); + + private static Expression Contains(Expression instance, Expression value) => + Expression.Call(instance, nameof(string.Contains), Type.EmptyTypes, value); + + private static Expression OrElse(Expression? accumulated, Expression next) => + accumulated is null ? next : Expression.OrElse(accumulated, next); + public async Task AddAsync(Vendor vendor) { await _context.Vendors.AddAsync(vendor); diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 6458b4f..2dbd0c1 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -7,7 +7,15 @@ namespace SeaHaven.DataServices.Interfaces Task GetByIdAsync(int id); Task GetByIdWithDetailsAsync(int id); Task> GetAllAsync(); - Task<(IEnumerable Items, int TotalCount)> GetPagedAsync(int page, int pageSize, string? search = null); + Task<(IEnumerable Items, int TotalCount)> GetPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null); Task AddAsync(Vendor vendor); Task UpdateAsync(Vendor vendor); Task DeleteAsync(int id); diff --git a/SeaHaven.DataServices/Properties/AssemblyInfo.cs b/SeaHaven.DataServices/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..75fc406 --- /dev/null +++ b/SeaHaven.DataServices/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Api.SeaHavenIndustries.Tests")] diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 3a58934..6c1cd82 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -4,33 +4,52 @@ namespace SeaHaven.Services.DTOs { public int Id { get; set; } public string? Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public int TotalJobs { get; set; } + public bool IsActive { get; set; } } public class CreateVendorDTO { public required string Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool IsActive { get; set; } = true; } public class UpdateVendorDTO { public string? Name { get; set; } + public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } + public string? CompanyPhone { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } public string? Zipcode { get; set; } + public string? TradeSpecialties { get; set; } + public string? GoogleMapsUrl { get; set; } + public string? Notes { get; set; } + public bool? IsActive { get; set; } } } diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index eccc405..6ba8e71 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -29,7 +29,7 @@ namespace SeaHaven.Services.Implementation public async Task GetVendorByIdAsync(int id) { - var vendor = await _vendorDataService.GetByIdAsync(id); + var vendor = await _vendorDataService.GetByIdWithDetailsAsync(id); return vendor == null ? null : MapToDTO(vendor); } @@ -45,9 +45,25 @@ namespace SeaHaven.Services.Implementation return vendors.Select(MapToDTO); } - public async Task> GetVendorsPagedAsync(int page, int pageSize, string? search = null) + public async Task> GetVendorsPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null) { - var (items, totalCount) = await _vendorDataService.GetPagedAsync(page, pageSize, search); + var (items, totalCount) = await _vendorDataService.GetPagedAsync( + page, + pageSize, + search, + isActive, + companies, + trades, + locations, + jobBuckets); return new PagedResult { Items = items.Select(MapToDTO).ToList(), @@ -68,12 +84,18 @@ namespace SeaHaven.Services.Implementation var vendor = new Vendor { CompanyName = dto.Name, + ContactName = dto.ContactName, Email = dto.Email, Phone = dto.Phone, + CompanyPhone = dto.CompanyPhone, Address = dto.Address, City = dto.City, State = dto.State, Zip = dto.Zipcode, + TradeSpecialties = dto.TradeSpecialties, + GoogleMapsUrl = dto.GoogleMapsUrl, + Notes = dto.Notes, + IsActive = dto.IsActive, CreatedDate = DateTime.UtcNow, createdby = userId }; @@ -95,12 +117,18 @@ namespace SeaHaven.Services.Implementation throw new InvalidOperationException($"Vendor with ID {id} not found"); if (dto.Name != null) vendor.CompanyName = dto.Name; + if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; if (dto.Phone != null) vendor.Phone = dto.Phone; + if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone; if (dto.Address != null) vendor.Address = dto.Address; if (dto.City != null) vendor.City = dto.City; if (dto.State != null) vendor.State = dto.State; if (dto.Zipcode != null) vendor.Zip = dto.Zipcode; + if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties; + if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl; + if (dto.Notes != null) vendor.Notes = dto.Notes; + if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) @@ -116,9 +144,10 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); - vendor.IsDeleted = true; - vendor.DeleterUserId = userId; - vendor.DeletionTime = DateTime.UtcNow; + vendor.IsActive = false; + vendor.LastModificationTime = DateTime.UtcNow; + if (int.TryParse(userId, out int userIdInt)) + vendor.LastModifierUserId = userIdInt; await _vendorDataService.UpdateAsync(vendor); } @@ -139,13 +168,40 @@ namespace SeaHaven.Services.Implementation { Id = vendor.Id, Name = vendor.CompanyName, + ContactName = vendor.ContactName, Email = vendor.Email, Phone = vendor.Phone, + CompanyPhone = vendor.CompanyPhone, Address = vendor.Address, City = vendor.City, State = vendor.State, - Zipcode = vendor.Zip + Zipcode = vendor.Zip, + TradeSpecialties = vendor.TradeSpecialties, + GoogleMapsUrl = vendor.GoogleMapsUrl, + Notes = vendor.Notes, + IsActive = vendor.IsActive, + TotalJobs = vendor.Dispatches == null + ? vendor.TotalJobs + : CountDistinctWorkOrders(vendor) }; } + + private static int CountDistinctWorkOrders(Vendor vendor) + { + if (vendor.Dispatches == null) + return 0; + + return vendor.Dispatches + .SelectMany(dispatch => + { + var linked = dispatch.DispatchWorkOrders?.Select(item => item.WorkOrderId) + ?? Enumerable.Empty(); + return dispatch.WorkOrderId.HasValue + ? linked.Append(dispatch.WorkOrderId.Value) + : linked; + }) + .Distinct() + .Count(); + } } } diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index 13d996b..aad7b13 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -7,7 +7,15 @@ namespace SeaHaven.Services.Interfaces Task GetVendorByIdAsync(int id); Task GetVendorByIdWithDetailsAsync(int id); Task> GetAllVendorsAsync(); - Task> GetVendorsPagedAsync(int page, int pageSize, string? search = null); + Task> GetVendorsPagedAsync( + int page, + int pageSize, + string? search = null, + bool? isActive = true, + IReadOnlyCollection? companies = null, + IReadOnlyCollection? trades = null, + IReadOnlyCollection? locations = null, + IReadOnlyCollection? jobBuckets = null); Task CreateVendorAsync(CreateVendorDTO dto, string userId); Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId); Task DeleteVendorAsync(int id, string userId); diff --git a/SeaHaven.Services/Validation/VendorValidation.cs b/SeaHaven.Services/Validation/VendorValidation.cs index b2f5c03..acce074 100644 --- a/SeaHaven.Services/Validation/VendorValidation.cs +++ b/SeaHaven.Services/Validation/VendorValidation.cs @@ -19,10 +19,22 @@ namespace SeaHaven.Services.Validation .EmailAddress().WithMessage("Invalid email address") .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.ContactName) + .NotEmpty().WithMessage("Technician name is required") + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters"); + + RuleFor(x => x.TradeSpecialties) + .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") + .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); + RuleFor(x => x.Phone) .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") .When(x => !string.IsNullOrEmpty(x.Phone)); + RuleFor(x => x.CompanyPhone) + .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); + RuleFor(x => x.Address) .MaximumLength(500).WithMessage("Address cannot exceed 500 characters") .When(x => !string.IsNullOrEmpty(x.Address)); @@ -38,6 +50,14 @@ namespace SeaHaven.Services.Validation RuleFor(x => x.Zipcode) .MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters") .When(x => !string.IsNullOrEmpty(x.Zipcode)); + + RuleFor(x => x.GoogleMapsUrl) + .Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL") + .When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); } } @@ -57,10 +77,23 @@ namespace SeaHaven.Services.Validation .EmailAddress().WithMessage("Invalid email address") .When(x => !string.IsNullOrEmpty(x.Email)); + RuleFor(x => x.ContactName) + .NotEmpty().WithMessage("Technician name is required") + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") + .When(x => x.ContactName != null); + + RuleFor(x => x.TradeSpecialties) + .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") + .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); + RuleFor(x => x.Phone) .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") .When(x => !string.IsNullOrEmpty(x.Phone)); + RuleFor(x => x.CompanyPhone) + .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); + RuleFor(x => x.Address) .MaximumLength(500).WithMessage("Address cannot exceed 500 characters") .When(x => !string.IsNullOrEmpty(x.Address)); @@ -76,6 +109,21 @@ namespace SeaHaven.Services.Validation RuleFor(x => x.Zipcode) .MaximumLength(10).WithMessage("Zipcode cannot exceed 10 characters") .When(x => !string.IsNullOrEmpty(x.Zipcode)); + + RuleFor(x => x.GoogleMapsUrl) + .Must(VendorValidationRules.BeValidHttpsUrl).WithMessage("Google Maps URL must be a valid HTTPS URL") + .When(x => !string.IsNullOrEmpty(x.GoogleMapsUrl)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); } + + } + + internal static class VendorValidationRules + { + internal static bool BeValidHttpsUrl(string? value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; } } From 8cf49afc2c395208440b88a8905855c60df8b300 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 17:02:40 -0300 Subject: [PATCH 5/8] feat(vendors): complete core vendor workflows --- .../VendorDataServiceTests.cs | 55 ++++ .../VendorServiceTests.cs | 183 +++++++++++- .../Controllers/VendorController.cs | 90 ++++++ Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs | 12 + .../Auth/ApplicationDbContext.cs | 12 + ...20260723194013_AddVendorCompanyAndAudit.cs | 185 ++++++++++++ .../ApplicationDbContextModelSnapshot.cs | 144 +++++++++- .../Models/LinkedWorkOrderInfo.cs | 15 + Data.SeaHavenIndustries/Models/Vendor.cs | 7 + .../Models/VendorAuditLog.cs | 18 ++ .../Models/VendorCompany.cs | 19 ++ .../Implementation/VendorDataService.cs | 80 ++++++ .../Interfaces/IVendorDataService.cs | 7 + SeaHaven.Services/DTOs/VendorDTOs.cs | 44 +++ .../Helpers/VendorPhoneNormalizer.cs | 25 ++ .../Implementation/VendorService.cs | 269 +++++++++++++++++- .../Interfaces/IVendorService.cs | 2 + .../Validation/VendorValidation.cs | 66 ++++- 18 files changed, 1213 insertions(+), 20 deletions(-) create mode 100644 Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs create mode 100644 Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorAuditLog.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorCompany.cs create mode 100644 SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 31c4940..80057f5 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -1,4 +1,5 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SeaHaven.DataServices.Implementation; @@ -266,4 +267,58 @@ public class VendorDataServiceTests if (filter == "job-bucket") sql.Should().Contain("UNION"); } + + [Fact] + public void BuildOpenLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation() + { + using var context = NewSqlServerContext(); + var service = new VendorDataService(context); + + var sql = service.BuildOpenLinkedWorkOrdersQuery(60).ToQueryString(); + + sql.Should().Contain("SELECT"); + sql.Should().Contain("Dispatches"); + sql.Should().Contain("DispatchWorkOrders"); + sql.Should().Contain("workOrders"); + } + + [Fact] + public async Task GetOpenLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks() + { + await using var context = NewContext(); + var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true }; + context.Vendors.Add(vendor); + context.workOrders.AddRange( + new WorkOrder + { + Id = 701, + WorkerOrderNumber = "WO-701", + WorkerOrderTitle = "Direct And Multi", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + }, + new WorkOrder + { + Id = 702, + WorkerOrderNumber = "WO-702", + WorkerOrderTitle = "Multi Only", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + }); + + var directDispatch = new Dispatch { Id = 7001, VendorId = 60, WorkOrderId = 701, Vendor = vendor }; + var multiDispatch = new Dispatch { Id = 7002, VendorId = 60, WorkOrderId = null, Vendor = vendor }; + context.Dispatches.AddRange(directDispatch, multiDispatch); + context.DispatchWorkOrders.AddRange( + new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 702 }, + new DispatchWorkOrder { DispatchId = 7002, Dispatch = multiDispatch, WorkOrderId = 701 }); + await context.SaveChangesAsync(); + + var service = new VendorDataService(context); + var result = await service.GetOpenLinkedWorkOrdersAsync(60); + + result.Should().HaveCount(2); + result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 }); + result.Select(x => x.WorkOrderId).Should().OnlyHaveUniqueItems(); + } } diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 1067869..9c49893 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -1,5 +1,7 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentAssertions; +using FluentValidation; using Moq; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; @@ -12,7 +14,7 @@ namespace Api.SeaHavenIndustries.Tests; public class VendorServiceTests { private static VendorService NewService(Mock data) => - new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation()); + new(data.Object, new CreateVendorValidation(), new UpdateVendorValidation(), new WorkOrderVendorUpdateValidation()); [Fact] public async Task CreateVendor_PersistsCompleteProfile() @@ -27,8 +29,8 @@ public class VendorServiceTests Name = "Gateway Plumbing", ContactName = "Jordan Lee", Email = "dispatch@gateway.example", - Phone = "555-0100", - CompanyPhone = "555-0199", + Phone = "(555) 555-0100", + CompanyPhone = "(555) 555-0199", Address = "10 Lake St", City = "Chicago", State = "IL", @@ -42,7 +44,7 @@ public class VendorServiceTests saved.Should().NotBeNull(); saved!.ContactName.Should().Be("Jordan Lee"); saved.TradeSpecialties.Should().Be("Plumbing, Backflow"); - saved.CompanyPhone.Should().Be("555-0199"); + saved.CompanyPhone.Should().Be("(555) 555-0199"); saved.GoogleMapsUrl.Should().Be("https://maps.google.com/example"); saved.Notes.Should().Be("After-hours dispatch available"); saved.City.Should().Be("Chicago"); @@ -56,6 +58,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); + data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO @@ -80,6 +83,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); + data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).DeleteVendorAsync(9, "42"); @@ -210,4 +214,175 @@ public class VendorServiceTests result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.ContactName)); result.Errors.Should().Contain(error => error.PropertyName == nameof(CreateVendorDTO.GoogleMapsUrl)); } + + [Fact] + public async Task UpdateVendor_DeactivatingWithOpenWorkOrders_ThrowsAndLeavesActive() + { + var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing); + data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(20)) + .ReturnsAsync(new List + { + new() + { + WorkOrderId = 500, + WorkOrderNumber = "WO-500", + Status = "Scheduled", + LifecycleStatus = LifecycleStatus.Scheduled + } + }); + + var act = () => NewService(data).UpdateVendorAsync(20, new UpdateVendorDTO { IsActive = false }, "42"); + + await act.Should().ThrowAsync(); + existing.IsActive.Should().BeTrue(); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateVendor_WithUnknownCompanyId_ThrowsAndDoesNotFallBackToName() + { + var existing = new Vendor { Id = 30, CompanyName = "Existing", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(30)).ReturnsAsync(existing); + data.Setup(x => x.GetCompanyByIdAsync(999)).ReturnsAsync((VendorCompany?)null); + + var act = () => NewService(data).UpdateVendorAsync(30, new UpdateVendorDTO + { + CompanyId = 999, + Name = "Fallback Try" + }, "42"); + + await act.Should().ThrowAsync(); + data.Verify(x => x.GetCompanyByNormalizedNameAsync(It.IsAny()), Times.Never); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task CreateVendor_PropagatesExistingCompanyIdWithoutCreatingCompany() + { + var data = new Mock(); + var company = new VendorCompany { Id = 77, Name = "Acme", NormalizedName = "acme" }; + data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor v) => { saved = v; v.Id = 40; return v; }); + + var result = await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Acme", + CompanyId = 77, + ContactName = "Riley" + }, "42"); + + saved.Should().NotBeNull(); + saved!.CompanyId.Should().Be(77); + result.CompanyId.Should().Be(77); + data.Verify(x => x.AddCompanyAsync(It.IsAny()), Times.Never); + } + + [Fact] + public void CreateVendor_RejectsNonCanonicalPhoneAndInvalidPreferredContact() + { + var validator = new CreateVendorValidation(); + + var result = validator.Validate(new CreateVendorDTO + { + Name = "Acme", + ContactName = "Riley", + Phone = "555-1234", + PreferredContact = "Fax" + }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.Phone)); + result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact)); + } + + [Fact] + public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact() + { + var validator = new WorkOrderVendorUpdateValidation(); + + var result = validator.Validate(new WorkOrderVendorUpdateDTO + { + WorkOrderId = 5, + Phone = "(312) 555-0100", + PreferredContact = "Text" + }); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public async Task UpdateVendorFromWorkOrder_RejectsUnassignedWorkOrder() + { + var existing = new Vendor { Id = 50, ContactName = "Original", IsActive = true }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); + data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(false); + + var act = () => NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO + { + WorkOrderId = 777, + ContactName = "Should Not Apply" + }, "42"); + + await act.Should().ThrowAsync(); + existing.ContactName.Should().Be("Original"); + data.Verify(x => x.UpdateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateVendorFromWorkOrder_RecordsAuditOldAndNewValues() + { + var existing = new Vendor + { + Id = 50, + CompanyName = "Assigned", + ContactName = "Old Name", + Phone = "(312) 555-0000", + PreferredContact = "Phone", + IsActive = true + }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(50)).ReturnsAsync(existing); + data.Setup(x => x.IsVendorAssignedToWorkOrderAsync(50, 777)).ReturnsAsync(true); + var auditLogs = new List(); + data.Setup(x => x.UpdateWithAuditLogsAsync( + existing, + It.IsAny>())) + .Callback>((_, logs) => auditLogs.AddRange(logs)) + .Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorFromWorkOrderAsync(50, new WorkOrderVendorUpdateDTO + { + WorkOrderId = 777, + ContactName = "New Name", + Phone = "(312) 555-0199", + PreferredContact = "Email" + }, "42"); + + existing.ContactName.Should().Be("New Name"); + existing.Phone.Should().Be("(312) 555-0199"); + existing.PreferredContact.Should().Be("Email"); + auditLogs.Should().NotBeEmpty(); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.ContactName) && a.OldValue == "Old Name" && a.NewValue == "New Name"); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.Phone) && a.OldValue == "(312) 555-0000" && a.NewValue == "(312) 555-0199"); + auditLogs.Should().Contain(a => a.FieldName == nameof(Vendor.PreferredContact) && a.OldValue == "Phone" && a.NewValue == "Email"); + } + + [Fact] + public async Task GetDeactivationImpact_RejectsUnknownVendor() + { + var data = new Mock(); + data.Setup(x => x.ExistsAsync(404)).ReturnsAsync(false); + + var act = () => NewService(data).GetDeactivationImpactAsync(404); + + await act.Should().ThrowAsync() + .WithMessage("*404*not found"); + data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny()), Times.Never); + } } diff --git a/Api.SeaHavenIndustries/Controllers/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index ecc4ccd..00763f0 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -56,10 +56,12 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, CompanyName = v.Name, + v.CompanyId, v.ContactName, v.Email, v.Phone, v.CompanyPhone, + v.PreferredContact, v.Address, v.City, v.State, @@ -98,10 +100,12 @@ namespace Api.SeaHavenIndustries.Controllers { vendor.Id, CompanyName = vendor.Name, + vendor.CompanyId, vendor.ContactName, vendor.Email, vendor.Phone, vendor.CompanyPhone, + vendor.PreferredContact, vendor.Address, vendor.City, vendor.State, @@ -123,10 +127,12 @@ namespace Api.SeaHavenIndustries.Controllers var createDto = new CreateVendorDTO { Name = model.CompanyName ?? throw new ArgumentException("CompanyName is required"), + CompanyId = model.CompanyId, ContactName = model.ContactName, Email = model.Email, Phone = model.Phone, CompanyPhone = model.CompanyPhone, + PreferredContact = model.PreferredContact, Address = model.Address, City = model.City, State = model.State, @@ -169,10 +175,12 @@ namespace Api.SeaHavenIndustries.Controllers var updateDto = new UpdateVendorDTO { Name = model.CompanyName, + CompanyId = model.CompanyId, ContactName = model.ContactName, Email = model.Email, Phone = model.Phone, CompanyPhone = model.CompanyPhone, + PreferredContact = model.PreferredContact, Address = model.Address, City = model.City, State = model.State, @@ -195,6 +203,15 @@ namespace Api.SeaHavenIndustries.Controllers var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); return BadRequest(new Response { Status = "Validation Error", Message = errors }); } + catch (VendorDeactivationBlockedException dbex) + { + return Conflict(new + { + Status = "Conflict", + Message = dbex.Message, + OpenWorkOrders = dbex.OpenWorkOrders + }); + } catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); @@ -223,6 +240,15 @@ namespace Api.SeaHavenIndustries.Controllers await _vendorService.DeleteVendorAsync(vendorId, userId); return Ok(new DataResponse { Message = "Vendor Deactivated", Status = "200" }); } + catch (VendorDeactivationBlockedException dbex) + { + return Conflict(new + { + Status = "Conflict", + Message = dbex.Message, + OpenWorkOrders = dbex.OpenWorkOrders + }); + } catch (InvalidOperationException) { return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); @@ -233,6 +259,64 @@ namespace Api.SeaHavenIndustries.Controllers } } + [HttpGet("{id:int}/deactivation-impact")] + public async Task GetDeactivationImpact(int id) + { + try + { + var impact = await _vendorService.GetDeactivationImpactAsync(id); + return Ok(impact); + } + catch (InvalidOperationException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpPut("{id:int}/work-order-update")] + public async Task UpdateFromWorkOrder(int id, [FromBody] WorkOrderVendorUpdate_DTO model) + { + try + { + if (model == null || model.WorkOrderId <= 0) + return BadRequest(new Response { Status = "Error", Message = "A valid workOrderId is required" }); + + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) + return Unauthorized(new Response { Status = "Error", Message = "User not authenticated" }); + + var dto = new WorkOrderVendorUpdateDTO + { + WorkOrderId = model.WorkOrderId, + ContactName = model.ContactName, + PreferredContact = model.PreferredContact, + Phone = model.Phone, + Email = model.Email, + Notes = model.Notes + }; + + var result = await _vendorService.UpdateVendorFromWorkOrderAsync(id, dto, userId); + return Ok(result); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (InvalidOperationException) + { + return NotFound(new Response { Status = "Error", Message = "Vendor or work order link not found" }); + } + catch (Exception ex) + { + return StatusCode(500, new Response { Status = "Error", Message = ex.Message }); + } + } + [HttpGet("Dropdown")] public async Task GetDropdown([FromQuery] string? trade = null, [FromQuery] string? siteZip = null) { @@ -243,6 +327,8 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, + v.PreferredContact, v.TradeSpecialties, v.Address, v.City, @@ -260,6 +346,8 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, + v.PreferredContact, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null @@ -303,8 +391,10 @@ namespace Api.SeaHavenIndustries.Controllers .Select(v => new { Name = v.CompanyName, + v.CompanyId, v.CompanyPhone, v.Email, + v.PreferredContact, v.Address, v.City, v.State, diff --git a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs index d2a4717..6249bb8 100644 --- a/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Vendor_DTO.cs @@ -3,10 +3,12 @@ namespace Api.SeaHavenIndustries.DTOs public class Vendor_DTO { public string? CompanyName { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -21,4 +23,14 @@ namespace Api.SeaHavenIndustries.DTOs { public int Id { get; set; } } + + public class WorkOrderVendorUpdate_DTO + { + public int WorkOrderId { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + } } diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index a34d5d4..c7c85c6 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -51,6 +51,16 @@ namespace Data.SeaHavenIndustries .WithMany() .HasForeignKey(w => w.PrimaryDispatchId) .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(c => c.NormalizedName) + .IsUnique(); + + builder.Entity() + .HasOne(v => v.Company) + .WithMany(c => c.Vendors) + .HasForeignKey(v => v.CompanyId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -68,6 +78,8 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderFieldLocks { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } + public DbSet VendorCompanies { get; set; } + public DbSet VendorAuditLogs { get; set; } public DbSet VendorAccessTokens { get; set; } public DbSet Dispatches { get; set; } public DbSet DispatchWorkOrders { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs b/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs new file mode 100644 index 0000000..bda0f92 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723194013_AddVendorCompanyAndAudit.cs @@ -0,0 +1,185 @@ +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260723194013_AddVendorCompanyAndAudit")] + public partial class AddVendorCompanyAndAudit : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompanyId", + table: "Vendors", + type: "int", + nullable: true); + + migrationBuilder.AddColumn( + name: "PreferredContact", + table: "Vendors", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.CreateTable( + name: "VendorCompanies", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + NormalizedName = table.Column(type: "nvarchar(450)", nullable: false), + CompanyPhone = table.Column(type: "nvarchar(max)", nullable: true), + Email = table.Column(type: "nvarchar(max)", nullable: true), + Address = table.Column(type: "nvarchar(max)", nullable: true), + City = table.Column(type: "nvarchar(max)", nullable: true), + State = table.Column(type: "nvarchar(max)", nullable: true), + Zip = table.Column(type: "nvarchar(max)", nullable: true), + GoogleMapsUrl = table.Column(type: "nvarchar(max)", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorCompanies", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "VendorAuditLogs", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VendorId = table.Column(type: "int", nullable: false), + WorkOrderId = table.Column(type: "int", nullable: true), + FieldName = table.Column(type: "nvarchar(max)", nullable: true), + OldValue = table.Column(type: "nvarchar(max)", nullable: true), + NewValue = table.Column(type: "nvarchar(max)", nullable: true), + Actor = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorAuditLogs", x => x.Id); + table.ForeignKey( + name: "FK_VendorAuditLogs_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + // Backfill: create one VendorCompany per distinct (case-insensitive, trimmed) legacy name. + // Keep all company-owned fields from the same deterministic source vendor row. + migrationBuilder.Sql(@" +WITH RankedVendors AS ( + SELECT + v.[CompanyName] AS [Name], + LOWER(LTRIM(RTRIM(v.[CompanyName]))) AS [NormalizedName], + v.[CompanyPhone], + v.[Email], + v.[Address], + v.[City], + v.[State], + v.[Zip], + v.[GoogleMapsUrl], + ROW_NUMBER() OVER ( + PARTITION BY LOWER(LTRIM(RTRIM(v.[CompanyName]))) + ORDER BY v.[Id] + ) AS [RowNumber] + FROM [Vendors] v + WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> '' +) +INSERT INTO [VendorCompanies] ( + [Name], [NormalizedName], [CompanyPhone], [Email], [Address], [City], [State], [Zip], [GoogleMapsUrl], [CreatedDate] +) +SELECT + ranked.[Name], + ranked.[NormalizedName], + ranked.[CompanyPhone], + ranked.[Email], + ranked.[Address], + ranked.[City], + ranked.[State], + ranked.[Zip], + ranked.[GoogleMapsUrl], + GETUTCDATE() AS [CreatedDate] +FROM RankedVendors ranked +WHERE ranked.[RowNumber] = 1; + +UPDATE v +SET v.[CompanyId] = vc.[Id] +FROM [Vendors] v +INNER JOIN [VendorCompanies] vc + ON vc.[NormalizedName] = LOWER(LTRIM(RTRIM(v.[CompanyName]))) +WHERE v.[CompanyName] IS NOT NULL AND LTRIM(RTRIM(v.[CompanyName])) <> ''; +"); + + migrationBuilder.CreateIndex( + name: "IX_Vendors_CompanyId", + table: "Vendors", + column: "CompanyId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorAuditLogs_VendorId", + table: "VendorAuditLogs", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompanies_NormalizedName", + table: "VendorCompanies", + column: "NormalizedName", + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Vendors_VendorCompanies_CompanyId", + table: "Vendors", + column: "CompanyId", + principalTable: "VendorCompanies", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Vendors_VendorCompanies_CompanyId", + table: "Vendors"); + + migrationBuilder.DropIndex( + name: "IX_Vendors_CompanyId", + table: "Vendors"); + + migrationBuilder.DropIndex( + name: "IX_VendorAuditLogs_VendorId", + table: "VendorAuditLogs"); + + migrationBuilder.DropIndex( + name: "IX_VendorCompanies_NormalizedName", + table: "VendorCompanies"); + + migrationBuilder.DropTable( + name: "VendorAuditLogs"); + + migrationBuilder.DropTable( + name: "VendorCompanies"); + + migrationBuilder.DropColumn( + name: "CompanyId", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "PreferredContact", + table: "Vendors"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 190a690..09b4ef3 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1730,6 +1730,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("City") .HasColumnType("nvarchar(max)"); + b.Property("CompanyId") + .HasColumnType("int"); + b.Property("CompanyName") .HasColumnType("nvarchar(max)"); @@ -1772,6 +1775,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Phone") .HasColumnType("nvarchar(max)"); + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + b.Property("State") .HasColumnType("nvarchar(max)"); @@ -1786,6 +1792,8 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); + b.HasIndex("CompanyId"); + b.ToTable("Vendors"); }); @@ -1848,6 +1856,108 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("VendorAccessTokens"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Property("Id") @@ -1978,13 +2088,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("ScheduledStart") .HasColumnType("datetime2"); - b.Property("Severity") - .HasColumnType("nvarchar(max)"); - b.Property("Service") .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + b.Property("SignOffAttachment") .HasColumnType("nvarchar(max)"); @@ -2728,6 +2838,16 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("POC"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => { b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") @@ -2739,6 +2859,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") @@ -2972,9 +3103,16 @@ namespace Data.SeaHavenIndustries.Migrations modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => { + b.Navigation("AuditLogs"); + b.Navigation("Dispatches"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Navigation("Comments"); diff --git a/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs b/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs new file mode 100644 index 0000000..db76d4b --- /dev/null +++ b/Data.SeaHavenIndustries/Models/LinkedWorkOrderInfo.cs @@ -0,0 +1,15 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + public class LinkedWorkOrderInfo + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public string? WorkOrderTitle { get; set; } + public string? Status { get; set; } + public LifecycleStatus? LifecycleStatus { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? DispatchId { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index d1d48b7..9f8071a 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -17,8 +17,15 @@ namespace Data.SeaHavenIndustries public string? GoogleMapsUrl { get; set; } public string? Notes { get; set; } public bool IsActive { get; set; } = true; + public string? PreferredContact { get; set; } + + public int? CompanyId { get; set; } + [ForeignKey(nameof(CompanyId))] + public virtual VendorCompany? Company { get; set; } + [NotMapped] public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } + public ICollection? AuditLogs { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/VendorAuditLog.cs b/Data.SeaHavenIndustries/Models/VendorAuditLog.cs new file mode 100644 index 0000000..278b165 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorAuditLog.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorAuditLog + { + public int Id { get; set; } + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public virtual Vendor? Vendor { get; set; } + public int? WorkOrderId { get; set; } + public string? FieldName { get; set; } + public string? OldValue { get; set; } + public string? NewValue { get; set; } + public string? Actor { get; set; } + public DateTime CreatedAt { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/VendorCompany.cs b/Data.SeaHavenIndustries/Models/VendorCompany.cs new file mode 100644 index 0000000..5b2e062 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorCompany.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorCompany : FullAuditEntity + { + public string Name { get; set; } = string.Empty; + public string NormalizedName { get; set; } = string.Empty; + public string? CompanyPhone { get; set; } + public string? Email { 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 ICollection? Vendors { get; set; } + } +} diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index 273dec0..e89620b 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -141,10 +141,12 @@ namespace SeaHaven.DataServices.Implementation { Id = v.Id, CompanyName = v.CompanyName, + CompanyId = v.CompanyId, ContactName = v.ContactName, Email = v.Email, Phone = v.Phone, CompanyPhone = v.CompanyPhone, + PreferredContact = v.PreferredContact, Address = v.Address, City = v.City, State = v.State, @@ -298,5 +300,83 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Vendors.CountAsync(); } + + public async Task GetCompanyByIdAsync(int id) + { + return await _context.VendorCompanies + .AsNoTracking() + .FirstOrDefaultAsync(c => c.Id == id); + } + + public async Task GetCompanyByNormalizedNameAsync(string normalizedName) + { + return await _context.VendorCompanies + .AsNoTracking() + .FirstOrDefaultAsync(c => c.NormalizedName == normalizedName); + } + + public async Task AddCompanyAsync(VendorCompany company) + { + await _context.VendorCompanies.AddAsync(company); + await _context.SaveChangesAsync(); + return company; + } + + internal IQueryable BuildOpenLinkedWorkOrdersQuery(int vendorId) + { + var directLinks = _context.Dispatches + .Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue) + .Select(d => new { DispatchId = d.Id, WorkOrderId = d.WorkOrderId!.Value }); + + var multiLinks = _context.DispatchWorkOrders + .Where(link => link.Dispatch != null && link.Dispatch.VendorId == vendorId) + .Select(link => new { DispatchId = link.DispatchId, WorkOrderId = link.WorkOrderId }); + + var linkedIds = directLinks.Concat(multiLinks); + + return from link in linkedIds + join wo in _context.workOrders on link.WorkOrderId equals wo.Id + orderby wo.Id, link.DispatchId + select new LinkedWorkOrderInfo + { + WorkOrderId = wo.Id, + WorkOrderNumber = wo.WorkerOrderNumber, + WorkOrderTitle = wo.WorkerOrderTitle, + Status = wo.Status, + LifecycleStatus = wo.LifecycleStatus, + ScheduledDate = wo.ScheduledDate, + DispatchId = link.DispatchId + }; + } + + public async Task> GetOpenLinkedWorkOrdersAsync(int vendorId) + { + var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync(); + return raw.DistinctBy(x => x.WorkOrderId).ToList(); + } + + public async Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId) + { + var hasDirect = await _context.Dispatches + .AnyAsync(d => d.VendorId == vendorId && d.WorkOrderId == workOrderId); + + if (hasDirect) + return true; + + return await _context.DispatchWorkOrders + .AnyAsync(link => link.WorkOrderId == workOrderId + && link.Dispatch != null + && link.Dispatch.VendorId == vendorId); + } + + public async Task UpdateWithAuditLogsAsync( + Vendor vendor, + IReadOnlyCollection auditLogs) + { + _context.Vendors.Update(vendor); + if (auditLogs.Count > 0) + await _context.VendorAuditLogs.AddRangeAsync(auditLogs); + await _context.SaveChangesAsync(); + } } } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 2dbd0c1..6f5a469 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -23,5 +23,12 @@ namespace SeaHaven.DataServices.Interfaces Task GetActiveTokenAsync(int vendorId); Task GetTokenByStringAsync(string token); Task CountAsync(); + + Task GetCompanyByIdAsync(int id); + Task GetCompanyByNormalizedNameAsync(string normalizedName); + Task AddCompanyAsync(VendorCompany company); + Task> GetOpenLinkedWorkOrdersAsync(int vendorId); + Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId); + Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection auditLogs); } } diff --git a/SeaHaven.Services/DTOs/VendorDTOs.cs b/SeaHaven.Services/DTOs/VendorDTOs.cs index 6c1cd82..780b724 100644 --- a/SeaHaven.Services/DTOs/VendorDTOs.cs +++ b/SeaHaven.Services/DTOs/VendorDTOs.cs @@ -4,10 +4,12 @@ namespace SeaHaven.Services.DTOs { public int Id { get; set; } public string? Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -22,10 +24,12 @@ namespace SeaHaven.Services.DTOs public class CreateVendorDTO { public required string Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -39,10 +43,12 @@ namespace SeaHaven.Services.DTOs public class UpdateVendorDTO { public string? Name { get; set; } + public int? CompanyId { get; set; } public string? ContactName { get; set; } public string? Email { get; set; } public string? Phone { get; set; } public string? CompanyPhone { get; set; } + public string? PreferredContact { get; set; } public string? Address { get; set; } public string? City { get; set; } public string? State { get; set; } @@ -52,4 +58,42 @@ namespace SeaHaven.Services.DTOs public string? Notes { get; set; } public bool? IsActive { get; set; } } + + public class VendorDeactivationImpactDTO + { + public int VendorId { get; set; } + public bool CanDeactivate { get; set; } + public List OpenWorkOrders { get; set; } = new(); + } + + public class LinkedWorkOrderDTO + { + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public string? WorkOrderTitle { get; set; } + public string? Status { get; set; } + public DateTime? ScheduledDate { get; set; } + public int? DispatchId { get; set; } + } + + public class WorkOrderVendorUpdateDTO + { + public int WorkOrderId { get; set; } + public string? ContactName { get; set; } + public string? PreferredContact { get; set; } + public string? Phone { get; set; } + public string? Email { get; set; } + public string? Notes { get; set; } + } + + public class VendorDeactivationBlockedException : InvalidOperationException + { + public List OpenWorkOrders { get; } + + public VendorDeactivationBlockedException(string message, List openWorkOrders) + : base(message) + { + OpenWorkOrders = openWorkOrders; + } + } } diff --git a/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs b/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs new file mode 100644 index 0000000..5254184 --- /dev/null +++ b/SeaHaven.Services/Helpers/VendorPhoneNormalizer.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; + +namespace SeaHaven.Services.Helpers +{ + public static class VendorPhoneNormalizer + { + public static readonly Regex NorthAmericanPhoneRegex = new( + @"^\(\d{3}\) \d{3}-\d{4}$", + RegexOptions.Compiled); + + public static string? NormalizeToCanonical(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + var digits = new string(value.Where(char.IsDigit).ToArray()); + if (digits.Length == 11 && digits.StartsWith('1')) + digits = digits[1..]; + + return digits.Length == 10 + ? $"({digits[..3]}) {digits[3..6]}-{digits[6..]}" + : value; + } + } +} diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 6ba8e71..39b20c9 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -1,30 +1,35 @@ using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; using FluentValidation; +using FluentValidation.Results; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; using SeaHaven.Services.Validation; namespace SeaHaven.Services.Implementation { - /// - /// Vendor service implementation - /// Contains business logic, validation, and orchestration for Vendor operations - /// public class VendorService : IVendorService { + private static readonly string[] TerminalLegacyStatuses = + { "completed", "cancelled", "canceled" }; + private readonly IVendorDataService _vendorDataService; private readonly ICreateVendorValidation _createValidator; private readonly IUpdateVendorValidation _updateValidator; + private readonly IWorkOrderVendorUpdateValidation _workOrderUpdateValidator; public VendorService( IVendorDataService vendorDataService, ICreateVendorValidation createValidator, - IUpdateVendorValidation updateValidator) + IUpdateVendorValidation updateValidator, + IWorkOrderVendorUpdateValidation workOrderUpdateValidator) { _vendorDataService = vendorDataService; _createValidator = createValidator; _updateValidator = updateValidator; + _workOrderUpdateValidator = workOrderUpdateValidator; } public async Task GetVendorByIdAsync(int id) @@ -81,13 +86,16 @@ namespace SeaHaven.Services.Implementation throw new ValidationException(validationResult.Errors); } + var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + var vendor = new Vendor { CompanyName = dto.Name, ContactName = dto.ContactName, Email = dto.Email, - Phone = dto.Phone, - CompanyPhone = dto.CompanyPhone, + Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone), + CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone), + PreferredContact = dto.PreferredContact, Address = dto.Address, City = dto.City, State = dto.State, @@ -96,10 +104,13 @@ namespace SeaHaven.Services.Implementation GoogleMapsUrl = dto.GoogleMapsUrl, Notes = dto.Notes, IsActive = dto.IsActive, + CompanyId = company?.Id, CreatedDate = DateTime.UtcNow, createdby = userId }; + ApplyCompanyFields(vendor, company); + var created = await _vendorDataService.AddAsync(vendor); return MapToDTO(created); } @@ -116,11 +127,15 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); + if (dto.IsActive.HasValue && !dto.IsActive.Value) + await AssertNoOpenLinkedWorkOrdersAsync(id); + if (dto.Name != null) vendor.CompanyName = dto.Name; if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; - if (dto.Phone != null) vendor.Phone = dto.Phone; - if (dto.CompanyPhone != null) vendor.CompanyPhone = dto.CompanyPhone; + if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + if (dto.CompanyPhone != null) vendor.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); + if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact; if (dto.Address != null) vendor.Address = dto.Address; if (dto.City != null) vendor.City = dto.City; if (dto.State != null) vendor.State = dto.State; @@ -130,6 +145,13 @@ namespace SeaHaven.Services.Implementation if (dto.Notes != null) vendor.Notes = dto.Notes; if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; + if (dto.CompanyId.HasValue || dto.Name != null) + { + var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + vendor.CompanyId = company?.Id; + ApplyCompanyFields(vendor, company); + } + vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) vendor.LastModifierUserId = userIdInt; @@ -144,6 +166,8 @@ namespace SeaHaven.Services.Implementation if (vendor == null) throw new InvalidOperationException($"Vendor with ID {id} not found"); + await AssertNoOpenLinkedWorkOrdersAsync(id); + vendor.IsActive = false; vendor.LastModificationTime = DateTime.UtcNow; if (int.TryParse(userId, out int userIdInt)) @@ -162,16 +186,243 @@ namespace SeaHaven.Services.Implementation return await _vendorDataService.CountAsync(); } + public async Task GetDeactivationImpactAsync(int vendorId) + { + if (!await _vendorDataService.ExistsAsync(vendorId)) + throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); + + var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var openWorkOrders = linked + .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) + .Select(MapToLinkedWorkOrderDTO) + .ToList(); + + return new VendorDeactivationImpactDTO + { + VendorId = vendorId, + CanDeactivate = openWorkOrders.Count == 0, + OpenWorkOrders = openWorkOrders + }; + } + + public async Task UpdateVendorFromWorkOrderAsync( + int vendorId, + WorkOrderVendorUpdateDTO dto, + string userId) + { + var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto); + if (!validationResult.IsValid) + { + throw new ValidationException(validationResult.Errors); + } + + var vendor = await _vendorDataService.GetByIdAsync(vendorId); + if (vendor == null) + throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); + + var isAssigned = await _vendorDataService.IsVendorAssignedToWorkOrderAsync(vendorId, dto.WorkOrderId); + if (!isAssigned) + throw new InvalidOperationException( + $"Vendor {vendorId} is not actively linked to work order {dto.WorkOrderId}."); + + var changes = new List(); + var now = DateTime.UtcNow; + + void RecordChange(string fieldName, string? oldValue, string? newValue) + { + if (!string.Equals(oldValue, newValue, StringComparison.Ordinal)) + { + changes.Add(new VendorAuditLog + { + VendorId = vendorId, + WorkOrderId = dto.WorkOrderId, + FieldName = fieldName, + OldValue = oldValue, + NewValue = newValue, + Actor = userId, + CreatedAt = now + }); + } + } + + if (dto.ContactName != null) + { + RecordChange(nameof(Vendor.ContactName), vendor.ContactName, dto.ContactName); + vendor.ContactName = dto.ContactName; + } + + if (dto.PreferredContact != null) + { + RecordChange(nameof(Vendor.PreferredContact), vendor.PreferredContact, dto.PreferredContact); + vendor.PreferredContact = dto.PreferredContact; + } + + if (dto.Phone != null) + { + var normalized = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + RecordChange(nameof(Vendor.Phone), vendor.Phone, normalized); + vendor.Phone = normalized; + } + + if (dto.Email != null) + { + RecordChange(nameof(Vendor.Email), vendor.Email, dto.Email); + vendor.Email = dto.Email; + } + + if (dto.Notes != null) + { + RecordChange(nameof(Vendor.Notes), vendor.Notes, dto.Notes); + vendor.Notes = dto.Notes; + } + + vendor.LastModificationTime = now; + if (int.TryParse(userId, out int userIdInt)) + vendor.LastModifierUserId = userIdInt; + + await _vendorDataService.UpdateWithAuditLogsAsync(vendor, changes); + + return MapToDTO(vendor); + } + + private async Task ResolveCompanyAsync( + int? requestedCompanyId, + string? companyName, + object dto, + string userId) + { + if (requestedCompanyId.HasValue) + { + var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value); + if (byId != null) + return byId; + + throw new ValidationException(new[] + { + new ValidationFailure( + nameof(CreateVendorDTO.CompanyId), + $"No vendor company exists with ID {requestedCompanyId.Value}.") + }); + } + + if (string.IsNullOrWhiteSpace(companyName)) + return null; + + var normalizedName = companyName.Trim().ToLowerInvariant(); + var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName); + if (existing != null) + return existing; + + string? companyPhone = null; + string? email = null; + string? address = null; + string? city = null; + string? state = null; + string? zip = null; + string? mapsUrl = null; + + if (dto is CreateVendorDTO create) + { + companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(create.CompanyPhone); + email = create.Email; + address = create.Address; + city = create.City; + state = create.State; + zip = create.Zipcode; + mapsUrl = create.GoogleMapsUrl; + } + else if (dto is UpdateVendorDTO update) + { + companyPhone = VendorPhoneNormalizer.NormalizeToCanonical(update.CompanyPhone); + email = update.Email; + address = update.Address; + city = update.City; + state = update.State; + zip = update.Zipcode; + mapsUrl = update.GoogleMapsUrl; + } + + var company = new VendorCompany + { + Name = companyName.Trim(), + NormalizedName = normalizedName, + CompanyPhone = companyPhone, + Email = email, + Address = address, + City = city, + State = state, + Zip = zip, + GoogleMapsUrl = mapsUrl, + CreatedDate = DateTime.UtcNow, + createdby = userId + }; + + return await _vendorDataService.AddCompanyAsync(company); + } + + private static void ApplyCompanyFields(Vendor vendor, VendorCompany? company) + { + if (company == null) + return; + + vendor.CompanyPhone = company.CompanyPhone; + vendor.Address = company.Address; + vendor.City = company.City; + vendor.State = company.State; + vendor.Zip = company.Zip; + vendor.GoogleMapsUrl = company.GoogleMapsUrl; + } + + private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId) + { + var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var openWorkOrders = linked + .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) + .Select(MapToLinkedWorkOrderDTO) + .ToList(); + + if (openWorkOrders.Count > 0) + { + throw new VendorDeactivationBlockedException( + "Vendor cannot be deactivated while linked work orders are open or scheduled.", + openWorkOrders); + } + } + + private static bool IsTerminalWorkOrderStatus(LifecycleStatus? lifecycleStatus, string? legacyStatus) + { + if (lifecycleStatus == LifecycleStatus.Completed || lifecycleStatus == LifecycleStatus.Canceled) + return true; + + if (!string.IsNullOrWhiteSpace(legacyStatus) && + TerminalLegacyStatuses.Contains(legacyStatus.Trim().ToLowerInvariant())) + return true; + + return false; + } + + private static LinkedWorkOrderDTO MapToLinkedWorkOrderDTO(LinkedWorkOrderInfo info) => new() + { + WorkOrderId = info.WorkOrderId, + WorkOrderNumber = info.WorkOrderNumber, + WorkOrderTitle = info.WorkOrderTitle, + Status = info.Status, + ScheduledDate = info.ScheduledDate, + DispatchId = info.DispatchId + }; + private VendorDTO MapToDTO(Vendor vendor) { return new VendorDTO { Id = vendor.Id, Name = vendor.CompanyName, + CompanyId = vendor.CompanyId, ContactName = vendor.ContactName, Email = vendor.Email, Phone = vendor.Phone, CompanyPhone = vendor.CompanyPhone, + PreferredContact = vendor.PreferredContact, Address = vendor.Address, City = vendor.City, State = vendor.State, diff --git a/SeaHaven.Services/Interfaces/IVendorService.cs b/SeaHaven.Services/Interfaces/IVendorService.cs index aad7b13..2ab4c67 100644 --- a/SeaHaven.Services/Interfaces/IVendorService.cs +++ b/SeaHaven.Services/Interfaces/IVendorService.cs @@ -21,5 +21,7 @@ namespace SeaHaven.Services.Interfaces Task DeleteVendorAsync(int id, string userId); Task VendorExistsAsync(int id); Task GetTotalVendorCountAsync(); + Task GetDeactivationImpactAsync(int vendorId); + Task UpdateVendorFromWorkOrderAsync(int vendorId, WorkOrderVendorUpdateDTO dto, string userId); } } diff --git a/SeaHaven.Services/Validation/VendorValidation.cs b/SeaHaven.Services/Validation/VendorValidation.cs index acce074..5ee2c06 100644 --- a/SeaHaven.Services/Validation/VendorValidation.cs +++ b/SeaHaven.Services/Validation/VendorValidation.cs @@ -1,5 +1,6 @@ using FluentValidation; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; namespace SeaHaven.Services.Validation { @@ -23,16 +24,23 @@ namespace SeaHaven.Services.Validation .NotEmpty().WithMessage("Technician name is required") .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters"); + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + RuleFor(x => x.TradeSpecialties) .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); RuleFor(x => x.Phone) - .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.Phone)); RuleFor(x => x.CompanyPhone) - .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); RuleFor(x => x.Address) @@ -82,16 +90,23 @@ namespace SeaHaven.Services.Validation .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") .When(x => x.ContactName != null); + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + RuleFor(x => x.TradeSpecialties) .MaximumLength(1000).WithMessage("Trade specialties cannot exceed 1000 characters") .When(x => !string.IsNullOrEmpty(x.TradeSpecialties)); RuleFor(x => x.Phone) - .MaximumLength(20).WithMessage("Phone number cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.Phone)); RuleFor(x => x.CompanyPhone) - .MaximumLength(20).WithMessage("Company phone cannot exceed 20 characters") + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Company phone must be in North American format: (XXX) XXX-XXXX") .When(x => !string.IsNullOrEmpty(x.CompanyPhone)); RuleFor(x => x.Address) @@ -121,9 +136,52 @@ namespace SeaHaven.Services.Validation } + public interface IWorkOrderVendorUpdateValidation : IValidator + { + } + + public class WorkOrderVendorUpdateValidation : AbstractValidator, IWorkOrderVendorUpdateValidation + { + public WorkOrderVendorUpdateValidation() + { + RuleFor(x => x.WorkOrderId) + .GreaterThan(0).WithMessage("A valid work order id is required"); + + RuleFor(x => x.PreferredContact) + .Must(VendorValidationRules.BeValidPreferredContact) + .WithMessage("PreferredContact must be one of: Phone, Email, Text") + .When(x => !string.IsNullOrEmpty(x.PreferredContact)); + + RuleFor(x => x.Phone) + .Must(VendorValidationRules.BeValidNorthAmericanPhone) + .WithMessage("Phone must be in North American format: (XXX) XXX-XXXX") + .When(x => !string.IsNullOrEmpty(x.Phone)); + + RuleFor(x => x.Email) + .EmailAddress().WithMessage("Invalid email address") + .When(x => !string.IsNullOrEmpty(x.Email)); + + RuleFor(x => x.Notes) + .MaximumLength(2000).WithMessage("Notes cannot exceed 2000 characters") + .When(x => !string.IsNullOrEmpty(x.Notes)); + + RuleFor(x => x.ContactName) + .MaximumLength(200).WithMessage("Contact name cannot exceed 200 characters") + .When(x => !string.IsNullOrEmpty(x.ContactName)); + } + } + internal static class VendorValidationRules { + private static readonly string[] AllowedPreferredContacts = { "Phone", "Email", "Text" }; + internal static bool BeValidHttpsUrl(string? value) => Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps; + + internal static bool BeValidPreferredContact(string? value) => + AllowedPreferredContacts.Contains(value, StringComparer.OrdinalIgnoreCase); + + internal static bool BeValidNorthAmericanPhone(string? value) => + value is not null && VendorPhoneNormalizer.NorthAmericanPhoneRegex.IsMatch(value); } } From 5b4b538c16e6d07523298ffc140591d667467def Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 19:17:14 -0300 Subject: [PATCH 6/8] fix(vendors): preserve company and phone integrity --- .../VendorDataServiceTests.cs | 8 +- .../VendorServiceTests.cs | 103 +++++++++++++++++- .../Implementation/VendorDataService.cs | 29 ++++- .../Interfaces/IVendorDataService.cs | 3 +- .../Implementation/VendorService.cs | 97 ++++++++++++++--- 5 files changed, 209 insertions(+), 31 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs index 80057f5..482e4bb 100644 --- a/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorDataServiceTests.cs @@ -269,12 +269,12 @@ public class VendorDataServiceTests } [Fact] - public void BuildOpenLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation() + public void BuildLinkedWorkOrdersQuery_TranslatesForSqlServerWithoutClientEvaluation() { using var context = NewSqlServerContext(); var service = new VendorDataService(context); - var sql = service.BuildOpenLinkedWorkOrdersQuery(60).ToQueryString(); + var sql = service.BuildLinkedWorkOrdersQuery(60).ToQueryString(); sql.Should().Contain("SELECT"); sql.Should().Contain("Dispatches"); @@ -283,7 +283,7 @@ public class VendorDataServiceTests } [Fact] - public async Task GetOpenLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks() + public async Task GetLinkedWorkOrdersAsync_DedupsToOneRecordPerWorkOrderAcrossDirectAndMultiLinks() { await using var context = NewContext(); var vendor = new Vendor { Id = 60, CompanyName = "Linked Co", IsActive = true }; @@ -315,7 +315,7 @@ public class VendorDataServiceTests await context.SaveChangesAsync(); var service = new VendorDataService(context); - var result = await service.GetOpenLinkedWorkOrdersAsync(60); + var result = await service.GetLinkedWorkOrdersAsync(60); result.Should().HaveCount(2); result.Select(x => x.WorkOrderId).Should().BeEquivalentTo(new[] { 701, 702 }); diff --git a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs index 9c49893..315bd18 100644 --- a/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorServiceTests.cs @@ -58,7 +58,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 8, CompanyName = "Old", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(8)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); + data.Setup(x => x.GetLinkedWorkOrdersAsync(8)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); var result = await NewService(data).UpdateVendorAsync(8, new UpdateVendorDTO @@ -83,7 +83,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 9, CompanyName = "Keep History", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(9)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); + data.Setup(x => x.GetLinkedWorkOrdersAsync(9)).ReturnsAsync(new List()); data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); await NewService(data).DeleteVendorAsync(9, "42"); @@ -221,7 +221,7 @@ public class VendorServiceTests var existing = new Vendor { Id = 20, CompanyName = "Guarded", IsActive = true }; var data = new Mock(); data.Setup(x => x.GetByIdAsync(20)).ReturnsAsync(existing); - data.Setup(x => x.GetOpenLinkedWorkOrdersAsync(20)) + data.Setup(x => x.GetLinkedWorkOrdersAsync(20)) .ReturnsAsync(new List { new() @@ -283,7 +283,7 @@ public class VendorServiceTests } [Fact] - public void CreateVendor_RejectsNonCanonicalPhoneAndInvalidPreferredContact() + public void CreateVendor_RejectsInvalidPhoneAndInvalidPreferredContact() { var validator = new CreateVendorValidation(); @@ -300,6 +300,99 @@ public class VendorServiceTests result.Errors.Should().Contain(e => e.PropertyName == nameof(CreateVendorDTO.PreferredContact)); } + [Fact] + public async Task CreateVendor_NormalizesCommonNorthAmericanPhoneFormatsBeforeValidation() + { + var data = new Mock(); + Vendor? saved = null; + data.Setup(x => x.AddAsync(It.IsAny())) + .ReturnsAsync((Vendor vendor) => + { + saved = vendor; + vendor.Id = 41; + return vendor; + }); + + await NewService(data).CreateVendorAsync(new CreateVendorDTO + { + Name = "Acme", + ContactName = "Riley", + Phone = "+1 312 555 0100" + }, "42"); + + saved!.Phone.Should().Be("(312) 555-0100"); + } + + [Fact] + public async Task UpdateVendor_RoutesCompanyOwnedFieldsToTheLinkedCompany() + { + var existing = new Vendor + { + Id = 42, + CompanyId = 77, + CompanyName = "Acme", + Address = "Old address", + IsActive = true + }; + var company = new VendorCompany + { + Id = 77, + Name = "Acme", + NormalizedName = "acme", + Address = "Old address" + }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(42)).ReturnsAsync(existing); + data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(company); + data.Setup(x => x.UpdateCompanyAsync(company)).Returns(Task.CompletedTask); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorAsync(42, new UpdateVendorDTO + { + Name = "Acme Services", + Address = "200 New Street" + }, "42"); + + company.Name.Should().Be("Acme Services"); + company.NormalizedName.Should().Be("acme services"); + company.Address.Should().Be("200 New Street"); + existing.Address.Should().Be("200 New Street"); + data.Verify(x => x.UpdateCompanyAsync(company), Times.Once); + } + + [Fact] + public async Task UpdateVendor_SparseCompanyDoesNotEraseExistingVendorFields() + { + var existing = new Vendor + { + Id = 43, + CompanyId = 77, + CompanyName = "Acme", + Address = "Keep this address", + CompanyPhone = "(312) 555-0199", + IsActive = true + }; + var sparseCompany = new VendorCompany + { + Id = 77, + Name = "Acme", + NormalizedName = "acme" + }; + var data = new Mock(); + data.Setup(x => x.GetByIdAsync(43)).ReturnsAsync(existing); + data.Setup(x => x.GetCompanyByIdAsync(77)).ReturnsAsync(sparseCompany); + data.Setup(x => x.UpdateCompanyAsync(sparseCompany)).Returns(Task.CompletedTask); + data.Setup(x => x.UpdateAsync(existing)).Returns(Task.CompletedTask); + + await NewService(data).UpdateVendorAsync(43, new UpdateVendorDTO + { + Name = "Acme" + }, "42"); + + existing.Address.Should().Be("Keep this address"); + existing.CompanyPhone.Should().Be("(312) 555-0199"); + } + [Fact] public void WorkOrderVendorUpdate_AcceptsCanonicalPhoneAndValidPreferredContact() { @@ -383,6 +476,6 @@ public class VendorServiceTests await act.Should().ThrowAsync() .WithMessage("*404*not found"); - data.Verify(x => x.GetOpenLinkedWorkOrdersAsync(It.IsAny()), Times.Never); + data.Verify(x => x.GetLinkedWorkOrdersAsync(It.IsAny()), Times.Never); } } diff --git a/SeaHaven.DataServices/Implementation/VendorDataService.cs b/SeaHaven.DataServices/Implementation/VendorDataService.cs index e89620b..541c89f 100644 --- a/SeaHaven.DataServices/Implementation/VendorDataService.cs +++ b/SeaHaven.DataServices/Implementation/VendorDataService.cs @@ -318,11 +318,30 @@ namespace SeaHaven.DataServices.Implementation public async Task AddCompanyAsync(VendorCompany company) { await _context.VendorCompanies.AddAsync(company); - await _context.SaveChangesAsync(); - return company; + try + { + await _context.SaveChangesAsync(); + return company; + } + catch (DbUpdateException) + { + _context.Entry(company).State = EntityState.Detached; + var existing = await _context.VendorCompanies + .AsNoTracking() + .SingleOrDefaultAsync(c => c.NormalizedName == company.NormalizedName); + if (existing != null) + return existing; + throw; + } } - internal IQueryable BuildOpenLinkedWorkOrdersQuery(int vendorId) + public async Task UpdateCompanyAsync(VendorCompany company) + { + _context.VendorCompanies.Update(company); + await _context.SaveChangesAsync(); + } + + internal IQueryable BuildLinkedWorkOrdersQuery(int vendorId) { var directLinks = _context.Dispatches .Where(d => d.VendorId == vendorId && d.WorkOrderId.HasValue) @@ -349,9 +368,9 @@ namespace SeaHaven.DataServices.Implementation }; } - public async Task> GetOpenLinkedWorkOrdersAsync(int vendorId) + public async Task> GetLinkedWorkOrdersAsync(int vendorId) { - var raw = await BuildOpenLinkedWorkOrdersQuery(vendorId).ToListAsync(); + var raw = await BuildLinkedWorkOrdersQuery(vendorId).ToListAsync(); return raw.DistinctBy(x => x.WorkOrderId).ToList(); } diff --git a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs index 6f5a469..b0acd6d 100644 --- a/SeaHaven.DataServices/Interfaces/IVendorDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IVendorDataService.cs @@ -27,7 +27,8 @@ namespace SeaHaven.DataServices.Interfaces Task GetCompanyByIdAsync(int id); Task GetCompanyByNormalizedNameAsync(string normalizedName); Task AddCompanyAsync(VendorCompany company); - Task> GetOpenLinkedWorkOrdersAsync(int vendorId); + Task UpdateCompanyAsync(VendorCompany company); + Task> GetLinkedWorkOrdersAsync(int vendorId); Task IsVendorAssignedToWorkOrderAsync(int vendorId, int workOrderId); Task UpdateWithAuditLogsAsync(Vendor vendor, IReadOnlyCollection auditLogs); } diff --git a/SeaHaven.Services/Implementation/VendorService.cs b/SeaHaven.Services/Implementation/VendorService.cs index 39b20c9..009a9e4 100644 --- a/SeaHaven.Services/Implementation/VendorService.cs +++ b/SeaHaven.Services/Implementation/VendorService.cs @@ -80,6 +80,7 @@ namespace SeaHaven.Services.Implementation public async Task CreateVendorAsync(CreateVendorDTO dto, string userId) { + NormalizePhoneFields(dto); var validationResult = await _createValidator.ValidateAsync(dto); if (!validationResult.IsValid) { @@ -117,6 +118,7 @@ namespace SeaHaven.Services.Implementation public async Task UpdateVendorAsync(int id, UpdateVendorDTO dto, string userId) { + NormalizePhoneFields(dto); var validationResult = await _updateValidator.ValidateAsync(dto); if (!validationResult.IsValid) { @@ -134,20 +136,18 @@ namespace SeaHaven.Services.Implementation if (dto.ContactName != null) vendor.ContactName = dto.ContactName; if (dto.Email != null) vendor.Email = dto.Email; if (dto.Phone != null) vendor.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); - if (dto.CompanyPhone != null) vendor.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); if (dto.PreferredContact != null) vendor.PreferredContact = dto.PreferredContact; - if (dto.Address != null) vendor.Address = dto.Address; - if (dto.City != null) vendor.City = dto.City; - if (dto.State != null) vendor.State = dto.State; - if (dto.Zipcode != null) vendor.Zip = dto.Zipcode; if (dto.TradeSpecialties != null) vendor.TradeSpecialties = dto.TradeSpecialties; - if (dto.GoogleMapsUrl != null) vendor.GoogleMapsUrl = dto.GoogleMapsUrl; if (dto.Notes != null) vendor.Notes = dto.Notes; if (dto.IsActive.HasValue) vendor.IsActive = dto.IsActive.Value; - if (dto.CompanyId.HasValue || dto.Name != null) + if (dto.CompanyId.HasValue || dto.Name != null || HasCompanyFieldUpdate(dto)) { - var company = await ResolveCompanyAsync(dto.CompanyId, dto.Name, dto, userId); + var company = await ResolveCompanyAsync( + dto.CompanyId ?? vendor.CompanyId, + dto.Name ?? vendor.CompanyName, + dto, + userId); vendor.CompanyId = company?.Id; ApplyCompanyFields(vendor, company); } @@ -191,7 +191,7 @@ namespace SeaHaven.Services.Implementation if (!await _vendorDataService.ExistsAsync(vendorId)) throw new InvalidOperationException($"Vendor with ID {vendorId} not found"); - var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId); var openWorkOrders = linked .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) .Select(MapToLinkedWorkOrderDTO) @@ -210,6 +210,7 @@ namespace SeaHaven.Services.Implementation WorkOrderVendorUpdateDTO dto, string userId) { + dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); var validationResult = await _workOrderUpdateValidator.ValidateAsync(dto); if (!validationResult.IsValid) { @@ -295,7 +296,11 @@ namespace SeaHaven.Services.Implementation { var byId = await _vendorDataService.GetCompanyByIdAsync(requestedCompanyId.Value); if (byId != null) + { + ApplyCompanyUpdates(byId, companyName, dto, userId); + await _vendorDataService.UpdateCompanyAsync(byId); return byId; + } throw new ValidationException(new[] { @@ -311,7 +316,11 @@ namespace SeaHaven.Services.Implementation var normalizedName = companyName.Trim().ToLowerInvariant(); var existing = await _vendorDataService.GetCompanyByNormalizedNameAsync(normalizedName); if (existing != null) + { + ApplyCompanyUpdates(existing, companyName, dto, userId); + await _vendorDataService.UpdateCompanyAsync(existing); return existing; + } string? companyPhone = null; string? email = null; @@ -365,17 +374,73 @@ namespace SeaHaven.Services.Implementation if (company == null) return; - vendor.CompanyPhone = company.CompanyPhone; - vendor.Address = company.Address; - vendor.City = company.City; - vendor.State = company.State; - vendor.Zip = company.Zip; - vendor.GoogleMapsUrl = company.GoogleMapsUrl; + if (company.CompanyPhone != null) vendor.CompanyPhone = company.CompanyPhone; + if (company.Address != null) vendor.Address = company.Address; + if (company.City != null) vendor.City = company.City; + if (company.State != null) vendor.State = company.State; + if (company.Zip != null) vendor.Zip = company.Zip; + if (company.GoogleMapsUrl != null) vendor.GoogleMapsUrl = company.GoogleMapsUrl; + } + + private static bool HasCompanyFieldUpdate(UpdateVendorDTO dto) => + dto.CompanyPhone != null || + dto.Address != null || + dto.City != null || + dto.State != null || + dto.Zipcode != null || + dto.GoogleMapsUrl != null; + + private static void ApplyCompanyUpdates( + VendorCompany company, + string? companyName, + object dto, + string userId) + { + if (!string.IsNullOrWhiteSpace(companyName)) + { + company.Name = companyName.Trim(); + company.NormalizedName = company.Name.ToLowerInvariant(); + } + + if (dto is CreateVendorDTO create) + { + if (create.CompanyPhone != null) company.CompanyPhone = create.CompanyPhone; + if (create.Address != null) company.Address = create.Address; + if (create.City != null) company.City = create.City; + if (create.State != null) company.State = create.State; + if (create.Zipcode != null) company.Zip = create.Zipcode; + if (create.GoogleMapsUrl != null) company.GoogleMapsUrl = create.GoogleMapsUrl; + } + else if (dto is UpdateVendorDTO update) + { + if (update.CompanyPhone != null) company.CompanyPhone = update.CompanyPhone; + if (update.Address != null) company.Address = update.Address; + if (update.City != null) company.City = update.City; + if (update.State != null) company.State = update.State; + if (update.Zipcode != null) company.Zip = update.Zipcode; + if (update.GoogleMapsUrl != null) company.GoogleMapsUrl = update.GoogleMapsUrl; + } + + company.LastModificationTime = DateTime.UtcNow; + if (int.TryParse(userId, out int userIdInt)) + company.LastModifierUserId = userIdInt; + } + + private static void NormalizePhoneFields(CreateVendorDTO dto) + { + dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); + } + + private static void NormalizePhoneFields(UpdateVendorDTO dto) + { + dto.Phone = VendorPhoneNormalizer.NormalizeToCanonical(dto.Phone); + dto.CompanyPhone = VendorPhoneNormalizer.NormalizeToCanonical(dto.CompanyPhone); } private async Task AssertNoOpenLinkedWorkOrdersAsync(int vendorId) { - var linked = await _vendorDataService.GetOpenLinkedWorkOrdersAsync(vendorId); + var linked = await _vendorDataService.GetLinkedWorkOrdersAsync(vendorId); var openWorkOrders = linked .Where(wo => !IsTerminalWorkOrderStatus(wo.LifecycleStatus, wo.Status)) .Select(MapToLinkedWorkOrderDTO) From 4863d1fdaf3f15d1705fcf2fe13e1648fe5d550d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Thu, 23 Jul 2026 19:18:06 -0300 Subject: [PATCH 7/8] feat(vendors): complete operations roadmap backend --- .../VendorOperationsControllerTests.cs | 313 ++ .../VendorPortalDocumentTests.cs | 134 + .../Controllers/VendorOperationsController.cs | 724 ++++ .../Controllers/VendorPortalController.cs | 178 +- .../Controllers/WorkOrderController.cs | 41 +- Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs | 2 + .../DTOs/UpdateDispatch_DTO.cs | 2 + .../Helper/ClamAvDocumentScanner.cs | 70 + .../Helper/VendorDocumentScanWorker.cs | 75 + Api.SeaHavenIndustries/Program.cs | 3 + .../Auth/ApplicationDbContext.cs | 12 + ...614_AddVendorRoadmapOperations.Designer.cs | 3441 +++++++++++++++++ ...260723220614_AddVendorRoadmapOperations.cs | 230 ++ .../ApplicationDbContextModelSnapshot.cs | 303 ++ Data.SeaHavenIndustries/Models/Dispatch.cs | 7 + .../Models/SitePreferredVendor.cs | 19 + Data.SeaHavenIndustries/Models/Vendor.cs | 3 + .../Models/VendorCompletionDocument.cs | 31 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 1 + 19 files changed, 5587 insertions(+), 2 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs create mode 100644 Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs create mode 100644 Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs create mode 100644 Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs create mode 100644 Data.SeaHavenIndustries/Models/SitePreferredVendor.cs create mode 100644 Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs diff --git a/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs new file mode 100644 index 0000000..d7d7bec --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorOperationsControllerTests.cs @@ -0,0 +1,313 @@ +using System.Collections; +using System.Security.Claims; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class VendorOperationsControllerTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static VendorOperationsController NewController(ApplicationDbContext context) + { + var controller = new VendorOperationsController( + context, + new DispatchDataService(context), + TimeProvider.System); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") }, "test")) + } + }; + return controller; + } + + private static object Prop(object source, string name) => + source.GetType().GetProperty(name)!.GetValue(source)!; + + [Fact] + public async Task Notifications_ReturnsUnassignedWorkOrderInsideFortyEightHours() + { + using var context = NewContext(); + context.workOrders.Add(new WorkOrder + { + InternalWONumber = "WO-48", + WorkerOrderTitle = "Needs vendor", + ScheduledDate = DateTime.UtcNow.AddHours(47) + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetNotifications(); + + var value = ((OkObjectResult)result).Value!; + var items = ((IEnumerable)Prop(value, "Items")).Cast().ToList(); + items.Should().ContainSingle(); + Prop(items[0], "Type").Should().Be("NoVendor"); + Prop(items[0], "Title").Should().Be("No vendor assigned — 48h to service"); + } + + [Fact] + public async Task Notifications_RemovesReminderAsSoonAsVendorIsAssigned() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-ASSIGNED", + ScheduledDate = DateTime.UtcNow.AddHours(24) + }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledDate + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetNotifications(); + + var items = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Items")).Cast(); + items.Should().BeEmpty(); + } + + [Fact] + public async Task Availability_SurfacesOverlapAndRanksPreferredVendorFirst() + { + using var context = NewContext(); + var site = new Locations { Name = "St Louis" }; + var preferred = new Vendor + { + CompanyName = "Preferred", + ContactName = "Alex", + IsActive = true, + TradeSpecialties = "HVAC", + AvailabilityStatus = "Available", + AvailabilityUpdatedAt = DateTime.UtcNow + }; + var busy = new Vendor { CompanyName = "Busy", ContactName = "Sam", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-BUSY", + ScheduledStart = DateTime.UtcNow.AddHours(1), + ScheduledEnd = DateTime.UtcNow.AddHours(3) + }; + context.AddRange(site, preferred, busy, workOrder); + await context.SaveChangesAsync(); + context.SitePreferredVendors.Add(new SitePreferredVendor + { + LocationId = site.Id, + VendorId = preferred.Id, + Trade = "HVAC", + SortOrder = 0 + }); + context.Dispatches.Add(new Dispatch + { + VendorId = busy.Id, + WorkOrderId = workOrder.Id, + Status = "Sent", + ScheduledDate = workOrder.ScheduledStart + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).GetAvailability( + DateTime.UtcNow.AddHours(2), + DateTime.UtcNow.AddHours(4), + site.Id, + "HVAC"); + + var rows = ((IEnumerable)Prop(((OkObjectResult)result).Value!, "Data")).Cast().ToList(); + Prop(rows[0], "CompanyName").Should().Be("Preferred"); + Prop(rows[0], "IsPreferred").Should().Be(true); + var busyRow = rows.Single(row => (string?)Prop(row, "CompanyName") == "Busy"); + Prop(busyRow, "AvailabilityStatus").Should().Be("Unavailable"); + } + + [Fact] + public async Task ReplaceSitePreferences_RejectsInactiveVendor() + { + using var context = NewContext(); + var site = new Locations { Name = "St Louis" }; + var inactive = new Vendor { CompanyName = "Inactive", IsActive = false }; + context.AddRange(site, inactive); + await context.SaveChangesAsync(); + + var result = await NewController(context).ReplaceSitePreferences(site.Id, + new List + { + new() { VendorId = inactive.Id } + }); + + result.Should().BeOfType(); + context.SitePreferredVendors.Should().BeEmpty(); + } + + [Fact] + public async Task InsightsExports_ReturnStableCsvAndPdfFormats() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "=HYPERLINK(\"https://invalid.test\")", IsActive = false }; + var workOrder = new WorkOrder { DueDate = DateTime.UtcNow.AddHours(1), Trade = "Plumbing" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed", + DispatchedAt = DateTime.UtcNow.AddHours(-4), + ScheduledDate = DateTime.UtcNow.AddHours(-2), + AcknowledgedAt = DateTime.UtcNow.AddHours(-3), + CompletedDate = DateTime.UtcNow + }); + await context.SaveChangesAsync(); + var controller = NewController(context); + + var csv = (FileContentResult)await controller.ExportInsightsCsv(null, null, null, null, null); + var pdf = (FileContentResult)await controller.ExportInsightsPdf(null, null, null, null, null); + + csv.ContentType.Should().Be("text/csv"); + var csvText = System.Text.Encoding.UTF8.GetString(csv.FileContents); + csvText.Should().Contain("AverageCycleHours"); + csvText.Should().Contain("\"'=HYPERLINK(\"\"https://invalid.test\"\")\""); + pdf.ContentType.Should().Be("application/pdf"); + System.Text.Encoding.ASCII.GetString(pdf.FileContents, 0, 8).Should().StartWith("%PDF-1.4"); + } + + [Fact] + public async Task AssignVendor_RejectsInactiveVendor() + { + using var context = NewContext(); + var vendor = new Vendor { CompanyName = "Inactive", IsActive = false }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + + var result = await NewController(context).AssignVendor( + workOrder.Id, + new VendorOperationsController.VendorAssignmentRequest { VendorId = vendor.Id }); + + result.Should().BeOfType(); + context.Dispatches.Should().BeEmpty(); + } + + [Fact] + public async Task AssignVendor_PersistsEtaAuditAndDisclosesConflicts() + { + using var context = NewContext(); + var start = DateTime.UtcNow.AddHours(2); + var vendor = new Vendor + { + CompanyName = "Gateway", + IsActive = true, + AvailabilityStatus = "Available", + AvailabilityUpdatedAt = DateTime.UtcNow + }; + var existingWorkOrder = new WorkOrder + { + InternalWONumber = "WO-EXISTING", + ScheduledStart = start, + ScheduledEnd = start.AddHours(2) + }; + var assignedWorkOrder = new WorkOrder + { + InternalWONumber = "WO-NEW", + ScheduledStart = start.AddMinutes(30), + ScheduledEnd = start.AddHours(1) + }; + context.AddRange(vendor, existingWorkOrder, assignedWorkOrder); + await context.SaveChangesAsync(); + context.Dispatches.Add(new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = existingWorkOrder.Id, + Status = "Sent", + ScheduledDate = existingWorkOrder.ScheduledStart + }); + await context.SaveChangesAsync(); + var eta = start.AddMinutes(45); + + var result = await NewController(context).AssignVendor( + assignedWorkOrder.Id, + new VendorOperationsController.VendorAssignmentRequest + { + VendorId = vendor.Id, + ScheduledStart = assignedWorkOrder.ScheduledStart, + ScheduledEnd = assignedWorkOrder.ScheduledEnd, + EstimatedArrivalAt = eta, + EtaManualOverride = true + }); + + var value = ((OkObjectResult)result).Value!; + Prop(value, "AvailabilityStatus").Should().Be("Unavailable"); + ((IEnumerable)Prop(value, "Conflicts")).Cast().Should().ContainSingle(); + var dispatch = await context.Dispatches.SingleAsync(item => + item.WorkOrderId == assignedWorkOrder.Id); + dispatch.EstimatedArrivalAt.Should().Be(eta); + dispatch.EtaManualOverride.Should().BeTrue(); + dispatch.DispatchNumber.Should().StartWith("DISP-"); + context.WorkOrderAuditLogs.Should().ContainSingle(log => + log.WorkOrderId == assignedWorkOrder.Id && log.Action == "vendor_assigned"); + } + + [Fact] + public async Task AssignVendor_ReusesDispatchLinkedThroughMultiWorkOrderJoin() + { + using var context = NewContext(); + var originalVendor = new Vendor { CompanyName = "Original", IsActive = true }; + var replacementVendor = new Vendor { CompanyName = "Replacement", IsActive = true }; + var workOrder = new WorkOrder + { + InternalWONumber = "WO-LINKED", + ScheduledStart = DateTime.UtcNow.AddHours(2), + ScheduledEnd = DateTime.UtcNow.AddHours(4) + }; + context.AddRange(originalVendor, replacementVendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = originalVendor.Id, + Status = "Sent", + DispatchNumber = "DISP-LINKED" + }; + context.Dispatches.Add(dispatch); + await context.SaveChangesAsync(); + context.DispatchWorkOrders.Add(new DispatchWorkOrder + { + DispatchId = dispatch.Id, + WorkOrderId = workOrder.Id + }); + await context.SaveChangesAsync(); + + var result = await NewController(context).AssignVendor( + workOrder.Id, + new VendorOperationsController.VendorAssignmentRequest + { + VendorId = replacementVendor.Id, + ScheduledStart = workOrder.ScheduledStart, + ScheduledEnd = workOrder.ScheduledEnd + }); + + result.Should().BeOfType(); + context.Dispatches.Should().ContainSingle(); + (await context.Dispatches.SingleAsync()).VendorId.Should().Be(replacementVendor.Id); + } +} diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs new file mode 100644 index 0000000..e6b6167 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -0,0 +1,134 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Helper; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class VendorPortalDocumentTests : IDisposable +{ + private const string Token = "vendor-document-test-token"; + private readonly string _contentRoot = Path.Combine( + Path.GetTempPath(), + $"seahaven-vendor-documents-{Guid.NewGuid():N}"); + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private VendorPortalController NewController(ApplicationDbContext context) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["VendorPortal:TokenLifetimeDays"] = "365", + ["SendGrid:ApiKey"] = "not-used" + }) + .Build(); + var environment = new Mock(); + environment.SetupGet(item => item.ContentRootPath).Returns(_contentRoot); + var controller = new VendorPortalController( + context, + new VendorPortalTokenService(context, configuration), + configuration, + new SendMessage(configuration), + environment.Object); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + controller.Request.Headers["X-Vendor-Token"] = Token; + return controller; + } + + private static async Task<(Vendor Vendor, Dispatch Dispatch)> SeedDispatch( + ApplicationDbContext context) + { + var vendor = new Vendor { CompanyName = "Gateway", IsActive = true }; + var workOrder = new WorkOrder { WorkerOrderTitle = "Repair" }; + context.AddRange(vendor, workOrder); + await context.SaveChangesAsync(); + var dispatch = new Dispatch + { + VendorId = vendor.Id, + WorkOrderId = workOrder.Id, + Status = "Completed" + }; + context.Dispatches.Add(dispatch); + context.VendorAccessTokens.Add(new VendorAccessToken + { + VendorId = vendor.Id, + Token = Token, + IssuedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(1) + }); + await context.SaveChangesAsync(); + return (vendor, dispatch); + } + + private static FormFile FormFile(byte[] bytes, string fileName, string contentType) + { + return new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", fileName) + { + Headers = new HeaderDictionary(), + ContentType = contentType + }; + } + + [Fact] + public async Task UploadCompletionDocument_RejectsMismatchedFileSignature() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile("not a pdf"u8.ToArray(), "completion.pdf", "application/pdf")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().BeEmpty(); + } + + [Fact] + public async Task UploadCompletionDocument_QuarantinesValidFileUntilScanPasses() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var controller = NewController(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + var upload = await controller.UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "application/pdf")); + + upload.Should().BeOfType(); + var document = await context.VendorCompletionDocuments.SingleAsync(); + document.ScanStatus.Should().Be("Pending"); + document.ReviewStatus.Should().Be("Processing"); + File.Exists(VendorDocumentStorage.ResolvePath(_contentRoot, document)).Should().BeTrue(); + + var download = await controller.DownloadCompletionDocument(dispatch.Id, document.Id); + + var locked = download.Should().BeOfType().Subject; + locked.StatusCode.Should().Be(StatusCodes.Status423Locked); + } + + public void Dispose() + { + if (Directory.Exists(_contentRoot)) + { + Directory.Delete(_contentRoot, recursive: true); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs new file mode 100644 index 0000000..5dd93cc --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/VendorOperationsController.cs @@ -0,0 +1,724 @@ +using System.Security.Claims; +using System.Text; +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [Authorize] + [ApiController] + [Route("api/vendor-operations")] + public class VendorOperationsController : ControllerBase + { + private static readonly string[] TerminalDispatchStatuses = { "Completed", "Verified", "Cancelled", "Canceled" }; + private readonly ApplicationDbContext _db; + private readonly IDispatchDataService _dispatchDataService; + private readonly TimeProvider _timeProvider; + + public VendorOperationsController( + ApplicationDbContext db, + IDispatchDataService dispatchDataService, + TimeProvider timeProvider) + { + _db = db; + _dispatchDataService = dispatchDataService; + _timeProvider = timeProvider; + } + + [HttpGet("notifications")] + public async Task GetNotifications() + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var cutoff = now.AddHours(48); + + var directlyAssignedIds = await _db.Dispatches + .AsNoTracking() + .Where(d => !TerminalDispatchStatuses.Contains(d.Status ?? "") && d.WorkOrderId.HasValue) + .Select(d => d.WorkOrderId!.Value) + .ToListAsync(); + var multiplyAssignedIds = await _db.DispatchWorkOrders + .AsNoTracking() + .Where(link => link.Dispatch != null + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "")) + .Select(link => link.WorkOrderId) + .ToListAsync(); + var assignedWorkOrderIds = directlyAssignedIds.Concat(multiplyAssignedIds).Distinct().ToList(); + + var noVendor = await _db.workOrders + .AsNoTracking() + .Where(wo => wo.ScheduledDate.HasValue + && wo.ScheduledDate.Value <= cutoff + && wo.LifecycleStatus != LifecycleStatus.Completed + && wo.LifecycleStatus != LifecycleStatus.Canceled + && !assignedWorkOrderIds.Contains(wo.Id)) + .OrderBy(wo => wo.ScheduledDate) + .Select(wo => new + { + Id = $"no-vendor-{wo.Id}", + Type = "NoVendor", + Severity = "Medium", + Group = "No Vendor", + Title = "No vendor assigned — 48h to service", + WorkOrderId = wo.Id, + WorkOrderNumber = wo.InternalWONumber ?? wo.WorkerOrderNumber, + TriggeredAt = wo.ScheduledDate!.Value.AddHours(-48), + ScheduledAt = wo.ScheduledDate, + Action = new { Label = "Choose vendor", Href = $"/workorders/{wo.Id}?assignVendor=1" } + }) + .ToListAsync(); + + var dispatches = await ActiveScheduledDispatches().ToListAsync(); + var conflicts = BuildConflictPairs(dispatches) + .Select(pair => new + { + Id = $"vendor-conflict-{pair.Left.Id}-{pair.Right.Id}", + Type = "VendorConflict", + Severity = "Medium", + Group = "Vendor Conflict", + Title = "Vendor has overlapping appointments", + VendorId = pair.Left.VendorId, + VendorName = pair.Left.VendorName, + WorkOrders = new[] + { + new { pair.Left.WorkOrderId, pair.Left.WorkOrderNumber, pair.Left.Start, pair.Left.End }, + new { pair.Right.WorkOrderId, pair.Right.WorkOrderNumber, pair.Right.Start, pair.Right.End } + }, + TriggeredAt = pair.Left.Start < pair.Right.Start ? pair.Left.Start : pair.Right.Start, + Actions = new[] + { + new { Label = pair.Left.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Left.WorkOrderId}" }, + new { Label = pair.Right.WorkOrderNumber ?? "Open work order", Href = $"/workorders/{pair.Right.WorkOrderId}" } + } + }) + .ToList(); + + return Ok(new + { + GeneratedAt = now, + Items = noVendor.Cast().Concat(conflicts).OrderBy(item => + item.GetType().GetProperty("TriggeredAt")?.GetValue(item)).ToList() + }); + } + + [HttpGet("availability")] + public async Task GetAvailability( + [FromQuery] DateTime? start, + [FromQuery] DateTime? end, + [FromQuery] int? locationId, + [FromQuery] string? trade) + { + var now = _timeProvider.GetUtcNow().UtcDateTime; + var requestedStart = start ?? now; + var requestedEnd = end ?? requestedStart.AddHours(2); + if (requestedEnd <= requestedStart) + return BadRequest(new Response { Status = "Error", Message = "end must be after start" }); + + var vendors = await _db.Vendors.AsNoTracking() + .Where(v => v.IsActive && (v.IsDeleted == null || v.IsDeleted == false)) + .ToListAsync(); + + var preferences = locationId.HasValue + ? await _db.SitePreferredVendors.AsNoTracking() + .Where(p => p.LocationId == locationId.Value) + .ToListAsync() + : new List(); + + var dispatches = await ActiveScheduledDispatches().ToListAsync(); + var result = vendors.Select(vendor => + { + var matchingDispatches = dispatches + .Where(dispatch => dispatch.VendorId == vendor.Id + && WindowsOverlap(requestedStart, requestedEnd, dispatch.Start, dispatch.End)) + .ToList(); + var matchingPreference = preferences + .Where(preference => preference.VendorId == vendor.Id + && (string.IsNullOrWhiteSpace(preference.Trade) + || string.Equals(preference.Trade, trade, StringComparison.OrdinalIgnoreCase))) + .OrderBy(preference => preference.SortOrder) + .FirstOrDefault(); + var signalIsFresh = vendor.AvailabilityUpdatedAt.HasValue + && vendor.AvailabilityUpdatedAt.Value >= now.AddHours(-24); + var status = matchingDispatches.Count > 0 + ? "Unavailable" + : signalIsFresh && new[] { "Available", "Unavailable" }.Contains(vendor.AvailabilityStatus) + ? vendor.AvailabilityStatus + : "Unknown"; + + return new + { + vendor.Id, + vendor.CompanyName, + vendor.ContactName, + vendor.TradeSpecialties, + AvailabilityStatus = status, + vendor.AvailabilityUpdatedAt, + IsPreferred = matchingPreference != null, + PreferenceRank = matchingPreference?.SortOrder, + Conflicts = matchingDispatches.Select(dispatch => new + { + dispatch.WorkOrderId, + dispatch.WorkOrderNumber, + dispatch.Start, + dispatch.End + }) + }; + }) + .OrderBy(vendor => vendor.IsPreferred ? 0 : 1) + .ThenBy(vendor => vendor.PreferenceRank ?? int.MaxValue) + .ThenBy(vendor => vendor.AvailabilityStatus == "Available" ? 0 + : vendor.AvailabilityStatus == "Unknown" ? 1 : 2) + .ThenBy(vendor => vendor.CompanyName) + .ToList(); + + return Ok(new { GeneratedAt = now, StaleAfterHours = 24, Data = result }); + } + + [HttpPut("vendors/{vendorId:int}/availability")] + public async Task UpdateAvailability(int vendorId, [FromBody] AvailabilityRequest body) + { + var normalized = body.Status?.Trim(); + if (normalized is not ("Available" or "Unavailable" or "Unknown")) + return BadRequest(new Response { Status = "Error", Message = "status must be Available, Unavailable, or Unknown" }); + + var vendor = await _db.Vendors.FindAsync(vendorId); + if (vendor == null) return NotFound(); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var actor = Actor(); + var previous = vendor.AvailabilityStatus; + vendor.AvailabilityStatus = normalized; + vendor.AvailabilityUpdatedAt = now; + vendor.LastModificationTime = now; + _db.VendorAuditLogs.Add(new VendorAuditLog + { + VendorId = vendorId, + FieldName = nameof(Vendor.AvailabilityStatus), + OldValue = previous, + NewValue = normalized, + Actor = actor, + CreatedAt = now + }); + await _db.SaveChangesAsync(); + return Ok(new { vendor.Id, vendor.AvailabilityStatus, vendor.AvailabilityUpdatedAt }); + } + + [HttpGet("sites/{locationId:int}/preferred-vendors")] + public async Task GetSitePreferences(int locationId) + { + var preferences = await _db.SitePreferredVendors + .AsNoTracking() + .Where(p => p.LocationId == locationId) + .Include(p => p.Vendor) + .OrderBy(p => p.SortOrder) + .Select(p => new + { + p.Id, + p.LocationId, + p.VendorId, + VendorName = p.Vendor!.CompanyName, + p.Trade, + p.SortOrder, + IsStale = !p.Vendor.IsActive || (p.Vendor.IsDeleted ?? false), + p.LastModificationTime, + p.UpdatedBy + }) + .ToListAsync(); + return Ok(preferences); + } + + [HttpPut("sites/{locationId:int}/preferred-vendors")] + public async Task ReplaceSitePreferences(int locationId, [FromBody] List body) + { + if (!await _db.Locations.AnyAsync(location => location.Id == locationId)) + return NotFound(new Response { Status = "Error", Message = "Location not found" }); + if (body.GroupBy(item => new { item.VendorId, Trade = item.Trade?.Trim().ToLowerInvariant() }).Any(group => group.Count() > 1)) + return BadRequest(new Response { Status = "Error", Message = "Duplicate vendor/trade preference" }); + + var vendorIds = body.Select(item => item.VendorId).Distinct().ToList(); + var vendors = await _db.Vendors.Where(vendor => vendorIds.Contains(vendor.Id)).ToDictionaryAsync(vendor => vendor.Id); + foreach (var item in body) + { + if (!vendors.TryGetValue(item.VendorId, out var vendor) || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} is inactive or missing" }); + if (!string.IsNullOrWhiteSpace(item.Trade) + && !(vendor.TradeSpecialties ?? "").Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Contains(item.Trade.Trim(), StringComparer.OrdinalIgnoreCase)) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {item.VendorId} does not support trade '{item.Trade}'" }); + } + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var actor = Actor(); + var existing = await _db.SitePreferredVendors.Where(p => p.LocationId == locationId).ToListAsync(); + _db.SitePreferredVendors.RemoveRange(existing); + _db.SitePreferredVendors.AddRange(body.Select((item, index) => new SitePreferredVendor + { + LocationId = locationId, + VendorId = item.VendorId, + Trade = string.IsNullOrWhiteSpace(item.Trade) ? null : item.Trade.Trim(), + SortOrder = item.SortOrder ?? index, + CreatedDate = now, + LastModificationTime = now, + createdby = actor, + UpdatedBy = actor + })); + foreach (var vendorId in existing.Select(p => p.VendorId).Concat(vendorIds).Distinct()) + { + _db.VendorAuditLogs.Add(new VendorAuditLog + { + VendorId = vendorId, + FieldName = $"SitePreference:{locationId}", + OldValue = string.Join(",", existing.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + NewValue = string.Join(",", body.Where(p => p.VendorId == vendorId).Select(p => $"{p.Trade}:{p.SortOrder}")), + Actor = actor, + CreatedAt = now + }); + } + await _db.SaveChangesAsync(); + return await GetSitePreferences(locationId); + } + + [HttpPut("documents/{documentId:int}/review")] + public async Task ReviewDocument(int documentId, [FromBody] DocumentReviewRequest body) + { + if (body.ReviewStatus is not ("Approved" or "Rejected")) + return BadRequest(new Response { Status = "Error", Message = "reviewStatus must be Approved or Rejected" }); + if (body.ReviewStatus == "Rejected" && string.IsNullOrWhiteSpace(body.RejectionReason)) + return BadRequest(new Response { Status = "Error", Message = "A rejection reason is required" }); + + var document = await _db.VendorCompletionDocuments.FindAsync(documentId); + if (document == null) return NotFound(); + if (document.ScanStatus != "Passed") + return Conflict(new Response { Status = "Conflict", Message = "A document cannot be approved or reviewed until malware scanning passes" }); + + var now = _timeProvider.GetUtcNow().UtcDateTime; + var previous = document.ReviewStatus; + document.ReviewStatus = body.ReviewStatus; + document.RejectionReason = body.ReviewStatus == "Rejected" ? body.RejectionReason?.Trim() : null; + document.ReviewedAt = now; + document.LastModificationTime = now; + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = document.WorkOrderId, + UserId = Actor(), + FieldName = $"Completion Document {document.OriginalFileName}", + OldValue = previous, + NewValue = document.ReviewStatus, + Action = "completion_document_reviewed", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + return Ok(new { document.Id, document.ReviewStatus, document.RejectionReason, document.ReviewedAt }); + } + + [HttpPut("dispatches/{dispatchId:int}/commercial-status")] + public async Task UpdateCommercialStatus(int dispatchId, [FromBody] CommercialStatusRequest body) + { + var invoiceStatuses = new[] { "Not Submitted", "Processing", "Confirmed", "Rejected" }; + var paymentStatuses = new[] { "Unavailable", "Pending", "Scheduled", "Paid", "On Hold" }; + if (!invoiceStatuses.Contains(body.InvoiceStatus)) + return BadRequest(new Response { Status = "Error", Message = "Invalid invoice status" }); + if (!paymentStatuses.Contains(body.PaymentStatus)) + return BadRequest(new Response { Status = "Error", Message = "Invalid payment status" }); + + var dispatch = await _db.Dispatches.FindAsync(dispatchId); + if (dispatch == null) return NotFound(); + var now = _timeProvider.GetUtcNow().UtcDateTime; + var oldValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}"; + dispatch.InvoiceStatus = body.InvoiceStatus; + dispatch.PaymentStatus = body.PaymentStatus; + dispatch.InvoiceNumber = string.IsNullOrWhiteSpace(body.InvoiceNumber) ? null : body.InvoiceNumber.Trim(); + dispatch.CommercialStatusUpdatedAt = now; + dispatch.LastModificationTime = now; + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = Actor(), + FieldName = $"Dispatch {dispatch.DispatchNumber} Commercial Status", + OldValue = oldValue, + NewValue = $"{dispatch.InvoiceStatus}|{dispatch.PaymentStatus}|{dispatch.InvoiceNumber}", + Action = "commercial_status_updated", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + return Ok(new + { + dispatch.Id, + dispatch.PONumber, + dispatch.InvoiceNumber, + dispatch.InvoiceStatus, + dispatch.PaymentStatus, + dispatch.CommercialStatusUpdatedAt + }); + } + + [HttpPut("work-orders/{workOrderId:int}/assignment")] + public async Task AssignVendor(int workOrderId, [FromBody] VendorAssignmentRequest body) + { + var workOrder = await _db.workOrders.FindAsync(workOrderId); + if (workOrder == null) return NotFound(new Response { Status = "Error", Message = "Work order not found" }); + var vendor = await _db.Vendors.FindAsync(body.VendorId); + if (vendor == null || !vendor.IsActive || (vendor.IsDeleted ?? false)) + return BadRequest(new Response { Status = "Error", Message = "Only an active vendor can be assigned" }); + + var start = body.ScheduledStart ?? workOrder.ScheduledStart ?? workOrder.ScheduledDate; + var end = body.ScheduledEnd ?? workOrder.ScheduledEnd ?? start?.AddHours(2); + var scheduledDispatches = start.HasValue && end.HasValue + ? await ActiveScheduledDispatches() + .Where(dispatch => dispatch.VendorId == vendor.Id && dispatch.WorkOrderId != workOrderId) + .ToListAsync() + : new List(); + var conflicts = start.HasValue && end.HasValue + ? scheduledDispatches.Where(dispatch => WindowsOverlap(start.Value, end.Value, dispatch.Start, dispatch.End)).ToList() + : new List(); + + var linkedDispatchIds = _db.DispatchWorkOrders + .Where(link => link.WorkOrderId == workOrderId) + .Select(link => link.DispatchId); + var dispatch = await _db.Dispatches + .Where(item => (item.WorkOrderId == workOrderId || linkedDispatchIds.Contains(item.Id)) + && !TerminalDispatchStatuses.Contains(item.Status ?? "")) + .OrderByDescending(item => item.CreatedDate) + .FirstOrDefaultAsync(); + var now = _timeProvider.GetUtcNow().UtcDateTime; + var previousVendorId = dispatch?.VendorId; + if (dispatch == null) + { + dispatch = new Dispatch + { + WorkOrderId = workOrderId, + VendorId = vendor.Id, + DispatchNumber = await _dispatchDataService.GenerateNextDispatchNumberAsync(), + PONumber = await _dispatchDataService.GenerateNextPONumberAsync(), + Status = "Sent", + ScheduledDate = start, + EstimatedArrivalAt = body.EstimatedArrivalAt, + EtaManualOverride = body.EtaManualOverride, + DispatchedAt = now, + CreatedDate = now, + createdby = Actor() + }; + _db.Dispatches.Add(dispatch); + } + else + { + dispatch.VendorId = vendor.Id; + dispatch.ScheduledDate = start; + dispatch.EstimatedArrivalAt = body.EstimatedArrivalAt; + dispatch.EtaManualOverride = body.EtaManualOverride; + dispatch.LastModificationTime = now; + } + + workOrder.ScheduledStart = body.ScheduledStart ?? workOrder.ScheduledStart; + workOrder.ScheduledEnd = body.ScheduledEnd ?? workOrder.ScheduledEnd; + workOrder.ScheduledDate = start ?? workOrder.ScheduledDate; + workOrder.LastModificationTime = now; + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = workOrderId, + UserId = Actor(), + FieldName = "Vendor Assignment", + OldValue = previousVendorId?.ToString(), + NewValue = vendor.Id.ToString(), + Action = previousVendorId.HasValue ? "vendor_assignment_changed" : "vendor_assigned", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + + return Ok(new + { + dispatch.Id, + dispatch.VendorId, + VendorName = vendor.CompanyName, + dispatch.EstimatedArrivalAt, + dispatch.EtaManualOverride, + AvailabilityStatus = conflicts.Count > 0 ? "Unavailable" + : vendor.AvailabilityUpdatedAt >= now.AddHours(-24) ? vendor.AvailabilityStatus : "Unknown", + Conflicts = conflicts.Select(conflict => new + { + conflict.WorkOrderId, + conflict.WorkOrderNumber, + conflict.Start, + conflict.End + }) + }); + } + + [HttpGet("insights")] + public async Task GetInsights( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId) + { + var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + return Ok(new + { + GeneratedAt = _timeProvider.GetUtcNow().UtcDateTime, + Filters = new { from, to, locationId, trade, vendorId }, + MetricDefinitions = MetricDefinitions(), + Data = rows + }); + } + + [HttpGet("insights.csv")] + public async Task ExportInsightsCsv( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId) + { + var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + var csv = new StringBuilder("VendorId,Vendor,CompletedJobs,CancellationRefusalRate,OnTimeArrivalRate,OnTimeCompletionRate,AverageCycleHours\n"); + foreach (var row in rows) + { + csv.AppendLine(string.Join(",", + row.VendorId, + Csv(row.Vendor), + row.CompletedJobs, + row.CancellationRefusalRate.ToString("0.####"), + row.OnTimeArrivalRate.ToString("0.####"), + row.OnTimeCompletionRate.ToString("0.####"), + row.AverageCycleHours?.ToString("0.##") ?? "")); + } + return File(Encoding.UTF8.GetBytes(csv.ToString()), "text/csv", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.csv"); + } + + [HttpGet("insights.pdf")] + public async Task ExportInsightsPdf( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] int? locationId, + [FromQuery] string? trade, + [FromQuery] int? vendorId) + { + var rows = await BuildInsightRows(from, to, locationId, trade, vendorId); + var lines = new List + { + "Vendor Performance Insights", + $"Generated: {_timeProvider.GetUtcNow():u}", + $"Filters: from={from:u}; to={to:u}; site={locationId}; trade={trade}; vendor={vendorId}", + "Definitions: completed jobs; cancellation/refusal rate; on-time acknowledgement; on-time completion; average dispatch-to-completion hours." + }; + lines.AddRange(rows.Take(35).Select(row => + $"{row.Vendor}: completed {row.CompletedJobs}; cancel/refusal {row.CancellationRefusalRate:P1}; arrival {row.OnTimeArrivalRate:P1}; completion {row.OnTimeCompletionRate:P1}; cycle {row.AverageCycleHours:0.0}h")); + return File(SimplePdf(lines), "application/pdf", $"vendor-insights-{DateTime.UtcNow:yyyyMMddHHmmss}.pdf"); + } + + private IQueryable ActiveScheduledDispatches() + { + var direct = _db.Dispatches.AsNoTracking() + .Where(dispatch => !TerminalDispatchStatuses.Contains(dispatch.Status ?? "") + && (dispatch.IsDeleted == null || dispatch.IsDeleted == false) + && dispatch.WorkOrderId.HasValue + && (dispatch.ScheduledDate.HasValue + || (dispatch.WorkOrder != null && dispatch.WorkOrder.ScheduledStart.HasValue))) + .Select(dispatch => new ScheduledDispatch + { + Id = dispatch.Id, + VendorId = dispatch.VendorId, + VendorName = dispatch.Vendor!.CompanyName, + WorkOrderId = dispatch.WorkOrderId!.Value, + WorkOrderNumber = dispatch.WorkOrder!.InternalWONumber ?? dispatch.WorkOrder.WorkerOrderNumber, + Start = dispatch.WorkOrder!.ScheduledStart ?? dispatch.ScheduledDate!.Value, + End = dispatch.WorkOrder.ScheduledEnd + ?? (dispatch.WorkOrder.ScheduledStart ?? dispatch.ScheduledDate!.Value).AddHours(2) + }); + + var linked = _db.DispatchWorkOrders.AsNoTracking() + .Where(link => link.Dispatch != null + && link.WorkOrder != null + && link.Dispatch.WorkOrderId != link.WorkOrderId + && !TerminalDispatchStatuses.Contains(link.Dispatch.Status ?? "") + && (link.Dispatch.IsDeleted == null || link.Dispatch.IsDeleted == false) + && (link.Dispatch.ScheduledDate.HasValue || link.WorkOrder.ScheduledStart.HasValue)) + .Select(link => new ScheduledDispatch + { + Id = link.DispatchId, + VendorId = link.Dispatch!.VendorId, + VendorName = link.Dispatch.Vendor!.CompanyName, + WorkOrderId = link.WorkOrderId, + WorkOrderNumber = link.WorkOrder!.InternalWONumber ?? link.WorkOrder.WorkerOrderNumber, + Start = link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value, + End = link.WorkOrder.ScheduledEnd + ?? (link.WorkOrder.ScheduledStart ?? link.Dispatch.ScheduledDate!.Value).AddHours(2) + }); + + return direct.Concat(linked); + } + + private static IEnumerable<(ScheduledDispatch Left, ScheduledDispatch Right)> BuildConflictPairs(List dispatches) + { + for (var leftIndex = 0; leftIndex < dispatches.Count; leftIndex++) + { + for (var rightIndex = leftIndex + 1; rightIndex < dispatches.Count; rightIndex++) + { + var left = dispatches[leftIndex]; + var right = dispatches[rightIndex]; + if (left.VendorId == right.VendorId + && left.WorkOrderId != right.WorkOrderId + && WindowsOverlap(left.Start, left.End, right.Start, right.End)) + yield return (left, right); + } + } + } + + private async Task> BuildInsightRows( + DateTime? from, DateTime? to, int? locationId, string? trade, int? vendorId) + { + var query = _db.Dispatches.AsNoTracking() + .Include(dispatch => dispatch.Vendor) + .Include(dispatch => dispatch.WorkOrder) + .Where(dispatch => dispatch.Vendor != null); + if (from.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) >= from); + if (to.HasValue) query = query.Where(dispatch => (dispatch.DispatchedAt ?? dispatch.CreatedDate) <= to); + if (locationId.HasValue) query = query.Where(dispatch => dispatch.WorkOrder!.LocationId == locationId); + if (!string.IsNullOrWhiteSpace(trade)) query = query.Where(dispatch => dispatch.WorkOrder!.Trade == trade); + if (vendorId.HasValue) query = query.Where(dispatch => dispatch.VendorId == vendorId); + + var dispatches = await query.ToListAsync(); + return dispatches.GroupBy(dispatch => dispatch.VendorId) + .Select(group => + { + var completed = group.Where(dispatch => dispatch.Status is "Completed" or "Verified").ToList(); + var canceled = group.Count(dispatch => dispatch.Status is "Cancelled" or "Canceled" or "Refused"); + var acknowledgedWithSchedule = group.Where(dispatch => dispatch.AcknowledgedAt.HasValue && dispatch.ScheduledDate.HasValue).ToList(); + var completedWithDue = completed.Where(dispatch => dispatch.CompletedDate.HasValue && dispatch.WorkOrder?.DueDate != null).ToList(); + var cycleRows = completed.Where(dispatch => dispatch.CompletedDate.HasValue && (dispatch.DispatchedAt ?? dispatch.CreatedDate).HasValue).ToList(); + return new VendorInsightRow + { + VendorId = group.Key, + Vendor = group.First().Vendor?.CompanyName ?? $"Vendor {group.Key}", + CompletedJobs = completed.Count, + CancellationRefusalRate = Rate(canceled, group.Count()), + OnTimeArrivalRate = Rate( + acknowledgedWithSchedule.Count(dispatch => dispatch.AcknowledgedAt <= dispatch.ScheduledDate), + acknowledgedWithSchedule.Count), + OnTimeCompletionRate = Rate( + completedWithDue.Count(dispatch => dispatch.CompletedDate <= dispatch.WorkOrder!.DueDate), + completedWithDue.Count), + AverageCycleHours = cycleRows.Count == 0 ? null : cycleRows.Average(dispatch => + (dispatch.CompletedDate!.Value - (dispatch.DispatchedAt ?? dispatch.CreatedDate)!.Value).TotalHours) + }; + }) + .OrderBy(row => row.Vendor) + .ToList(); + } + + private string Actor() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.Identity?.Name + ?? "unknown"; + + private static bool WindowsOverlap(DateTime leftStart, DateTime leftEnd, DateTime rightStart, DateTime rightEnd) + => leftStart < rightEnd && rightStart < leftEnd; + + private static decimal Rate(int numerator, int denominator) + => denominator == 0 ? 0 : Math.Round((decimal)numerator / denominator, 4); + + private static string Csv(string value) + { + var spreadsheetSafe = value.Length > 0 && "=+-@\t\r".Contains(value[0]) + ? $"'{value}" + : value; + return $"\"{spreadsheetSafe.Replace("\"", "\"\"")}\""; + } + + private static object MetricDefinitions() => new + { + CompletedJobs = "Dispatches with Completed or Verified status.", + CancellationRefusalRate = "Cancelled, Canceled, or Refused dispatches divided by all dispatches.", + OnTimeArrivalRate = "Acknowledgements at or before scheduled time divided by dispatches with both timestamps.", + OnTimeCompletionRate = "Completions at or before the work-order due date divided by completed dispatches with a due date.", + AverageCycleHours = "Average elapsed hours from dispatch to completion." + }; + + private static byte[] SimplePdf(IEnumerable lines) + { + var escaped = lines.Select(line => line.Replace("\\", "\\\\").Replace("(", "\\(").Replace(")", "\\)")); + var content = "BT /F1 10 Tf 40 760 Td 13 TL " + + string.Join(" T* ", escaped.Select(line => $"({line}) Tj")) + + " ET"; + var objects = new[] + { + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + $"<< /Length {Encoding.ASCII.GetByteCount(content)} >>\nstream\n{content}\nendstream", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>" + }; + var builder = new StringBuilder("%PDF-1.4\n"); + var offsets = new List { 0 }; + for (var index = 0; index < objects.Length; index++) + { + offsets.Add(Encoding.ASCII.GetByteCount(builder.ToString())); + builder.Append($"{index + 1} 0 obj\n{objects[index]}\nendobj\n"); + } + var xrefOffset = Encoding.ASCII.GetByteCount(builder.ToString()); + builder.Append($"xref\n0 {objects.Length + 1}\n0000000000 65535 f \n"); + foreach (var offset in offsets.Skip(1)) builder.Append($"{offset:D10} 00000 n \n"); + builder.Append($"trailer << /Size {objects.Length + 1} /Root 1 0 R >>\nstartxref\n{xrefOffset}\n%%EOF"); + return Encoding.ASCII.GetBytes(builder.ToString()); + } + + public sealed class AvailabilityRequest + { + public string? Status { get; set; } + } + + public sealed class SitePreferenceRequest + { + public int VendorId { get; set; } + public string? Trade { get; set; } + public int? SortOrder { get; set; } + } + + public sealed class DocumentReviewRequest + { + public string ReviewStatus { get; set; } = ""; + public string? RejectionReason { get; set; } + } + + public sealed class CommercialStatusRequest + { + public string InvoiceStatus { get; set; } = "Not Submitted"; + public string PaymentStatus { get; set; } = "Unavailable"; + public string? InvoiceNumber { get; set; } + } + + public sealed class VendorAssignmentRequest + { + public int VendorId { get; set; } + public DateTime? ScheduledStart { get; set; } + public DateTime? ScheduledEnd { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + } + + private sealed class ScheduledDispatch + { + public int Id { get; set; } + public int VendorId { get; set; } + public string? VendorName { get; set; } + public int WorkOrderId { get; set; } + public string? WorkOrderNumber { get; set; } + public DateTime Start { get; set; } + public DateTime End { get; set; } + } + + private sealed class VendorInsightRow + { + public int VendorId { get; set; } + public string Vendor { get; set; } = ""; + public int CompletedJobs { get; set; } + public decimal CancellationRefusalRate { get; set; } + public decimal OnTimeArrivalRate { get; set; } + public decimal OnTimeCompletionRate { get; set; } + public double? AverageCycleHours { get; set; } + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index 8841861..eba515f 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -20,13 +20,20 @@ namespace Api.SeaHavenIndustries.Controllers private readonly VendorPortalTokenService _tokens; private readonly IConfiguration _config; private readonly SendMessage _sendMessage; + private readonly IWebHostEnvironment _environment; - public VendorPortalController(ApplicationDbContext db, VendorPortalTokenService tokens, IConfiguration config, SendMessage sendMessage) + public VendorPortalController( + ApplicationDbContext db, + VendorPortalTokenService tokens, + IConfiguration config, + SendMessage sendMessage, + IWebHostEnvironment environment) { _db = db; _tokens = tokens; _config = config; _sendMessage = sendMessage; + _environment = environment; } [HttpGet("session")] @@ -71,6 +78,9 @@ namespace Api.SeaHavenIndustries.Controllers d.Id, d.DispatchNumber, d.PONumber, + d.InvoiceNumber, + d.InvoiceStatus, + d.PaymentStatus, d.Status, d.NTEAmount, d.ScheduledDate, @@ -108,6 +118,27 @@ namespace Api.SeaHavenIndustries.Controllers .Select(s => new { s.Id, s.SignoffType, s.Name, s.SignatureMethod, s.SignedAt }) .ToListAsync(); + var documents = await _db.VendorCompletionDocuments + .Where(document => document.DispatchId == id && document.VendorId == vendor.Id) + .OrderByDescending(document => document.CreatedDate) + .Select(document => new + { + document.Id, + document.OriginalFileName, + document.ContentType, + document.SizeBytes, + document.ScanStatus, + document.ReviewStatus, + document.RejectionReason, + document.Version, + document.ReplacesDocumentId, + document.CreatedDate, + document.ScannedAt, + document.ReviewedAt, + CanDownload = document.ScanStatus == "Passed" + }) + .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 @@ -210,6 +241,10 @@ namespace Api.SeaHavenIndustries.Controllers dispatch.Id, dispatch.DispatchNumber, dispatch.PONumber, + dispatch.InvoiceNumber, + dispatch.InvoiceStatus, + dispatch.PaymentStatus, + dispatch.CommercialStatusUpdatedAt, dispatch.Status, dispatch.NTEAmount, dispatch.Description, @@ -239,6 +274,7 @@ namespace Api.SeaHavenIndustries.Controllers }, Checklist = checklist, Signoffs = signoffs, + Documents = documents, Comments = comments, UpliftRequests = upliftRequests } @@ -589,6 +625,133 @@ namespace Api.SeaHavenIndustries.Controllers return Ok(new DataResponse { Status = "Success", Data = new { req.Id, req.Status } }); } + [HttpPost("dispatches/{id:int}/documents")] + [RequestSizeLimit(10 * 1024 * 1024)] + public async Task UploadCompletionDocument( + int id, + [FromForm] IFormFile file, + [FromForm] int? replacesDocumentId = null) + { + var vendor = await ResolveVendorAsync(); + if (vendor == 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 (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "A non-empty file is required" }); + if (file.Length > 10 * 1024 * 1024) + return BadRequest(new Response { Status = "Error", Message = "Files must be 10 MB or smaller" }); + + var allowed = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["application/pdf"] = new[] { ".pdf" }, + ["image/jpeg"] = new[] { ".jpg", ".jpeg" }, + ["image/png"] = new[] { ".png" } + }; + var extension = Path.GetExtension(file.FileName); + if (!allowed.TryGetValue(file.ContentType, out var extensions) + || !extensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + return BadRequest(new Response { Status = "Error", Message = "Only PDF, JPG, and PNG files are allowed" }); + + await using var signatureStream = file.OpenReadStream(); + if (!await HasExpectedSignature(signatureStream, file.ContentType)) + return BadRequest(new Response { Status = "Error", Message = "File content does not match its declared type" }); + + var workOrderId = dispatch.WorkOrderId + ?? await _db.DispatchWorkOrders + .Where(link => link.DispatchId == dispatch.Id) + .OrderBy(link => link.Id) + .Select(link => link.WorkOrderId) + .FirstOrDefaultAsync(); + if (workOrderId == 0) + return BadRequest(new Response { Status = "Error", Message = "Dispatch is not linked to a work order" }); + + VendorCompletionDocument? replaced = null; + if (replacesDocumentId.HasValue) + { + replaced = await _db.VendorCompletionDocuments.FirstOrDefaultAsync(document => + document.Id == replacesDocumentId.Value + && document.DispatchId == id + && document.VendorId == vendor.Id); + if (replaced == null) + return BadRequest(new Response { Status = "Error", Message = "Replacement document was not found" }); + } + + var now = DateTime.UtcNow; + var document = new VendorCompletionDocument + { + VendorId = vendor.Id, + DispatchId = id, + WorkOrderId = workOrderId, + OriginalFileName = Path.GetFileName(file.FileName), + StoredFileName = $"{Guid.NewGuid():N}{extension.ToLowerInvariant()}", + ContentType = file.ContentType, + SizeBytes = file.Length, + ScanStatus = "Pending", + ReviewStatus = "Processing", + Version = (replaced?.Version ?? 0) + 1, + ReplacesDocumentId = replaced?.Id, + CreatedDate = now, + createdby = $"vendor:{vendor.Id}" + }; + var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using (var target = System.IO.File.Create(path)) + { + await file.CopyToAsync(target); + } + + try + { + _db.VendorCompletionDocuments.Add(document); + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = document.WorkOrderId, + FieldName = "Completion Document", + OldValue = replaced?.OriginalFileName, + NewValue = document.OriginalFileName, + Action = replaced == null ? "vendor_document_uploaded" : "vendor_document_replaced", + CreatedAt = now + }); + await _db.SaveChangesAsync(); + } + catch + { + System.IO.File.Delete(path); + throw; + } + + return Ok(new DataResponse + { + Status = "Success", + Data = new + { + document.Id, + document.OriginalFileName, + document.SizeBytes, + document.ScanStatus, + document.ReviewStatus, + document.Version, + document.ReplacesDocumentId + } + }); + } + + [HttpGet("dispatches/{id:int}/documents/{documentId:int}")] + public async Task DownloadCompletionDocument(int id, int documentId) + { + var vendor = await ResolveVendorAsync(); + if (vendor == null) return Unauthorized(new Response { Status = "Error", Message = "Invalid or expired token" }); + var document = await _db.VendorCompletionDocuments.AsNoTracking().FirstOrDefaultAsync(item => + item.Id == documentId && item.DispatchId == id && item.VendorId == vendor.Id); + if (document == null) return NotFound(); + if (document.ScanStatus != "Passed") + return StatusCode(StatusCodes.Status423Locked, + new Response { Status = "Processing", Message = "This file remains quarantined until malware scanning passes" }); + var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); + if (!System.IO.File.Exists(path)) return NotFound(); + return PhysicalFile(path, document.ContentType, document.OriginalFileName); + } + private async Task NotifyDispatcherOfUpliftAsync(Dispatch dispatch, DispatchUpliftRequest req, Vendor vendor) { try @@ -636,6 +799,19 @@ namespace Api.SeaHavenIndustries.Controllers } } + private static async Task HasExpectedSignature(Stream stream, string contentType) + { + var bytes = new byte[8]; + var count = await stream.ReadAsync(bytes); + if (contentType == "application/pdf") + return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D; + if (contentType == "image/jpeg") + return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; + if (contentType == "image/png") + return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); + return false; + } + private async Task ResolveVendorAsync() { if (!Request.Headers.TryGetValue(TokenHeader, out var values)) return null; diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 9c3b181..1f86f21 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -494,6 +494,8 @@ namespace Api.SeaHavenIndustries.Controllers d.Description, d.Status, d.ScheduledDate, + d.EstimatedArrivalAt, + d.EtaManualOverride, d.CompletedDate, d.DispatchedAt, d.EmailSent, @@ -863,7 +865,10 @@ namespace Api.SeaHavenIndustries.Controllers foreach (var vendorId in model.VendorIds) { var vendor = await _vendorDataService.GetByIdAsync(vendorId); - if (vendor == null) continue; + if (vendor == null) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} was not found" }); + if (!vendor.IsActive || (vendor.IsDeleted ?? false)) + return BadRequest(new Response { Status = "Error", Message = $"Vendor {vendorId} is inactive and cannot receive a new assignment" }); var poNumber = $"VPO-{nextPO:D5}"; nextPO++; @@ -882,6 +887,8 @@ namespace Api.SeaHavenIndustries.Controllers Description = model.Description, Status = "Sent", ScheduledDate = model.ScheduledDate, + EstimatedArrivalAt = model.EstimatedArrivalAt, + EtaManualOverride = model.EtaManualOverride, DispatchedAt = DateTime.UtcNow, ReplyToAddress = replyTo, DispatchWorkOrders = workOrders.Select(wo => new DispatchWorkOrder { WorkOrderId = wo.Id }).ToList() @@ -961,6 +968,19 @@ namespace Api.SeaHavenIndustries.Controllers Action = AuditAction.Dispatch.ToString(), CreatedAt = DateTime.UtcNow }); + if (model.EstimatedArrivalAt.HasValue) + { + await _workOrderDataService.AddAuditLogAsync(new WorkOrderAuditLog + { + WorkOrderId = wo.Id, + UserId = userId, + FieldName = "Vendor ETA", + OldValue = "", + NewValue = model.EstimatedArrivalAt.Value.ToUniversalTime().ToString("O"), + Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_set", + CreatedAt = DateTime.UtcNow + }); + } } dispatches.Add(new { savedDispatch.Id, dispatchNumber, poNumber, vendorName = vendor.CompanyName, savedDispatch.Status, workOrderCount = workOrders.Count }); @@ -987,6 +1007,8 @@ namespace Api.SeaHavenIndustries.Controllers d.Description, d.Status, d.ScheduledDate, + d.EstimatedArrivalAt, + d.EtaManualOverride, d.CompletedDate, d.DispatchedAt, d.EmailSent, @@ -1013,6 +1035,8 @@ namespace Api.SeaHavenIndustries.Controllers x.Description, x.Status, x.ScheduledDate, + x.EstimatedArrivalAt, + x.EtaManualOverride, x.CompletedDate, x.DispatchedAt, x.EmailSent, @@ -1101,6 +1125,21 @@ namespace Api.SeaHavenIndustries.Controllers dispatch.Status = model.Status; dispatch.NTEAmount = model.NTEAmount; dispatch.ScheduledDate = model.ScheduledDate; + if (dispatch.EstimatedArrivalAt != model.EstimatedArrivalAt) + { + _db.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = dispatch.WorkOrderId ?? 0, + UserId = userId, + FieldName = "Vendor ETA", + OldValue = dispatch.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "", + NewValue = model.EstimatedArrivalAt?.ToUniversalTime().ToString("O") ?? "", + Action = model.EtaManualOverride ? "vendor_eta_manual_override" : "vendor_eta_change", + CreatedAt = DateTime.UtcNow + }); + } + dispatch.EstimatedArrivalAt = model.EstimatedArrivalAt; + dispatch.EtaManualOverride = model.EtaManualOverride; dispatch.CompletedDate = model.CompletedDate; dispatch.Description = model.Description; diff --git a/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs b/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs index 4601436..49f17f0 100644 --- a/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/Dispatch_DTO.cs @@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs public List VendorIds { get; set; } = new(); public decimal? NTEAmount { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } public string? Description { get; set; } public int? TaskListTemplateId { get; set; } public List? CustomChecklistItems { get; set; } diff --git a/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs b/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs index 31770e1..40052f1 100644 --- a/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs +++ b/Api.SeaHavenIndustries/DTOs/UpdateDispatch_DTO.cs @@ -6,6 +6,8 @@ namespace Api.SeaHavenIndustries.DTOs public string? Status { get; set; } public decimal? NTEAmount { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } public DateTime? CompletedDate { get; set; } public string? Description { get; set; } } diff --git a/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs b/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs new file mode 100644 index 0000000..7280c9f --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/ClamAvDocumentScanner.cs @@ -0,0 +1,70 @@ +using System.Buffers.Binary; +using System.Net.Sockets; +using System.Text; + +namespace Api.SeaHavenIndustries.Helper +{ + public enum DocumentScanResult + { + Passed, + Infected, + Unavailable + } + + public interface IDocumentScanner + { + Task ScanAsync(string path, CancellationToken cancellationToken); + } + + public class ClamAvDocumentScanner : IDocumentScanner + { + private readonly IConfiguration _configuration; + + public ClamAvDocumentScanner(IConfiguration configuration) + { + _configuration = configuration; + } + + public async Task ScanAsync(string path, CancellationToken cancellationToken) + { + var host = _configuration["ClamAV:Host"]; + var port = _configuration.GetValue("ClamAV:Port") ?? 3310; + if (string.IsNullOrWhiteSpace(host) || !File.Exists(path)) + return DocumentScanResult.Unavailable; + + try + { + using var client = new TcpClient(); + await client.ConnectAsync(host, port, cancellationToken); + await using var network = client.GetStream(); + await network.WriteAsync(Encoding.ASCII.GetBytes("zINSTREAM\0"), cancellationToken); + await using var file = File.OpenRead(path); + var buffer = new byte[64 * 1024]; + while (true) + { + var count = await file.ReadAsync(buffer, cancellationToken); + if (count == 0) break; + var length = new byte[4]; + BinaryPrimitives.WriteInt32BigEndian(length, count); + await network.WriteAsync(length, cancellationToken); + await network.WriteAsync(buffer.AsMemory(0, count), cancellationToken); + } + await network.WriteAsync(new byte[4], cancellationToken); + await network.FlushAsync(cancellationToken); + + var responseBuffer = new byte[1024]; + var responseLength = await network.ReadAsync(responseBuffer, cancellationToken); + var response = Encoding.ASCII.GetString(responseBuffer, 0, responseLength); + if (response.Contains("FOUND", StringComparison.OrdinalIgnoreCase)) + return DocumentScanResult.Infected; + return response.Contains("OK", StringComparison.OrdinalIgnoreCase) + ? DocumentScanResult.Passed + : DocumentScanResult.Unavailable; + } + catch (Exception) when (!cancellationToken.IsCancellationRequested) + { + return DocumentScanResult.Unavailable; + } + } + } +} diff --git a/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs new file mode 100644 index 0000000..0f4adcd --- /dev/null +++ b/Api.SeaHavenIndustries/Helper/VendorDocumentScanWorker.cs @@ -0,0 +1,75 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; + +namespace Api.SeaHavenIndustries.Helper +{ + public class VendorDocumentScanWorker : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly IWebHostEnvironment _environment; + private readonly ILogger _logger; + + public VendorDocumentScanWorker( + IServiceScopeFactory scopeFactory, + IWebHostEnvironment environment, + ILogger logger) + { + _scopeFactory = scopeFactory; + _environment = environment; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await ScanPendingAsync(stoppingToken); + await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); + } + } + + private async Task ScanPendingAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var scanner = scope.ServiceProvider.GetRequiredService(); + var pending = await db.VendorCompletionDocuments + .Where(document => document.ScanStatus == "Pending") + .OrderBy(document => document.CreatedDate) + .Take(20) + .ToListAsync(cancellationToken); + + foreach (var document in pending) + { + var path = VendorDocumentStorage.ResolvePath(_environment.ContentRootPath, document); + var result = await scanner.ScanAsync(path, cancellationToken); + if (result == DocumentScanResult.Unavailable) + { + _logger.LogWarning("Vendor document scan service unavailable; document {DocumentId} remains quarantined", document.Id); + continue; + } + + document.ScanStatus = result == DocumentScanResult.Passed ? "Passed" : "Rejected"; + document.ReviewStatus = result == DocumentScanResult.Passed ? "Processing" : "Rejected"; + document.RejectionReason = result == DocumentScanResult.Infected + ? "The upload failed malware scanning." + : document.RejectionReason; + document.ScannedAt = DateTime.UtcNow; + document.LastModificationTime = DateTime.UtcNow; + await db.SaveChangesAsync(cancellationToken); + + if (result == DocumentScanResult.Infected && File.Exists(path)) + File.Delete(path); + } + } + } + + public static class VendorDocumentStorage + { + public static string Root(string contentRoot) => + Path.Combine(contentRoot, "App_Data", "vendor-documents"); + + public static string ResolvePath(string contentRoot, VendorCompletionDocument document) => + Path.Combine(Root(contentRoot), document.VendorId.ToString(), document.DispatchId.ToString(), document.StoredFileName); + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 6dc0e95..17022c8 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -39,6 +39,9 @@ option.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().All builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); builder.Services.AddSingleton(sp => { var zipService = new Api.SeaHavenIndustries.Helper.ZipCodeDistance(); diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index c7c85c6..9511955 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -61,6 +61,16 @@ namespace Data.SeaHavenIndustries .WithMany(c => c.Vendors) .HasForeignKey(v => v.CompanyId) .OnDelete(DeleteBehavior.Restrict); + + builder.Entity() + .HasIndex(p => new { p.LocationId, p.VendorId, p.Trade }) + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + builder.Entity() + .HasIndex(p => new { p.LocationId, p.VendorId }) + .IsUnique() + .HasFilter("[Trade] IS NULL"); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -81,6 +91,8 @@ namespace Data.SeaHavenIndustries public DbSet VendorCompanies { get; set; } public DbSet VendorAuditLogs { get; set; } public DbSet VendorAccessTokens { get; set; } + public DbSet VendorCompletionDocuments { get; set; } + public DbSet SitePreferredVendors { get; set; } public DbSet Dispatches { get; set; } public DbSet DispatchWorkOrders { get; set; } public DbSet DispatchChecklistItems { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs new file mode 100644 index 0000000..9dc7d67 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.Designer.cs @@ -0,0 +1,3441 @@ +// +using System; +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260723220614_AddVendorRoadmapOperations")] + partial class AddVendorRoadmapOperations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrencyCode") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Importance") + .HasColumnType("int"); + + b.Property("IndustryId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("OwnerEmployeeId") + .HasColumnType("int"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceInstructions") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("TerritoryId") + .HasColumnType("int"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Type") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("AddressType") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("Country") + .HasColumnType("nvarchar(max)"); + + b.Property("County") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("ContactId"); + + b.ToTable("Addresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Color") + .HasColumnType("nvarchar(max)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Contact") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeletedDate") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("Initials") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("Locations") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UniqueName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserImage") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("AssetCode") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Assets"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CommentType") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenter") + .HasColumnType("nvarchar(max)"); + + b.Property("Commenttext") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("Documents") + .HasColumnType("nvarchar(max)"); + + b.Property("ExternalCommentId") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RecordType") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkerOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkerOrderId"); + + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("PrivacySetting") + .HasColumnType("nvarchar(max)"); + + b.Property("Tags") + .HasColumnType("nvarchar(max)"); + + b.Property("Territory") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.ToTable("ContactDetails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactType") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("Owner") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("LocationId"); + + b.ToTable("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Departments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AcceptToken") + .HasColumnType("nvarchar(max)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime2"); + + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("DispatchedAt") + .HasColumnType("datetime2"); + + b.Property("EmailSent") + .HasColumnType("bit"); + + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NTEAmount") + .HasColumnType("decimal(18,2)"); + + b.Property("PONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReplyToAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VerifiedAt") + .HasColumnType("datetime2"); + + b.Property("VerifiedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("Dispatches"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsCompleted") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchChecklistItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("Signature") + .HasColumnType("nvarchar(max)"); + + b.Property("SignatureMethod") + .HasColumnType("nvarchar(max)"); + + b.Property("SignedAt") + .HasColumnType("datetime2"); + + b.Property("SignoffType") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.ToTable("DispatchSignoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("CurrentNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("DecidedAt") + .HasColumnType("datetime2"); + + b.Property("DecidedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DecisionNote") + .HasColumnType("nvarchar(max)"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("VendorReason") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId", "Status") + .IsUnique() + .HasFilter("[Status] = 'Pending'"); + + b.ToTable("DispatchUpliftRequests"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("DispatchWorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("ParentValue") + .HasColumnType("nvarchar(max)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("DropdownOptions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("About") + .HasColumnType("nvarchar(max)"); + + b.Property("CanChangeProviderNumber") + .HasColumnType("bit"); + + b.Property("ConfirmUserName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DateOfBirth") + .HasColumnType("datetime2"); + + b.Property("DepartmentId") + .HasColumnType("int"); + + b.Property("EmployeeNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("FacebookUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("JobTitleId") + .HasColumnType("int"); + + b.Property("LastLogin") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("LinkedInUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("MiddleName") + .HasColumnType("nvarchar(max)"); + + b.Property("PermissionGroupId") + .HasColumnType("int"); + + b.Property("RegionId") + .HasColumnType("int"); + + b.Property("SMSAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TimeZone") + .HasColumnType("nvarchar(max)"); + + b.Property("TwitterUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("UserName") + .HasColumnType("nvarchar(max)"); + + b.Property("Website") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DepartmentId"); + + b.HasIndex("JobTitleId"); + + b.HasIndex("RegionId"); + + b.ToTable("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CountryCode") + .HasColumnType("nvarchar(max)"); + + b.Property("CountyId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("StateCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Street") + .HasColumnType("nvarchar(max)"); + + b.Property("StreetLineTwo") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId") + .IsUnique(); + + b.ToTable("EmployeeAddresses"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailTypeId") + .HasColumnType("int"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeeEmails"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("Extension") + .HasColumnType("nvarchar(max)"); + + b.Property("IsPrimary") + .HasColumnType("bit"); + + b.Property("Number") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneTypeId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("EmployeeId"); + + b.ToTable("EmployeePhones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccountId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("EmployeeId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScheduleDate") + .HasColumnType("datetime2"); + + b.Property("ScheduleStartTime") + .HasColumnType("time"); + + b.Property("Status") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("EmployeeId"); + + b.HasIndex("LocationId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("ForgetPasswordCodes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("JobTitles"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address1") + .HasColumnType("nvarchar(max)"); + + b.Property("Address2") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Latitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Longitude") + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("Title") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssetId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssetId"); + + b.HasIndex("LocationId"); + + b.ToTable("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BillingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Date") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("ProjectName") + .HasColumnType("nvarchar(max)"); + + b.Property("QuotId") + .HasColumnType("nvarchar(max)"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteId") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteName") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Descriptions") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Lineitem") + .HasColumnType("decimal(18,2)"); + + b.Property("LineitemNumber") + .HasColumnType("int"); + + b.Property("QuoteId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("QuoteId"); + + b.ToTable("QuotesLines"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("TaskListTemplates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("ItemText") + .HasColumnType("nvarchar(max)"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TaskListTemplateId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("TaskListTemplateId"); + + b.ToTable("TaskListTemplateItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContactId"); + + b.HasIndex("LocationId"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyId") + .HasColumnType("int"); + + b.Property("CompanyName") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("ContactName") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("Phone") + .HasColumnType("nvarchar(max)"); + + b.Property("PreferredContact") + .HasColumnType("nvarchar(max)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("TradeSpecialties") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("IssuedAt") + .HasColumnType("datetime2"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LastUsedAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAccessTokens"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.ToTable("VendorAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("nvarchar(max)"); + + b.Property("City") + .HasColumnType("nvarchar(max)"); + + b.Property("CompanyPhone") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Email") + .HasColumnType("nvarchar(max)"); + + b.Property("GoogleMapsUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("VendorCompanies"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AfterPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("AfterPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("AssignDate") + .HasColumnType("date"); + + b.Property("AssignTo") + .HasColumnType("nvarchar(450)"); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("AvettaTask") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("BeforPhotoIssue") + .HasColumnType("nvarchar(max)"); + + b.Property("Building") + .HasColumnType("nvarchar(max)"); + + b.Property("CarriedOver") + .HasColumnType("int"); + + b.Property("CompletedDate") + .HasColumnType("datetime2"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("Customer") + .HasColumnType("nvarchar(max)"); + + b.Property("DateReported") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DocStatus") + .HasColumnType("int"); + + b.Property("DueDate") + .HasColumnType("datetime2"); + + b.Property("ExternalWorkOrderId") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LegacyStatus") + .HasColumnType("nvarchar(max)"); + + b.Property("LifecycleStatus") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("OperationalFlags") + .HasColumnType("int"); + + b.Property("OriginalDate") + .HasColumnType("date"); + + b.Property("OriginalWeek") + .HasColumnType("date"); + + b.Property("PO") + .HasColumnType("nvarchar(max)"); + + b.Property("PrimaryDispatchId") + .HasColumnType("int"); + + b.Property("Priority") + .HasColumnType("nvarchar(max)"); + + b.Property("Problem") + .HasColumnType("nvarchar(max)"); + + b.Property("RescheduleCount") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("ScheduleWeekOnly") + .HasColumnType("bit"); + + b.Property("ScheduledDate") + .HasColumnType("datetime2"); + + b.Property("ScheduledEnd") + .HasColumnType("datetime2"); + + b.Property("ScheduledStart") + .HasColumnType("datetime2"); + + b.Property("Service") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Severity") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffAttachment") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffName") + .HasColumnType("nvarchar(max)"); + + b.Property("SignOffSignature") + .HasColumnType("nvarchar(max)"); + + b.Property("SiteCode") + .HasColumnType("nvarchar(max)"); + + b.Property("Source") + .HasColumnType("nvarchar(max)"); + + b.Property("SourceEmailS3Key") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .HasColumnType("nvarchar(max)"); + + b.Property("SubTrade") + .HasColumnType("nvarchar(max)"); + + b.Property("TT") + .HasColumnType("nvarchar(max)"); + + b.Property("TargetWeek") + .HasColumnType("date"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("WorkerOrderNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkerOrderTitle") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.Property("istemplate") + .HasColumnType("bit"); + + b.HasKey("Id"); + + b.HasIndex("AssignTo"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.ToTable("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Attachments") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderAttachments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("nvarchar(max)"); + + b.Property("ActorType") + .HasColumnType("nvarchar(max)"); + + b.Property("CorrelationId") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("nvarchar(max)"); + + b.Property("FieldName") + .HasColumnType("nvarchar(max)"); + + b.Property("NewValue") + .HasColumnType("nvarchar(max)"); + + b.Property("OldValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("WorkOrderAuditLogs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CategoryId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("workOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContactId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkorderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ContactId"); + + b.HasIndex("WorkorderId"); + + b.ToTable("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("FieldName") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("LockedAt") + .HasColumnType("datetime2"); + + b.Property("LockedByUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "FieldName") + .IsUnique(); + + b.ToTable("WorkOrderFieldLocks"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Addresses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Comments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "ApplicationUser") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Comments") + .HasForeignKey("WorkerOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ApplicationUser"); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contact") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Contact"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany("Contacts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("Dispatches") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Dispatches") + .HasForeignKey("WorkOrderId"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("ChecklistItems") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("Signoffs") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany() + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("DispatchWorkOrders") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.HasOne("Data.SeaHavenIndustries.Department", "Department") + .WithMany("Employees") + .HasForeignKey("DepartmentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.JobTitle", "JobTitle") + .WithMany("Employees") + .HasForeignKey("JobTitleId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Region", "Region") + .WithMany("Employees") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Department"); + + b.Navigation("JobTitle"); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithOne("Address") + .HasForeignKey("Data.SeaHavenIndustries.EmployeeAddress", "EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Emails") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => + { + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany("Phones") + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Employee"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Employee", "Employee") + .WithMany() + .HasForeignKey("EmployeeId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("Employee"); + + b.Navigation("Location"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => + { + b.HasOne("Data.SeaHavenIndustries.Assets", "Asset") + .WithMany("PMSchedules") + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Asset"); + + b.Navigation("Location"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "Contacts") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("Quotes") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Contacts"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => + { + b.HasOne("Data.SeaHavenIndustries.Quotes", "Quotes") + .WithMany("LineItems") + .HasForeignKey("QuoteId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Quotes"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => + { + b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") + .WithMany("Items") + .HasForeignKey("TaskListTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Template"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("Templates") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("Templates") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany() + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("Templates") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Category"); + + b.Navigation("Locations"); + + b.Navigation("POC"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.HasOne("Data.SeaHavenIndustries.VendorCompany", "Company") + .WithMany("Vendors") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("AuditLogs") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Vendor"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") + .WithMany("WorkOrders") + .HasForeignKey("AssignTo") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Locations", "Locations") + .WithMany("workOrders") + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.Dispatch", "PrimaryDispatch") + .WithMany() + .HasForeignKey("PrimaryDispatchId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("workOrderAttachments") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => + { + b.HasOne("Data.SeaHavenIndustries.Category", "Category") + .WithMany("WorkOrderCategories") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderCategories") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Category"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => + { + b.HasOne("Data.SeaHavenIndustries.Contacts", "POC") + .WithMany("WorkOrderContacts") + .HasForeignKey("ContactId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("WorkOrderContacts") + .HasForeignKey("WorkorderId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("POC"); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany("FieldLocks") + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("WorkOrder"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Accounts", b => + { + b.Navigation("Addresses"); + + b.Navigation("Contacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Assets", b => + { + b.Navigation("PMSchedules"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => + { + b.Navigation("Templates"); + + b.Navigation("WorkOrderCategories"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => + { + b.Navigation("WorkOrderContacts"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => + { + b.Navigation("ChecklistItems"); + + b.Navigation("Comments"); + + b.Navigation("CompletionDocuments"); + + b.Navigation("DispatchWorkOrders"); + + b.Navigation("Signoffs"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => + { + b.Navigation("Address"); + + b.Navigation("Emails"); + + b.Navigation("Phones"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => + { + b.Navigation("Templates"); + + b.Navigation("workOrders"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => + { + b.Navigation("LineItems"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => + { + b.Navigation("Employees"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => + { + b.Navigation("AuditLogs"); + + b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => + { + b.Navigation("Vendors"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => + { + b.Navigation("Comments"); + + b.Navigation("Dispatches"); + + b.Navigation("FieldLocks"); + + b.Navigation("Quotes"); + + b.Navigation("WorkOrderCategories"); + + b.Navigation("WorkOrderContacts"); + + b.Navigation("workOrderAttachments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs new file mode 100644 index 0000000..bea27f8 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs @@ -0,0 +1,230 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class AddVendorRoadmapOperations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvailabilityStatus", + table: "Vendors", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "AvailabilityUpdatedAt", + table: "Vendors", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "CommercialStatusUpdatedAt", + table: "Dispatches", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EstimatedArrivalAt", + table: "Dispatches", + type: "datetime2", + nullable: true); + + migrationBuilder.AddColumn( + name: "EtaManualOverride", + table: "Dispatches", + type: "bit", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InvoiceNumber", + table: "Dispatches", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "InvoiceStatus", + table: "Dispatches", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "PaymentStatus", + table: "Dispatches", + type: "nvarchar(max)", + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "SitePreferredVendors", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + LocationId = table.Column(type: "int", nullable: false), + VendorId = table.Column(type: "int", nullable: false), + Trade = table.Column(type: "nvarchar(450)", nullable: true), + SortOrder = table.Column(type: "int", nullable: false), + UpdatedBy = table.Column(type: "nvarchar(max)", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_SitePreferredVendors", x => x.Id); + table.ForeignKey( + name: "FK_SitePreferredVendors_Locations_LocationId", + column: x => x.LocationId, + principalTable: "Locations", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_SitePreferredVendors_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "VendorCompletionDocuments", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + VendorId = table.Column(type: "int", nullable: false), + DispatchId = table.Column(type: "int", nullable: false), + WorkOrderId = table.Column(type: "int", nullable: false), + OriginalFileName = table.Column(type: "nvarchar(max)", nullable: false), + StoredFileName = table.Column(type: "nvarchar(max)", nullable: false), + ContentType = table.Column(type: "nvarchar(max)", nullable: false), + SizeBytes = table.Column(type: "bigint", nullable: false), + ScanStatus = table.Column(type: "nvarchar(max)", nullable: false), + ReviewStatus = table.Column(type: "nvarchar(max)", nullable: false), + RejectionReason = table.Column(type: "nvarchar(max)", nullable: true), + Version = table.Column(type: "int", nullable: false), + ReplacesDocumentId = table.Column(type: "int", nullable: true), + ScannedAt = table.Column(type: "datetime2", nullable: true), + ReviewedAt = table.Column(type: "datetime2", nullable: true), + IsDeleted = table.Column(type: "bit", nullable: true), + createdby = table.Column(type: "nvarchar(max)", nullable: true), + DeleterUserId = table.Column(type: "nvarchar(max)", nullable: true), + DeletionTime = table.Column(type: "datetime2", nullable: true), + CreatedDate = table.Column(type: "datetime2", nullable: true), + LastModificationTime = table.Column(type: "datetime2", nullable: true), + LastModifierUserId = table.Column(type: "int", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_VendorCompletionDocuments", x => x.Id); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_Dispatches_DispatchId", + column: x => x.DispatchId, + principalTable: "Dispatches", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_Vendors_VendorId", + column: x => x.VendorId, + principalTable: "Vendors", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_VendorCompletionDocuments_workOrders_WorkOrderId", + column: x => x.WorkOrderId, + principalTable: "workOrders", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_LocationId_VendorId", + table: "SitePreferredVendors", + columns: new[] { "LocationId", "VendorId" }, + unique: true, + filter: "[Trade] IS NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_LocationId_VendorId_Trade", + table: "SitePreferredVendors", + columns: new[] { "LocationId", "VendorId", "Trade" }, + unique: true, + filter: "[Trade] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_SitePreferredVendors_VendorId", + table: "SitePreferredVendors", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_DispatchId", + table: "VendorCompletionDocuments", + column: "DispatchId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_VendorId", + table: "VendorCompletionDocuments", + column: "VendorId"); + + migrationBuilder.CreateIndex( + name: "IX_VendorCompletionDocuments_WorkOrderId", + table: "VendorCompletionDocuments", + column: "WorkOrderId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "SitePreferredVendors"); + + migrationBuilder.DropTable( + name: "VendorCompletionDocuments"); + + migrationBuilder.DropColumn( + name: "AvailabilityStatus", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "AvailabilityUpdatedAt", + table: "Vendors"); + + migrationBuilder.DropColumn( + name: "CommercialStatusUpdatedAt", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "EstimatedArrivalAt", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "EtaManualOverride", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "InvoiceNumber", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "InvoiceStatus", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "PaymentStatus", + table: "Dispatches"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 09b4ef3..33dd3a9 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -631,6 +631,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("AcknowledgedAt") .HasColumnType("datetime2"); + b.Property("CommercialStatusUpdatedAt") + .HasColumnType("datetime2"); + b.Property("CompletedDate") .HasColumnType("datetime2"); @@ -655,6 +658,19 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("EmailSent") .HasColumnType("bit"); + b.Property("EstimatedArrivalAt") + .HasColumnType("datetime2"); + + b.Property("EtaManualOverride") + .HasColumnType("bit"); + + b.Property("InvoiceNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("InvoiceStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("IsDeleted") .HasColumnType("bit"); @@ -670,6 +686,10 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("PONumber") .HasColumnType("nvarchar(max)"); + b.Property("PaymentStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + b.Property("ReplyToAddress") .HasColumnType("nvarchar(max)"); @@ -1147,6 +1167,86 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("EmployeePhones"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AllDay") + .HasColumnType("bit"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("EndDate") + .HasColumnType("datetime2"); + + b.Property("EndTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("EventColor") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Location") + .HasMaxLength(150) + .HasColumnType("nvarchar(150)"); + + b.Property("RecordID") + .HasColumnType("int"); + + b.Property("RecordName") + .HasColumnType("nvarchar(max)"); + + b.Property("RecordTargetType") + .HasColumnType("nvarchar(max)"); + + b.Property("ReminderMinutes") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("datetime2"); + + b.Property("StartTime") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.ToTable("Events"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.FollowUps", b => { b.Property("Id") @@ -1526,6 +1626,65 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("Regions"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("LocationId") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("Trade") + .HasColumnType("nvarchar(450)"); + + b.Property("UpdatedBy") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("VendorId"); + + b.HasIndex("LocationId", "VendorId") + .IsUnique() + .HasFilter("[Trade] IS NULL"); + + b.HasIndex("LocationId", "VendorId", "Trade") + .IsUnique() + .HasFilter("[Trade] IS NOT NULL"); + + b.ToTable("SitePreferredVendors"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplate", b => { b.Property("Id") @@ -1727,6 +1886,13 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Address") .HasColumnType("nvarchar(max)"); + b.Property("AvailabilityStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("AvailabilityUpdatedAt") + .HasColumnType("datetime2"); + b.Property("City") .HasColumnType("nvarchar(max)"); @@ -1958,6 +2124,93 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("VendorCompanies"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("DispatchId") + .HasColumnType("int"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("RejectionReason") + .HasColumnType("nvarchar(max)"); + + b.Property("ReplacesDocumentId") + .HasColumnType("int"); + + b.Property("ReviewStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime2"); + + b.Property("ScanStatus") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ScannedAt") + .HasColumnType("datetime2"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("Version") + .HasColumnType("int"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DispatchId"); + + b.HasIndex("VendorId"); + + b.HasIndex("WorkOrderId"); + + b.ToTable("VendorCompletionDocuments"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.Property("Id") @@ -2796,6 +3049,25 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Quotes"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => + { + b.HasOne("Data.SeaHavenIndustries.Locations", "Location") + .WithMany() + .HasForeignKey("LocationId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany("SitePreferences") + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Location"); + + b.Navigation("Vendor"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => { b.HasOne("Data.SeaHavenIndustries.TaskListTemplate", "Template") @@ -2870,6 +3142,33 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Vendor"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompletionDocument", b => + { + b.HasOne("Data.SeaHavenIndustries.Dispatch", "Dispatch") + .WithMany("CompletionDocuments") + .HasForeignKey("DispatchId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.Vendor", "Vendor") + .WithMany() + .HasForeignKey("VendorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .HasForeignKey("WorkOrderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Dispatch"); + + b.Navigation("Vendor"); + + b.Navigation("WorkOrder"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrder", b => { b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") @@ -3060,6 +3359,8 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Comments"); + b.Navigation("CompletionDocuments"); + b.Navigation("DispatchWorkOrders"); b.Navigation("Signoffs"); @@ -3106,6 +3407,8 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("AuditLogs"); b.Navigation("Dispatches"); + + b.Navigation("SitePreferences"); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorCompany", b => diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 831cc17..4e7b927 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -19,6 +19,12 @@ namespace Data.SeaHavenIndustries public string? Description { get; set; } public string? Status { get; set; } public DateTime? ScheduledDate { get; set; } + public DateTime? EstimatedArrivalAt { get; set; } + public bool EtaManualOverride { get; set; } + public string InvoiceStatus { get; set; } = "Not Submitted"; + public string PaymentStatus { get; set; } = "Unavailable"; + public string? InvoiceNumber { get; set; } + public DateTime? CommercialStatusUpdatedAt { get; set; } public DateTime? CompletedDate { get; set; } public DateTime? DispatchedAt { get; set; } public DateTime? AcknowledgedAt { get; set; } @@ -35,5 +41,6 @@ namespace Data.SeaHavenIndustries public ICollection? DispatchWorkOrders { get; set; } public ICollection? ChecklistItems { get; set; } public ICollection? Signoffs { get; set; } + public ICollection? CompletionDocuments { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs b/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs new file mode 100644 index 0000000..7dc34eb --- /dev/null +++ b/Data.SeaHavenIndustries/Models/SitePreferredVendor.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class SitePreferredVendor : FullAuditEntity + { + public int LocationId { get; set; } + [ForeignKey(nameof(LocationId))] + public Locations? Location { get; set; } + + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public Vendor? Vendor { get; set; } + + public string? Trade { get; set; } + public int SortOrder { get; set; } + public string? UpdatedBy { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/Vendor.cs b/Data.SeaHavenIndustries/Models/Vendor.cs index 9f8071a..62ba763 100644 --- a/Data.SeaHavenIndustries/Models/Vendor.cs +++ b/Data.SeaHavenIndustries/Models/Vendor.cs @@ -18,6 +18,8 @@ namespace Data.SeaHavenIndustries public string? Notes { get; set; } public bool IsActive { get; set; } = true; public string? PreferredContact { get; set; } + public string AvailabilityStatus { get; set; } = "Unknown"; + public DateTime? AvailabilityUpdatedAt { get; set; } public int? CompanyId { get; set; } [ForeignKey(nameof(CompanyId))] @@ -27,5 +29,6 @@ namespace Data.SeaHavenIndustries public int TotalJobs { get; set; } public ICollection? Dispatches { get; set; } public ICollection? AuditLogs { get; set; } + public ICollection? SitePreferences { get; set; } } } diff --git a/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs new file mode 100644 index 0000000..2058e00 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/VendorCompletionDocument.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + public class VendorCompletionDocument : FullAuditEntity + { + public int VendorId { get; set; } + [ForeignKey(nameof(VendorId))] + public Vendor? Vendor { get; set; } + + public int DispatchId { get; set; } + [ForeignKey(nameof(DispatchId))] + public Dispatch? Dispatch { get; set; } + + public int WorkOrderId { get; set; } + [ForeignKey(nameof(WorkOrderId))] + public WorkOrder? WorkOrder { get; set; } + + public string OriginalFileName { get; set; } = ""; + public string StoredFileName { get; set; } = ""; + public string ContentType { get; set; } = ""; + public long SizeBytes { get; set; } + public string ScanStatus { get; set; } = "Pending"; + public string ReviewStatus { get; set; } = "Processing"; + public string? RejectionReason { get; set; } + public int Version { get; set; } = 1; + public int? ReplacesDocumentId { get; set; } + public DateTime? ScannedAt { get; set; } + public DateTime? ReviewedAt { get; set; } + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 18e51bf..81c6ad8 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -27,6 +27,7 @@ namespace Data.SeaHavenIndustries public string? LegacyStatus { get; set; } public WorkOrderType? WorkOrderType { get; set; } /// SHOC board "Service" column (PM type label). Not Trade/Problem. + [MaxLength(128)] public string? Service { get; set; } public int? PrimaryDispatchId { get; set; } [ForeignKey(nameof(PrimaryDispatchId))] From b1421de0b502a91d0af18927596de2cef5afcf91 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 11:49:57 -0300 Subject: [PATCH 8/8] fix(vendors): address roadmap review findings --- .../VendorPortalDocumentTests.cs | 30 +++++++++++++++++++ .../Controllers/VendorPortalController.cs | 6 ++-- .../appsettings.Production.json | 6 ++++ Api.SeaHavenIndustries/appsettings.json | 7 +++++ ...260723220614_AddVendorRoadmapOperations.cs | 6 ++-- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs index e6b6167..be73fbb 100644 --- a/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs +++ b/Api.SeaHavenIndustries.Tests/VendorPortalDocumentTests.cs @@ -124,6 +124,36 @@ public sealed class VendorPortalDocumentTests : IDisposable locked.StatusCode.Should().Be(StatusCodes.Status423Locked); } + [Fact] + public async Task UploadCompletionDocument_AcceptsMixedCasePdfContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var pdf = "%PDF-1.4\nvendor completion"u8.ToArray(); + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(pdf, "completion.pdf", "Application/PDF")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(1); + } + + [Fact] + public async Task UploadCompletionDocument_AcceptsMixedCaseImageContentType() + { + using var context = NewContext(); + var (_, dispatch) = await SeedDispatch(context); + var png = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00 }; + + var result = await NewController(context).UploadCompletionDocument( + dispatch.Id, + FormFile(png, "photo.png", "Image/PNG")); + + result.Should().BeOfType(); + context.VendorCompletionDocuments.Should().HaveCount(1); + } + public void Dispose() { if (Directory.Exists(_contentRoot)) diff --git a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs index eba515f..53fbcb1 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorPortalController.cs @@ -803,11 +803,11 @@ namespace Api.SeaHavenIndustries.Controllers { var bytes = new byte[8]; var count = await stream.ReadAsync(bytes); - if (contentType == "application/pdf") + if (string.Equals(contentType, "application/pdf", StringComparison.OrdinalIgnoreCase)) return count >= 5 && bytes[0] == 0x25 && bytes[1] == 0x50 && bytes[2] == 0x44 && bytes[3] == 0x46 && bytes[4] == 0x2D; - if (contentType == "image/jpeg") + if (string.Equals(contentType, "image/jpeg", StringComparison.OrdinalIgnoreCase)) return count >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF; - if (contentType == "image/png") + if (string.Equals(contentType, "image/png", StringComparison.OrdinalIgnoreCase)) return count >= 8 && bytes.SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); return false; } diff --git a/Api.SeaHavenIndustries/appsettings.Production.json b/Api.SeaHavenIndustries/appsettings.Production.json index 651d68c..b4aae5c 100644 --- a/Api.SeaHavenIndustries/appsettings.Production.json +++ b/Api.SeaHavenIndustries/appsettings.Production.json @@ -14,6 +14,12 @@ // Provide the real value via environment variable SendGrid__ApiKey or the instance secret store. "ApiKey": "${SENDGRID_API_KEY}" }, + // Production must supply a reachable ClamAV Host (environment variable ClamAV__Host). + // While Host is empty the scanner is unavailable and uploads stay quarantined (423 Locked). + "ClamAV": { + "Host": "", + "Port": 3310 + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://console.seahavenind.com", diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 2d5dc80..426d85b 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -17,6 +17,13 @@ // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. "ApiKey": "${SENDGRID_API_KEY}" }, + // Malware scanner for vendor completion document uploads. While Host is empty the + // scanner is considered unavailable and uploaded files stay quarantined (download + // returns 423 Locked until a scan passes). Production must supply a real Host. + "ClamAV": { + "Host": "", + "Port": 3310 + }, "AllowedHosts": "*", "JWT": { "ValidAudience": "http://localhost:4200", diff --git a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs index bea27f8..4125639 100644 --- a/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs +++ b/Data.SeaHavenIndustries/Migrations/20260723220614_AddVendorRoadmapOperations.cs @@ -16,7 +16,7 @@ namespace Data.SeaHavenIndustries.Migrations table: "Vendors", type: "nvarchar(max)", nullable: false, - defaultValue: ""); + defaultValue: "Unknown"); migrationBuilder.AddColumn( name: "AvailabilityUpdatedAt", @@ -54,14 +54,14 @@ namespace Data.SeaHavenIndustries.Migrations table: "Dispatches", type: "nvarchar(max)", nullable: false, - defaultValue: ""); + defaultValue: "Not Submitted"); migrationBuilder.AddColumn( name: "PaymentStatus", table: "Dispatches", type: "nvarchar(max)", nullable: false, - defaultValue: ""); + defaultValue: "Unavailable"); migrationBuilder.CreateTable( name: "SitePreferredVendors",