From bdffe77e42cb36314f455759a3ded6cf65b8d20c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 24 Jul 2026 21:03:50 -0300 Subject: [PATCH] feat: ingest signed procurement work-order webhooks --- .../WorkOrderWebhookSecretProviderTests.cs | 98 + .../Api.SeaHavenIndustries.csproj | 1 + .../Controllers/WorkOrderWebhookController.cs | 114 + .../AwsWorkOrderWebhookSecretProvider.cs | 117 + Api.SeaHavenIndustries/Program.cs | 25 + Api.SeaHavenIndustries/appsettings.json | 8 + .../Auth/ApplicationDbContext.cs | 16 + ...H133_WorkOrderWebhookIngestion.Designer.cs | 3638 +++++++++++++++++ ...4234953_SH133_WorkOrderWebhookIngestion.cs | 67 + .../ApplicationDbContextModelSnapshot.cs | 236 +- .../Models/WorkOrderWebhookDelivery.cs | 21 + Data.SeaHavenIndustries/Models/WorkerOrder.cs | 1 + .../WorkOrderWebhookDataService.cs | 160 + .../IWorkOrderWebhookDataService.cs | 51 + .../Configuration/WorkOrderWebhookOptions.cs | 15 + .../Implementation/WorkOrderWebhookService.cs | 371 ++ .../IWorkOrderWebhookSecretProvider.cs | 20 + .../Interfaces/IWorkOrderWebhookService.cs | 32 + .../WorkOrderWebhookTests.cs | 441 ++ 19 files changed, 5318 insertions(+), 114 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/WorkOrderWebhookController.cs create mode 100644 Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.cs create mode 100644 Data.SeaHavenIndustries/Models/WorkOrderWebhookDelivery.cs create mode 100644 SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs create mode 100644 SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs create mode 100644 SeaHaven.Services/Implementation/WorkOrderWebhookService.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderWebhookSecretProvider.cs create mode 100644 SeaHaven.Services/Interfaces/IWorkOrderWebhookService.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs new file mode 100644 index 0000000..45601e3 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderWebhookSecretProviderTests.cs @@ -0,0 +1,98 @@ +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; +using Api.SeaHavenIndustries.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class WorkOrderWebhookSecretProviderTests +{ + [Fact] + public async Task Known_key_is_cached_and_callers_receive_independent_secret_copies() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.GetSecretValueAsync( + It.Is(r => r.SecretId == "arn:configured"), + It.IsAny())) + .ReturnsAsync(new GetSecretValueResponse { SecretString = "shared-secret" }); + var provider = CreateProvider(client.Object); + + var first = await provider.GetSecretAsync("current", CancellationToken.None); + first.Secret![0] = 0; + var second = await provider.GetSecretAsync("current", CancellationToken.None); + + Assert.Equal(WorkOrderWebhookSecretStatus.Found, second.Status); + Assert.Equal("shared-secret", System.Text.Encoding.UTF8.GetString(second.Secret!)); + client.VerifyAll(); + client.Verify( + c => c.GetSecretValueAsync( + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Unknown_key_never_influences_an_aws_request() + { + var client = new Mock(MockBehavior.Strict); + var provider = CreateProvider(client.Object); + + var result = await provider.GetSecretAsync( + "attacker-controlled-key", + CancellationToken.None); + + Assert.Equal(WorkOrderWebhookSecretStatus.UnknownKey, result.Status); + client.VerifyNoOtherCalls(); + } + + [Fact] + public async Task Aws_failure_returns_unavailable_without_exposing_the_exception() + { + var client = new Mock(MockBehavior.Strict); + client.Setup(c => c.GetSecretValueAsync( + It.IsAny(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("sensitive provider detail")); + var provider = CreateProvider(client.Object); + + var result = await provider.GetSecretAsync("current", CancellationToken.None); + + Assert.Equal(WorkOrderWebhookSecretStatus.Unavailable, result.Status); + Assert.Null(result.Secret); + } + + private static AwsWorkOrderWebhookSecretProvider CreateProvider( + IAmazonSecretsManager client) => + new( + client, + new TestOptionsMonitor(new WorkOrderWebhookOptions + { + SecretCacheSeconds = 300, + KeySecrets = new Dictionary + { + ["current"] = "arn:configured" + } + }), + TimeProvider.System, + NullLogger.Instance); + + private sealed class TestOptionsMonitor : IOptionsMonitor + { + public TestOptionsMonitor(WorkOrderWebhookOptions currentValue) + { + CurrentValue = currentValue; + } + + public WorkOrderWebhookOptions CurrentValue { get; } + + public WorkOrderWebhookOptions Get(string? name) => CurrentValue; + + public IDisposable? OnChange( + Action listener) => null; + } +} diff --git a/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj b/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj index 9de9eaf..8364296 100644 --- a/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj +++ b/Api.SeaHavenIndustries/Api.SeaHavenIndustries.csproj @@ -8,6 +8,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderWebhookController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderWebhookController.cs new file mode 100644 index 0000000..1b4992b --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderWebhookController.cs @@ -0,0 +1,114 @@ +using System.Buffers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [ApiController] + [AllowAnonymous] + [Route("api/webhooks/work-orders")] + public sealed class WorkOrderWebhookController : ControllerBase + { + private readonly IWorkOrderWebhookService _service; + + public WorkOrderWebhookController(IWorkOrderWebhookService service) + { + _service = service; + } + + [HttpPost] + [Consumes("application/json")] + public async Task Receive(CancellationToken cancellationToken) + { + if (!HasSupportedContentType(Request.ContentType)) + return StatusCode(StatusCodes.Status415UnsupportedMediaType); + + if (Request.Headers.ContentEncoding.Count > 0 + && !string.Equals( + Request.Headers.ContentEncoding.ToString(), + "identity", + StringComparison.OrdinalIgnoreCase)) + return StatusCode(StatusCodes.Status415UnsupportedMediaType); + + var maxBytes = _service.MaximumBodyBytes; + if (Request.ContentLength > maxBytes) + return StatusCode(StatusCodes.Status413PayloadTooLarge); + + var body = await ReadBoundedBodyAsync(Request.Body, maxBytes, cancellationToken); + if (body == null) + return StatusCode(StatusCodes.Status413PayloadTooLarge); + + var result = await _service.ProcessAsync( + new WorkOrderWebhookRequest( + SingleHeader("X-SH-Timestamp"), + SingleHeader("X-SH-Key-Id"), + SingleHeader("X-SH-Signature"), + body), + cancellationToken); + + return result.Status switch + { + WorkOrderWebhookStatus.Applied => Ok(new + { + status = "accepted", + state_mutation_skipped = result.StateMutationSkipped + }), + WorkOrderWebhookStatus.Duplicate => Ok(new { status = "duplicate" }), + WorkOrderWebhookStatus.Unauthorized => Unauthorized(new { error = "unauthorized" }), + WorkOrderWebhookStatus.Disabled => StatusCode( + StatusCodes.Status503ServiceUnavailable, + new { error = "webhook unavailable" }), + WorkOrderWebhookStatus.InvalidEnvelope => BadRequest(new { error = "invalid envelope" }), + WorkOrderWebhookStatus.HashConflict => Conflict(new { error = "delivery conflict" }), + WorkOrderWebhookStatus.Unavailable => StatusCode( + StatusCodes.Status503ServiceUnavailable, + new { error = "webhook unavailable" }), + _ => StatusCode(StatusCodes.Status503ServiceUnavailable) + }; + } + + private string? SingleHeader(string name) + { + var values = Request.Headers[name]; + return values.Count == 1 ? values[0] : null; + } + + private static bool HasSupportedContentType(string? contentType) + { + return MediaTypeHeaderValue.TryParse(contentType, out var parsed) + && string.Equals( + parsed.MediaType.Value, + "application/json", + StringComparison.OrdinalIgnoreCase); + } + + private static async Task ReadBoundedBodyAsync( + Stream source, + int maxBytes, + CancellationToken cancellationToken) + { + var rented = ArrayPool.Shared.Rent(81920); + try + { + using var destination = new MemoryStream(Math.Min(maxBytes, 81920)); + while (true) + { + var read = await source.ReadAsync(rented.AsMemory(0, rented.Length), cancellationToken); + if (read == 0) + return destination.ToArray(); + + if (destination.Length + read > maxBytes) + return null; + + await destination.WriteAsync(rented.AsMemory(0, read), cancellationToken); + } + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + } +} diff --git a/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs b/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs new file mode 100644 index 0000000..dd4f3f3 --- /dev/null +++ b/Api.SeaHavenIndustries/Infrastructure/AwsWorkOrderWebhookSecretProvider.cs @@ -0,0 +1,117 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using Amazon.SecretsManager; +using Amazon.SecretsManager.Model; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Infrastructure +{ + public sealed class AwsWorkOrderWebhookSecretProvider : IWorkOrderWebhookSecretProvider + { + private readonly IAmazonSecretsManager _client; + private readonly IOptionsMonitor _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _cache = + new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _locks = + new(StringComparer.Ordinal); + + public AwsWorkOrderWebhookSecretProvider( + IAmazonSecretsManager client, + IOptionsMonitor options, + TimeProvider timeProvider, + ILogger logger) + { + _client = client; + _options = options; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task GetSecretAsync( + string keyId, + CancellationToken cancellationToken) + { + var options = _options.CurrentValue; + if (!options.KeySecrets.TryGetValue(keyId, out var secretArn) + || string.IsNullOrWhiteSpace(secretArn)) + { + if (_cache.TryRemove(keyId, out var removed)) + CryptographicOperations.ZeroMemory(removed.Secret); + return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey); + } + + var now = _timeProvider.GetUtcNow(); + if (_cache.TryGetValue(keyId, out var cached) + && cached.SecretArn == secretArn + && cached.ExpiresAt > now) + return FoundCopy(cached.Secret); + + var keyLock = _locks.GetOrAdd(keyId, _ => new SemaphoreSlim(1, 1)); + await keyLock.WaitAsync(cancellationToken); + try + { + now = _timeProvider.GetUtcNow(); + if (_cache.TryGetValue(keyId, out cached) + && cached.SecretArn == secretArn + && cached.ExpiresAt > now) + return FoundCopy(cached.Secret); + + var response = await _client.GetSecretValueAsync( + new GetSecretValueRequest { SecretId = secretArn }, + cancellationToken); + var secret = ReadSecret(response); + if (secret.Length == 0) + return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable); + + var ttl = TimeSpan.FromSeconds(Math.Clamp(options.SecretCacheSeconds, 1, 3600)); + var replacement = new CacheEntry(secretArn, secret, now.Add(ttl)); + _cache.AddOrUpdate( + keyId, + replacement, + (_, prior) => + { + CryptographicOperations.ZeroMemory(prior.Secret); + return replacement; + }); + return FoundCopy(secret); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + // AWS exceptions can contain the configured secret ARN. Keep + // diagnostics intentionally generic for this secret-bearing path. + _logger.LogError("Work-order webhook signing secret retrieval failed."); + return new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.Unavailable); + } + finally + { + keyLock.Release(); + } + } + + private static byte[] ReadSecret(GetSecretValueResponse response) + { + if (!string.IsNullOrEmpty(response.SecretString)) + return Encoding.UTF8.GetBytes(response.SecretString); + + return response.SecretBinary?.ToArray() ?? Array.Empty(); + } + + private static WorkOrderWebhookSecretResult FoundCopy(byte[] secret) => + new(WorkOrderWebhookSecretStatus.Found, (byte[])secret.Clone()); + + private sealed record CacheEntry( + string SecretArn, + byte[] Secret, + DateTimeOffset ExpiresAt); + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index d88536f..26ce19c 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -67,9 +67,34 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(_ => +{ + var region = builder.Configuration[$"{SeaHaven.Services.Configuration.WorkOrderWebhookOptions.SectionName}:Region"]; + return string.IsNullOrWhiteSpace(region) + ? new Amazon.SecretsManager.AmazonSecretsManagerClient() + : new Amazon.SecretsManager.AmazonSecretsManagerClient(Amazon.RegionEndpoint.GetBySystemName(region)); +}); +builder.Services.AddSingleton< + SeaHaven.Services.Interfaces.IWorkOrderWebhookSecretProvider, + Api.SeaHavenIndustries.Infrastructure.AwsWorkOrderWebhookSecretProvider>(); builder.Services.Configure( builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); +builder.Services.AddOptions() + .Bind(builder.Configuration.GetSection(SeaHaven.Services.Configuration.WorkOrderWebhookOptions.SectionName)) + .Validate( + o => o.MaxBodyBytes is > 0 and <= 1_048_576 + && o.AllowedClockSkewSeconds is >= 0 and <= 3600 + && o.SecretCacheSeconds is > 0 and <= 3600, + "WorkOrderWebhook size, clock-skew, and cache settings are out of range.") + .Validate( + o => !o.Enabled || (o.KeySecrets.Count > 0 + && o.KeySecrets.All(kvp => + !string.IsNullOrWhiteSpace(kvp.Key) + && kvp.Key.Length <= 128 + && !string.IsNullOrWhiteSpace(kvp.Value))), + "WorkOrderWebhook requires a non-empty key ID to secret ARN map when enabled.") + .ValidateOnStart(); builder.Services.AddOptions() .Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName)) .Validate( diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 4156b9d..6efa10f 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -39,6 +39,14 @@ "Enabled": false, "ApiKey": "${WORKORDER_INGEST_API_KEY}" }, + "WorkOrderWebhook": { + "Enabled": false, + "MaxBodyBytes": 1048576, + "AllowedClockSkewSeconds": 300, + "SecretCacheSeconds": 300, + "Region": "", + "KeySecrets": {} + }, "Sync": { "Enabled": true }, diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index 2e214bb..c3a84c0 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -73,6 +73,17 @@ namespace Data.SeaHavenIndustries .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) .HasFilter("[istemplate] = 0"); + // These indexes already exist in the migration history. Keep them in + // the runtime model so future additive migrations do not drop them. + builder.Entity() + .HasIndex(w => new { w.AssignTo, w.ScheduledDate }); + + builder.Entity() + .HasIndex(w => w.ScheduledDate); + + builder.Entity() + .HasIndex(w => w.WorkOrderType); + builder.Entity() .Property(w => w.ExternalWorkOrderId) .HasMaxLength(450); @@ -82,6 +93,10 @@ namespace Data.SeaHavenIndustries .IsUnique() .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + builder.Entity() + .HasIndex(d => d.DeliveryId) + .IsUnique(); + builder.Entity() .Property(d => d.RowVersion) @@ -129,6 +144,7 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderAuditLogs { get; set; } public DbSet WorkOrderFieldLocks { get; set; } public DbSet WorkOrderWeekRolledLedgers { get; set; } + public DbSet WorkOrderWebhookDeliveries { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } public DbSet VendorCompanies { get; set; } diff --git a/Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.Designer.cs new file mode 100644 index 0000000..18f5270 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.Designer.cs @@ -0,0 +1,3638 @@ +// +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("20260724234953_SH133_WorkOrderWebhookIngestion")] + partial class SH133_WorkOrderWebhookIngestion + { + /// + 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.CompletionDocTemplate", 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("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); + }); + + 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("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("VendorId") + .HasColumnType("int"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + 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("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalWorkOrderId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + 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("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + 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("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + 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") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + 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("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("Trade") + .HasColumnType("nvarchar(max)"); + + b.Property("VendorNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + 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("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + b.HasIndex("InternalWONumber") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("LocationId"); + + b.HasIndex("PrimaryDispatchId"); + + b.HasIndex("ScheduledDate"); + + b.HasIndex("SiteCode") + .HasFilter("[istemplate] = 0"); + + b.HasIndex("WorkOrderType"); + + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + 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("Category") + .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("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("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BodySha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.HasKey("Id"); + + b.HasIndex("DeliveryId") + .IsUnique(); + + b.ToTable("WorkOrderWebhookDeliveries"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers"); + }); + + 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("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") + .WithMany() + .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/20260724234953_SH133_WorkOrderWebhookIngestion.cs b/Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.cs new file mode 100644 index 0000000..92cba24 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260724234953_SH133_WorkOrderWebhookIngestion.cs @@ -0,0 +1,67 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class SH133_WorkOrderWebhookIngestion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ExternalLastOccurredAt", + table: "workOrders", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.CreateTable( + name: "WorkOrderWebhookDeliveries", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + DeliveryId = table.Column( + type: "nvarchar(128)", + maxLength: 128, + nullable: false), + EventType = table.Column( + type: "nvarchar(64)", + maxLength: 64, + nullable: false), + OccurredAt = table.Column( + type: "datetimeoffset", + nullable: false), + ProcessedAt = table.Column( + type: "datetimeoffset", + nullable: false), + BodySha256 = table.Column( + type: "nvarchar(64)", + maxLength: 64, + nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkOrderWebhookDeliveries", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_WorkOrderWebhookDeliveries_DeliveryId", + table: "WorkOrderWebhookDeliveries", + column: "DeliveryId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "WorkOrderWebhookDeliveries"); + + migrationBuilder.DropColumn( + name: "ExternalLastOccurredAt", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 7601a5f..8e9deb6 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Accounts", (string)null); + b.ToTable("Accounts"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => @@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("Addresses", (string)null); + b.ToTable("Addresses"); }); modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => @@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Assets", (string)null); + b.ToTable("Assets"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => @@ -371,62 +371,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Categories", (string)null); - }); - - modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", 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("IsActive") - .HasColumnType("bit"); - - b.Property("IsDeleted") - .HasColumnType("bit"); - - b.Property("LastModificationTime") - .HasColumnType("datetime2"); - - b.Property("LastModifierUserId") - .HasColumnType("int"); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("ServiceKey") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.Property("TemplateUrl") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("WorkOrderType") - .HasColumnType("int"); - - b.Property("createdby") - .HasColumnType("nvarchar(max)"); - - b.HasKey("Id"); - - b.HasIndex("ServiceKey", "IsActive") - .HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive"); - - b.ToTable("CompletionDocTemplates", (string)null); + b.ToTable("Categories"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => @@ -493,7 +438,61 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkerOrderId"); - b.ToTable("Comments", (string)null); + b.ToTable("Comments"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", 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("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive"); + + b.ToTable("CompletionDocTemplates"); }); modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => @@ -559,7 +558,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("ContactDetails", (string)null); + b.ToTable("ContactDetails"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => @@ -651,7 +650,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Contacts", (string)null); + b.ToTable("Contacts"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => @@ -669,7 +668,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Departments", (string)null); + b.ToTable("Departments"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => @@ -788,7 +787,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("Dispatches", (string)null); + b.ToTable("Dispatches"); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => @@ -847,7 +846,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchChecklistItems", (string)null); + b.ToTable("DispatchChecklistItems"); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => @@ -901,7 +900,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("DispatchId"); - b.ToTable("DispatchSignoffs", (string)null); + b.ToTable("DispatchSignoffs"); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => @@ -971,7 +970,7 @@ namespace Data.SeaHavenIndustries.Migrations .IsUnique() .HasFilter("[Status] = 'Pending'"); - b.ToTable("DispatchUpliftRequests", (string)null); + b.ToTable("DispatchUpliftRequests"); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => @@ -994,7 +993,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchWorkOrders", (string)null); + b.ToTable("DispatchWorkOrders"); }); modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => @@ -1024,7 +1023,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("DropdownOptions", (string)null); + b.ToTable("DropdownOptions"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => @@ -1129,7 +1128,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("RegionId"); - b.ToTable("Employees", (string)null); + b.ToTable("Employees"); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => @@ -1169,7 +1168,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId") .IsUnique(); - b.ToTable("EmployeeAddresses", (string)null); + b.ToTable("EmployeeAddresses"); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => @@ -1197,7 +1196,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeeEmails", (string)null); + b.ToTable("EmployeeEmails"); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => @@ -1227,7 +1226,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeePhones", (string)null); + b.ToTable("EmployeePhones"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => @@ -1375,7 +1374,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("FollowUps", (string)null); + b.ToTable("FollowUps"); }); modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => @@ -1400,7 +1399,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("ForgetPasswordCodes", (string)null); + b.ToTable("ForgetPasswordCodes"); }); modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => @@ -1418,7 +1417,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("JobTitles", (string)null); + b.ToTable("JobTitles"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => @@ -1488,7 +1487,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Locations", (string)null); + b.ToTable("Locations"); }); modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => @@ -1546,7 +1545,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("PMSchedules", (string)null); + b.ToTable("PMSchedules"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => @@ -1620,7 +1619,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("Quotes", (string)null); + b.ToTable("Quotes"); }); modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => @@ -1668,7 +1667,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("QuoteId"); - b.ToTable("QuotesLines", (string)null); + b.ToTable("QuotesLines"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => @@ -1686,7 +1685,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Regions", (string)null); + b.ToTable("Regions"); }); modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => @@ -1788,7 +1787,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("TaskListTemplates", (string)null); + b.ToTable("TaskListTemplates"); }); modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => @@ -1833,7 +1832,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("TaskListTemplateId"); - b.ToTable("TaskListTemplateItems", (string)null); + b.ToTable("TaskListTemplateItems"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => @@ -1935,7 +1934,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Templates", (string)null); + b.ToTable("Templates"); }); modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => @@ -2023,7 +2022,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("CompanyId"); - b.ToTable("Vendors", (string)null); + b.ToTable("Vendors"); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => @@ -2082,7 +2081,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("VendorId"); - b.ToTable("VendorAccessTokens", (string)null); + b.ToTable("VendorAccessTokens"); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => @@ -2339,6 +2338,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("DueDate") .HasColumnType("datetime2"); + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + b.Property("ExternalWorkOrderId") .HasMaxLength(450) .HasColumnType("nvarchar(450)"); @@ -2499,10 +2501,6 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.HasIndex("AssignTo"); - - b.HasIndex("AssignTo", "ScheduledDate"); - b.HasIndex("ExternalWorkOrderId") .IsUnique() .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); @@ -2510,9 +2508,6 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("InternalWONumber") .HasFilter("[istemplate] = 0"); - b.HasIndex("LifecycleStatus", "ScheduledDate") - .HasFilter("[istemplate] = 0"); - b.HasIndex("LocationId"); b.HasIndex("PrimaryDispatchId"); @@ -2524,7 +2519,12 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderType"); - b.ToTable("workOrders", (string)null); + b.HasIndex("AssignTo", "ScheduledDate"); + + b.HasIndex("LifecycleStatus", "ScheduledDate") + .HasFilter("[istemplate] = 0"); + + b.ToTable("workOrders"); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => @@ -2569,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderAttachments", (string)null); + b.ToTable("workOrderAttachments"); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => @@ -2619,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("WorkOrderAuditLogs", (string)null); + b.ToTable("WorkOrderAuditLogs"); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => @@ -2663,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderCategories", (string)null); + b.ToTable("workOrderCategories"); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => @@ -2710,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("WorkOrderContacts", (string)null); + b.ToTable("WorkOrderContacts"); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => @@ -2739,10 +2739,10 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId", "FieldName") .IsUnique(); - b.ToTable("WorkOrderFieldLocks", (string)null); + b.ToTable("WorkOrderFieldLocks"); }); - modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWebhookDelivery", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -2750,25 +2750,33 @@ namespace Data.SeaHavenIndustries.Migrations SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("CorrelationId") + b.Property("BodySha256") .IsRequired() - .HasColumnType("nvarchar(max)"); + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); - b.Property("ProcessedAt") - .HasColumnType("datetime2"); + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("SourceWeekStart") - .HasColumnType("date"); + b.Property("EventType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); - b.Property("WorkOrderId") - .HasColumnType("int"); + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); b.HasKey("Id"); - b.HasIndex("WorkOrderId", "SourceWeekStart") + b.HasIndex("DeliveryId") .IsUnique(); - b.ToTable("WorkOrderWeekRolledLedgers", (string)null); + b.ToTable("WorkOrderWebhookDeliveries"); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => @@ -3433,10 +3441,10 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("WorkOrder"); }); - modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => { b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") - .WithMany() + .WithMany("FieldLocks") .HasForeignKey("WorkOrderId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); @@ -3444,10 +3452,10 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("WorkOrder"); }); - modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => { b.HasOne("Data.SeaHavenIndustries.WorkOrder", "WorkOrder") - .WithMany("FieldLocks") + .WithMany() .HasForeignKey("WorkOrderId") .OnDelete(DeleteBehavior.Restrict) .IsRequired(); diff --git a/Data.SeaHavenIndustries/Models/WorkOrderWebhookDelivery.cs b/Data.SeaHavenIndustries/Models/WorkOrderWebhookDelivery.cs new file mode 100644 index 0000000..74907d7 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/WorkOrderWebhookDelivery.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Data.SeaHavenIndustries +{ + public sealed class WorkOrderWebhookDelivery + { + public int Id { get; set; } + + [MaxLength(128)] + public string DeliveryId { get; set; } = ""; + + [MaxLength(64)] + public string EventType { get; set; } = ""; + + public DateTimeOffset OccurredAt { get; set; } + public DateTimeOffset ProcessedAt { get; set; } + + [MaxLength(64)] + public string BodySha256 { get; set; } = ""; + } +} diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 9ffe49e..a25fb6f 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -9,6 +9,7 @@ namespace Data.SeaHavenIndustries // --- Core slice --- public string? InternalWONumber { get; set; } public string? ExternalWorkOrderId { get; set; } + public DateTimeOffset? ExternalLastOccurredAt { get; set; } public string? WorkerOrderNumber { get; set; } public string? WorkerOrderTitle { get; set; } public string? Description { get; set; } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs new file mode 100644 index 0000000..bb796d3 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderWebhookDataService.cs @@ -0,0 +1,160 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public sealed class WorkOrderWebhookDataService : IWorkOrderWebhookDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderWebhookDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task ApplyAsync( + WorkOrderWebhookMutation mutation, + CancellationToken cancellationToken) + { + var prior = await _context.WorkOrderWebhookDeliveries + .AsNoTracking() + .SingleOrDefaultAsync(d => d.DeliveryId == mutation.DeliveryId, cancellationToken); + + if (prior != null) + return ExistingDeliveryResult(prior, mutation.BodySha256); + + var workOrder = await _context.workOrders + .SingleOrDefaultAsync( + w => w.ExternalWorkOrderId == mutation.ExternalWorkOrderId, + cancellationToken); + + if (workOrder == null) + { + var nextInternalNumber = Math.Max( + await _context.workOrders.MaxAsync( + w => (int?)w.Id, + cancellationToken) ?? 0, + 0) + + 1; + workOrder = new WorkOrder + { + ExternalWorkOrderId = mutation.ExternalWorkOrderId, + WorkerOrderNumber = mutation.WorkerOrderNumber, + InternalWONumber = nextInternalNumber.ToString("D11"), + WorkerOrderTitle = mutation.Title ?? mutation.Description + ?? $"Imported work order {mutation.ExternalWorkOrderId}", + Description = mutation.Description, + Source = mutation.Source, + istemplate = false, + CreatedDate = mutation.CreatedAt ?? mutation.ProcessedAt.UtcDateTime + }; + _context.workOrders.Add(workOrder); + } + + var staleState = mutation.IsStateEvent + && workOrder.ExternalLastOccurredAt.HasValue + && mutation.OccurredAt < workOrder.ExternalLastOccurredAt.Value; + + if (mutation.IsStateEvent && !staleState) + { + var locationKey = mutation.SiteCode ?? mutation.Building; + if (!string.IsNullOrWhiteSpace(locationKey)) + { + var location = await _context.Locations + .FirstOrDefaultAsync( + l => l.Name == locationKey || l.Title == locationKey, + cancellationToken); + if (location == null) + { + location = new Locations + { + Name = locationKey, + Title = mutation.Building, + Address1 = mutation.Address, + Status = "Active" + }; + _context.Locations.Add(location); + } + + workOrder.Locations = location; + } + + workOrder.WorkerOrderNumber = mutation.WorkerOrderNumber; + workOrder.WorkerOrderTitle = mutation.Title ?? mutation.Description; + workOrder.Description = mutation.Description; + workOrder.Status = mutation.IsCancelled ? "Cancelled" : mutation.Status; + workOrder.Severity = mutation.Severity; + workOrder.Priority = mutation.Priority; + workOrder.Customer = mutation.Customer; + workOrder.SiteCode = mutation.SiteCode; + workOrder.Building = mutation.Building; + workOrder.DueDate = mutation.DueDate; + workOrder.DateReported = mutation.DateReported; + workOrder.ScheduledStart = mutation.ScheduledStart; + workOrder.Source = mutation.Source; + workOrder.istemplate = false; + workOrder.ExternalLastOccurredAt = mutation.OccurredAt; + } + + if (mutation.CommentId != null) + { + var commentExists = await _context.Comments + .AnyAsync(c => c.ExternalCommentId == mutation.CommentId, cancellationToken); + + if (!commentExists) + { + _context.Comments.Add(new Comments + { + ExternalCommentId = mutation.CommentId, + Commenttext = mutation.CommentText, + Commenter = mutation.Commenter, + CommentType = mutation.CommentType, + RecordType = "WorkOrder", + WorkOrder = workOrder, + CreatedDate = mutation.OccurredAt.UtcDateTime + }); + } + } + + _context.WorkOrderWebhookDeliveries.Add(new WorkOrderWebhookDelivery + { + DeliveryId = mutation.DeliveryId, + EventType = mutation.EventType, + OccurredAt = mutation.OccurredAt, + ProcessedAt = mutation.ProcessedAt, + BodySha256 = mutation.BodySha256 + }); + + try + { + await _context.SaveChangesAsync(cancellationToken); + return new WorkOrderWebhookPersistenceResult( + WorkOrderWebhookPersistenceStatus.Applied, + staleState); + } + catch (DbUpdateException) + { + _context.ChangeTracker.Clear(); + var concurrent = await _context.WorkOrderWebhookDeliveries + .AsNoTracking() + .SingleOrDefaultAsync(d => d.DeliveryId == mutation.DeliveryId, cancellationToken); + + if (concurrent == null) + throw; + + return ExistingDeliveryResult(concurrent, mutation.BodySha256); + } + } + + private static WorkOrderWebhookPersistenceResult ExistingDeliveryResult( + WorkOrderWebhookDelivery delivery, + string bodySha256) + { + return new WorkOrderWebhookPersistenceResult( + string.Equals(delivery.BodySha256, bodySha256, StringComparison.OrdinalIgnoreCase) + ? WorkOrderWebhookPersistenceStatus.Duplicate + : WorkOrderWebhookPersistenceStatus.HashConflict); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs new file mode 100644 index 0000000..fe4bd02 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderWebhookDataService.cs @@ -0,0 +1,51 @@ +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderWebhookDataService + { + Task ApplyAsync( + WorkOrderWebhookMutation mutation, + CancellationToken cancellationToken); + } + + public sealed record WorkOrderWebhookMutation + { + public required string DeliveryId { get; init; } + public required string EventType { get; init; } + public required DateTimeOffset OccurredAt { get; init; } + public required DateTimeOffset ProcessedAt { get; init; } + public required string BodySha256 { get; init; } + public required string ExternalWorkOrderId { get; init; } + public required string WorkerOrderNumber { get; init; } + public required string Source { get; init; } + public bool IsStateEvent { get; init; } + public bool IsCancelled { get; init; } + public string? Title { get; init; } + public string? Description { get; init; } + public string? Status { get; init; } + public string? Severity { get; init; } + public string? Priority { get; init; } + public string? Customer { get; init; } + public string? SiteCode { get; init; } + public string? Building { get; init; } + public string? Address { get; init; } + public DateTime? DueDate { get; init; } + public DateTime? DateReported { get; init; } + public DateTime? ScheduledStart { get; init; } + public DateTime? CreatedAt { get; init; } + public string? CommentId { get; init; } + public string? CommentText { get; init; } + public string? Commenter { get; init; } + public string? CommentType { get; init; } + } + + public enum WorkOrderWebhookPersistenceStatus + { + Applied, + Duplicate, + HashConflict + } + + public sealed record WorkOrderWebhookPersistenceResult( + WorkOrderWebhookPersistenceStatus Status, + bool StateMutationSkipped = false); +} diff --git a/SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs b/SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs new file mode 100644 index 0000000..0a6d291 --- /dev/null +++ b/SeaHaven.Services/Configuration/WorkOrderWebhookOptions.cs @@ -0,0 +1,15 @@ +namespace SeaHaven.Services.Configuration +{ + public sealed class WorkOrderWebhookOptions + { + public const string SectionName = "WorkOrderWebhook"; + + public bool Enabled { get; set; } + public int MaxBodyBytes { get; set; } = 1_048_576; + public int AllowedClockSkewSeconds { get; set; } = 300; + public int SecretCacheSeconds { get; set; } = 300; + public string? Region { get; set; } + public Dictionary KeySecrets { get; set; } = + new(StringComparer.Ordinal); + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs new file mode 100644 index 0000000..cdef1b3 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderWebhookService.cs @@ -0,0 +1,371 @@ +using System.Diagnostics.Metrics; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public sealed class WorkOrderWebhookService : IWorkOrderWebhookService + { + private static readonly Meter Meter = new("SeaHaven.WorkOrderWebhook", "1.0"); + private static readonly Counter Accepted = Meter.CreateCounter("work_order_webhook.accepted"); + private static readonly Counter Duplicates = Meter.CreateCounter("work_order_webhook.duplicate"); + private static readonly Counter Rejected = Meter.CreateCounter("work_order_webhook.rejected"); + private static readonly Counter Invalid = Meter.CreateCounter("work_order_webhook.invalid"); + private static readonly Counter Failed = Meter.CreateCounter("work_order_webhook.failed"); + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + private readonly IWorkOrderWebhookSecretProvider _secretProvider; + private readonly IWorkOrderWebhookDataService _dataService; + private readonly IOptionsMonitor _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public WorkOrderWebhookService( + IWorkOrderWebhookSecretProvider secretProvider, + IWorkOrderWebhookDataService dataService, + IOptionsMonitor options, + TimeProvider timeProvider, + ILogger logger) + { + _secretProvider = secretProvider; + _dataService = dataService; + _options = options; + _timeProvider = timeProvider; + _logger = logger; + } + + public int MaximumBodyBytes => + Math.Clamp(_options.CurrentValue.MaxBodyBytes, 1, 1_048_576); + + public async Task ProcessAsync( + WorkOrderWebhookRequest request, + CancellationToken cancellationToken) + { + var options = _options.CurrentValue; + if (!options.Enabled) + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Disabled); + + if (!TryReadTimestamp(request.Timestamp, options.AllowedClockSkewSeconds, out var timestamp) + || string.IsNullOrWhiteSpace(request.KeyId) + || request.KeyId.Length > 128 + || string.IsNullOrWhiteSpace(request.Signature)) + { + Rejected.Add(1); + return Unauthorized(); + } + + WorkOrderWebhookSecretResult secretResult; + try + { + secretResult = await _secretProvider.GetSecretAsync(request.KeyId, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception) + { + Failed.Add(1); + _logger.LogError("Work-order webhook signing secret could not be loaded."); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable); + } + + if (secretResult.Status == WorkOrderWebhookSecretStatus.Unavailable) + { + Failed.Add(1); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable); + } + + if (secretResult.Status != WorkOrderWebhookSecretStatus.Found + || secretResult.Secret is not { Length: > 0 }) + { + Rejected.Add(1); + return Unauthorized(); + } + + var signatureValid = false; + try + { + signatureValid = VerifySignature( + request.Timestamp!, + request.Body, + request.Signature!, + secretResult.Secret); + } + finally + { + CryptographicOperations.ZeroMemory(secretResult.Secret); + } + + if (!signatureValid) + { + Rejected.Add(1); + return Unauthorized(); + } + + WorkOrderWebhookEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(request.Body, JsonOptions); + } + catch (JsonException) + { + Invalid.Add(1); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope); + } + + if (!TryCreateMutation(envelope, request.Body, out var mutation)) + { + Invalid.Add(1); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.InvalidEnvelope); + } + + try + { + var persisted = await _dataService.ApplyAsync(mutation!, cancellationToken); + switch (persisted.Status) + { + case WorkOrderWebhookPersistenceStatus.Applied: + Accepted.Add(1); + _logger.LogInformation( + "Work-order webhook delivery {DeliveryId} was accepted. State mutation skipped: {StateMutationSkipped}.", + mutation!.DeliveryId, + persisted.StateMutationSkipped); + return new WorkOrderWebhookResult( + WorkOrderWebhookStatus.Applied, + persisted.StateMutationSkipped); + case WorkOrderWebhookPersistenceStatus.Duplicate: + Duplicates.Add(1); + _logger.LogInformation( + "Work-order webhook delivery {DeliveryId} was already processed.", + mutation!.DeliveryId); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Duplicate); + case WorkOrderWebhookPersistenceStatus.HashConflict: + Rejected.Add(1); + _logger.LogWarning( + "Work-order webhook delivery {DeliveryId} conflicts with an existing delivery.", + mutation!.DeliveryId); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.HashConflict); + default: + throw new InvalidOperationException("Unknown persistence result."); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + Failed.Add(1); + _logger.LogError( + ex, + "Work-order webhook delivery {DeliveryId} could not be persisted.", + mutation!.DeliveryId); + return new WorkOrderWebhookResult(WorkOrderWebhookStatus.Unavailable); + } + } + + private bool TryReadTimestamp( + string? value, + int allowedClockSkewSeconds, + out DateTimeOffset timestamp) + { + timestamp = default; + if (string.IsNullOrEmpty(value) + || value.Any(c => c is < '0' or > '9') + || !long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var seconds)) + return false; + + try + { + timestamp = DateTimeOffset.FromUnixTimeSeconds(seconds); + } + catch (ArgumentOutOfRangeException) + { + return false; + } + + var skew = (_timeProvider.GetUtcNow() - timestamp).Duration(); + return skew <= TimeSpan.FromSeconds(Math.Clamp(allowedClockSkewSeconds, 0, 3600)); + } + + private static bool VerifySignature( + string timestamp, + byte[] body, + string signature, + byte[] secret) + { + const string prefix = "v1="; + if (!signature.StartsWith(prefix, StringComparison.Ordinal) + || signature.Length != prefix.Length + 64) + return false; + + byte[] supplied; + try + { + supplied = Convert.FromHexString(signature[prefix.Length..]); + } + catch (FormatException) + { + return false; + } + + var timestampBytes = Encoding.UTF8.GetBytes(timestamp); + var signed = new byte[timestampBytes.Length + 1 + body.Length]; + timestampBytes.CopyTo(signed, 0); + signed[timestampBytes.Length] = (byte)'.'; + body.CopyTo(signed, timestampBytes.Length + 1); + var expected = HMACSHA256.HashData(secret, signed); + return CryptographicOperations.FixedTimeEquals(expected, supplied); + } + + private bool TryCreateMutation( + WorkOrderWebhookEnvelope? envelope, + byte[] body, + out WorkOrderWebhookMutation? mutation) + { + mutation = null; + if (envelope == null + || envelope.SchemaVersion != 1 + || string.IsNullOrWhiteSpace(envelope.DeliveryId) + || envelope.DeliveryId.Length > 128 + || string.IsNullOrWhiteSpace(envelope.EventType) + || envelope.EventType.Length > 64 + || envelope.OccurredAt == null + || string.IsNullOrWhiteSpace(envelope.Source) + || envelope.Source.Length > 128 + || envelope.Replay == null + || envelope.Data == null + || string.IsNullOrWhiteSpace(envelope.Data.WorkOrderId) + || envelope.Data.WorkOrderId.Length > 450 + || !KnownEvents.Contains(envelope.EventType)) + return false; + + var isComment = envelope.EventType == "work_order.comment_added"; + if (isComment + && (string.IsNullOrWhiteSpace(envelope.Data.CommentId) + || envelope.Data.CommentId.Length > 450 + || string.IsNullOrWhiteSpace(envelope.Data.Text))) + return false; + + var statusSource = envelope.Data.WoStatus ?? envelope.Data.Status; + mutation = new WorkOrderWebhookMutation + { + DeliveryId = envelope.DeliveryId, + EventType = envelope.EventType, + OccurredAt = envelope.OccurredAt.Value, + ProcessedAt = _timeProvider.GetUtcNow(), + BodySha256 = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant(), + ExternalWorkOrderId = envelope.Data.WorkOrderId, + WorkerOrderNumber = envelope.Data.WorkOrderId, + Source = envelope.Source, + IsStateEvent = !isComment, + IsCancelled = envelope.EventType == "work_order.cancelled", + Title = envelope.Data.Title, + Description = envelope.Data.Description, + Status = WorkOrderIngestFieldMapper.MapStatus(statusSource), + Severity = envelope.Data.Severity, + Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(envelope.Data.Severity), + Customer = envelope.Data.Customer, + SiteCode = envelope.Data.SiteCode, + Building = envelope.Data.Building, + Address = envelope.Data.Address, + DueDate = envelope.Data.DueDate?.UtcDateTime, + DateReported = envelope.Data.DateReported?.UtcDateTime, + ScheduledStart = envelope.Data.ScheduledStart?.UtcDateTime, + CreatedAt = envelope.Data.CreatedAt?.UtcDateTime, + CommentId = isComment ? envelope.Data.CommentId : null, + CommentText = isComment ? envelope.Data.Text : null, + Commenter = isComment ? envelope.Data.Commenter : null, + CommentType = isComment ? envelope.Data.CommentType : null + }; + return true; + } + + private static WorkOrderWebhookResult Unauthorized() => + new(WorkOrderWebhookStatus.Unauthorized); + + private static readonly HashSet KnownEvents = new(StringComparer.Ordinal) + { + "work_order.created", + "work_order.updated", + "work_order.cancelled", + "work_order.comment_added" + }; + + private sealed class WorkOrderWebhookEnvelope + { + [JsonPropertyName("schema_version")] + public int? SchemaVersion { get; set; } + + [JsonPropertyName("delivery_id")] + public string? DeliveryId { get; set; } + + [JsonPropertyName("event_type")] + public string? EventType { get; set; } + + [JsonPropertyName("occurred_at")] + public DateTimeOffset? OccurredAt { get; set; } + + public string? Source { get; set; } + public bool? Replay { get; set; } + public WorkOrderWebhookData? Data { get; set; } + } + + private sealed class WorkOrderWebhookData + { + [JsonPropertyName("work_order_id")] + public string? WorkOrderId { get; set; } + + public string? Description { get; set; } + public string? Title { get; set; } + + [JsonPropertyName("wo_status")] + public string? WoStatus { get; set; } + + public string? Status { get; set; } + public string? Severity { get; set; } + public string? Customer { get; set; } + + [JsonPropertyName("site_code")] + public string? SiteCode { get; set; } + + public string? Building { get; set; } + public string? Address { get; set; } + + [JsonPropertyName("due_date")] + public DateTimeOffset? DueDate { get; set; } + + [JsonPropertyName("date_reported")] + public DateTimeOffset? DateReported { get; set; } + + [JsonPropertyName("scheduled_start")] + public DateTimeOffset? ScheduledStart { get; set; } + + [JsonPropertyName("created_at")] + public DateTimeOffset? CreatedAt { get; set; } + + [JsonPropertyName("comment_id")] + public string? CommentId { get; set; } + + public string? Text { get; set; } + public string? Commenter { get; set; } + + [JsonPropertyName("comment_type")] + public string? CommentType { get; set; } + } + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderWebhookSecretProvider.cs b/SeaHaven.Services/Interfaces/IWorkOrderWebhookSecretProvider.cs new file mode 100644 index 0000000..baceed3 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderWebhookSecretProvider.cs @@ -0,0 +1,20 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderWebhookSecretProvider + { + Task GetSecretAsync( + string keyId, + CancellationToken cancellationToken); + } + + public enum WorkOrderWebhookSecretStatus + { + Found, + UnknownKey, + Unavailable + } + + public sealed record WorkOrderWebhookSecretResult( + WorkOrderWebhookSecretStatus Status, + byte[]? Secret = null); +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderWebhookService.cs b/SeaHaven.Services/Interfaces/IWorkOrderWebhookService.cs new file mode 100644 index 0000000..488e68d --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderWebhookService.cs @@ -0,0 +1,32 @@ +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderWebhookService + { + int MaximumBodyBytes { get; } + + Task ProcessAsync( + WorkOrderWebhookRequest request, + CancellationToken cancellationToken); + } + + public sealed record WorkOrderWebhookRequest( + string? Timestamp, + string? KeyId, + string? Signature, + byte[] Body); + + public enum WorkOrderWebhookStatus + { + Applied, + Duplicate, + Unauthorized, + Disabled, + InvalidEnvelope, + HashConflict, + Unavailable + } + + public sealed record WorkOrderWebhookResult( + WorkOrderWebhookStatus Status, + bool StateMutationSkipped = false); +} diff --git a/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs b/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs new file mode 100644 index 0000000..c8ff0b9 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderWebhookTests.cs @@ -0,0 +1,441 @@ +using System.Security.Cryptography; +using System.Text; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public sealed class WorkOrderWebhookServiceTests +{ + private static readonly byte[] Secret = Encoding.UTF8.GetBytes("test-secret-with-enough-entropy"); + private static readonly DateTimeOffset Now = new(2026, 7, 24, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task Valid_signature_is_parsed_and_forwarded_with_cancellation() + { + var data = new RecordingDataService(); + var service = CreateService(data); + var body = ValidBody(); + var timestamp = Now.ToUnixTimeSeconds().ToString(); + using var cts = new CancellationTokenSource(); + + var result = await service.ProcessAsync( + new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body), + cts.Token); + + Assert.Equal(WorkOrderWebhookStatus.Applied, result.Status); + Assert.NotNull(data.Mutation); + Assert.Equal("delivery-1", data.Mutation.DeliveryId); + Assert.Equal("WO-123", data.Mutation.WorkerOrderNumber); + Assert.Equal(cts.Token, data.CancellationToken); + } + + [Fact] + public async Task Provider_external_id_is_preserved_without_internal_number_normalization() + { + var data = new RecordingDataService(); + var service = CreateService(data); + var externalId = "WO-PROCUREMENT-2026-000000000123"; + var body = ValidBody(workOrderId: externalId); + var timestamp = Now.ToUnixTimeSeconds().ToString(); + + var result = await service.ProcessAsync( + new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body), + CancellationToken.None); + + Assert.Equal(WorkOrderWebhookStatus.Applied, result.Status); + Assert.Equal(externalId, data.Mutation!.ExternalWorkOrderId); + Assert.Equal(externalId, data.Mutation.WorkerOrderNumber); + } + + [Theory] + [InlineData(-300, WorkOrderWebhookStatus.Applied)] + [InlineData(300, WorkOrderWebhookStatus.Applied)] + [InlineData(-301, WorkOrderWebhookStatus.Unauthorized)] + [InlineData(301, WorkOrderWebhookStatus.Unauthorized)] + public async Task Timestamp_boundaries_are_enforced( + int offsetSeconds, + WorkOrderWebhookStatus expected) + { + var data = new RecordingDataService(); + var service = CreateService(data); + var body = ValidBody(); + var timestamp = Now.AddSeconds(offsetSeconds).ToUnixTimeSeconds().ToString(); + + var result = await service.ProcessAsync( + new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body), + CancellationToken.None); + + Assert.Equal(expected, result.Status); + Assert.Equal(expected == WorkOrderWebhookStatus.Applied, data.Mutation != null); + } + + [Fact] + public async Task Tampered_body_is_rejected_before_parse_or_persistence() + { + var data = new RecordingDataService(); + var service = CreateService(data); + var signedBody = ValidBody(); + var tampered = Encoding.UTF8.GetBytes("{not-json"); + var timestamp = Now.ToUnixTimeSeconds().ToString(); + + var result = await service.ProcessAsync( + new WorkOrderWebhookRequest( + timestamp, + "current", + Sign(timestamp, signedBody), + tampered), + CancellationToken.None); + + Assert.Equal(WorkOrderWebhookStatus.Unauthorized, result.Status); + Assert.Null(data.Mutation); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("v1=xyz")] + public async Task Missing_or_malformed_signature_is_uniformly_unauthorized(string? signature) + { + var data = new RecordingDataService(); + var service = CreateService(data); + var body = ValidBody(); + + var result = await service.ProcessAsync( + new WorkOrderWebhookRequest( + Now.ToUnixTimeSeconds().ToString(), + "current", + signature, + body), + CancellationToken.None); + + Assert.Equal(WorkOrderWebhookStatus.Unauthorized, result.Status); + Assert.Null(data.Mutation); + } + + [Fact] + public async Task Signed_malformed_known_type_is_invalid_without_persistence() + { + var data = new RecordingDataService(); + var service = CreateService(data); + var body = Encoding.UTF8.GetBytes(""" + {"schema_version":"one","delivery_id":"d","event_type":"work_order.created", + "occurred_at":"2026-07-24T12:00:00Z","source":"procurement","replay":false, + "data":{"work_order_id":"123"}} + """); + var timestamp = Now.ToUnixTimeSeconds().ToString(); + + var result = await service.ProcessAsync( + new WorkOrderWebhookRequest(timestamp, "current", Sign(timestamp, body), body), + CancellationToken.None); + + Assert.Equal(WorkOrderWebhookStatus.InvalidEnvelope, result.Status); + Assert.Null(data.Mutation); + } + + private static WorkOrderWebhookService CreateService(RecordingDataService data) => + new( + new StaticSecretProvider(), + data, + new StaticOptionsMonitor(new WorkOrderWebhookOptions + { + Enabled = true, + AllowedClockSkewSeconds = 300, + KeySecrets = new Dictionary { ["current"] = "unused" } + }), + new FixedTimeProvider(Now), + NullLogger.Instance); + + private static byte[] ValidBody( + string deliveryId = "delivery-1", + string workOrderId = "WO-123") => + Encoding.UTF8.GetBytes( + "{\"schema_version\":1,\"delivery_id\":\"" + deliveryId + + "\",\"event_type\":\"work_order.created\"," + + "\"occurred_at\":\"2026-07-24T11:59:00Z\",\"source\":\"procurement\"," + + "\"replay\":false,\"data\":{\"work_order_id\":\"" + workOrderId + "\"," + + "\"title\":\"Leaking pipe\",\"wo_status\":\"new\",\"severity\":\"2\"}}"); + + private static string Sign(string timestamp, byte[] body) + { + var prefix = Encoding.UTF8.GetBytes(timestamp + "."); + var signed = new byte[prefix.Length + body.Length]; + prefix.CopyTo(signed, 0); + body.CopyTo(signed, prefix.Length); + return "v1=" + Convert.ToHexString(HMACSHA256.HashData(Secret, signed)).ToLowerInvariant(); + } + + private sealed class StaticSecretProvider : IWorkOrderWebhookSecretProvider + { + public Task GetSecretAsync( + string keyId, + CancellationToken cancellationToken) => + Task.FromResult(keyId == "current" + ? new WorkOrderWebhookSecretResult( + WorkOrderWebhookSecretStatus.Found, + (byte[])Secret.Clone()) + : new WorkOrderWebhookSecretResult(WorkOrderWebhookSecretStatus.UnknownKey)); + } + + private sealed class RecordingDataService : IWorkOrderWebhookDataService + { + public WorkOrderWebhookMutation? Mutation { get; private set; } + public CancellationToken CancellationToken { get; private set; } + + public Task ApplyAsync( + WorkOrderWebhookMutation mutation, + CancellationToken cancellationToken) + { + Mutation = mutation; + CancellationToken = cancellationToken; + return Task.FromResult(new WorkOrderWebhookPersistenceResult( + WorkOrderWebhookPersistenceStatus.Applied)); + } + } +} + +public sealed class WorkOrderWebhookDataServiceTests +{ + [Fact] + public async Task State_comment_staleness_and_delivery_deduplication_are_atomic() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var context = new CountingDbContext(options); + await context.Database.EnsureCreatedAsync(); + var data = new WorkOrderWebhookDataService(context); + + var comment = Mutation( + "comment-delivery", + "hash-comment", + "work_order.comment_added", + new DateTimeOffset(2026, 7, 24, 10, 0, 0, TimeSpan.Zero), + commentId: "comment-1"); + var commentResult = await data.ApplyAsync(comment, CancellationToken.None); + + Assert.Equal(WorkOrderWebhookPersistenceStatus.Applied, commentResult.Status); + var skeleton = await context.workOrders.SingleAsync(); + Assert.Equal("123", skeleton.ExternalWorkOrderId); + Assert.Equal("00000000001", skeleton.InternalWONumber); + Assert.Equal("00000000123", skeleton.WorkerOrderNumber); + Assert.Equal(skeleton.Id, (await context.Comments.SingleAsync()).WorkerOrderId); + + var state = Mutation( + "state-delivery", + "hash-state", + "work_order.updated", + new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero), + title: "Current title"); + await data.ApplyAsync(state, CancellationToken.None); + + var stale = Mutation( + "stale-delivery", + "hash-stale", + "work_order.updated", + new DateTimeOffset(2026, 7, 24, 11, 0, 0, TimeSpan.Zero), + title: "Stale title"); + var staleResult = await data.ApplyAsync(stale, CancellationToken.None); + + Assert.True(staleResult.StateMutationSkipped); + Assert.Equal("Current title", (await context.workOrders.SingleAsync()).WorkerOrderTitle); + Assert.Equal(3, await context.WorkOrderWebhookDeliveries.CountAsync()); + + var duplicate = await data.ApplyAsync(state, CancellationToken.None); + Assert.Equal(WorkOrderWebhookPersistenceStatus.Duplicate, duplicate.Status); + + var conflict = await data.ApplyAsync( + state with { BodySha256 = "different-hash" }, + CancellationToken.None); + Assert.Equal(WorkOrderWebhookPersistenceStatus.HashConflict, conflict.Status); + Assert.Equal(3, context.SaveCount); + } + + [Fact] + public async Task Cancel_event_forces_cancelled_status() + { + await using var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + await using var context = new ApplicationDbContext(options); + await context.Database.EnsureCreatedAsync(); + var data = new WorkOrderWebhookDataService(context); + var mutation = Mutation( + "cancel-delivery", + "hash", + "work_order.cancelled", + new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero)) with + { + IsCancelled = true, + Status = "Open" + }; + + await data.ApplyAsync(mutation, CancellationToken.None); + + Assert.Equal("Cancelled", (await context.workOrders.SingleAsync()).Status); + } + + private static WorkOrderWebhookMutation Mutation( + string deliveryId, + string bodyHash, + string eventType, + DateTimeOffset occurredAt, + string? title = null, + string? commentId = null) => + new() + { + DeliveryId = deliveryId, + EventType = eventType, + OccurredAt = occurredAt, + ProcessedAt = occurredAt.AddMinutes(1), + BodySha256 = bodyHash, + ExternalWorkOrderId = "123", + WorkerOrderNumber = "00000000123", + Source = "procurement", + IsStateEvent = eventType != "work_order.comment_added", + Title = title, + Status = "Open", + CommentId = commentId, + CommentText = commentId == null ? null : "A comment" + }; + + private sealed class CountingDbContext : ApplicationDbContext + { + public CountingDbContext(DbContextOptions options) + : base(options) + { + } + + public int SaveCount { get; private set; } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + SaveCount++; + return base.SaveChangesAsync(cancellationToken); + } + } +} + +public sealed class WorkOrderWebhookControllerTests +{ + [Theory] + [InlineData("text/plain")] + [InlineData("application/xml")] + public async Task Unsupported_media_type_is_rejected_before_service(string contentType) + { + var fake = new ControllerService(); + var controller = CreateController(fake, Encoding.UTF8.GetBytes("{}"), contentType); + + var result = await controller.Receive(CancellationToken.None); + + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, Assert.IsType(result).StatusCode); + Assert.False(fake.Called); + } + + [Fact] + public async Task Streaming_body_over_limit_is_rejected_before_service() + { + var fake = new ControllerService { MaximumBodyBytes = 4 }; + var controller = CreateController( + fake, + Encoding.UTF8.GetBytes("12345"), + "application/json", + contentLength: null); + + var result = await controller.Receive(CancellationToken.None); + + Assert.Equal(StatusCodes.Status413PayloadTooLarge, Assert.IsType(result).StatusCode); + Assert.False(fake.Called); + } + + [Fact] + public async Task Valid_request_forwards_exact_body_and_cancellation() + { + var body = Encoding.UTF8.GetBytes("{\"x\":1}"); + var fake = new ControllerService(); + var controller = CreateController(fake, body, "application/json; charset=utf-8"); + controller.Request.Headers["X-SH-Timestamp"] = "1"; + controller.Request.Headers["X-SH-Key-Id"] = "key"; + controller.Request.Headers["X-SH-Signature"] = "sig"; + using var cts = new CancellationTokenSource(); + + var result = await controller.Receive(cts.Token); + + Assert.IsType(result); + Assert.Equal(body, fake.Request!.Body); + Assert.Equal(cts.Token, fake.CancellationToken); + } + + private static WorkOrderWebhookController CreateController( + ControllerService service, + byte[] body, + string contentType, + long? contentLength = 0) + { + var context = new DefaultHttpContext(); + context.Request.Body = new MemoryStream(body); + context.Request.ContentType = contentType; + context.Request.ContentLength = contentLength == 0 ? body.Length : contentLength; + return new WorkOrderWebhookController(service) + { + ControllerContext = new ControllerContext { HttpContext = context } + }; + } + + private sealed class ControllerService : IWorkOrderWebhookService + { + public int MaximumBodyBytes { get; set; } = 1_048_576; + public bool Called { get; private set; } + public WorkOrderWebhookRequest? Request { get; private set; } + public CancellationToken CancellationToken { get; private set; } + + public Task ProcessAsync( + WorkOrderWebhookRequest request, + CancellationToken cancellationToken) + { + Called = true; + Request = request; + CancellationToken = cancellationToken; + return Task.FromResult(new WorkOrderWebhookResult(WorkOrderWebhookStatus.Applied)); + } + } +} + +internal sealed class FixedTimeProvider : TimeProvider +{ + private readonly DateTimeOffset _utcNow; + + public FixedTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; +} + +internal sealed class StaticOptionsMonitor : IOptionsMonitor +{ + public StaticOptionsMonitor(T value) + { + CurrentValue = value; + } + + public T CurrentValue { get; } + public T Get(string? name) => CurrentValue; + public IDisposable? OnChange(Action listener) => null; +}