From b545d4a4fe6a735b978b9204fcb272398067e1c6 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 10:57:34 -0300 Subject: [PATCH 01/11] feat(work-orders): add Overdue work order type Overdue (8) is a dispatcher-assigned type, separate from the derived past-due overlay. It takes no severity, resolves services and completion-doc templates from the PM catalog, and filters as its own type. The past-due flag now narrows a type filter instead of widening it, and the dashboard breakdown partitions by stored type. --- .../DashboardServiceTests.cs | 4 +- .../Enums/WorkOrderEnums.cs | 2 + .../Helpers/WorkOrderBoardQueryFilters.cs | 10 +- .../Helpers/WorkOrderBoardSearchFilter.cs | 1 + SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs | 2 +- .../DTOs/WorkOrderBoardRequestDTOs.cs | 2 +- SeaHaven.Services/Helpers/DashboardMetrics.cs | 25 +- .../Helpers/WorkOrderCatalogType.cs | 17 + .../Helpers/WorkOrderTypeMapper.cs | 5 +- .../WorkOrderBoardCreateService.cs | 5 +- .../WorkOrderBoardUpdateService.cs | 13 +- .../WorkOrderCompletionService.cs | 4 +- .../Implementation/WorkOrderDetailService.cs | 2 +- .../WorkOrderBoardSearchTests.cs | 13 +- .../WorkOrderBoardUpdateServiceTests.cs | 20 +- .../WorkOrderOverdueTypeTests.cs | 393 ++++++++++++++++++ .../WorkOrderTypeMapperTests.cs | 9 +- 17 files changed, 461 insertions(+), 66 deletions(-) create mode 100644 SeaHaven.Services/Helpers/WorkOrderCatalogType.cs create mode 100644 SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs diff --git a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs index 2cc73ee..7358083 100644 --- a/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/DashboardServiceTests.cs @@ -213,10 +213,10 @@ public class DashboardServiceTests AccountUser(1), query, CancellationToken.None); stats.Total.Should().Be(4); - stats.Breakdown.Overdue.Should().Be(1); + stats.Breakdown.Overdue.Should().Be(0); stats.Breakdown.Other.Should().Be(0); stats.Breakdown.PM.Should().Be(2); - stats.Breakdown.Emergency.Should().Be(0); + stats.Breakdown.Emergency.Should().Be(1); stats.Breakdown.Reactive.Should().Be(1); (stats.Breakdown.PM + stats.Breakdown.Emergency + stats.Breakdown.Reactive + stats.Breakdown.Overdue + stats.Breakdown.Other).Should().Be(stats.Total); diff --git a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs index f50824f..11ada94 100644 --- a/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs +++ b/Data.SeaHavenIndustries/Enums/WorkOrderEnums.cs @@ -35,6 +35,8 @@ namespace Data.SeaHavenIndustries.Enums Inspection = 5, Reactive = 6, AddOn = 7, + /// Client never responded after the due date passed. Distinct from the derived past-due overlay. + Overdue = 8, Other = 99 } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index 583aaa2..6557db2 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -58,9 +58,9 @@ namespace SeaHaven.DataServices.Helpers } /// - /// Filters by real (including ) - /// and/or past-due rows when is true. - /// Types and overdue are combined with OR when both are present. + /// Filters by stored (including and + /// ) and/or past-due rows when is true. + /// Past due is a status overlay, so types and overdue are combined with AND when both are present. /// public static IQueryable ApplyTypeAndOverdueFilter( IQueryable query, @@ -94,8 +94,8 @@ namespace SeaHaven.DataServices.Helpers && w.ScheduledDate.Value.Date < today && w.LifecycleStatus != LifecycleStatus.Completed && w.LifecycleStatus != LifecycleStatus.Canceled) - || (w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)) - || (includeAddOnFlag && w.IsAddOn)); + && ((w.WorkOrderType != null && typeFilters.Contains(w.WorkOrderType.Value)) + || (includeAddOnFlag && w.IsAddOn))); } return query.Where(w => diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs index 441802b..5232121 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardSearchFilter.cs @@ -57,6 +57,7 @@ namespace SeaHaven.DataServices.Helpers || (w.WorkOrderType == WorkOrderType.Project && "project".Contains(s)) || (w.WorkOrderType == WorkOrderType.Inspection && "inspection".Contains(s)) || (w.WorkOrderType == WorkOrderType.Reactive && "reactive".Contains(s)) + || (w.WorkOrderType == WorkOrderType.Overdue && "overdue".Contains(s)) || (w.WorkOrderType == WorkOrderType.AddOn && ("add-on".Contains(s) || "addon".Contains(s))) || (w.IsAddOn && ("add-on".Contains(s) || "addon".Contains(s))) || (w.WorkOrderType == WorkOrderType.Other && "other".Contains(s)) diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index b6ca553..44938d8 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -10,7 +10,7 @@ namespace SeaHaven.Services.DTOs public List? Dispatchers { get; set; } public bool MyWorkOrders { get; set; } public List? Types { get; set; } - /// When true, include past-due (isPastDue) rows. Combined with Types via OR. + /// When true, keep only past-due (isPastDue) rows. Combined with Types via AND. public bool Overdue { get; set; } public string? Search { get; set; } } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 38b4c24..de8f90d 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -98,7 +98,7 @@ namespace SeaHaven.Services.DTOs public List? Sites { get; set; } public List? Regions { get; set; } public List? Types { get; set; } - /// When true, include past-due (isPastDue) rows. Combined with Types via OR. + /// When true, keep only past-due (isPastDue) rows. Combined with Types via AND. public bool Overdue { get; set; } public List? Dispatchers { get; set; } public List? Statuses { get; set; } diff --git a/SeaHaven.Services/Helpers/DashboardMetrics.cs b/SeaHaven.Services/Helpers/DashboardMetrics.cs index 885ac7d..b75101d 100644 --- a/SeaHaven.Services/Helpers/DashboardMetrics.cs +++ b/SeaHaven.Services/Helpers/DashboardMetrics.cs @@ -21,9 +21,7 @@ public static class DashboardMetrics ?? LifecycleStatusMapper.ParseLifecycleStatus(workOrder.LegacyStatus); var completed = status == LifecycleStatus.Completed; var canceled = status == LifecycleStatus.Canceled; - var pastDue = IsPastDue(workOrder, status, today); - - AddBreakdownCount(breakdown, workOrder.WorkOrderType, pastDue); + AddBreakdownCount(breakdown, workOrder.WorkOrderType); var due = completed || (workOrder.ScheduledDate is DateTime scheduled && DateOnly.FromDateTime(scheduled.Date) <= today); @@ -81,25 +79,11 @@ public static class DashboardMetrics .ThenBy(bucket => bucket.Status, StringComparer.OrdinalIgnoreCase) .ToList(); - private static bool IsPastDue( - DashboardWorkOrder workOrder, - LifecycleStatus? status, - DateOnly today) - => workOrder.ScheduledDate is DateTime scheduled - && DateOnly.FromDateTime(scheduled.Date) < today - && status is not LifecycleStatus.Completed and not LifecycleStatus.Canceled; - + // Partition by the stored type only; past due is an overlay on any type, not a bucket. private static void AddBreakdownCount( DashboardBreakdownDTO breakdown, - WorkOrderType? type, - bool pastDue) + WorkOrderType? type) { - if (pastDue) - { - breakdown.Overdue++; - return; - } - switch (type) { case WorkOrderType.PM: @@ -111,6 +95,9 @@ public static class DashboardMetrics case WorkOrderType.Reactive: breakdown.Reactive++; break; + case WorkOrderType.Overdue: + breakdown.Overdue++; + break; default: breakdown.Other++; break; diff --git a/SeaHaven.Services/Helpers/WorkOrderCatalogType.cs b/SeaHaven.Services/Helpers/WorkOrderCatalogType.cs new file mode 100644 index 0000000..41daa0e --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderCatalogType.cs @@ -0,0 +1,17 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.Helpers +{ + /// + /// The services registry and completion-doc templates are keyed on PM, Reactive and Emergency. + /// An Overdue work order is PM work the client never confirmed, so it uses the PM catalog. + /// + public static class WorkOrderCatalogType + { + public static WorkOrderType For(WorkOrderType type) + => type == WorkOrderType.Overdue ? WorkOrderType.PM : type; + + public static WorkOrderType? For(WorkOrderType? type) + => type.HasValue ? For(type.Value) : null; + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs index 4eda69f..d12f555 100644 --- a/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs +++ b/SeaHaven.Services/Helpers/WorkOrderTypeMapper.cs @@ -4,7 +4,7 @@ namespace SeaHaven.Services.Helpers { public static class WorkOrderTypeMapper { - /// Board FE types. Overdue is not a type — use . + /// Board FE types. Overdue is a dispatcher-assigned type; past due is derived — see . public static bool TryParseFeLabel(string? label, out WorkOrderType type) { type = default; @@ -20,7 +20,7 @@ namespace SeaHaven.Services.Helpers "project" => Set(WorkOrderType.Project, out type), "inspection" => Set(WorkOrderType.Inspection, out type), "other" => Set(WorkOrderType.Other, out type), - "overdue" => false, + "overdue" => Set(WorkOrderType.Overdue, out type), _ => Enum.TryParse(label, true, out type) }; } @@ -30,6 +30,7 @@ namespace SeaHaven.Services.Helpers WorkOrderType.PM => "PM", WorkOrderType.Reactive => "Reactive", WorkOrderType.Emergency => "Emergency", + WorkOrderType.Overdue => "Overdue", WorkOrderType.AddOn => "Add-On", WorkOrderType.PO => "PO", WorkOrderType.Project => "Project", diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index 149a47b..6218c9b 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -298,7 +298,8 @@ namespace SeaHaven.Services.Implementation return null; } - if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrderType)) + var catalogType = WorkOrderCatalogType.For(workOrderType); + if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType)) { if (serviceId.HasValue) throw new WorkOrderBoardValidationException( @@ -307,7 +308,7 @@ namespace SeaHaven.Services.Implementation return null; } - var services = await _servicesRegistryService.GetAllAsync(true, workOrderType, cancellationToken); + var services = await _servicesRegistryService.GetAllAsync(true, catalogType, cancellationToken); if (serviceId.HasValue) { var selected = services.FirstOrDefault(service => service.Id == serviceId.Value); diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 5a04011..fcd8721 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -370,11 +370,6 @@ namespace SeaHaven.Services.Implementation if (string.IsNullOrWhiteSpace(value)) throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required."); - if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)) - throw new WorkOrderBoardValidationException( - "InvalidValue", - "Overdue is not a work order type; it is derived from dueDate (isPastDue)."); - if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); @@ -426,10 +421,11 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("ServiceInvalid", "serviceId must be a positive integer."); var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken); - if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM) + var catalogType = WorkOrderCatalogType.For(workOrder.WorkOrderType ?? WorkOrderType.PM); + if (!ServicesRegistryService.AllowedWorkOrderTypes.Contains(catalogType) || service == null || !service.IsActive - || !service.SupportedWorkOrderTypes.Contains(workOrder.WorkOrderType ?? WorkOrderType.PM)) + || !service.SupportedWorkOrderTypes.Contains(catalogType)) { throw new WorkOrderBoardValidationException( "ServiceInvalid", @@ -458,7 +454,7 @@ namespace SeaHaven.Services.Implementation throw new WorkOrderBoardValidationException("ServiceTypeInvalid", "Service registry is unavailable."); var service = await _servicesRegistryService.GetByIdAsync(serviceId, cancellationToken); - if (service == null || !service.SupportedWorkOrderTypes.Contains(workOrderType)) + if (service == null || !service.SupportedWorkOrderTypes.Contains(WorkOrderCatalogType.For(workOrderType))) throw new WorkOrderBoardValidationException( "ServiceTypeInvalid", "The selected service does not support this work order type."); @@ -467,7 +463,6 @@ namespace SeaHaven.Services.Implementation private static WorkOrderType ParseWorkOrderType(string? value) { if (string.IsNullOrWhiteSpace(value) - || string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase) || !WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed)) { throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs index 534cd6b..f2f0895 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -105,7 +105,9 @@ namespace SeaHaven.Services.Implementation await _completionData.SaveAsync(cancellationToken); var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); - var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType); + var template = await _templateData.ResolveForWorkOrderAsync( + workOrder.Trade, + WorkOrderCatalogType.For(workOrder.WorkOrderType)); var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); return new WorkOrderCompletionDto diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs index 9e77a69..432996d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -48,7 +48,7 @@ namespace SeaHaven.Services.Implementation var template = await _templateData.ResolveForWorkOrderAsync( extended?.Trade ?? boardRow.Pm, - boardRow.WorkOrderType); + WorkOrderCatalogType.For(boardRow.WorkOrderType)); var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments); var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds); diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs index 6a1f2e3..1e2ce22 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardSearchTests.cs @@ -601,7 +601,7 @@ public class WorkOrderAdvancedSearchServiceTests } [Fact] - public async Task SearchAsync_OtherPlusOverdue_ReturnsUnion() + public async Task SearchAsync_OtherPlusOverdue_ReturnsIntersection() { await using var context = CreateContext(); var today = DateTime.UtcNow.Date; @@ -610,7 +610,7 @@ public class WorkOrderAdvancedSearchServiceTests new WorkOrder { Id = 1, - WorkOrderType = WorkOrderType.PM, + WorkOrderType = WorkOrderType.Other, DueDate = today.AddDays(5), ScheduledDate = today.AddDays(-3), LifecycleStatus = LifecycleStatus.Scheduled, @@ -648,10 +648,11 @@ public class WorkOrderAdvancedSearchServiceTests Overdue = true }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); - Assert.Equal(2, result.TotalCount); - Assert.Contains(result.Items, i => i.Id == 1 && i.IsPastDue); - Assert.Contains(result.Items, i => i.Id == 2 && i.WorkOrderType == WorkOrderType.Other); - Assert.DoesNotContain(result.Items, i => i.Id == 3); + // Past due is a status overlay: it narrows the type filter instead of widening it. + var row = Assert.Single(result.Items); + Assert.Equal(1, row.Id); + Assert.True(row.IsPastDue); + Assert.Equal(WorkOrderType.Other, row.WorkOrderType); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index b2c715e..fcf096e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -581,7 +581,7 @@ public class WorkOrderBoardUpdateServiceTests } [Fact] - public async Task PatchField_RejectsOverdueAsWorkOrderType() + public async Task PatchField_AcceptsOverdueAsWorkOrderType() { var (context, service) = CreateSut(); var wo = new WorkOrder @@ -594,17 +594,15 @@ public class WorkOrderBoardUpdateServiceTests context.workOrders.Add(wo); await context.SaveChangesAsync(); - var ex = await Assert.ThrowsAsync(() => - service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto - { - Field = WorkOrderBoardFieldNames.WorkOrderType, - Value = "Overdue", - WorkOrderVersion = ToVersion(wo) - }, "actor-1")); + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Overdue", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); - Assert.Equal("InvalidValue", ex.Code); - Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal); - Assert.Contains("dueDate", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); + Assert.Equal(WorkOrderType.Overdue, (await context.workOrders.SingleAsync(w => w.Id == 1)).WorkOrderType); } [Fact] diff --git a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs new file mode 100644 index 0000000..0b3657b --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs @@ -0,0 +1,393 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +// Overdue is a dispatcher-assigned work-order type: no severity, PM catalog +// (services and completion-doc templates), and distinct from the derived +// past-due overlay. +public class WorkOrderOverdueTypeTests +{ + private const int PmServiceId = 10; + private const int ReactiveServiceId = 11; + + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static void SeedServices(ApplicationDbContext context) + { + context.Services.AddRange( + NewService(PmServiceId, "Backflow Preventers", WorkOrderType.PM), + NewService(ReactiveServiceId, "Leak Repair", WorkOrderType.Reactive)); + } + + private static Service NewService(int id, string name, WorkOrderType type) => new() + { + Id = id, + Name = name, + NormalizedName = name.ToUpperInvariant(), + Trade = "Plumbing & Water Systems", + IconKey = "plumbing-water-systems", + IsActive = true, + SupportedWorkOrderTypes = new List + { + new() { WorkOrderType = type } + } + }; + + private static WorkOrderBoardCreateService NewCreateService(ApplicationDbContext context) + { + WorkOrderAccountTestHelpers.SeedBoardCreateScope(context); + var boardData = new WorkOrderBoardDataService(context); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardCreateService( + boardData, + new WorkOrderBoardMutationDataService(context), + new WorkOrderBoardService(boardData, resolver), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), + new WorkOrderBoardCreateValidation(), + resolver, + new WorkOrderPocDataService(context), + new ServicesRegistryService(new ServicesRegistryDataService(context))); + } + + private static WorkOrderBoardUpdateService NewUpdateService(ApplicationDbContext context) + { + var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context)); + return new WorkOrderBoardUpdateService( + new WorkOrderBoardDataService(context), + new WorkOrderBoardMutationDataService(context), + new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks), + new ServicesRegistryService(new ServicesRegistryDataService(context))); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + [Fact] + public async Task Create_Overdue_WithoutSeverity_PersistsOverdueType() + { + await using var context = NewContext(); + var service = NewCreateService(context); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Overdue, + SiteCode = "BK5", + LocationId = 1 + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); + Assert.Null(result.Severity); + var persisted = await context.workOrders.SingleAsync(); + Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType); + Assert.Equal(8, (int)persisted.WorkOrderType!.Value); + } + + [Fact] + public async Task Create_Overdue_DropsSuppliedSeverity() + { + await using var context = NewContext(); + var service = NewCreateService(context); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Overdue, + SiteCode = "BK5", + LocationId = 1, + Severity = 2 + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Null(result.Severity); + Assert.Null((await context.workOrders.SingleAsync()).Severity); + } + + [Fact] + public async Task Create_Overdue_AcceptsPmCatalogService() + { + await using var context = NewContext(); + SeedServices(context); + var service = NewCreateService(context); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Overdue, + SiteCode = "BK5", + LocationId = 1, + ServiceId = PmServiceId + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1"); + + Assert.Equal(PmServiceId, result.ServiceId); + Assert.Equal("Backflow Preventers", (await context.workOrders.SingleAsync()).ServiceNameSnapshot); + } + + [Fact] + public async Task Create_Overdue_RejectsReactiveOnlyService() + { + await using var context = NewContext(); + SeedServices(context); + var service = NewCreateService(context); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Overdue, + SiteCode = "BK5", + LocationId = 1, + ServiceId = ReactiveServiceId + }, WorkOrderAccountTestHelpers.AccountUser(), "actor-1")); + + Assert.Equal("ServiceInvalid", ex.Code); + } + + [Fact] + public async Task PatchType_ToOverdue_ClearsSeverity() + { + await using var context = NewContext(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Reactive, + Severity = "3", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Overdue", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); + Assert.Null(result.Severity); + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(WorkOrderType.Overdue, persisted.WorkOrderType); + Assert.Null(persisted.Severity); + } + + [Fact] + public async Task PatchType_PmWithPmService_ToOverdue_KeepsService() + { + await using var context = NewContext(); + SeedServices(context); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.PM, + ServiceId = PmServiceId, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Overdue", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); + Assert.Equal(PmServiceId, (await context.workOrders.SingleAsync(w => w.Id == 1)).ServiceId); + } + + [Fact] + public async Task PatchServiceId_OnOverdue_AcceptsPmCatalogService() + { + await using var context = NewContext(); + SeedServices(context); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Overdue, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ServiceId, + Value = PmServiceId.ToString(), + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(PmServiceId, persisted.ServiceId); + Assert.Equal("Backflow Preventers", persisted.Trade); + } + + [Fact] + public async Task GetDetail_Overdue_ResolvesPmCompletionTemplate() + { + await using var context = NewContext(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + InternalWONumber = "10000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Overdue, + ScheduledDate = DateTime.UtcNow.AddDays(1), + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + context.CompletionDocTemplates.Add(new CompletionDocTemplate + { + Name = "PM Completion", + ServiceKey = "pm-generic", + WorkOrderType = WorkOrderType.PM, + TemplateUrl = "https://example.com/pm.pdf", + IsActive = true + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var resolver = WorkOrderAccountTestHelpers.Resolver(context); + var service = new WorkOrderDetailService( + new WorkOrderBoardService(boardData, resolver), + new WorkOrderDetailDataService(context), + new CompletionDocTemplateDataService(context), + new UserDataService(context), + resolver); + + var detail = await service.GetDetailAsync(1, WorkOrderAccountTestHelpers.OrgWideAdmin()); + + Assert.NotNull(detail); + Assert.Equal(DocStatus.No, detail!.Completion.DocStatus); + Assert.NotNull(detail.Completion.Template); + Assert.Equal("PM Completion", detail.Completion.Template!.Name); + } + + [Fact] + public async Task Search_TypeOverdue_ReturnsOverdueTypeOnly_NotPastDueRows() + { + await using var context = NewContext(); + var today = DateTime.UtcNow.Date; + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + WorkOrderType = WorkOrderType.Overdue, + ScheduledDate = today.AddDays(2), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = today.AddDays(-3), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }, + new WorkOrder + { + Id = 3, + WorkOrderType = WorkOrderType.Overdue, + ScheduledDate = today.AddDays(-2), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000003" + }); + await context.SaveChangesAsync(); + + var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), + DateTo = DateOnly.FromDateTime(today.AddMonths(1)), + Types = new List { WorkOrderType.Overdue } + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + Assert.Equal(new[] { 1, 3 }, result.Items.Select(i => i.Id).OrderBy(id => id)); + Assert.All(result.Items, i => Assert.Equal(WorkOrderType.Overdue, i.WorkOrderType)); + } + + [Fact] + public async Task Search_TypeOverdueWithPastDue_ReturnsIntersection() + { + await using var context = NewContext(); + var today = DateTime.UtcNow.Date; + context.workOrders.AddRange( + new WorkOrder + { + Id = 1, + WorkOrderType = WorkOrderType.Overdue, + ScheduledDate = today.AddDays(2), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000001" + }, + new WorkOrder + { + Id = 2, + WorkOrderType = WorkOrderType.PM, + ScheduledDate = today.AddDays(-3), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000002" + }, + new WorkOrder + { + Id = 3, + WorkOrderType = WorkOrderType.Overdue, + ScheduledDate = today.AddDays(-2), + LifecycleStatus = LifecycleStatus.Scheduled, + InternalWONumber = "10000000003" + }); + await context.SaveChangesAsync(); + + var result = await NewSearchService(context).SearchAsync(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom, + DateFrom = DateOnly.FromDateTime(today.AddMonths(-1)), + DateTo = DateOnly.FromDateTime(today.AddMonths(1)), + Types = new List { WorkOrderType.Overdue }, + Overdue = true + }, WorkOrderAccountTestHelpers.OrgWideAdmin(), null); + + var row = Assert.Single(result.Items); + Assert.Equal(3, row.Id); + Assert.True(row.IsPastDue); + } + + [Fact] + public void DashboardBreakdown_CountsByTypeOnly() + { + var today = new DateOnly(2026, 9, 25); + var pastDue = today.AddDays(-3).ToDateTime(TimeOnly.MinValue); + var future = today.AddDays(3).ToDateTime(TimeOnly.MinValue); + var rows = new List + { + new(1, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, future, null, null), + new(2, null, WorkOrderType.PM, LifecycleStatus.Scheduled, null, pastDue, null, null), + new(3, null, WorkOrderType.Overdue, LifecycleStatus.Scheduled, null, pastDue, null, null), + }; + + var result = DashboardMetrics.Calculate(rows, today); + + Assert.Equal(2, result.Breakdown.Overdue); + Assert.Equal(1, result.Breakdown.PM); + Assert.Equal(0, result.Breakdown.Other); + } + + private static WorkOrderAdvancedSearchService NewSearchService(ApplicationDbContext context) + => new(new WorkOrderAdvancedSearchDataService(context), WorkOrderAccountTestHelpers.Resolver(context)); +} diff --git a/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs b/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs index b25bcd5..e56b28c 100644 --- a/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderTypeMapperTests.cs @@ -10,21 +10,18 @@ public class WorkOrderTypeMapperTests [InlineData("Reactive", WorkOrderType.Reactive)] [InlineData("Emergency", WorkOrderType.Emergency)] [InlineData("Add-On", WorkOrderType.AddOn)] + [InlineData("Overdue", WorkOrderType.Overdue)] + [InlineData("overdue", WorkOrderType.Overdue)] public void TryParseFeLabel_BoardTypes(string label, WorkOrderType expected) { Assert.True(WorkOrderTypeMapper.TryParseFeLabel(label, out var type)); Assert.Equal(expected, type); } - [Fact] - public void TryParseFeLabel_Overdue_ReturnsFalse() - { - Assert.False(WorkOrderTypeMapper.TryParseFeLabel("Overdue", out _)); - } - [Theory] [InlineData(WorkOrderType.AddOn, "Add-On")] [InlineData(WorkOrderType.Reactive, "Reactive")] + [InlineData(WorkOrderType.Overdue, "Overdue")] public void ToFeLabel_ReturnsBoardLabel(WorkOrderType type, string expected) { Assert.Equal(expected, WorkOrderTypeMapper.ToFeLabel(type)); From d82fb18a3d2946b4cb130fb3b7232da8c26d3f26 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:30:34 -0300 Subject: [PATCH 02/11] fix(work-orders): map Overdue to PM catalog in service and template lists --- .../Implementation/ServicesRegistryService.cs | 6 ++- .../WorkOrderCompletionService.cs | 2 +- .../WorkOrderOverdueTypeTests.cs | 50 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/SeaHaven.Services/Implementation/ServicesRegistryService.cs b/SeaHaven.Services/Implementation/ServicesRegistryService.cs index 1a702bb..29bd32b 100644 --- a/SeaHaven.Services/Implementation/ServicesRegistryService.cs +++ b/SeaHaven.Services/Implementation/ServicesRegistryService.cs @@ -5,6 +5,7 @@ using SeaHaven.DataServices.Interfaces; using SeaHaven.DataServices.Models; using SeaHaven.Services.DTOs; using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHaven.Services.Implementation @@ -34,8 +35,9 @@ namespace SeaHaven.Services.Implementation public async Task> GetAllAsync(bool? isActive, WorkOrderType? workOrderType, CancellationToken cancellationToken) { - ValidateWorkOrderTypeFilter(workOrderType); - var rows = await _dataService.GetServiceRowsAsync(isActive, workOrderType, cancellationToken); + var catalogType = WorkOrderCatalogType.For(workOrderType); + ValidateWorkOrderTypeFilter(catalogType); + var rows = await _dataService.GetServiceRowsAsync(isActive, catalogType, cancellationToken); return rows.Select(MapToDto).ToList(); } diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs index f2f0895..c748c3d 100644 --- a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -33,7 +33,7 @@ namespace SeaHaven.Services.Implementation public async Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) { - var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType); + var rows = await _templateData.GetActiveAsync(serviceKey, WorkOrderCatalogType.For(workOrderType)); return rows.Select(WorkOrderDetailService.MapTemplate).ToList(); } diff --git a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs index 0b3657b..35c04e6 100644 --- a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs @@ -278,6 +278,56 @@ public class WorkOrderOverdueTypeTests Assert.Equal("PM Completion", detail.Completion.Template!.Name); } + [Fact] + public async Task ListServices_Overdue_ReturnsPmCatalog() + { + await using var context = NewContext(); + SeedServices(context); + await context.SaveChangesAsync(); + + var services = await new ServicesRegistryService(new ServicesRegistryDataService(context)) + .GetAllAsync(true, WorkOrderType.Overdue, CancellationToken.None); + + Assert.Equal(new[] { PmServiceId }, services.Select(s => s.Id).ToArray()); + } + + [Fact] + public async Task ListTemplates_Overdue_ReturnsPmTemplates() + { + await using var context = NewContext(); + context.CompletionDocTemplates.AddRange( + new CompletionDocTemplate + { + Name = "PM Completion", + ServiceKey = "pm-generic", + WorkOrderType = WorkOrderType.PM, + TemplateUrl = "https://example.com/pm.pdf", + IsActive = true + }, + new CompletionDocTemplate + { + Name = "Reactive Completion", + ServiceKey = "reactive-generic", + WorkOrderType = WorkOrderType.Reactive, + TemplateUrl = "https://example.com/reactive.pdf", + IsActive = true + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderCompletionService( + new WorkOrderCompletionDataService(context), + new CompletionDocTemplateDataService(context), + new WorkOrderDetailDataService(context), + new WorkOrderAuditService( + new WorkOrderAuditDataService(context), + new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context))), + WorkOrderAccountTestHelpers.Resolver(context)); + + var templates = await service.GetTemplatesAsync(null, WorkOrderType.Overdue); + + Assert.Equal(new[] { "PM Completion" }, templates.Select(t => t.Name).ToArray()); + } + [Fact] public async Task Search_TypeOverdue_ReturnsOverdueTypeOnly_NotPastDueRows() { From c1910e53102c8f8453ac068d37997267de0ab407 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:41:33 -0300 Subject: [PATCH 03/11] test(work-orders): pin severity-then-type correction from Overdue The board sends one PATCH per field, severity before workOrderType, so correcting an Overdue work order to Emergency or Reactive patches the severity while the stored type is still Overdue. Dropping or rejecting a severity patch for the current type would leave the corrected Emergency work order with no severity. Overdue's no-severity rule is enforced when the type changes, not on the severity patch. --- .../WorkOrderOverdueTypeTests.cs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs index 35c04e6..7a1ee1e 100644 --- a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs @@ -183,6 +183,47 @@ public class WorkOrderOverdueTypeTests Assert.Null(persisted.Severity); } + // The board sends one request per field, severity before type, so correcting + // Overdue to Emergency patches severity while the stored type is still Overdue. + // A severity patch must not be dropped or rejected for the current type; the + // no-severity invariant is enforced when the type itself changes. + [Fact] + public async Task PatchSeverityThenType_OverdueToEmergency_KeepsSeverity() + { + await using var context = NewContext(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Overdue, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + var service = NewUpdateService(context); + + await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.Severity, + Value = "2", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + var updated = await context.workOrders.SingleAsync(w => w.Id == 1); + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Emergency", + WorkOrderVersion = ToVersion(updated) + }, "actor-1"); + + Assert.Equal(WorkOrderType.Emergency, result.WorkOrderType); + Assert.Equal(2, result.Severity); + var persisted = await context.workOrders.SingleAsync(w => w.Id == 1); + Assert.Equal(WorkOrderType.Emergency, persisted.WorkOrderType); + Assert.Equal("2", persisted.Severity); + } + [Fact] public async Task PatchType_PmWithPmService_ToOverdue_KeepsService() { From c0ae8479ce24ea31ea441f9502806dd7a45e2f29 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 11:49:10 -0300 Subject: [PATCH 04/11] feat(team-members): invite registration with emailed code confirmation (SH-385) --- .../TeamMemberInviteControllerTests.cs | 120 + .../TeamMemberServiceTests.cs | 6 +- .../Controllers/AuthenticationController.cs | 11 +- .../Controllers/TeamMemberController.cs | 19 +- .../Controllers/TeamMemberInviteController.cs | 93 + Api.SeaHavenIndustries/DTOs/LoginPayload.cs | 24 + .../Auth/ApplicationDbContext.cs | 16 + ...925142034_AddTeamMemberInvites.Designer.cs | 4289 +++++++++++++++++ .../20260925142034_AddTeamMemberInvites.cs | 64 + .../ApplicationDbContextModelSnapshot.cs | 76 + .../Models/TeamMemberInvite.cs | 44 + .../Dto/TeamMemberInviteReadModels.cs | 36 + .../TeamMemberInviteDataService.cs | 144 + .../ITeamMemberInviteDataService.cs | 30 + .../DTOs/TeamMemberInviteDTOs.cs | 71 + .../DependencyInjection/ServicesModule.cs | 2 + .../Helpers/TeamMemberInviteSecrets.cs | 62 + .../Implementation/AuthenticationService.cs | 80 +- .../Implementation/TeamMemberInviteService.cs | 119 + .../TeamMemberRegistrationService.cs | 251 + .../Implementation/TeamMemberService.cs | 11 +- .../Interfaces/IAuthenticationService.cs | 2 + .../Interfaces/ITeamMemberInviteService.cs | 23 + .../ITeamMemberRegistrationService.cs | 23 + .../TeamMemberCreateTransactionTests.cs | 20 +- .../TeamMemberInviteRegistrationTests.cs | 523 ++ .../TeamMemberInviteTestHost.cs | 258 + 27 files changed, 6363 insertions(+), 54 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs create mode 100644 Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs create mode 100644 Api.SeaHavenIndustries/DTOs/LoginPayload.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs create mode 100644 Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.cs create mode 100644 Data.SeaHavenIndustries/Models/TeamMemberInvite.cs create mode 100644 SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs create mode 100644 SeaHaven.DataServices/Implementation/TeamMemberInviteDataService.cs create mode 100644 SeaHaven.DataServices/Interfaces/ITeamMemberInviteDataService.cs create mode 100644 SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs create mode 100644 SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs create mode 100644 SeaHaven.Services/Implementation/TeamMemberInviteService.cs create mode 100644 SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs create mode 100644 SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs create mode 100644 SeaHaven.Services/Interfaces/ITeamMemberRegistrationService.cs create mode 100644 SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs create mode 100644 SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs new file mode 100644 index 0000000..516897a --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs @@ -0,0 +1,120 @@ +using System.Reflection; +using Api.SeaHavenIndustries.Controllers; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Moq; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; +using System.Security.Claims; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class TeamMemberInviteControllerTests +{ + [Fact] + public void RegistrationRequests_CarryNoUserIdentifier() + { + var requestTypes = new[] + { + typeof(TeamMemberInviteTokenRequestDTO), + typeof(VerifyTeamMemberInviteCodeRequestDTO), + typeof(CompleteTeamMemberRegistrationRequestDTO) + }; + + var properties = requestTypes + .SelectMany(type => type.GetProperties(BindingFlags.Public | BindingFlags.Instance)) + .Select(property => property.Name) + .Distinct() + .OrderBy(name => name); + + properties.Should().Equal("Code", "Password", "Phone", "Token"); + } + + [Fact] + public void RegistrationEndpoints_AreAnonymousAndNeverCached() + { + var type = typeof(TeamMemberInviteController); + + type.GetCustomAttribute().Should().NotBeNull(); + var cache = type.GetCustomAttribute(); + cache.Should().NotBeNull(); + cache!.NoStore.Should().BeTrue(); + } + + [Theory] + [InlineData(TeamMemberRegistrationStatus.InvalidInvite)] + [InlineData(TeamMemberRegistrationStatus.Ok)] + public async Task Complete_WithoutASession_ReturnsTheGenericInviteError(TeamMemberRegistrationStatus status) + { + var service = new Mock(); + service.Setup(x => x.CompleteAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberRegistrationOutcomeDTO { Status = status }); + var controller = new TeamMemberInviteController(service.Object) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } + }; + + var result = await controller.Complete(new CompleteTeamMemberRegistrationRequestDTO(), CancellationToken.None); + + var failure = result.Should().BeOfType().Subject; + failure.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + failure.Value.Should().BeEquivalentTo(new + { + code = "invalid_invite", + message = TeamMemberInviteController.InvalidInviteMessage + }); + } + + [Fact] + public void ResendInvite_RequiresAuthenticatedCaller() + { + typeof(TeamMemberController).GetCustomAttribute().Should().NotBeNull(); + typeof(TeamMemberController).GetMethod(nameof(TeamMemberController.ResendInvite))! + .GetCustomAttribute().Should().BeNull(); + } + + [Fact] + public async Task ResendInvite_Success_ReturnsInviteSent() + { + var invites = new Mock(); + invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = true }); + + var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); + + result.Should().BeOfType().Which.Value.Should().BeEquivalentTo(new { message = "Invite sent" }); + } + + [Theory] + [InlineData("Forbidden", typeof(ForbidResult))] + [InlineData("Team member not found.", typeof(NotFoundObjectResult))] + [InlineData("Only pending team members can be re-invited.", typeof(BadRequestObjectResult))] + [InlineData("The invite could not be emailed. Try again.", typeof(BadRequestObjectResult))] + public async Task ResendInvite_Failure_MapsToHttpResult(string error, Type expected) + { + var invites = new Mock(); + invites.Setup(x => x.ResendAsync("user-1", It.IsAny(), It.IsAny())) + .ReturnsAsync(new TeamMemberInviteResendOutcomeDTO { Success = false, Error = error }); + + var result = await NewTeamMemberController(invites).ResendInvite("user-1", CancellationToken.None); + + result.Should().BeOfType(expected); + } + + private static TeamMemberController NewTeamMemberController(Mock invites) + { + return new TeamMemberController(Mock.Of(), invites.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test")) + } + } + }; + } +} diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs index cd2ced8..3297fb1 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberServiceTests.cs @@ -28,7 +28,8 @@ public sealed class TeamMemberServiceTests Mock.Of(), Mock.Of(), Mock.Of(), - Mock.Of()); + Mock.Of(), + Mock.Of()); var result = await service.CreateAsync( ValidRequest() with { ServiceAreas = Array.Empty() }, @@ -408,7 +409,8 @@ public sealed class TeamMemberServiceTests areas.Object, overrides.Object, userData.Object, - permissions.Object); + permissions.Object, + Mock.Of()); } private static Mock> UserManager() diff --git a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs index 7b6569a..9b8f983 100644 --- a/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs +++ b/Api.SeaHavenIndustries/Controllers/AuthenticationController.cs @@ -33,16 +33,7 @@ namespace Api.SeaHavenIndustries.Controllers var result = await _authenticationService.LoginAsync(model.Username, model.Password, cancellationToken); if (result != null) { - return Ok(new - { - token = result.Token, - expiration = result.Expiration, - email = result.Email, - userRoles = result.UserRole, - phoneNumber = result.PhoneNumber, - fullname = result.Fullname, - id = result.Id - }); + return Ok(LoginPayload.From(result)); } return Unauthorized(); diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs index f6537b6..b9476b4 100644 --- a/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberController.cs @@ -11,10 +11,27 @@ namespace Api.SeaHavenIndustries.Controllers; public sealed class TeamMemberController : ControllerBase { private readonly ITeamMemberService _teamMemberService; + private readonly ITeamMemberInviteService _inviteService; - public TeamMemberController(ITeamMemberService teamMemberService) + public TeamMemberController(ITeamMemberService teamMemberService, ITeamMemberInviteService inviteService) { _teamMemberService = teamMemberService; + _inviteService = inviteService; + } + + [HttpPost("{userId}/invite")] + public async Task ResendInvite(string userId, CancellationToken cancellationToken) + { + var outcome = await _inviteService.ResendAsync(userId, User, cancellationToken); + if (outcome.Success) + return Ok(new { message = "Invite sent" }); + + return outcome.Error switch + { + "Forbidden" => Forbid(), + "Team member not found." => NotFound(new { message = outcome.Error }), + _ => BadRequest(new { message = outcome.Error }) + }; } [HttpPost] diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs new file mode 100644 index 0000000..bd2d161 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs @@ -0,0 +1,93 @@ +using Api.SeaHavenIndustries.DTOs; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers; + +/// +/// Anonymous invite registration. The invite token travels only in request bodies, +/// and every failure maps to a fixed public message. +/// +[AllowAnonymous] +[ApiController] +[Route("api/team-member-invites")] +[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +public sealed class TeamMemberInviteController : ControllerBase +{ + public const string InvalidInviteMessage = + "This invite link is invalid or has expired. Ask your admin to send a new invite."; + + private readonly ITeamMemberRegistrationService _registrationService; + + public TeamMemberInviteController(ITeamMemberRegistrationService registrationService) + { + _registrationService = registrationService; + } + + [HttpPost("resolve")] + public async Task Resolve(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.ResolveAsync(request.Token, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok ? Ok(outcome.Details) : Failure(outcome); + } + + [HttpPost("send-code")] + public async Task SendCode(TeamMemberInviteTokenRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.SendCodeAsync(request.Token, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok + ? Ok(new { message = "Code sent" }) + : Failure(outcome); + } + + [HttpPost("verify-code")] + public async Task VerifyCode(VerifyTeamMemberInviteCodeRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.VerifyCodeAsync(request.Token, request.Code, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok + ? Ok(new { message = "Email confirmed" }) + : Failure(outcome); + } + + [HttpPost("complete")] + public async Task Complete(CompleteTeamMemberRegistrationRequestDTO request, CancellationToken cancellationToken) + { + var outcome = await _registrationService.CompleteAsync(request, cancellationToken); + return outcome.Status == TeamMemberRegistrationStatus.Ok && outcome.Session is not null + ? Ok(LoginPayload.From(outcome.Session)) + : Failure(outcome); + } + + private IActionResult Failure(TeamMemberRegistrationOutcomeDTO outcome) + { + var (statusCode, code, message) = outcome.Status switch + { + TeamMemberRegistrationStatus.CodeIncorrect => + (StatusCodes.Status400BadRequest, "code_incorrect", "Incorrect code — check your email and try again"), + TeamMemberRegistrationStatus.CodeExpired => + (StatusCodes.Status400BadRequest, "code_expired", "This code has expired — request a new code"), + TeamMemberRegistrationStatus.CodeLocked => + (StatusCodes.Status400BadRequest, "code_locked", "Too many incorrect attempts — request a new code"), + TeamMemberRegistrationStatus.ResendTooSoon => + (StatusCodes.Status429TooManyRequests, "resend_too_soon", "Please wait a moment before requesting another code"), + TeamMemberRegistrationStatus.ResendLimitReached => + (StatusCodes.Status429TooManyRequests, "resend_limit_reached", "Too many codes were requested. Ask your admin to send a new invite."), + TeamMemberRegistrationStatus.CodeDeliveryFailed => + (StatusCodes.Status503ServiceUnavailable, "code_delivery_failed", "We couldn't send the code. Try again in a minute."), + TeamMemberRegistrationStatus.EmailNotConfirmed => + (StatusCodes.Status400BadRequest, "email_not_confirmed", "Confirm your email before finishing registration"), + TeamMemberRegistrationStatus.PasswordRejected => + (StatusCodes.Status400BadRequest, "password_rejected", "Password must be at least 6 characters and include one uppercase letter, one number, and one special character."), + TeamMemberRegistrationStatus.InvalidPhone => + (StatusCodes.Status400BadRequest, "invalid_phone", "Enter a valid phone number"), + _ => (StatusCodes.Status400BadRequest, "invalid_invite", InvalidInviteMessage) + }; + + if (outcome.RetryAfterSeconds is int retryAfter) + Response.Headers.RetryAfter = retryAfter.ToString(System.Globalization.CultureInfo.InvariantCulture); + + return StatusCode(statusCode, new { code, message, retryAfterSeconds = outcome.RetryAfterSeconds }); + } +} diff --git a/Api.SeaHavenIndustries/DTOs/LoginPayload.cs b/Api.SeaHavenIndustries/DTOs/LoginPayload.cs new file mode 100644 index 0000000..9e13416 --- /dev/null +++ b/Api.SeaHavenIndustries/DTOs/LoginPayload.cs @@ -0,0 +1,24 @@ +using SeaHaven.Services.DTOs; + +namespace Api.SeaHavenIndustries.DTOs +{ + /// The session payload the web app stores after sign-in. + public static class LoginPayload + { + public static object From(LoginResultDTO result) + { + ArgumentNullException.ThrowIfNull(result); + + return new + { + token = result.Token, + expiration = result.Expiration, + email = result.Email, + userRoles = result.UserRole, + phoneNumber = result.PhoneNumber, + fullname = result.Fullname, + id = result.Id + }; + } + } +} diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index fe944ee..b2c7753 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -311,6 +311,21 @@ namespace Data.SeaHavenIndustries .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); }); + builder.Entity(entity => + { + entity.HasIndex(invite => invite.TokenHash) + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + entity.HasIndex(invite => invite.UserId) + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + entity.HasOne(invite => invite.User) + .WithMany() + .HasForeignKey(invite => invite.UserId) + .OnDelete(DeleteBehavior.Restrict); + }); + builder.Entity(entity => { entity.HasKey(area => new { area.UserId, area.Area }); @@ -388,6 +403,7 @@ namespace Data.SeaHavenIndustries public DbSet Regions { get; set; } public DbSet UserPermissionOverrides { get; set; } public DbSet UserServiceAreas { get; set; } + public DbSet TeamMemberInvites { get; set; } public override int SaveChanges() { diff --git a/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs new file mode 100644 index 0000000..0375458 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.Designer.cs @@ -0,0 +1,4289 @@ +// +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("20260925142034_AddTeamMemberInvites")] + partial class AddTeamMemberInvites + { + /// + 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.HasSequence("WorkOrderInternalNumberSequence"); + + 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("AccountId") + .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("IsAccountOwner") + .HasColumnType("bit"); + + 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("PendingRegistration") + .HasColumnType("bit"); + + b.Property("PendingRegistrationCreatedDate") + .HasColumnType("datetime2"); + + 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("AccountId"); + + b.HasIndex("IsAccountOwner") + .IsUnique() + .HasDatabaseName("IX_AspNetUsers_IsAccountOwner") + .HasFilter("IsAccountOwner = 1"); + + 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.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Areas"); + }); + + 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("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalSourceEmailS3Key") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + 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("SiteContactOrder") + .HasColumnType("int"); + + 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("EscalatedAt") + .HasColumnType("datetime2"); + + b.Property("EvidenceDocumentId") + .HasColumnType("int"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("InitialNotificationSentAt") + .HasColumnType("datetime2"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("NotificationError") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationStatus") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("RequestKey") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RequestedByVendorName") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestedNTE") + .HasColumnType("decimal(18,2)"); + + b.Property("RequiredTier") + .HasColumnType("int"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + 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") + .IsUnique() + .HasFilter("[Status] IN ('Pending', 'ChangesRequested')"); + + b.HasIndex("EvidenceDocumentId"); + + b.HasIndex("DispatchId", "RequestKey") + .IsUnique() + .HasFilter("[RequestKey] IS NOT NULL"); + + 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("AccountId") + .HasColumnType("int"); + + 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("ExternalLocationId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + 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.HasIndex("AccountId"); + + 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.Service", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CompletionDocTemplateId") + .HasColumnType("int"); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IconKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiresCompletionDocument") + .HasColumnType("bit"); + + b.Property("Trade") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("CompletionDocTemplateId"); + + b.HasIndex("IsActive"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.HasIndex("Trade"); + + b.ToTable("Services"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", 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("ServiceId") + .HasColumnType("int"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "WorkOrderType") + .IsUnique(); + + b.ToTable("ServiceWorkOrderTypes"); + }); + + 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.TeamMemberInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CodeExpiresAt") + .HasColumnType("datetime2"); + + b.Property("CodeFailedAttempts") + .HasColumnType("int"); + + b.Property("CodeHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("CodeSendCount") + .HasColumnType("int"); + + b.Property("CodeSentAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + b.ToTable("TeamMemberInvites"); + }); + + 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.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Trades"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserPermissionOverride", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("PermissionKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("State") + .HasColumnType("int"); + + b.HasKey("UserId", "PermissionKey"); + + b.HasIndex("UserId", "PermissionKey") + .IsUnique() + .HasDatabaseName("IX_UserPermissionOverrides_UserId_PermissionKey"); + + b.ToTable("UserPermissionOverrides"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Area") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.HasKey("UserId", "Area"); + + b.HasIndex("UserId", "Area") + .IsUnique() + .HasDatabaseName("IX_UserServiceAreas_UserId_Area"); + + b.ToTable("UserServiceAreas"); + }); + + 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("AreaId") + .HasColumnType("int"); + + 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("Notes") + .HasColumnType("nvarchar(max)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("State") + .HasColumnType("nvarchar(max)"); + + b.Property("Zip") + .HasColumnType("nvarchar(max)"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("AreaId"); + + 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("Purpose") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("nvarchar(30)"); + + 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("AccountId") + .HasColumnType("int"); + + b.Property("AdditionalContacts") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + 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("AvetaRequired") + .HasColumnType("bit"); + + 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("ExternalAssignedTo") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExternalLastOccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalRecordType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ExternalSource") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ExternalUpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ExternalVersionHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + 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("FrozenCompany") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenPoc") + .HasColumnType("nvarchar(max)"); + + b.Property("FrozenSite") + .HasColumnType("nvarchar(max)"); + + b.Property("InternalWONumber") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("IsAddOn") + .HasColumnType("bit"); + + 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("ServiceId") + .HasColumnType("int"); + + b.Property("ServiceNameSnapshot") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + 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("AccountId"); + + 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("ServiceId"); + + 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.WorkOrderExternalReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ProcessedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("VersionHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Source", "Kind", "ExternalId") + .IsUnique() + .HasDatabaseName("IX_WorkOrderExternalReceipts_Source_Kind_ExternalId"); + + b.ToTable("WorkOrderExternalReceipts"); + }); + + 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.WorkOrderReconciliationJob", b => + { + b.Property("Id") + .HasColumnType("int"); + + b.Property("CommentsProcessed") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetimeoffset"); + + b.Property("ErrorCode") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("FenceToken") + .HasColumnType("uniqueidentifier"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetimeoffset"); + + b.Property("Reason") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequestedAt") + .HasColumnType("datetimeoffset"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("RunId") + .HasColumnType("uniqueidentifier"); + + b.Property("StartedAt") + .HasColumnType("datetimeoffset"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("WorkOrdersProcessed") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("State"); + + b.ToTable("WorkOrderReconciliationJobs"); + }); + + 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.ApplicationUser", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + 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("Contacts") + .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.HasOne("Data.SeaHavenIndustries.VendorCompletionDocument", "EvidenceDocument") + .WithMany() + .HasForeignKey("EvidenceDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Dispatch"); + + b.Navigation("EvidenceDocument"); + }); + + 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.Locations", b => + { + b.HasOne("Data.SeaHavenIndustries.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + }); + + 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.Service", b => + { + b.HasOne("Data.SeaHavenIndustries.CompletionDocTemplate", "CompletionDocTemplate") + .WithMany() + .HasForeignKey("CompletionDocTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CompletionDocTemplate"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.ServiceWorkOrderType", b => + { + b.HasOne("Data.SeaHavenIndustries.Service", "Service") + .WithMany("SupportedWorkOrderTypes") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + 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.TeamMemberInvite", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.UserPermissionOverride", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.UserServiceArea", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany("ServiceAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + 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.VendorCompany", b => + { + b.HasOne("Data.SeaHavenIndustries.Area", "Area") + .WithMany() + .HasForeignKey("AreaId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Area"); + }); + + 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.Accounts", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Restrict); + + 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.HasOne("Data.SeaHavenIndustries.Service", "ServiceDefinition") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Account"); + + b.Navigation("AssignToUser"); + + b.Navigation("Locations"); + + b.Navigation("PrimaryDispatch"); + + b.Navigation("ServiceDefinition"); + }); + + 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("ServiceAreas"); + + 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("Contacts"); + + 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.Service", b => + { + b.Navigation("SupportedWorkOrderTypes"); + }); + + 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/20260925142034_AddTeamMemberInvites.cs b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.cs new file mode 100644 index 0000000..22ee2d0 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260925142034_AddTeamMemberInvites.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class AddTeamMemberInvites : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TeamMemberInvites", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", maxLength: 450, nullable: false), + TokenHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + UsedAt = table.Column(type: "datetime2", nullable: true), + RevokedAt = table.Column(type: "datetime2", nullable: true), + CodeHash = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + CodeSalt = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: true), + CodeExpiresAt = table.Column(type: "datetime2", nullable: true), + CodeFailedAttempts = table.Column(type: "int", nullable: false), + CodeSentAt = table.Column(type: "datetime2", nullable: true), + CodeSendCount = table.Column(type: "int", nullable: false), + EmailConfirmedAt = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TeamMemberInvites", x => x.Id); + table.ForeignKey( + name: "FK_TeamMemberInvites_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_TeamMemberInvites_TokenHash", + table: "TeamMemberInvites", + column: "TokenHash", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TeamMemberInvites_UserId", + table: "TeamMemberInvites", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TeamMemberInvites"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index 4a3d3c9..4ecca17 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -2067,6 +2067,71 @@ namespace Data.SeaHavenIndustries.Migrations b.ToTable("TaskListTemplateItems"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CodeExpiresAt") + .HasColumnType("datetime2"); + + b.Property("CodeFailedAttempts") + .HasColumnType("int"); + + b.Property("CodeHash") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CodeSalt") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("CodeSendCount") + .HasColumnType("int"); + + b.Property("CodeSentAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("EmailConfirmedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("RevokedAt") + .HasColumnType("datetime2"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique() + .HasDatabaseName("IX_TeamMemberInvites_TokenHash"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_TeamMemberInvites_UserId"); + + b.ToTable("TeamMemberInvites"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => { b.Property("Id") @@ -3785,6 +3850,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Navigation("Template"); }); + modelBuilder.Entity("Data.SeaHavenIndustries.TeamMemberInvite", b => + { + b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => { b.HasOne("Data.SeaHavenIndustries.ApplicationUser", "AssignToUser") diff --git a/Data.SeaHavenIndustries/Models/TeamMemberInvite.cs b/Data.SeaHavenIndustries/Models/TeamMemberInvite.cs new file mode 100644 index 0000000..29d2f16 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/TeamMemberInvite.cs @@ -0,0 +1,44 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace Data.SeaHavenIndustries +{ + /// + /// A single-use registration invite for one pending team member. Only hashes of + /// the invite token and of the email confirmation code are stored. + /// + public class TeamMemberInvite + { + public int Id { get; set; } + + [Required] + [MaxLength(450)] + public string UserId { get; set; } = string.Empty; + + [ForeignKey(nameof(UserId))] + public virtual ApplicationUser? User { get; set; } + + /// Lowercase hex SHA-256 of the raw invite token. + [Required] + [MaxLength(64)] + public string TokenHash { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } + public DateTime ExpiresAt { get; set; } + public DateTime? UsedAt { get; set; } + public DateTime? RevokedAt { get; set; } + + /// Lowercase hex SHA-256 of the salt followed by the confirmation code. + [MaxLength(64)] + public string? CodeHash { get; set; } + + [MaxLength(32)] + public string? CodeSalt { get; set; } + + public DateTime? CodeExpiresAt { get; set; } + public int CodeFailedAttempts { get; set; } + public DateTime? CodeSentAt { get; set; } + public int CodeSendCount { get; set; } + public DateTime? EmailConfirmedAt { get; set; } + } +} diff --git a/SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs b/SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs new file mode 100644 index 0000000..551fc50 --- /dev/null +++ b/SeaHaven.DataServices/Dto/TeamMemberInviteReadModels.cs @@ -0,0 +1,36 @@ +namespace SeaHaven.DataServices.Dto +{ + public sealed class TeamMemberInviteData + { + public required int Id { get; init; } + public required string UserId { get; init; } + public DateTime ExpiresAt { get; init; } + public DateTime? UsedAt { get; init; } + public DateTime? RevokedAt { get; init; } + public string? CodeHash { get; init; } + public string? CodeSalt { get; init; } + public DateTime? CodeExpiresAt { get; init; } + public int CodeFailedAttempts { get; init; } + public DateTime? CodeSentAt { get; init; } + public int CodeSendCount { get; init; } + public DateTime? EmailConfirmedAt { get; init; } + } + + /// + /// Replaces the confirmation code on an open invite when the resend cooldown has + /// elapsed and the send limit has not been reached. + /// + public sealed class StartTeamMemberInviteCodeCommand + { + public required int InviteId { get; init; } + public required string CodeHash { get; init; } + public required string CodeSalt { get; init; } + public required DateTime Now { get; init; } + public required DateTime CodeExpiresAt { get; init; } + + /// A code may be replaced only when the previous one was sent at or before this instant. + public required DateTime LastSentNoLaterThan { get; init; } + + public required int MaxSends { get; init; } + } +} diff --git a/SeaHaven.DataServices/Implementation/TeamMemberInviteDataService.cs b/SeaHaven.DataServices/Implementation/TeamMemberInviteDataService.cs new file mode 100644 index 0000000..4ddbd01 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/TeamMemberInviteDataService.cs @@ -0,0 +1,144 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class TeamMemberInviteDataService : ITeamMemberInviteDataService + { + private readonly ApplicationDbContext _context; + + public TeamMemberInviteDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(invite); + + _context.TeamMemberInvites.Add(invite); + await _context.SaveChangesAsync(cancellationToken); + } + + public async Task ReplaceOpenForUserAsync( + TeamMemberInvite invite, + DateTime now, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(invite); + + await using var transaction = await _context.Database.BeginTransactionAsync(cancellationToken); + await _context.TeamMemberInvites + .Where(existing => existing.UserId == invite.UserId + && existing.UsedAt == null + && existing.RevokedAt == null) + .ExecuteUpdateAsync( + setters => setters.SetProperty(existing => existing.RevokedAt, now), + cancellationToken); + _context.TeamMemberInvites.Add(invite); + await _context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + + public Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) + { + return _context.TeamMemberInvites + .AsNoTracking() + .Where(invite => invite.TokenHash == tokenHash) + .Select(invite => new TeamMemberInviteData + { + Id = invite.Id, + UserId = invite.UserId, + ExpiresAt = invite.ExpiresAt, + UsedAt = invite.UsedAt, + RevokedAt = invite.RevokedAt, + CodeHash = invite.CodeHash, + CodeSalt = invite.CodeSalt, + CodeExpiresAt = invite.CodeExpiresAt, + CodeFailedAttempts = invite.CodeFailedAttempts, + CodeSentAt = invite.CodeSentAt, + CodeSendCount = invite.CodeSendCount, + EmailConfirmedAt = invite.EmailConfirmedAt + }) + .SingleOrDefaultAsync(cancellationToken); + } + + public async Task TryStartCodeAsync( + StartTeamMemberInviteCodeCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + var updated = await OpenInvite(command.InviteId, command.Now) + .Where(invite => invite.CodeSendCount < command.MaxSends + && (invite.CodeSentAt == null || invite.CodeSentAt <= command.LastSentNoLaterThan)) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(invite => invite.CodeHash, command.CodeHash) + .SetProperty(invite => invite.CodeSalt, command.CodeSalt) + .SetProperty(invite => invite.CodeExpiresAt, command.CodeExpiresAt) + .SetProperty(invite => invite.CodeFailedAttempts, 0) + .SetProperty(invite => invite.CodeSentAt, command.Now) + .SetProperty(invite => invite.CodeSendCount, invite => invite.CodeSendCount + 1), + cancellationToken); + return updated == 1; + } + + public async Task TryReserveCodeAttemptAsync( + int inviteId, + int maxAttempts, + DateTime now, + CancellationToken cancellationToken) + { + var updated = await OpenInvite(inviteId, now) + .Where(invite => invite.CodeHash != null + && invite.CodeExpiresAt > now + && invite.CodeFailedAttempts < maxAttempts) + .ExecuteUpdateAsync( + setters => setters.SetProperty( + invite => invite.CodeFailedAttempts, + invite => invite.CodeFailedAttempts + 1), + cancellationToken); + return updated == 1; + } + + public async Task TryConfirmEmailAsync( + int inviteId, + string codeHash, + DateTime now, + CancellationToken cancellationToken) + { + var updated = await OpenInvite(inviteId, now) + .Where(invite => invite.CodeHash == codeHash && invite.CodeExpiresAt > now) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(invite => invite.EmailConfirmedAt, now) + .SetProperty(invite => invite.CodeHash, (string?)null) + .SetProperty(invite => invite.CodeSalt, (string?)null) + .SetProperty(invite => invite.CodeExpiresAt, (DateTime?)null) + .SetProperty(invite => invite.CodeFailedAttempts, 0), + cancellationToken); + return updated == 1; + } + + public async Task TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) + { + var updated = await OpenInvite(inviteId, now) + .Where(invite => invite.EmailConfirmedAt != null) + .ExecuteUpdateAsync( + setters => setters.SetProperty(invite => invite.UsedAt, now), + cancellationToken); + return updated == 1; + } + + private IQueryable OpenInvite(int inviteId, DateTime now) + { + return _context.TeamMemberInvites.Where(invite => invite.Id == inviteId + && invite.UsedAt == null + && invite.RevokedAt == null + && invite.ExpiresAt > now); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/ITeamMemberInviteDataService.cs b/SeaHaven.DataServices/Interfaces/ITeamMemberInviteDataService.cs new file mode 100644 index 0000000..4140f16 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/ITeamMemberInviteDataService.cs @@ -0,0 +1,30 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Dto; + +namespace SeaHaven.DataServices.Interfaces +{ + /// + /// Persistence for team member registration invites. Every state transition that + /// guards a security limit is a single conditional update, so concurrent requests + /// cannot exceed the limit; each returns whether the transition happened. + /// + public interface ITeamMemberInviteDataService + { + Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken); + + /// Revokes every open invite for the invite's user and adds the new one, atomically. + Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken); + + Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken); + + Task TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken); + + /// Counts one verification attempt against a live code; false once the attempt limit is used up. + Task TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken); + + Task TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken); + + /// Marks an open, unexpired, email-confirmed invite as used; false when another request already did. + Task TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken); + } +} diff --git a/SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs b/SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs new file mode 100644 index 0000000..36d7bd8 --- /dev/null +++ b/SeaHaven.Services/DTOs/TeamMemberInviteDTOs.cs @@ -0,0 +1,71 @@ +namespace SeaHaven.Services.DTOs; + +/// A freshly issued invite. The raw token is never persisted and never printed. +public sealed class IssuedTeamMemberInvite +{ + public IssuedTeamMemberInvite(string token, DateTime expiresAt) + { + Token = token; + ExpiresAt = expiresAt; + } + + public string Token { get; } + public DateTime ExpiresAt { get; } + + public override string ToString() => $"IssuedTeamMemberInvite {{ ExpiresAt = {ExpiresAt:O} }}"; +} + +public sealed class TeamMemberInviteResendOutcomeDTO +{ + public bool Success { get; init; } + public string? Error { get; init; } +} + +public enum TeamMemberRegistrationStatus +{ + Ok, + InvalidInvite, + CodeIncorrect, + CodeExpired, + CodeLocked, + ResendTooSoon, + ResendLimitReached, + CodeDeliveryFailed, + EmailNotConfirmed, + PasswordRejected, + InvalidPhone +} + +public sealed class TeamMemberInviteDetailsDTO +{ + public required string Name { get; init; } + public required string Role { get; init; } + public string? Email { get; init; } + public string? Phone { get; init; } +} + +public sealed class TeamMemberRegistrationOutcomeDTO +{ + public TeamMemberRegistrationStatus Status { get; init; } + public TeamMemberInviteDetailsDTO? Details { get; init; } + public LoginResultDTO? Session { get; init; } + public int? RetryAfterSeconds { get; init; } +} + +public sealed class TeamMemberInviteTokenRequestDTO +{ + public string? Token { get; init; } +} + +public sealed class VerifyTeamMemberInviteCodeRequestDTO +{ + public string? Token { get; init; } + public string? Code { get; init; } +} + +public sealed class CompleteTeamMemberRegistrationRequestDTO +{ + public string? Token { get; init; } + public string? Password { get; init; } + public string? Phone { get; init; } +} diff --git a/SeaHaven.Services/DependencyInjection/ServicesModule.cs b/SeaHaven.Services/DependencyInjection/ServicesModule.cs index e24dec3..455a0a8 100644 --- a/SeaHaven.Services/DependencyInjection/ServicesModule.cs +++ b/SeaHaven.Services/DependencyInjection/ServicesModule.cs @@ -1,6 +1,7 @@ using FluentValidation; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using SeaHaven.Services.Configuration; using SeaHaven.Services.Interfaces; using System.Reflection; @@ -13,6 +14,7 @@ namespace SeaHaven.Services.DependencyInjection public static IServiceCollection AddBusinessServices(this IServiceCollection services, IConfiguration configuration) { + services.TryAddSingleton(TimeProvider.System); services.Configure(configuration); services.Configure(configuration.GetSection(JwtOptions.SectionName)); services.Configure(configuration.GetSection(ApprovalsOptions.SectionName)); diff --git a/SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs b/SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs new file mode 100644 index 0000000..0d65d61 --- /dev/null +++ b/SeaHaven.Services/Helpers/TeamMemberInviteSecrets.cs @@ -0,0 +1,62 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + +namespace SeaHaven.Services.Helpers +{ + /// + /// Generation and hashing for invite tokens and email confirmation codes. Raw + /// values exist only in memory and in the email sent to the invitee. + /// + public static class TeamMemberInviteSecrets + { + /// 256 bits from the OS CSPRNG. + public const int TokenBytes = 32; + + /// Upper bound on an accepted token string; a 32-byte base64url token is 43 characters. + public const int MaxTokenLength = 128; + + public static string NewToken() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(TokenBytes)) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } + + public static string HashToken(string token) + { + ArgumentNullException.ThrowIfNull(token); + return Sha256Hex(token); + } + + public static string NewCode() + { + return RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6", CultureInfo.InvariantCulture); + } + + public static string NewCodeSalt() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(); + } + + public static string HashCode(string salt, string code) + { + ArgumentNullException.ThrowIfNull(salt); + ArgumentNullException.ThrowIfNull(code); + return Sha256Hex(salt + ":" + code); + } + + public static bool CodeMatches(string salt, string candidate, string expectedHash) + { + var actual = Encoding.ASCII.GetBytes(HashCode(salt, candidate)); + var expected = Encoding.ASCII.GetBytes(expectedHash); + return CryptographicOperations.FixedTimeEquals(actual, expected); + } + + private static string Sha256Hex(string value) + { + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + } + } +} diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index e293276..5df17de 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -39,47 +39,53 @@ namespace SeaHaven.Services.Implementation { var user = await _userManager.FindByNameAsync(username ?? ""); if (user != null && user.IsDeleted != true && await _userManager.CheckPasswordAsync(user, password ?? "")) - { - var userRoles = await _userManager.GetRolesAsync(user); - var authClaims = new List - { - new Claim(ClaimTypes.Name, user.UserName ?? ""), - new Claim(ClaimTypes.NameIdentifier, user.Id), - new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) - }; - foreach (var userRole in userRoles) - { - authClaims.Add(new Claim(ClaimTypes.Role, userRole)); - } - if (user.AccountId.HasValue) - { - authClaims.Add(new Claim( - SeaHavenClaimTypes.AccountId, - user.AccountId.Value.ToString())); - } - else if (userRoles.Contains("Admin")) - { - // Explicit signed org-wide elevation — never elevate via absence of account_id. - authClaims.Add(new Claim( - SeaHavenClaimTypes.OrgScope, - SeaHavenClaimTypes.OrgScopeAll)); - } - var token = GetToken(authClaims); - return new LoginResultDTO - { - Token = new JwtSecurityTokenHandler().WriteToken(token), - Expiration = token.ValidTo, - Email = user.Email, - UserRole = userRoles.FirstOrDefault(), - PhoneNumber = user.PhoneNumber, - Fullname = user.FirstName + " " + user.LastName, - Id = user.Id - }; - } + return await CreateSessionAsync(user, cancellationToken); return null; } + public async Task CreateSessionAsync(ApplicationUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + cancellationToken.ThrowIfCancellationRequested(); + + var userRoles = await _userManager.GetRolesAsync(user); + var authClaims = new List + { + new Claim(ClaimTypes.Name, user.UserName ?? ""), + new Claim(ClaimTypes.NameIdentifier, user.Id), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()) + }; + foreach (var userRole in userRoles) + { + authClaims.Add(new Claim(ClaimTypes.Role, userRole)); + } + if (user.AccountId.HasValue) + { + authClaims.Add(new Claim( + SeaHavenClaimTypes.AccountId, + user.AccountId.Value.ToString())); + } + else if (userRoles.Contains("Admin")) + { + // Explicit signed org-wide elevation — never elevate via absence of account_id. + authClaims.Add(new Claim( + SeaHavenClaimTypes.OrgScope, + SeaHavenClaimTypes.OrgScopeAll)); + } + var token = GetToken(authClaims); + return new LoginResultDTO + { + Token = new JwtSecurityTokenHandler().WriteToken(token), + Expiration = token.ValidTo, + Email = user.Email, + UserRole = userRoles.FirstOrDefault(), + PhoneNumber = user.PhoneNumber, + Fullname = user.FirstName + " " + user.LastName, + Id = user.Id + }; + } + public async Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs new file mode 100644 index 0000000..7577507 --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs @@ -0,0 +1,119 @@ +using System.Net; +using System.Security.Claims; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Configuration; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation; + +public sealed class TeamMemberInviteService : ITeamMemberInviteService +{ + /// The prototype states no lifetime; seven days is the product decision. + public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7); + + private readonly ITeamMemberInviteDataService _inviteDataService; + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + private readonly FrontendOptions _frontendOptions; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public TeamMemberInviteService( + ITeamMemberInviteDataService inviteDataService, + UserManager userManager, + IEmailSender emailSender, + IOptions frontendOptions, + TimeProvider timeProvider, + ILogger logger) + { + _inviteDataService = inviteDataService; + _userManager = userManager; + _emailSender = emailSender; + _frontendOptions = frontendOptions.Value; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task CreateAsync(string userId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(userId); + + var (invite, issued) = NewInvite(userId); + await _inviteDataService.AddAsync(invite, cancellationToken); + return issued; + } + + public async Task SendAsync( + IssuedTeamMemberInvite invite, + string email, + string name, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(invite); + cancellationToken.ThrowIfCancellationRequested(); + + var link = $"{_frontendOptions.FrontendBaseUrl?.TrimEnd('/')}/invite#{invite.Token}"; + var body = + $"

Hi {WebUtility.HtmlEncode(name)},

" + + "

You've been added to Seahaven. Set your password and confirm your email to finish creating your account.

" + + $"

Finish registration

" + + $"

This link expires in {InviteLifetime.Days} days and can be used once.

"; + + var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body); + if (!sent) + _logger.LogWarning("Team member invite email could not be sent for invite expiring {ExpiresAt}.", invite.ExpiresAt); + + return sent; + } + + public async Task ResendAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken) + { + if (caller?.IsInRole("Admin") != true) + return ResendFailure("Forbidden"); + + cancellationToken.ThrowIfCancellationRequested(); + var user = await _userManager.FindByIdAsync(userId); + if (user is null) + return ResendFailure("Team member not found."); + + if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email)) + return ResendFailure("Only pending team members can be re-invited."); + + var (invite, issued) = NewInvite(user.Id); + await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken); + + var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(); + if (!await SendAsync(issued, user.Email, name, cancellationToken)) + return ResendFailure("The invite could not be emailed. Try again."); + + return new TeamMemberInviteResendOutcomeDTO { Success = true }; + } + + private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId) + { + var now = NowUtc(); + var token = TeamMemberInviteSecrets.NewToken(); + var invite = new TeamMemberInvite + { + UserId = userId, + TokenHash = TeamMemberInviteSecrets.HashToken(token), + CreatedAt = now, + ExpiresAt = now.Add(InviteLifetime) + }; + return (invite, new IssuedTeamMemberInvite(token, invite.ExpiresAt)); + } + + private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; + + private static TeamMemberInviteResendOutcomeDTO ResendFailure(string error) => + new() { Success = false, Error = error }; +} diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs new file mode 100644 index 0000000..2bc306c --- /dev/null +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -0,0 +1,251 @@ +using System.Text.RegularExpressions; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.Constants; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation; + +public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrationService +{ + public static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15); + public static readonly TimeSpan ResendCooldown = TimeSpan.FromSeconds(60); + public const int MaxCodeAttempts = 5; + public const int MaxCodeSends = 10; + + private readonly ITeamMemberInviteDataService _inviteDataService; + private readonly IUserDataService _userDataService; + private readonly UserManager _userManager; + private readonly IAuthenticationService _authenticationService; + private readonly IEmailSender _emailSender; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public TeamMemberRegistrationService( + ITeamMemberInviteDataService inviteDataService, + IUserDataService userDataService, + UserManager userManager, + IAuthenticationService authenticationService, + IEmailSender emailSender, + TimeProvider timeProvider, + ILogger logger) + { + _inviteDataService = inviteDataService; + _userDataService = userDataService; + _userManager = userManager; + _authenticationService = authenticationService; + _emailSender = emailSender; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task ResolveAsync(string? token, CancellationToken cancellationToken) + { + var context = await LoadAsync(token, NowUtc(), cancellationToken); + if (context is null) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + + var roles = await _userManager.GetRolesAsync(context.User); + var role = roles.FirstOrDefault(); + return new TeamMemberRegistrationOutcomeDTO + { + Status = TeamMemberRegistrationStatus.Ok, + Details = new TeamMemberInviteDetailsDTO + { + Name = $"{context.User.FirstName ?? ""} {context.User.LastName ?? ""}".Trim(), + Role = TeamMemberConstants.CanonicalRole(role) ?? role ?? "", + Email = context.User.Email, + Phone = context.User.Contact ?? context.User.PhoneNumber + } + }; + } + + public async Task SendCodeAsync(string? token, CancellationToken cancellationToken) + { + var now = NowUtc(); + var context = await LoadAsync(token, now, cancellationToken); + if (context is null || string.IsNullOrWhiteSpace(context.User.Email)) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + + var code = TeamMemberInviteSecrets.NewCode(); + var salt = TeamMemberInviteSecrets.NewCodeSalt(); + var started = await _inviteDataService.TryStartCodeAsync( + new StartTeamMemberInviteCodeCommand + { + InviteId = context.Invite.Id, + CodeHash = TeamMemberInviteSecrets.HashCode(salt, code), + CodeSalt = salt, + Now = now, + CodeExpiresAt = now.Add(CodeLifetime), + LastSentNoLaterThan = now.Subtract(ResendCooldown), + MaxSends = MaxCodeSends + }, + cancellationToken); + if (!started) + return ResendRefusal(context.Invite, now); + + // Read the address at send time so an admin's correction is honoured. + var body = + $"

Your Seahaven confirmation code is {code}.

" + + $"

It expires in {CodeLifetime.TotalMinutes:0} minutes. If you didn't request it, you can ignore this email.

"; + if (!await _emailSender.SendEmailAsync(context.User.Email, "Your Seahaven confirmation code", body)) + { + _logger.LogWarning("Confirmation code email could not be sent for team member invite {InviteId}.", context.Invite.Id); + return Outcome(TeamMemberRegistrationStatus.CodeDeliveryFailed); + } + + return Outcome(TeamMemberRegistrationStatus.Ok); + } + + public async Task VerifyCodeAsync( + string? token, + string? code, + CancellationToken cancellationToken) + { + var now = NowUtc(); + var context = await LoadAsync(token, now, cancellationToken); + if (context is null) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + + var invite = context.Invite; + if (invite.CodeHash is null || invite.CodeSalt is null || invite.CodeExpiresAt is null || invite.CodeExpiresAt <= now) + return Outcome(TeamMemberRegistrationStatus.CodeExpired); + + // Reserve the attempt before comparing, so parallel guesses cannot exceed the limit. + if (!await _inviteDataService.TryReserveCodeAttemptAsync(invite.Id, MaxCodeAttempts, now, cancellationToken)) + { + return Outcome(invite.CodeFailedAttempts >= MaxCodeAttempts + ? TeamMemberRegistrationStatus.CodeLocked + : TeamMemberRegistrationStatus.CodeExpired); + } + + if (!TeamMemberInviteSecrets.CodeMatches(invite.CodeSalt, (code ?? "").Trim(), invite.CodeHash)) + { + return Outcome(invite.CodeFailedAttempts + 1 >= MaxCodeAttempts + ? TeamMemberRegistrationStatus.CodeLocked + : TeamMemberRegistrationStatus.CodeIncorrect); + } + + return await _inviteDataService.TryConfirmEmailAsync(invite.Id, invite.CodeHash, now, cancellationToken) + ? Outcome(TeamMemberRegistrationStatus.Ok) + : Outcome(TeamMemberRegistrationStatus.CodeExpired); + } + + public async Task CompleteAsync( + CompleteTeamMemberRegistrationRequestDTO request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var now = NowUtc(); + var context = await LoadAsync(request.Token, now, cancellationToken); + if (context is null) + return Outcome(TeamMemberRegistrationStatus.InvalidInvite); + if (context.Invite.EmailConfirmedAt is null) + return Outcome(TeamMemberRegistrationStatus.EmailNotConfirmed); + + var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim(); + if (phone is not null && !PhonePattern().IsMatch(phone)) + return Outcome(TeamMemberRegistrationStatus.InvalidPhone); + + var user = context.User; + try + { + await _userDataService.ExecuteTransactionalAsync( + async transactionCancellationToken => + { + if (!await _inviteDataService.TryClaimAsync(context.Invite.Id, now, transactionCancellationToken)) + throw new RegistrationAbortedException(TeamMemberRegistrationStatus.InvalidInvite); + + user.EmailConfirmed = true; + user.PendingRegistration = false; + user.UniqueName = "Active"; + user.PhoneNumber = phone; + user.Contact = phone; + + // Identity applies the shared password policy and persists the user. + var result = await _userManager.AddPasswordAsync(user, request.Password ?? ""); + if (!result.Succeeded) + throw new RegistrationAbortedException(StatusFor(result)); + }, + cancellationToken); + } + catch (RegistrationAbortedException aborted) + { + return Outcome(aborted.Status); + } + + _logger.LogInformation("Team member {UserId} completed invite registration.", user.Id); + return new TeamMemberRegistrationOutcomeDTO + { + Status = TeamMemberRegistrationStatus.Ok, + Session = await _authenticationService.CreateSessionAsync(user, cancellationToken) + }; + } + + private async Task LoadAsync(string? token, DateTime now, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (string.IsNullOrWhiteSpace(token) || token.Length > TeamMemberInviteSecrets.MaxTokenLength) + return null; + + var invite = await _inviteDataService.GetByTokenHashAsync( + TeamMemberInviteSecrets.HashToken(token.Trim()), + cancellationToken); + if (invite is null || invite.UsedAt is not null || invite.RevokedAt is not null || invite.ExpiresAt <= now) + return null; + + var user = await _userManager.FindByIdAsync(invite.UserId); + if (user is null || user.IsDeleted == true || user.PendingRegistration != true) + return null; + + return new RegistrationContext(invite, user); + } + + private static TeamMemberRegistrationOutcomeDTO ResendRefusal(TeamMemberInviteData invite, DateTime now) + { + if (invite.CodeSendCount >= MaxCodeSends) + return Outcome(TeamMemberRegistrationStatus.ResendLimitReached); + + var allowedAt = (invite.CodeSentAt ?? now).Add(ResendCooldown); + return new TeamMemberRegistrationOutcomeDTO + { + Status = TeamMemberRegistrationStatus.ResendTooSoon, + RetryAfterSeconds = Math.Max(1, (int)Math.Ceiling((allowedAt - now).TotalSeconds)) + }; + } + + private static TeamMemberRegistrationStatus StatusFor(IdentityResult result) + { + // A password already set means the account was registered by another route. + return result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword)) + ? TeamMemberRegistrationStatus.InvalidInvite + : TeamMemberRegistrationStatus.PasswordRejected; + } + + private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; + + private static TeamMemberRegistrationOutcomeDTO Outcome(TeamMemberRegistrationStatus status) => + new() { Status = status }; + + [GeneratedRegex(@"^[0-9+()\-.\s]{7,32}$")] + private static partial Regex PhonePattern(); + + private sealed record RegistrationContext(TeamMemberInviteData Invite, ApplicationUser User); + + private sealed class RegistrationAbortedException : Exception + { + public RegistrationAbortedException(TeamMemberRegistrationStatus status) + : base(status.ToString()) + { + Status = status; + } + + public TeamMemberRegistrationStatus Status { get; } + } +} diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index faf64f3..6a25aef 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -20,6 +20,7 @@ public sealed class TeamMemberService : ITeamMemberService private readonly ITeamPermissionOverrideDataService _permissionDataService; private readonly IUserDataService _userDataService; private readonly ITeamPermissionService _permissionService; + private readonly ITeamMemberInviteService _inviteService; public TeamMemberService( UserManager userManager, @@ -27,7 +28,8 @@ public sealed class TeamMemberService : ITeamMemberService IUserServiceAreaDataService areaDataService, ITeamPermissionOverrideDataService permissionDataService, IUserDataService userDataService, - ITeamPermissionService permissionService) + ITeamPermissionService permissionService, + ITeamMemberInviteService inviteService) { _userManager = userManager; _roleManager = roleManager; @@ -35,6 +37,7 @@ public sealed class TeamMemberService : ITeamMemberService _permissionDataService = permissionDataService; _userDataService = userDataService; _permissionService = permissionService; + _inviteService = inviteService; } public async Task CreateAsync( @@ -68,6 +71,7 @@ public sealed class TeamMemberService : ITeamMemberService PendingRegistrationCreatedDate = now }; + IssuedTeamMemberInvite? invite = null; try { await _userDataService.ExecuteTransactionalAsync( @@ -100,6 +104,7 @@ public sealed class TeamMemberService : ITeamMemberService await _areaDataService.ReplaceAsync(user.Id, areas!, transactionCancellationToken); await _permissionDataService.SetOverridesAsync(user.Id, overrides!, transactionCancellationToken); + invite = await _inviteService.CreateAsync(user.Id, transactionCancellationToken); }, cancellationToken); } @@ -108,6 +113,10 @@ public sealed class TeamMemberService : ITeamMemberService return Failure(exception.Message); } + // The member and their invite commit together; the email goes out only after + // commit, so a rolled-back member never receives a link. + await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken); + return new CreateTeamMemberOutcomeDTO { Success = true, diff --git a/SeaHaven.Services/Interfaces/IAuthenticationService.cs b/SeaHaven.Services/Interfaces/IAuthenticationService.cs index e4ffc9b..5d41a32 100644 --- a/SeaHaven.Services/Interfaces/IAuthenticationService.cs +++ b/SeaHaven.Services/Interfaces/IAuthenticationService.cs @@ -5,6 +5,8 @@ namespace SeaHaven.Services.Interfaces public interface IAuthenticationService { Task LoginAsync(string? username, string? password, CancellationToken cancellationToken); + /// Builds the same signed session payload that a successful login returns. + Task CreateSessionAsync(Data.SeaHavenIndustries.ApplicationUser user, CancellationToken cancellationToken); Task ChangePasswordAsync(string userId, string? currentPassword, string? newPassword, CancellationToken cancellationToken); Task UpdateProfileAsync(string userId, UpdateProfileRequestDTO dto, CancellationToken cancellationToken); Task ForgetPasswordAsync(string email, CancellationToken cancellationToken); diff --git a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs new file mode 100644 index 0000000..9b12928 --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces; + +public interface ITeamMemberInviteService +{ + /// Persists a new invite for a pending member and returns its raw token. + Task CreateAsync(string userId, CancellationToken cancellationToken); + + /// Emails the invite link. Returns false when the email could not be handed off. + Task SendAsync( + IssuedTeamMemberInvite invite, + string email, + string name, + CancellationToken cancellationToken); + + /// Admin-only: revokes a pending member's open invites and emails a new one. + Task ResendAsync( + string userId, + ClaimsPrincipal caller, + CancellationToken cancellationToken); +} diff --git a/SeaHaven.Services/Interfaces/ITeamMemberRegistrationService.cs b/SeaHaven.Services/Interfaces/ITeamMemberRegistrationService.cs new file mode 100644 index 0000000..8d2692c --- /dev/null +++ b/SeaHaven.Services/Interfaces/ITeamMemberRegistrationService.cs @@ -0,0 +1,23 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces; + +/// +/// The anonymous invite-registration flow. Every operation is scoped by the invite +/// token alone; unknown, expired, used and revoked invites share one outcome. +/// +public interface ITeamMemberRegistrationService +{ + Task ResolveAsync(string? token, CancellationToken cancellationToken); + + Task SendCodeAsync(string? token, CancellationToken cancellationToken); + + Task VerifyCodeAsync( + string? token, + string? code, + CancellationToken cancellationToken); + + Task CompleteAsync( + CompleteTeamMemberRegistrationRequestDTO request, + CancellationToken cancellationToken); +} diff --git a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs index 8c5e2bc..0d690b5 100644 --- a/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberCreateTransactionTests.cs @@ -35,7 +35,8 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); - await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true); + var emailSender = new CapturingEmailSender(); + await using var serviceProvider = BuildServiceProvider(connection, injectFailure: true, emailSender); ThrowingAfterPersistPermissionDataService failingPermissionData; await using (var createScope = serviceProvider.CreateAsyncScope()) { @@ -66,6 +67,8 @@ public sealed class TeamMemberCreateTransactionTests Assert.Empty(await verify.UserRoles.AsNoTracking().ToListAsync()); Assert.Empty(await verify.UserServiceAreas.AsNoTracking().ToListAsync()); Assert.Empty(await verify.UserPermissionOverrides.AsNoTracking().ToListAsync()); + Assert.Empty(await verify.TeamMemberInvites.AsNoTracking().ToListAsync()); + Assert.Empty(emailSender.Messages); } [Fact] @@ -81,7 +84,8 @@ public sealed class TeamMemberCreateTransactionTests await using (var setup = new SqliteTeamMemberTestDbContext(options)) await setup.Database.EnsureCreatedAsync(); - await using var serviceProvider = BuildServiceProvider(connection); + var emailSender = new CapturingEmailSender(); + await using var serviceProvider = BuildServiceProvider(connection, emailSender: emailSender); await using (var createScope = serviceProvider.CreateAsyncScope()) { var service = createScope.ServiceProvider.GetRequiredService(); @@ -110,9 +114,18 @@ public sealed class TeamMemberCreateTransactionTests var permissionOverride = await verify.UserPermissionOverrides.AsNoTracking() .SingleAsync(permission => permission.UserId == user.Id); Assert.Equal("deleteSites", permissionOverride.PermissionKey); + + var invite = await verify.TeamMemberInvites.AsNoTracking().SingleAsync(); + Assert.Equal(user.Id, invite.UserId); + var sent = Assert.Single(emailSender.Messages); + Assert.Equal("taylor@example.com", sent.To); + Assert.Equal("You're invited to Seahaven", sent.Subject); } - private static ServiceProvider BuildServiceProvider(SqliteConnection connection, bool injectFailure = false) + private static ServiceProvider BuildServiceProvider( + SqliteConnection connection, + bool injectFailure = false, + CapturingEmailSender? emailSender = null) { var configuration = new ConfigurationBuilder().Build(); var services = new ServiceCollection(); @@ -124,6 +137,7 @@ public sealed class TeamMemberCreateTransactionTests services.AddIdentity() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + services.AddSingleton(emailSender ?? new CapturingEmailSender()); services.AddDataServices(); services.AddBusinessServices(configuration); diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs new file mode 100644 index 0000000..11632c4 --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -0,0 +1,523 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +public sealed class TeamMemberInviteRegistrationTests +{ + private const string Email = "taylor@example.com"; + private const string Password = "Abc1!x"; + + [Fact] + public async Task CreatingPendingMember_EmailsHighEntropyInviteAndStoresOnlyItsHash() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + + var (userId, token) = await host.AddPendingMemberAsync(Email); + + var invite = Assert.Single(await host.InvitesAsync(userId)); + Assert.True(Base64UrlDecode(token).Length >= 16); + Assert.Equal(Sha256Hex(token), invite.TokenHash); + Assert.DoesNotContain(token, JsonSerializer.Serialize(invite)); + Assert.Equal(host.Time.Now.UtcDateTime.AddDays(7), invite.ExpiresAt); + Assert.Null(invite.UsedAt); + + var sent = Assert.Single(host.Sent.Messages); + Assert.Equal(Email, sent.To); + Assert.Contains($"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}", sent.Body); + } + + [Fact] + public async Task FullFlow_FromEmailedToken_ActivatesMemberConfirmsEmailAndSignsIn() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email, "Taylor Reed"); + + var resolved = await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.Ok, resolved.Status); + Assert.Equal("Taylor Reed", resolved.Details!.Name); + Assert.Equal("Dispatcher", resolved.Details.Role); + Assert.Equal(Email, resolved.Details.Email); + + await host.ConfirmEmailAsync(token, Email); + var completed = await CompleteAsync(host, token, Password, "(555) 010-2000"); + + Assert.Equal(TeamMemberRegistrationStatus.Ok, completed.Status); + Assert.False(string.IsNullOrWhiteSpace(completed.Session!.Token)); + Assert.Equal(Email, completed.Session.Email); + Assert.Equal(userId, completed.Session.Id); + Assert.Contains("Dispatcher", completed.Session.UserRole); + + var user = await host.ReloadUserAsync(userId); + Assert.False(user.PendingRegistration); + Assert.True(user.EmailConfirmed); + Assert.Equal("Active", user.UniqueName); + Assert.Equal("(555) 010-2000", user.PhoneNumber); + Assert.Equal("(555) 010-2000", user.Contact); + Assert.True(await host.InScopeAsync(provider => + provider.GetRequiredService>().CheckPasswordAsync(user, Password))); + Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + } + + [Fact] + public async Task UsedToken_CannotRegisterAgain() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status); + + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await CompleteAsync(host, token, "Xyz9?q")).Status); + } + + [Fact] + public async Task UnknownExpiredUsedRevokedAndDeactivatedInvites_GetTheSameGenericResponse() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + + var (_, used) = await host.AddPendingMemberAsync("used@example.com"); + await host.ConfirmEmailAsync(used, "used@example.com"); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, used, Password)).Status); + + var (revokedUserId, revoked) = await host.AddPendingMemberAsync("revoked@example.com"); + Assert.True((await ResendInviteAsync(host, revokedUserId)).Success); + + var (deactivatedUserId, deactivated) = await host.AddPendingMemberAsync("deactivated@example.com"); + await host.InScopeAsync(provider => provider.GetRequiredService().Users + .Where(user => user.Id == deactivatedUserId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(user => user.IsDeleted, true) + .SetProperty(user => user.UniqueName, "Inactive"))); + + var (_, expired) = await host.AddPendingMemberAsync("expired@example.com"); + + var responses = new List + { + await ResolveResponseAsync(host, used), + await ResolveResponseAsync(host, revoked), + await ResolveResponseAsync(host, deactivated), + await ResolveResponseAsync(host, TeamMemberInviteSecretsForTests.UnknownToken()), + await ResolveResponseAsync(host, ""), + await ResolveResponseAsync(host, new string('a', 4096)) + }; + host.Time.Advance(TimeSpan.FromDays(7).Add(TimeSpan.FromSeconds(1))); + responses.Add(await ResolveResponseAsync(host, expired)); + + var expected = + "400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}"; + Assert.All(responses, response => Assert.Equal(expected, response)); + } + + [Fact] + public async Task Invite_ExpiresAfterSevenDays() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + + host.Time.Advance(TimeSpan.FromDays(7).Subtract(TimeSpan.FromSeconds(1))); + Assert.Equal(TeamMemberRegistrationStatus.Ok, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + + host.Time.Advance(TimeSpan.FromSeconds(1)); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + } + + [Fact] + public async Task Code_IsSixDigitsAndStoredOnlyAsSaltedHash() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + + var code = await host.SendCodeAsync(token, Email); + + Assert.Matches("^[0-9]{6}$", code); + var invite = Assert.Single(await host.InvitesAsync(userId)); + Assert.NotNull(invite.CodeHash); + Assert.NotNull(invite.CodeSalt); + Assert.NotEqual(code, invite.CodeHash); + Assert.DoesNotContain(code, invite.CodeHash); + Assert.Equal(Sha256Hex($"{invite.CodeSalt}:{code}"), invite.CodeHash); + Assert.Equal(host.Time.Now.UtcDateTime.AddMinutes(15), invite.CodeExpiresAt); + } + + [Fact] + public async Task WrongCodes_AreCountedAndLockAfterFive_UntilANewCodeIsSent() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + var wrong = code == "000000" ? "111111" : "000000"; + + for (var attempt = 1; attempt <= 4; attempt++) + { + var outcome = await VerifyAsync(host, token, wrong); + Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, outcome.Status); + Assert.Equal(attempt, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts); + } + + Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, wrong)).Status); + Assert.Equal(TeamMemberRegistrationStatus.CodeLocked, (await VerifyAsync(host, token, code)).Status); + Assert.Equal(5, Assert.Single(await host.InvitesAsync(userId)).CodeFailedAttempts); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt); + + host.Time.Advance(TeamMemberRegistrationService.ResendCooldown); + var fresh = await host.SendCodeAsync(token, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, fresh)).Status); + Assert.NotNull(Assert.Single(await host.InvitesAsync(userId)).EmailConfirmedAt); + } + + [Fact] + public async Task AttemptReservation_IsEnforcedByTheDatabaseGuard() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.SendCodeAsync(token, Email); + var inviteId = Assert.Single(await host.InvitesAsync(userId)).Id; + + var granted = 0; + for (var i = 0; i < 8; i++) + { + if (await host.InScopeAsync(provider => provider.GetRequiredService() + .TryReserveCodeAttemptAsync(inviteId, TeamMemberRegistrationService.MaxCodeAttempts, host.Time.Now.UtcDateTime, CancellationToken.None))) + granted++; + } + + Assert.Equal(TeamMemberRegistrationService.MaxCodeAttempts, granted); + } + + [Fact] + public async Task Code_ExpiresAfterFifteenMinutes() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + + host.Time.Advance(TeamMemberRegistrationService.CodeLifetime); + + Assert.Equal(TeamMemberRegistrationStatus.CodeExpired, (await VerifyAsync(host, token, code)).Status); + } + + [Fact] + public async Task Resend_IsRateLimitedAndReplacesThePreviousCode() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + var first = await host.SendCodeAsync(token, Email); + + var tooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, tooSoon.Status); + Assert.Equal(60, tooSoon.RetryAfterSeconds); + + host.Time.Advance(TimeSpan.FromSeconds(45)); + var stillTooSoon = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.ResendTooSoon, stillTooSoon.Status); + Assert.Equal(15, stillTooSoon.RetryAfterSeconds); + Assert.Single(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code"); + + host.Time.Advance(TimeSpan.FromSeconds(15)); + var second = await host.SendCodeAsync(token, Email); + if (second != first) + Assert.Equal(TeamMemberRegistrationStatus.CodeIncorrect, (await VerifyAsync(host, token, first)).Status); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await VerifyAsync(host, token, second)).Status); + } + + [Fact] + public async Task Resend_StopsAfterTheSendLimit() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + + for (var send = 0; send < TeamMemberRegistrationService.MaxCodeSends; send++) + { + await host.SendCodeAsync(token, Email); + host.Time.Advance(TeamMemberRegistrationService.ResendCooldown); + } + + var refused = await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.ResendLimitReached, refused.Status); + Assert.Equal(TeamMemberRegistrationService.MaxCodeSends, + host.Sent.Messages.Count(message => message.Subject == "Your Seahaven confirmation code")); + } + + [Fact] + public async Task Complete_RequiresConfirmedEmail() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.SendCodeAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, token, Password)).Status); + + var user = await host.ReloadUserAsync(userId); + Assert.True(user.PendingRegistration); + Assert.Null(user.PasswordHash); + } + + [Fact] + public async Task Complete_WeakPassword_RollsBackAndARetrySucceeds() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.PasswordRejected, (await CompleteAsync(host, token, "abc12!")).Status); + + var pending = await host.ReloadUserAsync(userId); + Assert.True(pending.PendingRegistration); + Assert.False(pending.EmailConfirmed); + Assert.Null(pending.PasswordHash); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status); + Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration); + } + + [Fact] + public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + Assert.Equal(TeamMemberRegistrationStatus.InvalidPhone, (await CompleteAsync(host, token, Password, "call me")).Status); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + } + + [Fact] + public async Task Invite_OnlyEverChangesItsOwnMember() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (ownerId, ownerToken) = await host.AddPendingMemberAsync(Email, "Taylor Reed"); + var (otherId, otherToken) = await host.AddPendingMemberAsync("jordan@example.com", "Jordan Lee"); + var otherBefore = await host.ReloadUserAsync(otherId); + + await host.ConfirmEmailAsync(ownerToken, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, ownerToken, Password, "555-123-4567")).Status); + + Assert.DoesNotContain(host.Sent.Messages, + message => message.To == "jordan@example.com" && message.Subject == "Your Seahaven confirmation code"); + var otherAfter = await host.ReloadUserAsync(otherId); + Assert.True(otherAfter.PendingRegistration); + Assert.False(otherAfter.EmailConfirmed); + Assert.Null(otherAfter.PasswordHash); + Assert.Equal(otherBefore.PhoneNumber, otherAfter.PhoneNumber); + Assert.Equal(otherBefore.SecurityStamp, otherAfter.SecurityStamp); + var otherInvite = Assert.Single(await host.InvitesAsync(otherId)); + Assert.Null(otherInvite.UsedAt); + Assert.Null(otherInvite.EmailConfirmedAt); + + var otherDetails = await host.RegistrationAsync(service => service.ResolveAsync(otherToken, CancellationToken.None)); + Assert.Equal("Jordan Lee", otherDetails.Details!.Name); + Assert.False((await host.ReloadUserAsync(ownerId)).PendingRegistration); + } + + [Fact] + public async Task Reinvite_RevokesOlderTokensAndOnlyAdminsCanSendIt() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, original) = await host.AddPendingMemberAsync(Email); + + var forbidden = await host.InScopeAsync(provider => provider.GetRequiredService() + .ResendAsync(userId, TeamMemberInviteTestHost.Dispatcher(), CancellationToken.None)); + Assert.Equal("Forbidden", forbidden.Error); + + Assert.True((await ResendInviteAsync(host, userId)).Success); + var replacement = host.Sent.LatestTokenFor(Email); + + Assert.NotEqual(original, replacement); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(original, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.Ok, + (await host.RegistrationAsync(service => service.ResolveAsync(replacement, CancellationToken.None))).Status); + var invites = await host.InvitesAsync(userId); + Assert.Equal(2, invites.Count); + Assert.NotNull(invites[0].RevokedAt); + Assert.Null(invites[1].RevokedAt); + } + + [Fact] + public async Task Reinvite_IsRefusedOnceTheMemberIsActive() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, token, Password)).Status); + + var refused = await ResendInviteAsync(host, userId); + + Assert.False(refused.Success); + Assert.Equal("Only pending team members can be re-invited.", refused.Error); + } + + [Fact] + public async Task TokensAndCodes_NeverReachTheLogs() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + await VerifyAsync(host, token, code == "000000" ? "111111" : "000000"); + + host.Sent.Succeeds = false; + host.Time.Advance(TeamMemberRegistrationService.ResendCooldown); + Assert.Equal(TeamMemberRegistrationStatus.CodeDeliveryFailed, + (await host.RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None))).Status); + Assert.False((await ResendInviteAsync(host, userId)).Success); + var failedInviteToken = host.Sent.LatestTokenFor(Email); + host.Sent.Succeeds = true; + + Assert.True((await ResendInviteAsync(host, userId)).Success); + var liveToken = host.Sent.LatestTokenFor(Email); + await host.ConfirmEmailAsync(liveToken, Email); + var liveCode = host.Sent.LatestCodeFor(Email); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, liveToken, Password)).Status); + + Assert.NotEmpty(host.Logged.Entries); + Assert.Contains(host.Logged.Entries, entry => entry.Contains("could not be sent", StringComparison.Ordinal)); + foreach (var secret in new[] { token, failedInviteToken, liveToken, code, liveCode, Password }) + Assert.DoesNotContain(host.Logged.Entries, entry => entry.Contains(secret, StringComparison.Ordinal)); + } + + [Fact] + public async Task RequestObjects_DoNotPrintTheirSecrets() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + + var printed = string.Join(" ", + new TeamMemberInviteTokenRequestDTO { Token = token }.ToString(), + new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = "123456" }.ToString(), + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }.ToString(), + new IssuedTeamMemberInvite(token, DateTime.UtcNow).ToString()); + + Assert.DoesNotContain(token, printed); + Assert.DoesNotContain("123456", printed); + Assert.DoesNotContain(Password, printed); + } + + [Fact] + public async Task CancelledRequests_StopBeforeChangingAnything() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => + host.RegistrationAsync(service => service.SendCodeAsync(token, cancelled.Token))); + await Assert.ThrowsAnyAsync(() => host.RegistrationAsync(service => + service.CompleteAsync(new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }, cancelled.Token))); + + Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code"); + Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount); + Assert.True((await host.ReloadUserAsync(userId)).PendingRegistration); + } + + [Fact] + public async Task Controller_MapsCodeFailuresToClearMessagesAndRetryAfter() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (_, token) = await host.AddPendingMemberAsync(Email); + var code = await host.SendCodeAsync(token, Email); + + var wrong = await InvokeControllerAsync(host, controller => controller.VerifyCode( + new VerifyTeamMemberInviteCodeRequestDTO { Token = token, Code = code == "000000" ? "111111" : "000000" }, + CancellationToken.None)); + Assert.Equal( + "400 {\"code\":\"code_incorrect\",\"message\":\"Incorrect code \\u2014 check your email and try again\",\"retryAfterSeconds\":null}", + wrong.Body); + + var tooSoon = await InvokeControllerAsync(host, controller => controller.SendCode( + new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None)); + Assert.StartsWith("429 {\"code\":\"resend_too_soon\"", tooSoon.Body); + Assert.Equal("60", tooSoon.RetryAfter); + } + + [Fact] + public async Task Controller_CompleteReturnsTheLoginPayloadShape() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + var response = await InvokeControllerAsync(host, controller => controller.Complete( + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password, Phone = "555-123-4567" }, + CancellationToken.None)); + + Assert.StartsWith("200 ", response.Body); + using var payload = JsonDocument.Parse(response.Body[4..]); + var keys = payload.RootElement.EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal(new[] { "token", "expiration", "email", "userRoles", "phoneNumber", "fullname", "id" }, keys); + Assert.Equal(userId, payload.RootElement.GetProperty("id").GetString()); + Assert.Equal("555-123-4567", payload.RootElement.GetProperty("phoneNumber").GetString()); + } + + private static Task VerifyAsync(TeamMemberInviteTestHost host, string token, string code) => + host.RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None)); + + private static Task CompleteAsync( + TeamMemberInviteTestHost host, + string token, + string password, + string? phone = null) => + host.RegistrationAsync(service => service.CompleteAsync( + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = password, Phone = phone }, + CancellationToken.None)); + + private static Task ResendInviteAsync(TeamMemberInviteTestHost host, string userId) => + host.InScopeAsync(provider => provider.GetRequiredService() + .ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); + + private static async Task ResolveResponseAsync(TeamMemberInviteTestHost host, string token) => + (await InvokeControllerAsync(host, controller => controller.Resolve( + new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None))).Body; + + private static Task<(string Body, string? RetryAfter)> InvokeControllerAsync( + TeamMemberInviteTestHost host, + Func> action) => + host.InScopeAsync(async provider => + { + var httpContext = new DefaultHttpContext(); + var controller = new TeamMemberInviteController(provider.GetRequiredService()) + { + ControllerContext = new ControllerContext { HttpContext = httpContext } + }; + var result = Assert.IsAssignableFrom(await action(controller)); + var status = result.StatusCode ?? StatusCodes.Status200OK; + var retryAfter = httpContext.Response.Headers.RetryAfter.ToString(); + return ($"{status} {JsonSerializer.Serialize(result.Value)}", string.IsNullOrEmpty(retryAfter) ? null : retryAfter); + }); + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + padded += new string('=', (4 - padded.Length % 4) % 4); + return Convert.FromBase64String(padded); + } + + private static string Sha256Hex(string value) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); + + private static class TeamMemberInviteSecretsForTests + { + public static string UnknownToken() => + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + } +} diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs new file mode 100644 index 0000000..e1d6f9e --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs @@ -0,0 +1,258 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using System.Text.RegularExpressions; +using Api.SeaHavenIndustries.Infrastructure; +using Data.SeaHavenIndustries; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using SeaHaven.DataServices.DependencyInjection; +using SeaHaven.Services.DependencyInjection; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +/// +/// A SQLite-backed composition of the real team member, invite and registration +/// services, with Identity registered exactly as the API host registers it. Email +/// goes to an in-memory sender and every log line is captured. +/// +internal sealed class TeamMemberInviteTestHost : IAsyncDisposable +{ + public const string FrontendBaseUrl = "https://shoc.test"; + + private readonly SqliteConnection _connection; + private ServiceProvider _provider = null!; + + private TeamMemberInviteTestHost(SqliteConnection connection) + { + _connection = connection; + } + + public CapturingEmailSender Sent { get; } = new(); + public ManualTimeProvider Time { get; } = new(); + public CapturingLoggerProvider Logged { get; } = new(); + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); + await connection.OpenAsync(); + + var options = new DbContextOptionsBuilder().UseSqlite(connection).Options; + await using (var setup = new SqliteInviteTestDbContext(options)) + await setup.Database.EnsureCreatedAsync(); + + var host = new TeamMemberInviteTestHost(connection); + host._provider = BuildProvider(connection, host); + return host; + } + + private static ServiceProvider BuildProvider(SqliteConnection connection, TeamMemberInviteTestHost fakes) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["FrontendBaseUrl"] = FrontendBaseUrl, + ["JWT:Secret"] = new string('k', 64), + ["JWT:ValidIssuer"] = "issuer", + ["JWT:ValidAudience"] = "audience" + }) + .Build(); + + var services = new ServiceCollection(); + services.AddLogging(logging => logging + .SetMinimumLevel(LogLevel.Trace) + .AddProvider(fakes.Logged)); + services.AddDbContext(builder => builder.UseSqlite(connection)); + services.Replace(ServiceDescriptor.Scoped(provider => + new SqliteInviteTestDbContext(provider.GetRequiredService>()))); + services.AddSeaHavenIdentity(); + services.AddSingleton(fakes.Time); + services.AddSingleton(fakes.Sent); + services.AddDataServices(); + services.AddBusinessServices(configuration); + return services.BuildServiceProvider(); + } + + public async Task InScopeAsync(Func> action) + { + await using var scope = _provider.CreateAsyncScope(); + return await action(scope.ServiceProvider); + } + + public Task RegistrationAsync( + Func> action) => + InScopeAsync(provider => action(provider.GetRequiredService())); + + /// Creates a pending member through the admin create path and returns the emailed token. + public async Task<(string UserId, string Token)> AddPendingMemberAsync(string email, string name = "Taylor Reed") + { + var outcome = await InScopeAsync(provider => provider.GetRequiredService().CreateAsync( + new CreateTeamMemberRequestDTO + { + Name = name, + Role = "dispatcher", + Color = "#0D9488", + Email = email, + Phone = "555-0100", + ServiceAreas = new[] { "East" } + }, + Admin(), + CancellationToken.None)); + Assert.True(outcome.Success, outcome.Error); + + return (outcome.Member!.Id, Sent.LatestTokenFor(email)); + } + + public async Task SendCodeAsync(string token, string email) + { + var outcome = await RegistrationAsync(service => service.SendCodeAsync(token, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.Ok, outcome.Status); + return Sent.LatestCodeFor(email); + } + + public async Task ConfirmEmailAsync(string token, string email) + { + var code = await SendCodeAsync(token, email); + var verified = await RegistrationAsync(service => service.VerifyCodeAsync(token, code, CancellationToken.None)); + Assert.Equal(TeamMemberRegistrationStatus.Ok, verified.Status); + } + + public Task ReloadUserAsync(string userId) => + InScopeAsync(async provider => await provider.GetRequiredService() + .Users.AsNoTracking().SingleAsync(user => user.Id == userId)); + + public Task> InvitesAsync(string userId) => + InScopeAsync(provider => provider.GetRequiredService() + .TeamMemberInvites.AsNoTracking().Where(invite => invite.UserId == userId) + .OrderBy(invite => invite.Id).ToListAsync()); + + public static ClaimsPrincipal Admin() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Admin") }, "Test")); + + public static ClaimsPrincipal Dispatcher() => + new(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "Dispatcher") }, "Test")); + + public async ValueTask DisposeAsync() + { + await _provider.DisposeAsync(); + await _connection.DisposeAsync(); + } + + private sealed class SqliteInviteTestDbContext : ApplicationDbContext + { + public SqliteInviteTestDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + // Keep filtered unique indexes filtered: translate SQL Server identifier quoting. + foreach (var index in builder.Model.GetEntityTypes().SelectMany(entity => entity.GetIndexes())) + { + if (index.GetFilter() is { } filter) + index.SetFilter(filter.Replace('[', '"').Replace(']', '"')); + } + + foreach (var property in builder.Model.GetEntityTypes() + .SelectMany(entity => entity.GetProperties()) + .Where(property => property.Name == "RowVersion" && property.ClrType == typeof(byte[]))) + { + property.ValueGenerated = ValueGenerated.Never; + property.IsConcurrencyToken = false; + } + } + } +} + +internal sealed partial class CapturingEmailSender : IEmailSender +{ + private readonly ConcurrentQueue _messages = new(); + + public bool Succeeds { get; set; } = true; + + public IReadOnlyList Messages => _messages.ToArray(); + + public Task SendEmailAsync(string emailTo, string subject, string body) + { + _messages.Enqueue(new SentEmail(emailTo, subject, body)); + return Task.FromResult(Succeeds); + } + + public string LatestTokenFor(string email) + { + var message = Messages.Last(sent => sent.To == email && sent.Subject == "You're invited to Seahaven"); + return InviteLink().Match(message.Body).Groups["token"].Value; + } + + public string LatestCodeFor(string email) + { + var message = Messages.Last(sent => sent.To == email && sent.Subject == "Your Seahaven confirmation code"); + return Code().Match(message.Body).Groups["code"].Value; + } + + [GeneratedRegex("https://shoc\\.test/invite#(?[A-Za-z0-9_-]+)")] + public static partial Regex InviteLink(); + + [GeneratedRegex("(?[0-9]{6})")] + private static partial Regex Code(); +} + +internal sealed record SentEmail(string To, string Subject, string Body); + +internal sealed class ManualTimeProvider : TimeProvider +{ + public DateTimeOffset Now { get; private set; } = new(2026, 9, 25, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => Now; + + public void Advance(TimeSpan by) => Now = Now.Add(by); +} + +internal sealed class CapturingLoggerProvider : ILoggerProvider +{ + private readonly ConcurrentQueue _entries = new(); + + public IReadOnlyList Entries => _entries.ToArray(); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(_entries); + + public void Dispose() + { + } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _entries; + + public CapturingLogger(ConcurrentQueue entries) => _entries = entries; + + public IDisposable? BeginScope(TState state) where TState : notnull + { + _entries.Enqueue(state.ToString() ?? ""); + return null; + } + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + var values = state is IEnumerable> pairs + ? string.Join(" ", pairs.Select(pair => $"{pair.Key}={pair.Value}")) + : ""; + _entries.Enqueue($"{formatter(state, exception)} {values} {exception}"); + } + } +} From 385229c64daf3b20742a9670bae2542cb77ab65d Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:11:52 -0300 Subject: [PATCH 05/11] fix(team-members): keep omitted phone, report invite email failures, never echo invite errors --- .../TeamMemberInviteControllerTests.cs | 51 +++++++++++++++++ .../Controllers/TeamMemberInviteController.cs | 2 + .../InviteRegistrationExceptionFilter.cs | 38 +++++++++++++ SeaHaven.Services/DTOs/TeamMemberDTOs.cs | 5 ++ .../Implementation/AuthenticationService.cs | 2 +- .../Implementation/TeamMemberInviteService.cs | 3 +- .../TeamMemberRegistrationService.cs | 9 ++- .../Implementation/TeamMemberService.cs | 5 +- .../TeamMemberInviteRegistrationTests.cs | 55 +++++++++++++++++++ 9 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs diff --git a/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs index 516897a..f3ec64b 100644 --- a/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs +++ b/Api.SeaHavenIndustries.Tests/TeamMemberInviteControllerTests.cs @@ -1,5 +1,11 @@ using System.Reflection; +using System.Text.Json; using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging.Abstractions; using FluentAssertions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -44,6 +50,51 @@ public class TeamMemberInviteControllerTests cache!.NoStore.Should().BeTrue(); } + [Fact] + public void RegistrationEndpoints_UseTheControllerScopedExceptionFilter() + { + var filter = typeof(TeamMemberInviteController).GetCustomAttribute(); + + filter.Should().NotBeNull(); + filter!.ImplementationType.Should().Be(typeof(InviteRegistrationExceptionFilter)); + } + + [Fact] + public void ExceptionFilter_ReturnsAFixedBodyWithoutExceptionDetail() + { + var context = ExceptionContextFor(new ArgumentException( + "SELECT [Id] FROM [TeamMemberInvites] WHERE [TokenHash] = 'leak-me'")); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeTrue(); + var result = context.Result.Should().BeOfType().Subject; + result.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + var body = JsonSerializer.Serialize(result.Value); + body.Should().Be( + "{\"code\":\"server_error\",\"message\":\"Something went wrong. Try again in a minute.\",\"retryAfterSeconds\":null}"); + body.Should().NotContain("SELECT").And.NotContain("leak-me"); + } + + [Fact] + public void ExceptionFilter_LeavesCancellationToTheHost() + { + var context = ExceptionContextFor(new OperationCanceledException()); + + new InviteRegistrationExceptionFilter(NullLogger.Instance) + .OnException(context); + + context.ExceptionHandled.Should().BeFalse(); + context.Result.Should().BeNull(); + } + + private static ExceptionContext ExceptionContextFor(Exception exception) + { + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()); + return new ExceptionContext(actionContext, new List()) { Exception = exception }; + } + [Theory] [InlineData(TeamMemberRegistrationStatus.InvalidInvite)] [InlineData(TeamMemberRegistrationStatus.Ok)] diff --git a/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs index bd2d161..af16db7 100644 --- a/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs +++ b/Api.SeaHavenIndustries/Controllers/TeamMemberInviteController.cs @@ -1,4 +1,5 @@ using Api.SeaHavenIndustries.DTOs; +using Api.SeaHavenIndustries.Filters; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using SeaHaven.Services.DTOs; @@ -14,6 +15,7 @@ namespace Api.SeaHavenIndustries.Controllers; [ApiController] [Route("api/team-member-invites")] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] +[TypeFilter(typeof(InviteRegistrationExceptionFilter))] public sealed class TeamMemberInviteController : ControllerBase { public const string InvalidInviteMessage = diff --git a/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs new file mode 100644 index 0000000..93b9b00 --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/InviteRegistrationExceptionFilter.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; + +namespace Api.SeaHavenIndustries.Filters +{ + /// + /// Anonymous invite endpoints never echo an exception: controller-scoped exception + /// filters run before the global ones, so no message, stack or SQL reaches the caller. + /// Cancellation is left to the host. + /// + public sealed class InviteRegistrationExceptionFilter : IExceptionFilter + { + public const string Message = "Something went wrong. Try again in a minute."; + + private readonly ILogger _logger; + + public InviteRegistrationExceptionFilter(ILogger logger) + { + _logger = logger; + } + + public void OnException(ExceptionContext context) + { + if (context.Exception is OperationCanceledException) + return; + + _logger.LogError(context.Exception, "Invite registration request failed."); + + context.Result = new ObjectResult(new { code = "server_error", message = Message, retryAfterSeconds = (int?)null }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + context.ExceptionHandled = true; + } + } +} diff --git a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs index 814e279..3a830fe 100644 --- a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs +++ b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Data.SeaHavenIndustries.Enums; namespace SeaHaven.Services.DTOs; @@ -23,6 +24,10 @@ public class TeamMemberCreatedDTO public string? Phone { get; init; } public required IReadOnlyList ServiceAreas { get; init; } public bool PendingRegistration { get; init; } + + /// Set only on create: false means the member exists but the invite email failed; re-invite from their details. + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? InviteEmailSent { get; init; } } public sealed class TeamMemberDetailDTO : TeamMemberCreatedDTO diff --git a/SeaHaven.Services/Implementation/AuthenticationService.cs b/SeaHaven.Services/Implementation/AuthenticationService.cs index 5df17de..bc7663c 100644 --- a/SeaHaven.Services/Implementation/AuthenticationService.cs +++ b/SeaHaven.Services/Implementation/AuthenticationService.cs @@ -81,7 +81,7 @@ namespace SeaHaven.Services.Implementation Email = user.Email, UserRole = userRoles.FirstOrDefault(), PhoneNumber = user.PhoneNumber, - Fullname = user.FirstName + " " + user.LastName, + Fullname = $"{user.FirstName} {user.LastName}".Trim(), Id = user.Id }; } diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs index 7577507..330254b 100644 --- a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs @@ -62,7 +62,8 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService var body = $"

Hi {WebUtility.HtmlEncode(name)},

" + "

You've been added to Seahaven. Set your password and confirm your email to finish creating your account.

" + - $"

Finish registration

" + + // clicktracking=off stops SendGrid rewriting the link, so the token never passes through its redirect. + $"

Finish registration

" + $"

This link expires in {InviteLifetime.Days} days and can be used once.

"; var sent = await _emailSender.SendEmailAsync(email, "You're invited to Seahaven", body); diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs index 2bc306c..c64e119 100644 --- a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -149,6 +149,8 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat if (context.Invite.EmailConfirmedAt is null) return Outcome(TeamMemberRegistrationStatus.EmailNotConfirmed); + // An omitted phone keeps what the admin entered; an explicit empty value clears it. + var updatePhone = request.Phone is not null; var phone = string.IsNullOrWhiteSpace(request.Phone) ? null : request.Phone.Trim(); if (phone is not null && !PhonePattern().IsMatch(phone)) return Outcome(TeamMemberRegistrationStatus.InvalidPhone); @@ -165,8 +167,11 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat user.EmailConfirmed = true; user.PendingRegistration = false; user.UniqueName = "Active"; - user.PhoneNumber = phone; - user.Contact = phone; + if (updatePhone) + { + user.PhoneNumber = phone; + user.Contact = phone; + } // Identity applies the shared password policy and persists the user. var result = await _userManager.AddPasswordAsync(user, request.Password ?? ""); diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index 6a25aef..2f62818 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -115,7 +115,7 @@ public sealed class TeamMemberService : ITeamMemberService // The member and their invite commit together; the email goes out only after // commit, so a rolled-back member never receives a link. - await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken); + var inviteEmailSent = await _inviteService.SendAsync(invite!, user.Email!, user.FirstName!, cancellationToken); return new CreateTeamMemberOutcomeDTO { @@ -129,7 +129,8 @@ public sealed class TeamMemberService : ITeamMemberService Email = user.Email, Phone = user.PhoneNumber, ServiceAreas = areas!, - PendingRegistration = true + PendingRegistration = true, + InviteEmailSent = inviteEmailSent } }; } diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs index 11632c4..dac402a 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -37,6 +37,7 @@ public sealed class TeamMemberInviteRegistrationTests var sent = Assert.Single(host.Sent.Messages); Assert.Equal(Email, sent.To); Assert.Contains($"{TeamMemberInviteTestHost.FrontendBaseUrl}/invite#{token}", sent.Body); + Assert.Contains(" provider.GetRequiredService().CreateAsync( + new CreateTeamMemberRequestDTO + { + Name = "Taylor Reed", + Role = "dispatcher", + Color = "#0D9488", + Email = Email, + ServiceAreas = new[] { "East" } + }, + TeamMemberInviteTestHost.Admin(), + CancellationToken.None)); + + Assert.True(outcome.Success); + Assert.Equal(delivered, outcome.Member!.InviteEmailSent); + Assert.Single(await host.InvitesAsync(outcome.Member.Id)); + } + [Fact] public async Task Complete_InvalidPhone_IsRejectedWithoutConsumingTheInvite() { From f491d4c721551e82e6aba38af1d88b0ab447d5c8 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:45:04 -0300 Subject: [PATCH 06/11] fix(team-members): delete invites with their member, re-invite on email change, trust only 2xx SendGrid responses --- .../SendMessageTests.cs | 105 +++++++++++++++++ Api.SeaHavenIndustries/Helper/SendMessage.cs | 46 +++++--- .../Implementation/UserDataService.cs | 4 + .../Implementation/TeamMemberInviteService.cs | 22 +++- .../Implementation/TeamMemberService.cs | 7 +- .../Interfaces/ITeamMemberInviteService.cs | 8 ++ .../TeamMemberInviteLifecycleTests.cs | 106 ++++++++++++++++++ 7 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 Api.SeaHavenIndustries.Tests/SendMessageTests.cs create mode 100644 SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs diff --git a/Api.SeaHavenIndustries.Tests/SendMessageTests.cs b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs new file mode 100644 index 0000000..806bcd1 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/SendMessageTests.cs @@ -0,0 +1,105 @@ +using System.Net; +using Api.SeaHavenIndustries.Helper; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using SendGrid; +using SendGrid.Helpers.Mail; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public sealed class SendMessageTests +{ + private const string Recipient = "taylor@example.com"; + private const string Subject = "You're invited to Seahaven"; + private const string RejectionBody = "{\"errors\":[{\"message\":\"taylor@example.com does not exist\"}]}"; + + [Theory] + [InlineData(HttpStatusCode.OK, true)] + [InlineData(HttpStatusCode.Accepted, true)] + [InlineData(HttpStatusCode.BadRequest, false)] + [InlineData(HttpStatusCode.Unauthorized, false)] + [InlineData(HttpStatusCode.TooManyRequests, false)] + [InlineData(HttpStatusCode.InternalServerError, false)] + public async Task EverySendPath_ReportsDeliveryOnlyForASuccessStatus(HttpStatusCode status, bool delivered) + { + var sender = new StubbedSendMessage(status, new CapturingLogger()); + + Assert.Equal(delivered, await sender.SendEMail(Recipient, Subject, "

Hi

")); + Assert.Equal(delivered, await sender.SendEmailAsync(Recipient, Subject, "

Hi

")); + Assert.Equal(delivered, await sender.SendEMailAttachment(Recipient, Subject, new byte[] { 1, 2, 3 })); + Assert.Equal(delivered, await sender.SendDispatchEmail(Recipient, Subject, "

Hi

", null)); + } + + [Fact] + public async Task ARejectedSend_LogsOnlyTheStatusCode() + { + var logger = new CapturingLogger(); + var sender = new StubbedSendMessage(HttpStatusCode.BadRequest, logger); + + Assert.False(await sender.SendEMail(Recipient, Subject, "

secret link

")); + + var entry = Assert.Single(logger.Entries); + Assert.Contains("400", entry); + Assert.DoesNotContain(Recipient, entry); + Assert.DoesNotContain(Subject, entry); + Assert.DoesNotContain("secret link", entry); + Assert.DoesNotContain("errors", entry); + } + + [Fact] + public async Task AFailedSend_LogsOnlyTheExceptionType() + { + var logger = new CapturingLogger(); + var sender = new StubbedSendMessage( + new HttpRequestException($"could not reach SendGrid for {Recipient}"), + logger); + + Assert.False(await sender.SendEMail(Recipient, Subject, "

Hi

")); + + var entry = Assert.Single(logger.Entries); + Assert.Contains(nameof(HttpRequestException), entry); + Assert.DoesNotContain(Recipient, entry); + } + + private sealed class StubbedSendMessage : SendMessage + { + private readonly Mock _client = new(); + + public StubbedSendMessage(HttpStatusCode status, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => new Response(status, new StringContent(RejectionBody), null)); + } + + public StubbedSendMessage(Exception failure, ILogger logger) + : base(new ConfigurationBuilder().Build(), logger) + { + _client + .Setup(client => client.SendEmailAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(failure); + } + + protected override ISendGridClient CreateClient(string? apiKey) => _client.Object; + } + + private sealed class CapturingLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) => + Entries.Add($"{formatter(state, exception)} {exception}"); + } +} diff --git a/Api.SeaHavenIndustries/Helper/SendMessage.cs b/Api.SeaHavenIndustries/Helper/SendMessage.cs index 08db640..5bab7ee 100644 --- a/Api.SeaHavenIndustries/Helper/SendMessage.cs +++ b/Api.SeaHavenIndustries/Helper/SendMessage.cs @@ -9,14 +9,37 @@ namespace Api.SeaHavenIndustries.Helper public class SendMessage : IEmailSender { private IConfiguration _configuration; + private readonly ILogger _logger; //string keysapi = ""; - public SendMessage(IConfiguration configuration) + public SendMessage(IConfiguration configuration, ILogger logger) { _configuration = configuration; + _logger = logger; //keysapi = _configuration.GetValue("SendGrid:ApiKey"); } + protected virtual ISendGridClient CreateClient(string? apiKey) => new SendGridClient(apiKey); + + /// + /// SendGrid reports a rejected message through the status code, not an exception. + /// Only the status is logged: never the recipient, subject or response body. + /// + private bool Delivered(Response response) + { + if (response.IsSuccessStatusCode) + return true; + + _logger.LogWarning("SendGrid rejected an email with status {StatusCode}.", (int)response.StatusCode); + return false; + } + + private bool Failed(Exception ex) + { + _logger.LogWarning("SendGrid email send failed with {ExceptionType}.", ex.GetType().Name); + return false; + } + public async Task SendEMail(string emailTo, string subject, string body) { try @@ -41,7 +64,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; var to = new EmailAddress(emailTo, ""); @@ -51,12 +74,11 @@ namespace Api.SeaHavenIndustries.Helper var htmlContent = body; var msg = MailHelper.CreateSingleEmail(from, to, subject, plainTextContent, htmlContent); var response = await client.SendEmailAsync(msg); - var dd = response.Body.ReadAsStringAsync(); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } public async Task SendEmailAsync(string emailTo, string subject, string htmlBody) @@ -69,7 +91,7 @@ namespace Api.SeaHavenIndustries.Helper var apiKey = _configuration.GetValue("SendGrid:ApiKey"); //var apiKey = ""; - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea haven Industries"); //var subject = "Sending with SendGrid is Fun"; var to = new EmailAddress(emailTo, ""); @@ -92,13 +114,11 @@ namespace Api.SeaHavenIndustries.Helper msg.Attachments = new List { attachment }; var response = await client.SendEmailAsync(msg); - - var dd = response.Body.ReadAsStringAsync(); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } public async Task SendDispatchEmail(string emailTo, string subject, string htmlBody, string? replyTo) @@ -106,7 +126,7 @@ namespace Api.SeaHavenIndustries.Helper try { var apiKey = _configuration.GetValue("SendGrid:ApiKey"); - var client = new SendGridClient(apiKey); + var client = CreateClient(apiKey); var from = new EmailAddress("tech@seahavenind.com", "Sea Haven Industries"); var to = new EmailAddress(emailTo, ""); @@ -116,11 +136,11 @@ namespace Api.SeaHavenIndustries.Helper msg.SetReplyTo(new EmailAddress(replyTo)); var response = await client.SendEmailAsync(msg); - return true; + return Delivered(response); } catch (Exception ex) { - return false; + return Failed(ex); } } } diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 32ac851..dce7af1 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -159,6 +159,10 @@ namespace SeaHaven.DataServices.Implementation .Where(permissionOverride => permissionOverride.UserId == id) .ExecuteDeleteAsync(cancellationToken); + await _context.TeamMemberInvites + .Where(invite => invite.UserId == id) + .ExecuteDeleteAsync(cancellationToken); + await _context.Users .Where(existingUser => existingUser.Id == id) .ExecuteDeleteAsync(cancellationToken); diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs index 330254b..3b355ba 100644 --- a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs @@ -89,16 +89,28 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email)) return ResendFailure("Only pending team members can be re-invited."); - var (invite, issued) = NewInvite(user.Id); - await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken); - - var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(); - if (!await SendAsync(issued, user.Email, name, cancellationToken)) + if (!await ReissueAsync(user, cancellationToken)) return ResendFailure("The invite could not be emailed. Try again."); return new TeamMemberInviteResendOutcomeDTO { Success = true }; } + public async Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(user); + ArgumentException.ThrowIfNullOrWhiteSpace(user.Email); + + var (invite, issued) = NewInvite(user.Id); + await _inviteDataService.ReplaceOpenForUserAsync(invite, NowUtc(), cancellationToken); + + // A deactivated member cannot register; the admin re-sends the invite after reactivating them. + if (user.IsDeleted == true) + return true; + + var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(); + return await SendAsync(issued, user.Email, name, cancellationToken); + } + private (TeamMemberInvite Invite, IssuedTeamMemberInvite Issued) NewInvite(string userId) { var now = NowUtc(); diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index 2f62818..d15a347 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -199,7 +199,8 @@ public sealed class TeamMemberService : ITeamMemberService user.UniqueName = request.IsActive ? ActiveStatus : "Inactive"; user.IsDeleted = !request.IsActive; - if (!string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase)) + var emailChanged = !string.Equals(user.Email, email, StringComparison.OrdinalIgnoreCase); + if (emailChanged) { var emailResult = await _userManager.SetEmailAsync(user, email!); if (!emailResult.Succeeded) @@ -237,6 +238,10 @@ public sealed class TeamMemberService : ITeamMemberService await _permissionDataService.SetOverridesAsync(user.Id, overrides!, cancellationToken); } + // An invite sent to the old address, and any code confirmed through it, must not register the new one. + if (emailChanged && user.PendingRegistration == true) + await _inviteService.ReissueAsync(user, cancellationToken); + return await OperationSuccessAsync(user, caller, cancellationToken); } diff --git a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs index 9b12928..54eb702 100644 --- a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs +++ b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using Data.SeaHavenIndustries; using SeaHaven.Services.DTOs; namespace SeaHaven.Services.Interfaces; @@ -15,6 +16,13 @@ public interface ITeamMemberInviteService string name, CancellationToken cancellationToken); + /// + /// Revokes the member's open invites, including any email confirmation made through them, + /// and emails a new invite to their current address unless they are deactivated. + /// Returns false when that email failed. + /// + Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken); + /// Admin-only: revokes a pending member's open invites and emails a new one. Task ResendAsync( string userId, diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs new file mode 100644 index 0000000..521d7a4 --- /dev/null +++ b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace SeaHavenIndustries.Tests; + +/// What happens to a member's invites when an admin edits or deletes that member. +public sealed class TeamMemberInviteLifecycleTests +{ + private const string Email = "taylor@example.com"; + private const string CorrectedEmail = "taylor.reed@example.com"; + private const string Password = "Abc1!x"; + + [Fact] + public async Task DeletingAnInvitedMember_RemovesTheMemberAndTheirInvites() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.SendCodeAsync(token, Email); + Assert.True((await ResendInviteAsync(host, userId)).Success); + Assert.Equal(2, (await host.InvitesAsync(userId)).Count); + + var deleted = await host.InScopeAsync(provider => provider.GetRequiredService() + .DeleteUserAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); + + Assert.True(deleted.Success, deleted.Error); + Assert.Empty(await host.InvitesAsync(userId)); + Assert.False(await host.InScopeAsync(provider => provider.GetRequiredService() + .Users.AnyAsync(user => user.Id == userId))); + } + + [Fact] + public async Task ChangingAPendingMembersEmail_RevokesTheOldInviteAndItsConfirmation() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, oldToken) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(oldToken, Email); + + var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin"); + Assert.True(updated.Success, updated.Error); + + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.SendCodeAsync(oldToken, CancellationToken.None))).Status); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, (await CompleteAsync(host, oldToken)).Status); + + var user = await host.ReloadUserAsync(userId); + Assert.False(user.EmailConfirmed); + Assert.True(user.PendingRegistration); + + // The new address gets its own invite, and it must be confirmed again before finishing. + var newToken = host.Sent.LatestTokenFor(CorrectedEmail); + Assert.NotEqual(oldToken, newToken); + Assert.Equal(TeamMemberRegistrationStatus.EmailNotConfirmed, (await CompleteAsync(host, newToken)).Status); + await host.ConfirmEmailAsync(newToken, CorrectedEmail); + Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, newToken)).Status); + } + + [Fact] + public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, token) = await host.AddPendingMemberAsync(Email); + var emailsBefore = host.Sent.Messages.Count; + + var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher"); + + Assert.True(updated.Success, updated.Error); + Assert.Equal(emailsBefore, host.Sent.Messages.Count); + var invite = Assert.Single(await host.InvitesAsync(userId)); + Assert.Null(invite.RevokedAt); + Assert.Equal(TeamMemberRegistrationStatus.Ok, + (await host.RegistrationAsync(service => service.ResolveAsync(token, CancellationToken.None))).Status); + } + + private static Task UpdateAsync( + TeamMemberInviteTestHost host, + string userId, + string email, + string role) => + host.InScopeAsync(provider => provider.GetRequiredService().UpdateAsync( + userId, + new UpdateTeamMemberRequestDTO + { + Name = "Taylor Reed", + Role = role, + Color = "#0D9488", + Email = email, + Phone = "555-0100", + ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty() + }, + TeamMemberInviteTestHost.Admin(), + CancellationToken.None)); + + private static Task CompleteAsync(TeamMemberInviteTestHost host, string token) => + host.RegistrationAsync(service => service.CompleteAsync( + new CompleteTeamMemberRegistrationRequestDTO { Token = token, Password = Password }, + CancellationToken.None)); + + private static Task ResendInviteAsync(TeamMemberInviteTestHost host, string userId) => + host.InScopeAsync(provider => provider.GetRequiredService() + .ResendAsync(userId, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); +} From b50cd5f5df316b0c5d7e6f3bf46fae99434ff0aa Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 12:54:27 -0300 Subject: [PATCH 07/11] feat(team-members): report whether the re-invite after an email change was emailed --- SeaHaven.Services/DTOs/TeamMemberDTOs.cs | 5 ++++- .../Implementation/TeamMemberService.cs | 9 ++++++--- .../TeamMemberInviteLifecycleTests.cs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs index 3a830fe..e0b8e31 100644 --- a/SeaHaven.Services/DTOs/TeamMemberDTOs.cs +++ b/SeaHaven.Services/DTOs/TeamMemberDTOs.cs @@ -25,7 +25,10 @@ public class TeamMemberCreatedDTO public required IReadOnlyList ServiceAreas { get; init; } public bool PendingRegistration { get; init; } - /// Set only on create: false means the member exists but the invite email failed; re-invite from their details. + /// + /// Set only when an invite was emailed: on create, and on an update that changed a pending member's email. + /// False means the invite email failed; re-invite from their details. + /// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool? InviteEmailSent { get; init; } } diff --git a/SeaHaven.Services/Implementation/TeamMemberService.cs b/SeaHaven.Services/Implementation/TeamMemberService.cs index d15a347..6d78a7b 100644 --- a/SeaHaven.Services/Implementation/TeamMemberService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberService.cs @@ -239,10 +239,11 @@ public sealed class TeamMemberService : ITeamMemberService } // An invite sent to the old address, and any code confirmed through it, must not register the new one. + bool? inviteEmailSent = null; if (emailChanged && user.PendingRegistration == true) - await _inviteService.ReissueAsync(user, cancellationToken); + inviteEmailSent = await _inviteService.ReissueAsync(user, cancellationToken); - return await OperationSuccessAsync(user, caller, cancellationToken); + return await OperationSuccessAsync(user, caller, cancellationToken, inviteEmailSent); } private static void ApplyName(ApplicationUser user, string fullName) @@ -277,7 +278,8 @@ public sealed class TeamMemberService : ITeamMemberService private async Task OperationSuccessAsync( ApplicationUser user, ClaimsPrincipal caller, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool? inviteEmailSent = null) { var roles = await _userManager.GetRolesAsync(user); var profile = await _permissionService.GetProfileAsync(user.Id, caller, cancellationToken); @@ -295,6 +297,7 @@ public sealed class TeamMemberService : ITeamMemberService Phone = user.Contact ?? user.PhoneNumber, ServiceAreas = areas, PendingRegistration = user.PendingRegistration == true, + InviteEmailSent = inviteEmailSent, Status = user.UniqueName ?? "Active", IsAccountOwner = user.IsAccountOwner, Permissions = profile?.Value?.Permissions ?? Array.Empty() diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs index 521d7a4..c963a62 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs @@ -40,6 +40,7 @@ public sealed class TeamMemberInviteLifecycleTests var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "admin"); Assert.True(updated.Success, updated.Error); + Assert.True(updated.Member!.InviteEmailSent); Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, (await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status); @@ -59,6 +60,20 @@ public sealed class TeamMemberInviteLifecycleTests Assert.Equal(TeamMemberRegistrationStatus.Ok, (await CompleteAsync(host, newToken)).Status); } + [Fact] + public async Task ChangingAPendingMembersEmail_ReportsWhenTheNewInviteCouldNotBeEmailed() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, _) = await host.AddPendingMemberAsync(Email); + host.Sent.Succeeds = false; + + var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "dispatcher"); + + Assert.True(updated.Success, updated.Error); + Assert.Equal(CorrectedEmail, updated.Member!.Email); + Assert.False(updated.Member.InviteEmailSent); + } + [Fact] public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite() { @@ -69,6 +84,7 @@ public sealed class TeamMemberInviteLifecycleTests var updated = await UpdateAsync(host, userId, Email.ToUpperInvariant(), role: "dispatcher"); Assert.True(updated.Success, updated.Error); + Assert.Null(updated.Member!.InviteEmailSent); Assert.Equal(emailsBefore, host.Sent.Messages.Count); var invite = Assert.Single(await host.InvitesAsync(userId)); Assert.Null(invite.RevokedAt); From afc2330184b502602e480d513c403441848ddac9 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:05:35 -0300 Subject: [PATCH 08/11] fix(team-members): report only password-rule failures at finish as a rejected password --- .../TeamMemberRegistrationService.cs | 13 ++++++--- .../TeamMemberInviteRegistrationTests.cs | 27 +++++++++++++++++++ .../TeamMemberInviteTestHost.cs | 10 ++++--- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs index c64e119..de7ae0a 100644 --- a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -228,9 +228,16 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat private static TeamMemberRegistrationStatus StatusFor(IdentityResult result) { // A password already set means the account was registered by another route. - return result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword)) - ? TeamMemberRegistrationStatus.InvalidInvite - : TeamMemberRegistrationStatus.PasswordRejected; + if (result.Errors.Any(error => error.Code == nameof(IdentityErrorDescriber.UserAlreadyHasPassword))) + return TeamMemberRegistrationStatus.InvalidInvite; + + if (IdentityPasswordPolicy.IsPolicyRejection(result)) + return TeamMemberRegistrationStatus.PasswordRejected; + + // Anything else (a concurrency conflict, a store failure) is not the member's password: + // roll back and let the endpoint answer with its generic error. Only codes are recorded. + throw new InvalidOperationException( + "Identity refused the registration: " + string.Join(", ", result.Errors.Select(error => error.Code))); } private DateTime NowUtc() => _timeProvider.GetUtcNow().UtcDateTime; diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs index dac402a..f0a4c49 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -292,6 +292,33 @@ public sealed class TeamMemberInviteRegistrationTests Assert.False((await host.ReloadUserAsync(userId)).PendingRegistration); } + [Fact] + public async Task Complete_NonPolicyIdentityFailure_IsNotReportedAsAWeakPasswordAndRollsBack() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(services => + services.AddScoped, FailingPasswordValidator>()); + var (userId, token) = await host.AddPendingMemberAsync(Email); + await host.ConfirmEmailAsync(token, Email); + + var failure = await Assert.ThrowsAsync(() => CompleteAsync(host, token, Password)); + + Assert.Contains(FailingPasswordValidator.Code, failure.Message); + Assert.DoesNotContain(Password, failure.Message); + var pending = await host.ReloadUserAsync(userId); + Assert.True(pending.PendingRegistration); + Assert.Null(pending.PasswordHash); + Assert.Null(Assert.Single(await host.InvitesAsync(userId)).UsedAt); + } + + /// Stands in for any Identity failure that is not the password rule itself. + private sealed class FailingPasswordValidator : IPasswordValidator + { + public const string Code = "ConcurrencyFailure"; + + public Task ValidateAsync(UserManager manager, ApplicationUser user, string? password) => + Task.FromResult(IdentityResult.Failed(new IdentityError { Code = Code, Description = "Optimistic concurrency failure." })); + } + [Fact] public async Task Complete_WithoutPhone_KeepsThePhoneTheAdminEntered() { diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs index e1d6f9e..a2e5a1a 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteTestHost.cs @@ -38,7 +38,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable public ManualTimeProvider Time { get; } = new(); public CapturingLoggerProvider Logged { get; } = new(); - public static async Task CreateAsync() + public static async Task CreateAsync(Action? configure = null) { var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=True"); await connection.OpenAsync(); @@ -48,11 +48,14 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable await setup.Database.EnsureCreatedAsync(); var host = new TeamMemberInviteTestHost(connection); - host._provider = BuildProvider(connection, host); + host._provider = BuildProvider(connection, host, configure); return host; } - private static ServiceProvider BuildProvider(SqliteConnection connection, TeamMemberInviteTestHost fakes) + private static ServiceProvider BuildProvider( + SqliteConnection connection, + TeamMemberInviteTestHost fakes, + Action? configure) { var configuration = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary @@ -76,6 +79,7 @@ internal sealed class TeamMemberInviteTestHost : IAsyncDisposable services.AddSingleton(fakes.Sent); services.AddDataServices(); services.AddBusinessServices(configuration); + configure?.Invoke(services); return services.BuildServiceProvider(); } From cc328ce22aa14dd087def2b7bf239b2e12ee2ac0 Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:13:50 -0300 Subject: [PATCH 09/11] fix(team-members): do not report an invite email for a deactivated member --- .../Implementation/TeamMemberInviteService.cs | 6 +++--- .../Interfaces/ITeamMemberInviteService.cs | 4 ++-- .../TeamMemberInviteLifecycleTests.cs | 21 +++++++++++++++++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs index 3b355ba..e78130d 100644 --- a/SeaHaven.Services/Implementation/TeamMemberInviteService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberInviteService.cs @@ -89,13 +89,13 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService if (user.IsDeleted == true || user.PendingRegistration != true || string.IsNullOrWhiteSpace(user.Email)) return ResendFailure("Only pending team members can be re-invited."); - if (!await ReissueAsync(user, cancellationToken)) + if (await ReissueAsync(user, cancellationToken) != true) return ResendFailure("The invite could not be emailed. Try again."); return new TeamMemberInviteResendOutcomeDTO { Success = true }; } - public async Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken) + public async Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(user); ArgumentException.ThrowIfNullOrWhiteSpace(user.Email); @@ -105,7 +105,7 @@ public sealed class TeamMemberInviteService : ITeamMemberInviteService // A deactivated member cannot register; the admin re-sends the invite after reactivating them. if (user.IsDeleted == true) - return true; + return null; var name = $"{user.FirstName ?? ""} {user.LastName ?? ""}".Trim(); return await SendAsync(issued, user.Email, name, cancellationToken); diff --git a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs index 54eb702..25d5b6b 100644 --- a/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs +++ b/SeaHaven.Services/Interfaces/ITeamMemberInviteService.cs @@ -19,9 +19,9 @@ public interface ITeamMemberInviteService /// /// Revokes the member's open invites, including any email confirmation made through them, /// and emails a new invite to their current address unless they are deactivated. - /// Returns false when that email failed. + /// Returns whether that email went out, or null when the member is deactivated and nothing was emailed. /// - Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken); + Task ReissueAsync(ApplicationUser user, CancellationToken cancellationToken); /// Admin-only: revokes a pending member's open invites and emails a new one. Task ResendAsync( diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs index c963a62..9ddb383 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteLifecycleTests.cs @@ -74,6 +74,21 @@ public sealed class TeamMemberInviteLifecycleTests Assert.False(updated.Member.InviteEmailSent); } + [Fact] + public async Task DeactivatingAPendingMemberWhileChangingTheirEmail_RevokesTheInviteWithoutClaimingAnEmail() + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(); + var (userId, oldToken) = await host.AddPendingMemberAsync(Email); + + var updated = await UpdateAsync(host, userId, CorrectedEmail, role: "dispatcher", isActive: false); + + Assert.True(updated.Success, updated.Error); + Assert.Null(updated.Member!.InviteEmailSent); + Assert.DoesNotContain(host.Sent.Messages, message => message.To == CorrectedEmail); + Assert.Equal(TeamMemberRegistrationStatus.InvalidInvite, + (await host.RegistrationAsync(service => service.ResolveAsync(oldToken, CancellationToken.None))).Status); + } + [Fact] public async Task EditingAPendingMemberWithoutChangingTheirEmail_KeepsTheirInvite() { @@ -96,7 +111,8 @@ public sealed class TeamMemberInviteLifecycleTests TeamMemberInviteTestHost host, string userId, string email, - string role) => + string role, + bool isActive = true) => host.InScopeAsync(provider => provider.GetRequiredService().UpdateAsync( userId, new UpdateTeamMemberRequestDTO @@ -106,7 +122,8 @@ public sealed class TeamMemberInviteLifecycleTests Color = "#0D9488", Email = email, Phone = "555-0100", - ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty() + ServiceAreas = role == "dispatcher" ? new[] { "East" } : Array.Empty(), + IsActive = isActive }, TeamMemberInviteTestHost.Admin(), CancellationToken.None)); From 47060f6a73bae943aa6ad6ffe4c9020afd1a8f4e Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 13:41:19 -0300 Subject: [PATCH 10/11] fix(work-orders): never return a severity on an Overdue board row A standalone severity patch on an Overdue WO still stores the value, because the board patches severity before type when correcting Overdue to Emergency/Reactive and that write must not be dropped or rejected. Project the severity as null for Overdue in the board row mapping, which also feeds the PATCH response, search results and the detail view, so a stored value never surfaces on a type that carries no severity. --- .../Implementation/WorkOrderBoardService.cs | 7 ++++- .../WorkOrderOverdueTypeTests.cs | 30 ++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index f21a21c..9341fa8 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -147,7 +147,12 @@ namespace SeaHaven.Services.Implementation MediaCount = row.MediaCount, CompletedDate = row.CompletedDate, FlagColor = row.FlagColor, - Severity = WorkOrderSeverityRules.ParseLevel(row.Severity), + // Overdue never carries a severity. A standalone severity patch can still + // store one (the board patches severity before type when correcting Overdue + // to Emergency/Reactive), so the row never returns it for an Overdue WO. + Severity = row.WorkOrderType == WorkOrderType.Overdue + ? null + : WorkOrderSeverityRules.ParseLevel(row.Severity), PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, DispatchRowVersion = row.DispatchRowVersion, diff --git a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs index 7a1ee1e..fe3b61b 100644 --- a/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderOverdueTypeTests.cs @@ -202,12 +202,13 @@ public class WorkOrderOverdueTypeTests await context.SaveChangesAsync(); var service = NewUpdateService(context); - await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + var severityStep = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto { Field = WorkOrderBoardFieldNames.Severity, Value = "2", WorkOrderVersion = ToVersion(wo) }, "actor-1"); + Assert.Null(severityStep.Severity); var updated = await context.workOrders.SingleAsync(w => w.Id == 1); var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto @@ -224,6 +225,33 @@ public class WorkOrderOverdueTypeTests Assert.Equal("2", persisted.Severity); } + // A severity patch with no following type change leaves a stored value on an + // Overdue WO; the board row still reports no severity. + [Fact] + public async Task PatchSeverity_OnOverdue_RowShowsNoSeverity() + { + await using var context = NewContext(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Overdue, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await NewUpdateService(context).PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.Severity, + Value = "3", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(WorkOrderType.Overdue, result.WorkOrderType); + Assert.Null(result.Severity); + } + [Fact] public async Task PatchType_PmWithPmService_ToOverdue_KeepsService() { From 9084b7f642d0b3a8557336b4bb908670cc23177c Mon Sep 17 00:00:00 2001 From: Alexandre Brandizzi Date: Fri, 25 Sep 2026 16:40:54 -0300 Subject: [PATCH 11/11] fix(team-members): answer invalid_invite when send-code finds the invite closed SendCodeAsync validates the invite, then starts the code with a conditional update that also requires the invite to still be open. When an admin revoked the link (or it was used) between those two reads, the refusal was reported as resend_too_soon with a Retry-After, although the link was already dead. On a refused start the invite is now read again: a closed invite gets the same generic invalid_invite response as any other dead link, and a cooldown or send limit refusal is computed from the fresh row. --- .../TeamMemberRegistrationService.cs | 9 ++- .../TeamMemberInviteRegistrationTests.cs | 70 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs index de7ae0a..b83abd8 100644 --- a/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs +++ b/SeaHaven.Services/Implementation/TeamMemberRegistrationService.cs @@ -87,7 +87,14 @@ public sealed partial class TeamMemberRegistrationService : ITeamMemberRegistrat }, cancellationToken); if (!started) - return ResendRefusal(context.Invite, now); + { + // The refusal may be the invite closing since it was read, not the cooldown or limit: + // read it again so a revoked or used link gets the same answer as any other dead link. + var current = await LoadAsync(token, now, cancellationToken); + return current is null + ? Outcome(TeamMemberRegistrationStatus.InvalidInvite) + : ResendRefusal(current.Invite, now); + } // Read the address at send time so an admin's correction is honoured. var body = diff --git a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs index f0a4c49..1a986a1 100644 --- a/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs +++ b/SeaHavenIndustries.Tests/TeamMemberInviteRegistrationTests.cs @@ -8,6 +8,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using SeaHaven.DataServices.Dto; +using SeaHaven.DataServices.Implementation; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.DTOs; using SeaHaven.Services.Implementation; @@ -259,6 +262,73 @@ public sealed class TeamMemberInviteRegistrationTests host.Sent.Messages.Count(message => message.Subject == "Your Seahaven confirmation code")); } + [Theory] + [InlineData("revoked")] + [InlineData("used")] + public async Task SendCode_ForAnInviteClosedMidRequest_GetsTheGenericInvalidInviteResponse(string closedBy) + { + await using var host = await TeamMemberInviteTestHost.CreateAsync(services => + services.Replace(ServiceDescriptor.Scoped(provider => + new ClosesInviteBeforeCodeStart( + new TeamMemberInviteDataService(provider.GetRequiredService()), + provider.GetRequiredService(), + closedBy)))); + var (userId, token) = await host.AddPendingMemberAsync(Email); + + var response = await InvokeControllerAsync(host, controller => controller.SendCode( + new TeamMemberInviteTokenRequestDTO { Token = token }, CancellationToken.None)); + + Assert.Equal( + "400 {\"code\":\"invalid_invite\",\"message\":\"This invite link is invalid or has expired. Ask your admin to send a new invite.\",\"retryAfterSeconds\":null}", + response.Body); + Assert.Null(response.RetryAfter); + Assert.DoesNotContain(host.Sent.Messages, message => message.Subject == "Your Seahaven confirmation code"); + Assert.Equal(0, Assert.Single(await host.InvitesAsync(userId)).CodeSendCount); + } + + /// Closes the invite after the service has read it and before the conditional code start runs. + private sealed class ClosesInviteBeforeCodeStart : ITeamMemberInviteDataService + { + private readonly ITeamMemberInviteDataService _inner; + private readonly ApplicationDbContext _context; + private readonly string _closedBy; + + public ClosesInviteBeforeCodeStart(ITeamMemberInviteDataService inner, ApplicationDbContext context, string closedBy) + { + _inner = inner; + _context = context; + _closedBy = closedBy; + } + + public async Task TryStartCodeAsync(StartTeamMemberInviteCodeCommand command, CancellationToken cancellationToken) + { + var invite = _context.TeamMemberInvites.Where(existing => existing.Id == command.InviteId); + if (_closedBy == "revoked") + await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.RevokedAt, command.Now), cancellationToken); + else + await invite.ExecuteUpdateAsync(setters => setters.SetProperty(existing => existing.UsedAt, command.Now), cancellationToken); + return await _inner.TryStartCodeAsync(command, cancellationToken); + } + + public Task AddAsync(TeamMemberInvite invite, CancellationToken cancellationToken) => + _inner.AddAsync(invite, cancellationToken); + + public Task ReplaceOpenForUserAsync(TeamMemberInvite invite, DateTime now, CancellationToken cancellationToken) => + _inner.ReplaceOpenForUserAsync(invite, now, cancellationToken); + + public Task GetByTokenHashAsync(string tokenHash, CancellationToken cancellationToken) => + _inner.GetByTokenHashAsync(tokenHash, cancellationToken); + + public Task TryReserveCodeAttemptAsync(int inviteId, int maxAttempts, DateTime now, CancellationToken cancellationToken) => + _inner.TryReserveCodeAttemptAsync(inviteId, maxAttempts, now, cancellationToken); + + public Task TryConfirmEmailAsync(int inviteId, string codeHash, DateTime now, CancellationToken cancellationToken) => + _inner.TryConfirmEmailAsync(inviteId, codeHash, now, cancellationToken); + + public Task TryClaimAsync(int inviteId, DateTime now, CancellationToken cancellationToken) => + _inner.TryClaimAsync(inviteId, now, cancellationToken); + } + [Fact] public async Task Complete_RequiresConfirmedEmail() {